Spring Security实现RBAC权限模型练习
创始人
2024-05-24 17:04:15
0

1.Spring Security介绍

Spring Security的核心功能就是认证、授权、攻击防护,Spring Boot项目启动之后会自动进行配置,其核心就是一组链式过滤器。

如下图所示,对于一个用户请求,Username Password Authentication Filter验证用户名和密码是否正确,通过就放行,然后Basic Authentication Filter就实现了去验证请求中是否包含有权限认证的basic信息。

FilterSecurityInterceptor验证请求是否能够访问REST API,如果不能够访问即被拒绝了的话就会抛出不同类型的异常,这些异常由Exception Translation Filter来捕获。成功走完这条链式过滤器的请求才会返回成功的响应数据给客户端。

在这里插入图片描述

2.RBAC介绍

RBAC即Role-Based-Access-Control基于角色的访问控制,核心就是一个用户可以拥有若干的角色,每个角色拥有若干的权限。所以就会建立起用户-角色-权限之间的关系图,这就是一个授权模型。在这种关系模型中,用户和角色,角色和权限之间往往是多对多的关系。

一个用户拥有了不同的角色,不同的角色拥有不同的权限,只要拥有了某个角色就会实现某些功能,这也是权限管理及设计的关键所在。

RBAC三原则:

  • 最小权限原则:给角色配置的权限是其完成任务所需要的最小权限集合。一个角色可能会对应很多权限,但是要满足其权限集合最小。
  • 责任分离:通过相互独立互斥的角色来共同完成任务。角色之间在业务性上也会相互分离。
  • 数据抽象:RBAC支持的数据抽象度与RBAC的实现细节有关,通过权限的抽象来体现。

3.基于RBAC模型的表设计

一般建立权限关系需要有5张表,这里分别设计为tb_user、tb_role、tb_permission、tb_role_permission、tb_user_role分别对应着用户表、角色表、权限表、用户角色关系表、角色权限关系表,其中的角色权限关系表和用户角色关系表就建立起了角色以及多权限、多用户和角色之间的关系,角色和权限关系表通过role_id和permission_id绑定,用户角色关系表通过user_id 和 role_id绑定。
在这里插入图片描述

DROP TABLE IF EXISTS `tb_permission`;
CREATE TABLE `tb_permission`
(`id`         bigint(64)  NOT NULL COMMENT '主键',`name`       varchar(50) NOT NULL COMMENT '权限名',`url`        varchar(1000) DEFAULT NULL COMMENT '类型为页面时,代表前端路由地址,类型为按钮时,代表后端接口地址',`type`       int(2)      NOT NULL COMMENT '权限类型,页面-1,按钮-2',`permission` varchar(50)   DEFAULT NULL COMMENT '权限表达式',`method`     varchar(50)   DEFAULT NULL COMMENT '后端接口访问方式',`sort`       int(11)     NOT NULL COMMENT '排序',`parent_id`  bigint(64)  NOT NULL COMMENT '父级id',PRIMARY KEY (`id`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8 COMMENT ='权限表';BEGIN;
INSERT INTO `tb_permission`
VALUES (1072806379288399872, '测试页面', '/test', 1, 'page:test', NULL, 1, 0);
INSERT INTO `tb_permission`
VALUES (1072806379313565696, '测试页面-查询', '/**/test', 2, 'btn:test:query', 'GET', 1, 1072806379288399872);
INSERT INTO `tb_permission`
VALUES (1072806379330342912, '测试页面-添加', '/**/test', 2, 'btn:test:insert', 'POST', 2, 1072806379288399872);
INSERT INTO `tb_permission`
VALUES (1072806379342925824, '监控在线用户页面', '/monitor', 1, 'page:monitor:online', NULL, 2, 0);
INSERT INTO `tb_permission`
VALUES (1072806379363897344, '在线用户页面-查询', '/**/api/monitor/online/user', 2, 'btn:monitor:online:query', 'GET', 1,1072806379342925824);
INSERT INTO `tb_permission`
VALUES (1072806379384868864, '在线用户页面-踢出', '/**/api/monitor/online/user/kickout', 2, 'btn:monitor:online:kickout','DELETE', 2, 1072806379342925824);
COMMIT;DROP TABLE IF EXISTS `tb_role`;
CREATE TABLE `tb_role`
(`id`          bigint(64)  NOT NULL COMMENT '主键',`name`        varchar(50) NOT NULL COMMENT '角色名',`description` varchar(100) DEFAULT NULL COMMENT '描述',`create_time` bigint(13)  NOT NULL COMMENT '创建时间',`update_time` bigint(13)  NOT NULL COMMENT '更新时间',PRIMARY KEY (`id`),UNIQUE KEY `name` (`name`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8 COMMENT ='角色表';BEGIN;
INSERT INTO `tb_role`
VALUES (1072806379208708096, '管理员', '超级管理员', 1544611947239, 1544611947239);
INSERT INTO `tb_role`
VALUES (1072806379238068224, '普通用户', '普通用户', 1544611947246, 1544611947246);
COMMIT;DROP TABLE IF EXISTS `tb_role_permission`;
CREATE TABLE `tb_role_permission`
(`role_id`       bigint(64) NOT NULL COMMENT '角色主键',`permission_id` bigint(64) NOT NULL COMMENT '权限主键',PRIMARY KEY (`role_id`, `permission_id`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8 COMMENT ='角色权限关系表';BEGIN;
INSERT INTO `tb_role_permission`
VALUES (1072806379208708096, 1072806379288399872);
INSERT INTO `tb_role_permission`
VALUES (1072806379208708096, 1072806379313565696);
INSERT INTO `tb_role_permission`
VALUES (1072806379208708096, 1072806379330342912);
INSERT INTO `tb_role_permission`
VALUES (1072806379208708096, 1072806379342925824);
INSERT INTO `tb_role_permission`
VALUES (1072806379208708096, 1072806379363897344);
INSERT INTO `tb_role_permission`
VALUES (1072806379208708096, 1072806379384868864);
INSERT INTO `tb_role_permission`
VALUES (1072806379238068224, 1072806379288399872);
INSERT INTO `tb_role_permission`
VALUES (1072806379238068224, 1072806379313565696);
COMMIT;DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user`
(`id`          bigint(64)  NOT NULL COMMENT '主键',`username`    varchar(50) NOT NULL COMMENT '用户名',`password`    varchar(60) NOT NULL COMMENT '密码',`nickname`    varchar(255)         DEFAULT NULL COMMENT '昵称',`phone`       varchar(11)          DEFAULT NULL COMMENT '手机',`email`       varchar(50)          DEFAULT NULL COMMENT '邮箱',`birthday`    bigint(13)           DEFAULT NULL COMMENT '生日',`sex`         int(2)               DEFAULT NULL COMMENT '性别,男-1,女-2',`status`      int(2)      NOT NULL DEFAULT '1' COMMENT '状态,启用-1,禁用-0',`create_time` bigint(13)  NOT NULL COMMENT '创建时间',`update_time` bigint(13)  NOT NULL COMMENT '更新时间',PRIMARY KEY (`id`),UNIQUE KEY `username` (`username`),UNIQUE KEY `phone` (`phone`),UNIQUE KEY `email` (`email`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8 COMMENT ='用户表';BEGIN;
INSERT INTO `tb_user`
VALUES (1072806377661009920, 'admin', '$2a$10$64iuSLkKNhpTN19jGHs7xePvFsub7ZCcCmBqEYw8fbACGTE3XetYq', '管理员','17300000000', 'admin@picacho.com', 785433600000, 1, 1, 1544611947032, 1544611947032);
INSERT INTO `tb_user`
VALUES (1072806378780889088, 'user', '$2a$10$OUDl4thpcHfs7WZ1kMUOb.ZO5eD4QANW5E.cexBLiKDIzDNt87QbO', '普通用户','17300001111', 'user@picacho.com', 785433600000, 1, 1, 1544611947234, 1544611947234);
COMMIT;DROP TABLE IF EXISTS `tb_user_role`;
CREATE TABLE `tb_user_role`
(`user_id` bigint(64) NOT NULL COMMENT '用户主键',`role_id` bigint(64) NOT NULL COMMENT '角色主键',PRIMARY KEY (`user_id`, `role_id`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8 COMMENT ='用户角色关系表';BEGIN;
INSERT INTO `tb_user_role`
VALUES (1072806377661009920, 1072806379208708096);
INSERT INTO `tb_user_role`
VALUES (1072806378780889088, 1072806379238068224);
COMMIT;

这里预设了两个用户,一个是普通用户,一个是管理员;他们的角色关系表通过id关联起来在 tb_user_role表中,同时tb_role_permission角色权限关系表,角色和权限之间的关联也是通过id关联起来的。

为了保护后台的Api,对某些Api会进行相应的验证才可以访问,有些Api我们可以设置成需要验证用户,需要某些角色,要具有一定的权限才可以访问的。对于每个后台授权相关 Api的时候,需要验证才可以得以访问,验证访问的基础就是通过RBAC模型的思想来建立用户-角色-权限之间的关联。

4.JWT 安全认证

JWT即Json WEB Token的简称,是一种采用Json的方式安全传输信息交互的方式。 JWT 最大的一个特点就是存取用户的信息在客户端,它属于是一种认证协议,主要应用在前后端分离的项目中,针对于对后台的Api保护时使用。

在JWT的认证过程中,它的验证消息主要包含三个主要的部分,头部(header)、载荷(payload)、签名(signature)。头部用于保存用户的一些基本的信息,比如其类型和签名所用的算法,而在载荷这一部分主要保存是一些不太敏感核心的数据信息,比如 JWT的签发者,以及其面向的客户是哪些等。头部、载荷、签名都是JSON字符串的格式信息,在前两部分完成了拼接之后,第三部分的签名算法就会对这一部分的信息内容进行加密处理,加密的时候会提供一个密钥,加密后呈现的结果是一串字符串,最后的加密的这个字符串就是签名,我们把这个签名拼接在上面已经拼接好的字符串后面就可以得到一个完整的JWT,篡改者想要修改头和载荷部分的话必须得知道密钥是什么,也没有办法生成新的签名,那么在服务端这边就无法验证通过,在JWT中,消息体是透明的,但是使用签名可以保证消息不被篡改,这也是验证过程中,这个认证安全协议的一个很重要的特点。

JWT 的主要工作流程

  • 用户使用username和password来初始请求login接口,即向服务器发起登录请求。
  • 服务器验证是否为合法的用户信息。
  • 服务器验证通过后会发给客户端用户一个token消息体。
  • 客户端存储由服务器返回的token,并且在后面每次的访问时都加上这一个token的值。
  • 服务器在之后的每次请求都会验证这个token,并且返回响应的数据信息。这个token必须在每次请求的时候传递给服务器,它应该保存在每次的请求头中。

5.实战练习

5.1 创建项目添加依赖

创建一个初始的Spring Boot项目,添加上必要依赖即可。

 UTF-8UTF-81.80.9.1org.springframework.bootspring-boot-starter-oauth2-client2.1.0.RELEASEorg.springframework.bootspring-boot-starter-oauth2-resource-server2.1.0.RELEASEorg.springframework.bootspring-boot-starter-weborg.springframework.bootspring-boot-starter-securityorg.springframework.bootspring-boot-starter-data-jpaorg.springframework.bootspring-boot-starter-data-redisorg.apache.commonscommons-pool2org.springframework.bootspring-boot-configuration-processortrueio.jsonwebtokenjjwt${jjwt.veersion}mysqlmysql-connector-javaorg.springframework.bootspring-boot-starter-testtestcn.hutoolhutool-all5.4.5com.google.guavaguava29.0-jreorg.projectlomboklomboktrue

5.2 配置主配置文件

这里主要配置mysql数据库连接信息;jpa配置信息,用于操作持久层;redis数据库连接信息与配置信息;jwt配置信息;配置一些需要绕过校验的客户端发起的请求,比如 /api/auth/login , /api/auth/logout 和 /test/* 请求。

server:port: 8080spring:datasource:hikari:username: rootpassword: 12345678driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://127.0.0.1:3306/spring-security?useUnicode=true&characterEncoding=UTF-8&useSSL=false&autoReconnect=true&failOverReadOnly=false&serverTimezone=GMT%2B8jpa:show-sql: truegenerate-ddl: falsehibernate:ddl-auto: validateopen-in-view: trueproperties:hibernate:dialect: org.hibernate.dialect.MySQL57InnoDBDialectresources:add-mappings: falsemvc:throw-exception-if-no-handler-found: trueredis:host: localhostport: 6379# 连接超时时间(记得添加单位,Duration)timeout: 10000ms# Redis默认情况下有16个分片,这里配置具体使用的分片# database: 0lettuce:pool:# 连接池最大连接数(使用负值表示没有限制) 默认 8max-active: 8# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1max-wait: -1ms# 连接池中的最大空闲连接 默认 8max-idle: 8# 连接池中的最小空闲连接 默认 0min-idle: 0
jwt:config:key: picachottl: 600000remember: 604800000
logging:level:com.example.rbac.security: debug
custom:config:ignores:# 需要过滤的 post 请求post:- "/api/auth/login"- "/api/auth/logout"# 需要过滤的请求,不限方法pattern:- "/test/*"

5.3 创建统一使用的公共类

创建common包,在该包下创建公共类。
Consts.java

public interface Consts {/*** 启用*/Integer ENABLE = 1;/*** 禁用*/Integer DISABLE = 0;/*** 页面*/Integer PAGE = 1;/*** 按钮*/Integer BUTTON = 2;/*** JWT 在 Redis 中保存的key前缀*/String REDIS_JWT_KEY_PREFIX = "security:jwt:";/*** 星号*/String SYMBOL_STAR = "*";/*** 邮箱符号*/String SYMBOL_EMAIL = "@";/*** 默认当前页码*/Integer DEFAULT_CURRENT_PAGE = 1;/*** 默认每页条数*/Integer DEFAULT_PAGE_SIZE = 10;/*** 匿名用户 用户名*/String ANONYMOUS_NAME = "匿名用户";
}

IStatus.java

public interface IStatus {/*** 状态码** @return 状态码*/Integer getCode();/*** 返回信息** @return 返回信息*/String getMessage();
}

Status.java

@Getter
public enum Status implements IStatus {/*** 操作成功!*/SUCCESS(200, "操作成功!"),/*** 操作异常!*/ERROR(500, "操作异常!"),/*** 退出成功!*/LOGOUT(200, "退出成功!"),/*** 请先登录!*/UNAUTHORIZED(401, "请先登录!"),/*** 暂无权限访问!*/ACCESS_DENIED(403, "权限不足!"),/*** 请求不存在!*/REQUEST_NOT_FOUND(404, "请求不存在!"),/*** 请求方式不支持!*/HTTP_BAD_METHOD(405, "请求方式不支持!"),/*** 请求异常!*/BAD_REQUEST(400, "请求异常!"),/*** 参数不匹配!*/PARAM_NOT_MATCH(400, "参数不匹配!"),/*** 参数不能为空!*/PARAM_NOT_NULL(400, "参数不能为空!"),/*** 当前用户已被锁定,请联系管理员解锁!*/USER_DISABLED(403, "当前用户已被锁定,请联系管理员解锁!"),/*** 用户名或密码错误!*/USERNAME_PASSWORD_ERROR(5001, "用户名或密码错误!"),/*** token 已过期,请重新登录!*/TOKEN_EXPIRED(5002, "token 已过期,请重新登录!"),/*** token 解析失败,请尝试重新登录!*/TOKEN_PARSE_ERROR(5002, "token 解析失败,请尝试重新登录!"),/*** 当前用户已在别处登录,请尝试更改密码或重新登录!*/TOKEN_OUT_OF_CTRL(5003, "当前用户已在别处登录,请尝试更改密码或重新登录!"),/*** 无法手动踢出自己,请尝试退出登录操作!*/KICKOUT_SELF(5004, "无法手动踢出自己,请尝试退出登录操作!");/*** 状态码*/private Integer code;/*** 返回信息*/private String message;Status(Integer code, String message) {this.code = code;this.message = message;}public static Status fromCode(Integer code) {Status[] statuses = Status.values();for (Status status : statuses) {if (status.getCode().equals(code)) {return status;}}return SUCCESS;}@Overridepublic String toString() {return String.format(" Status:{code=%s, message=%s} ", getCode(), getMessage());}}

BaseException.java

@EqualsAndHashCode(callSuper = true)
@Data
public class BaseException extends RuntimeException {private Integer code;private String message;private Object data;public BaseException(Status status) {super(status.getMessage());this.code = status.getCode();this.message = status.getMessage();}public BaseException(Status status, Object data) {this(status);this.data = data;}public BaseException(Integer code, String message) {super(message);this.code = code;this.message = message;}public BaseException(Integer code, String message, Object data) {this(code, message);this.data = data;}
}

Idconfig.java

@Configuration
public class IdConfig {/*** 雪花生成器*/@Beanpublic Snowflake snowflake() {return IdUtil.createSnowflake(1, 1);}
}

LoginRequest.java

@Data
public class LoginRequest {/*** 用户名或邮箱或手机号*/@NotBlank(message = "用户名不能为空")private String usernameOrEmailOrPhone;/*** 密码*/@NotBlank(message = "密码不能为空")private String password;/*** 记住我*/private Boolean rememberMe = false;}

PageCondition.java

@Data
public class PageCondition {/*** 当前页码*/private Integer currentPage;/*** 每页条数*/private Integer pageSize;}

PageResult.java

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult implements Serializable {private static final long serialVersionUID = 3420391142991247367L;/*** 当前页数据*/private List rows;/*** 总条数*/private Long total;public static  PageResult of(List rows, Long total) {return new PageResult<>(rows, total);}
}

ApiResponse.java

@Data
public class ApiResponse implements Serializable {private static final long serialVersionUID = 8993485788201922830L;/*** 状态码*/private Integer code;/*** 返回内容*/private String message;/*** 返回数据*/private Object data;/*** 无参构造函数*/private ApiResponse() {}/*** 全参构造函数** @param code    状态码* @param message 返回内容* @param data    返回数据*/private ApiResponse(Integer code, String message, Object data) {this.code = code;this.message = message;this.data = data;}/*** 构造一个自定义的API返回** @param code    状态码* @param message 返回内容* @param data    返回数据* @return ApiResponse*/public static ApiResponse of(Integer code, String message, Object data) {return new ApiResponse(code, message, data);}/*** 构造一个成功且不带数据的API返回** @return ApiResponse*/public static ApiResponse ofSuccess() {return ofSuccess(null);}/*** 构造一个成功且带数据的API返回** @param data 返回数据* @return ApiResponse*/public static ApiResponse ofSuccess(Object data) {return ofStatus(Status.SUCCESS, data);}/*** 构造一个成功且自定义消息的API返回** @param message 返回内容* @return ApiResponse*/public static ApiResponse ofMessage(String message) {return of(Status.SUCCESS.getCode(), message, null);}/*** 构造一个有状态的API返回** @param status 状态 {@link Status}* @return ApiResponse*/public static ApiResponse ofStatus(Status status) {return ofStatus(status, null);}/*** 构造一个有状态且带数据的API返回** @param status 状态 {@link IStatus}* @param data   返回数据* @return ApiResponse*/public static ApiResponse ofStatus(IStatus status, Object data) {return of(status.getCode(), status.getMessage(), data);}/*** 构造一个异常的API返回** @param t   异常* @param  {@link BaseException} 的子类* @return ApiResponse*/public static  ApiResponse ofException(T t) {return of(t.getCode(), t.getMessage(), t.getData());}
}

5.4 创建实体类

创建entity包,在该包下创建实体类。
User.java

@Data
@Entity
@Table(name = "tb_user")
public class User {@Idprivate Long id;private String username;private String password;private String nickname;private String phone;private String email;private Long birthday;private Integer sex;private Integer status;@Column(name = "create_time")private Long createTime;@Column(name = "update_time")private Long updateTime;
}

Role.java

@Data
@Entity
@Table(name = "tb_role")
public class Role {@Idprivate Long id;private String name;private String description;@Column(name = "create_time")private Long createTime;@Column(name = "update_time")private Long updateTime;
}

Permission.java

@Data
@Entity
@Table(name = "tb_permission")
public class Permission {@Idprivate Long id;private String name;private String url;private Integer type;private String permission;private String method;private Integer sort;@Column(name = "parent_id")private Long parentId;
}

当一个实体类要在多个不同的实体类中进行使用,而本身又不需要独立生成一个数据库表,这就是需要使用@Embedded、@Embeddable的时候了。

创建unionkey包,在该包下创建UserRoleKey.java与RolePermissionKey.java

@Embeddable
@Data
public class UserRoleKey implements Serializable {private static final long serialVersionUID = 5633412144183654743L;/*** 用户id*/@Column(name = "user_id")private Long userId;/*** 角色id*/@Column(name = "role_id")private Long roleId;
}@Data
@Embeddable
public class RolePermissionKey implements Serializable {private static final long serialVersionUID = 6850974328279713855L;/*** 角色id*/@Column(name = "role_id")private Long roleId;/*** 权限id*/@Column(name = "permission_id")private Long permissionId;
}

UserRole.java

@Data
@Entity
@Table(name = "tb_user_role")
public class UserRole {/*** 主键*/@EmbeddedIdprivate UserRoleKey id;
}

RolePermission.java

@Data
@Entity
@Table(name = "tb_role_permission")
public class RolePermission {/*** 主键*/@EmbeddedIdprivate RolePermissionKey id;
}

5.5 创建UserPrincipal类

在使用Spring Security时,该类需要实现UserDetails,用于从数据库获取用户信息,进而进行身份认证等。创建vo包,在该包下创建UserPrincipal.java

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserPrincipal implements UserDetails {/*** 主键*/private Long id;/*** 用户名*/private String username;/*** 密码*/@JsonIgnoreprivate String password;/*** 昵称*/private String nickname;/*** 手机*/private String phone;/*** 邮箱*/private String email;/*** 生日*/private Long birthday;/*** 性别,男-1,女-2*/private Integer sex;/*** 状态,启用-1,禁用-0*/private Integer status;/*** 创建时间*/private Long createTime;/*** 更新时间*/private Long updateTime;/*** 用户角色列表*/private List roles;/*** 用户权限列表*/private Collection authorities;public static UserPrincipal create(User user, List roles, List permissions) {List roleNames = roles.stream().map(Role::getName).collect(Collectors.toList());List authorities = permissions.stream().filter(permission -> StrUtil.isNotBlank(permission.getPermission())).map(permission -> new SimpleGrantedAuthority(permission.getPermission())).collect(Collectors.toList());return new UserPrincipal(user.getId(), user.getUsername(), user.getPassword(), user.getNickname(), user.getPhone(), user.getEmail(), user.getBirthday(), user.getSex(), user.getStatus(), user.getCreateTime(), user.getUpdateTime(), roleNames, authorities);}@Overridepublic Collection getAuthorities() {return authorities;}@Overridepublic String getPassword() {return password;}@Overridepublic String getUsername() {return username;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return Objects.equals(this.status, Consts.ENABLE);}
}

create()方法中通过传入用户基本信息和角色权限信息创建UserPrincipal对象,后面可以通过此对象获取登陆用户信息,进而进行用户认证。

5.6 创建异常处理类

创建exception包,在该包下创建SecurityException.java封装异常信息。
SecurityException.java

@EqualsAndHashCode(callSuper = true)
@Data
public class SecurityException extends BaseException {public SecurityException(Status status) {super(status);}public SecurityException(Status status, Object data) {super(status, data);}public SecurityException(Integer code, String message) {super(code, message);}public SecurityException(Integer code, String message, Object data) {super(code, message, data);}
}

5.7 JWT相关实现

首先需要创建创建JWT配置类。创建config包在该包下创建JwtConfig.java,这个类中主要包含jwt的一些可配置属性;@ConfigurationProperties(prefix = “jwt.config”)注解表明配置文件中可配置属性的前缀名。
JwtConfig.java

@ConfigurationProperties(prefix = "jwt.config")
@Data
public class JwtConfig {/*** jwt 加密 key,默认值:picacho.*/private String key = "picacho";/*** jwt 过期时间,默认值:600000 {@code 10 分钟}.*/private Long ttl = 600000L;/*** 开启 记住我 之后 jwt 过期时间,默认值 604800000 {@code 7 天}*/private Long remember = 604800000L;
}

我们可以对申请访问的请求进行拦截和过滤校验,对于允许访问的请求我们会通过一系列的处理在最后环节通过算法加签返回给客户端,即生成一个唯一标识在客户端保存,以后每次请求客户端都要带着这个认证标识来访问服务器后台api。这里需要创建一个类来封装响应结果。

创建vo包,在该包下创建JwtResponse.java。

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@NoArgsConstructor
@AllArgsConstructor
public class JwtResponse {/*** token 字段*/private String token;/*** token类型*/private String tokenType = "picacho";public JwtResponse(String token) {this.token = token;}
}

接着创建util包,在该包下创建操作JWT的相关工具类JwtUtil.java

@EnableConfigurationProperties(JwtConfig.class)
@Configuration
@Slf4j
public class JwtUtil {@Autowiredprivate JwtConfig jwtConfig;@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 创建JWT** @param rememberMe  记住我* @param id          用户id* @param subject     用户名* @param roles       用户角色* @param authorities 用户权限* @return JWT*/public String createJWT(Boolean rememberMe, Long id, String subject, List roles, Collection authorities) {Date now = new Date();JwtBuilder builder = Jwts.builder().setId(id.toString()).setSubject(subject).setIssuedAt(now).signWith(SignatureAlgorithm.HS256, jwtConfig.getKey()).claim("roles", roles).claim("authorities", authorities);// 设置过期时间Long ttl = rememberMe ? jwtConfig.getRemember() : jwtConfig.getTtl();if (ttl > 0) {builder.setExpiration(DateUtil.offsetMillisecond(now, ttl.intValue()));}String jwt = builder.compact();// 将生成的JWT保存至RedisstringRedisTemplate.opsForValue().set(Consts.REDIS_JWT_KEY_PREFIX + subject, jwt, ttl, TimeUnit.MILLISECONDS);return jwt;}/*** 创建JWT** @param authentication 用户认证信息* @param rememberMe     记住我* @return JWT*/public String createJWT(Authentication authentication, Boolean rememberMe) {UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();return createJWT(rememberMe, userPrincipal.getId(), userPrincipal.getUsername(), userPrincipal.getRoles(), userPrincipal.getAuthorities());}/*** 解析JWT** @param jwt JWT* @return {@link Claims}*/public Claims parseJWT(String jwt) {try {Claims claims = Jwts.parser().setSigningKey(jwtConfig.getKey()).parseClaimsJws(jwt).getBody();String username = claims.getSubject();String redisKey = Consts.REDIS_JWT_KEY_PREFIX + username;// 校验redis中的JWT是否存在Long expire = stringRedisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS);if (Objects.isNull(expire) || expire <= 0) {throw new SecurityException(Status.TOKEN_EXPIRED);}// 校验redis中的JWT是否与当前的一致,不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期String redisToken = stringRedisTemplate.opsForValue().get(redisKey);if (!StrUtil.equals(jwt, redisToken)) {throw new SecurityException(Status.TOKEN_OUT_OF_CTRL);}return claims;} catch (ExpiredJwtException e) {log.error("Token 已过期");throw new SecurityException(Status.TOKEN_EXPIRED);} catch (UnsupportedJwtException e) {log.error("不支持的 Token");throw new SecurityException(Status.TOKEN_PARSE_ERROR);} catch (MalformedJwtException e) {log.error("Token 无效");throw new SecurityException(Status.TOKEN_PARSE_ERROR);} catch (SignatureException e) {log.error("无效的 Token 签名");throw new SecurityException(Status.TOKEN_PARSE_ERROR);} catch (IllegalArgumentException e) {log.error("Token 参数不存在");throw new SecurityException(Status.TOKEN_PARSE_ERROR);}}/*** 设置JWT过期** @param request 请求*/public void invalidateJWT(HttpServletRequest request) {String jwt = getJwtFromRequest(request);String username = getUsernameFromJWT(jwt);// 从redis中清除JWTstringRedisTemplate.delete(Consts.REDIS_JWT_KEY_PREFIX + username);}/*** 根据 jwt 获取用户名** @param jwt JWT* @return 用户名*/public String getUsernameFromJWT(String jwt) {Claims claims = parseJWT(jwt);return claims.getSubject();}/*** 从 request 的 header 中获取 JWT** @param request 请求* @return JWT*/public String getJwtFromRequest(HttpServletRequest request) {String bearerToken = request.getHeader("Authorization");if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("picacho ")) {return bearerToken.substring(8);}return null;}
}

这里通过 @EnableConfigurationProperties 这个注解将 JwtConfig.class 注入到 IOC 容器中。然后实现创建、解析和设置 JWT 过期等方法,JwtUtil 工具类的作用主要就是生成 JWT 并存入 Redis、解析 JWT 并校验其准确性、从 Request 的 Header 中获取到 JWT等。

接着在安全认证环节中,需要对请求做一个拦截和过滤,在 config 包下面新建认证过滤器,JwtAuthenticationFilter.java是认证过滤器。不是所有的请求都会被认证,在此之前需要设置忽略的请求方式及URL等。

在config包下创建IgnoreConfig.java

@Data
public class IgnoreConfig {/*** 需要忽略的 URL 格式,不考虑请求方法*/private List pattern = Lists.newArrayList();/*** 需要忽略的 GET 请求*/private List get = Lists.newArrayList();/*** 需要忽略的 POST 请求*/private List post = Lists.newArrayList();/*** 需要忽略的 DELETE 请求*/private List delete = Lists.newArrayList();/*** 需要忽略的 PUT 请求*/private List put = Lists.newArrayList();/*** 需要忽略的 HEAD 请求*/private List head = Lists.newArrayList();/*** 需要忽略的 PATCH 请求*/private List patch = Lists.newArrayList();/*** 需要忽略的 OPTIONS 请求*/private List options = Lists.newArrayList();/*** 需要忽略的 TRACE 请求*/private List trace = Lists.newArrayList();
}

CustomConfig.java

@ConfigurationProperties(prefix = "custom.config")
@Data
public class CustomConfig {/*** 不需要拦截的地址*/private IgnoreConfig ignores;
}

添加了@ConfigurationProperties(prefix = “custom.config”)注解,允许我们在主配置文件中进行过滤请求的配置。

创建service包,在该包下创建CustomUserDetailsService.java,获取UserDetails,这也是用户认证的关键。
CustomUserDetailsService.java

@Service
public class CustomUserDetailsService implements UserDetailsService {@Autowiredprivate UserDao userDao;@Autowiredprivate RoleDao roleDao;@Autowiredprivate PermissionDao permissionDao;@Overridepublic UserDetails loadUserByUsername(String usernameOrEmailOrPhone) throws UsernameNotFoundException {User user = userDao.findByUsernameOrEmailOrPhone(usernameOrEmailOrPhone, usernameOrEmailOrPhone, usernameOrEmailOrPhone).orElseThrow(() -> new UsernameNotFoundException("未找到用户信息 : " + usernameOrEmailOrPhone));List roles = roleDao.selectByUserId(user.getId());List roleIds = roles.stream().map(Role::getId).collect(Collectors.toList());List permissions = permissionDao.selectByRoleIdList(roleIds);return UserPrincipal.create(user, roles, permissions);}
}

创建dao包,在该包下分别创建对应的dao。
UserDao

public interface UserDao extends JpaRepository, JpaSpecificationExecutor {/*** 根据用户名、邮箱、手机号查询用户** @param username 用户名* @param email    邮箱* @param phone    手机号* @return 用户信息*/Optional findByUsernameOrEmailOrPhone(String username, String email, String phone);/*** 根据用户名列表查询用户列表** @param usernameList 用户名列表* @return 用户列表*/List findByUsernameIn(List usernameList);
}

roleDao.java

public interface RoleDao extends JpaRepository, JpaSpecificationExecutor {/*** 根据用户id 查询角色列表** @param userId 用户id* @return 角色列表*/@Query(value = "SELECT tb_role.* FROM tb_role,tb_user,tb_user_role WHERE tb_user.id = tb_user_role.user_id AND tb_role.id = tb_user_role.role_id AND tb_user.id = :userId", nativeQuery = true)List selectByUserId(@Param("userId") Long userId);
}

permissionDao.java

public interface PermissionDao extends JpaRepository, JpaSpecificationExecutor {/*** 根据角色列表查询权限列表** @param ids 角色id列表* @return 权限列表*/@Query(value = "SELECT DISTINCT tb_permission.* FROM tb_permission,tb_role,tb_role_permission WHERE tb_role.id = tb_role_permission.role_id AND tb_permission.id = tb_role_permission.permission_id AND tb_role.id IN (:ids)", nativeQuery = true)List selectByRoleIdList(@Param("ids") List ids);
}

最后实现JwtAuthenticationFilter过滤器。
JwtAuthenticationFilter.java

@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {@Autowiredprivate CustomUserDetailsService customUserDetailsService;@Autowiredprivate JwtUtil jwtUtil;@Autowiredprivate CustomConfig customConfig;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {if (checkIgnores(request)) {filterChain.doFilter(request, response);return;}String jwt = jwtUtil.getJwtFromRequest(request);if (StrUtil.isNotBlank(jwt)) {try {String username = jwtUtil.getUsernameFromJWT(jwt);UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authentication);filterChain.doFilter(request, response);} catch (SecurityException e) {ResponseUtil.renderJson(response, e);}} else {ResponseUtil.renderJson(response, Status.UNAUTHORIZED, null);}}/*** 请求是否不需要进行权限拦截** @param request 当前请求* @return true - 忽略,false - 不忽略*/private boolean checkIgnores(HttpServletRequest request) {String method = request.getMethod();HttpMethod httpMethod = HttpMethod.resolve(method);if (ObjectUtil.isNull(httpMethod)) {httpMethod = HttpMethod.GET;}Set ignores = Sets.newHashSet();switch (httpMethod) {case GET:ignores.addAll(customConfig.getIgnores().getGet());break;case PUT:ignores.addAll(customConfig.getIgnores().getPut());break;case HEAD:ignores.addAll(customConfig.getIgnores().getHead());break;case POST:ignores.addAll(customConfig.getIgnores().getPost());break;case PATCH:ignores.addAll(customConfig.getIgnores().getPatch());break;case TRACE:ignores.addAll(customConfig.getIgnores().getTrace());break;case DELETE:ignores.addAll(customConfig.getIgnores().getDelete());break;case OPTIONS:ignores.addAll(customConfig.getIgnores().getOptions());break;default:break;}ignores.addAll(customConfig.getIgnores().getPattern());if (CollUtil.isNotEmpty(ignores)) {for (String ignore : ignores) {AntPathRequestMatcher matcher = new AntPathRequestMatcher(ignore, method);if (matcher.matches(request)) {return true;}}}return false;}
}

doFilterInternal是OncePerRequestFilter中的执行过滤的方法。checkIgnores方法首先会对请求进行一个判断,看是否是不需要拦截的请求,因为我们拦截下来的请求会进行一个权限验证。调用JwtUtil的getJwtFromRequest方法我们可以从客户端的请求中获取到JWT 字符串,如果JWT字符串非空的话,我们就可以从中获取到很多细节信息,比如用户的详情等包含用户名、userDetails 等。

5.8 创建RbacAuthorityService类

RbacAuthorityService类主要的功能是校验每次客户端发起的请求的合法性,根据当前请求路径与该用户的可访问的资源做匹配,通过则可以访问,否则将不允许被访问,这属于一个路由动态鉴权类。

RbacAuthorityService.java

@Component
public class RbacAuthorityService {@Autowiredprivate RoleDao roleDao;@Autowiredprivate PermissionDao permissionDao;@Autowiredprivate RequestMappingHandlerMapping mapping;public boolean hasPermission(HttpServletRequest request, Authentication authentication) {checkRequest(request);Object userInfo = authentication.getPrincipal();boolean hasPermission = false;if (userInfo instanceof UserDetails) {UserPrincipal principal = (UserPrincipal) userInfo;Long userId = principal.getId();List roles = roleDao.selectByUserId(userId);List roleIds = roles.stream().map(Role::getId).collect(Collectors.toList());List permissions = permissionDao.selectByRoleIdList(roleIds);//获取资源,前后端分离,所以过滤页面权限,只保留按钮权限List btnPerms = permissions.stream()// 过滤页面权限.filter(permission -> Objects.equals(permission.getType(), Consts.BUTTON))// 过滤 URL 为空.filter(permission -> StrUtil.isNotBlank(permission.getUrl()))// 过滤 METHOD 为空.filter(permission -> StrUtil.isNotBlank(permission.getMethod())).collect(Collectors.toList());for (Permission btnPerm : btnPerms) {AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(btnPerm.getUrl(), btnPerm.getMethod());if (antPathMatcher.matches(request)) {hasPermission = true;break;}}return hasPermission;} else {return false;}}/*** 校验请求是否存在** @param request 请求*/private void checkRequest(HttpServletRequest request) {// 获取当前 request 的方法String currentMethod = request.getMethod();Multimap urlMapping = allUrlMapping();for (String uri : urlMapping.keySet()) {// 通过 AntPathRequestMatcher 匹配 url// 可以通过 2 种方式创建 AntPathRequestMatcher// 1:new AntPathRequestMatcher(uri,method) 这种方式可以直接判断方法是否匹配,因为这里我们把 方法不匹配 自定义抛出,所以,我们使用第2种方式创建// 2:new AntPathRequestMatcher(uri) 这种方式不校验请求方法,只校验请求路径AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(uri);if (antPathMatcher.matches(request)) {if (!urlMapping.get(uri).contains(currentMethod)) {throw new SecurityException(Status.HTTP_BAD_METHOD);} else {return;}}}throw new SecurityException(Status.REQUEST_NOT_FOUND);}/*** 获取 所有URL Mapping,返回格式为{"/test":["GET","POST"],"/sys":["GET","DELETE"]}** @return {@link ArrayListMultimap} 格式的 URL Mapping*/private Multimap allUrlMapping() {Multimap urlMapping = ArrayListMultimap.create();// 获取url与类和方法的对应信息Map handlerMethods = mapping.getHandlerMethods();handlerMethods.forEach((k, v) -> {// 获取当前 key 下的获取所有URLSet url = k.getPatternsCondition().getPatterns();RequestMethodsRequestCondition method = k.getMethodsCondition();// 为每个URL添加所有的请求方法url.forEach(s -> urlMapping.putAll(s, method.getMethods().stream().map(Enum::toString).collect(Collectors.toList())));});return urlMapping;}
}

hasPermission 方法会进行一个是否具有权限的判断,首先会检查请求是否存在,获取所有的 URL 然后返回一个 Multimap 对象,在 if 判断里面三个 List 的语句,通过 userId 获取到 role 角色信息,然后通过 roleId 获取到 perimission 的信息。

5.9 配置SecurityConfig

SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(CustomConfig.class)
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate CustomConfig customConfig;@Autowiredprivate AccessDeniedHandler accessDeniedHandler;@Autowiredprivate CustomUserDetailsService customUserDetailsService;@Autowiredprivate JwtAuthenticationFilter jwtAuthenticationFilter;@Beanpublic BCryptPasswordEncoder encoder() {return new BCryptPasswordEncoder();}@Override@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(customUserDetailsService).passwordEncoder(encoder());}@Overrideprotected void configure(HttpSecurity http) throws Exception {// @formatter:offhttp.cors()// 关闭 CSRF.and().csrf().disable()// 登录行为由自己实现,参考 AuthController#login.formLogin().disable().httpBasic().disable()// 认证请求.authorizeRequests()// 所有请求都需要登录访问.anyRequest().authenticated()// RBAC 动态 url 认证.anyRequest().access("@rbacAuthorityService.hasPermission(request,authentication)")// 登出行为由自己实现,参考 AuthController#logout.and().logout().disable()// Session 管理.sessionManagement()// 因为使用了JWT,所以这里不管理Session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)// 异常处理.and().exceptionHandling().accessDeniedHandler(accessDeniedHandler);// @formatter:on// 添加自定义 JWT 过滤器http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);}/*** 放行所有不需要登录就可以访问的请求,参见 AuthController* 也可以在 {@link #configure(HttpSecurity)} 中配置* {@code http.authorizeRequests().antMatchers("/api/auth/**").permitAll()}*/@Overridepublic void configure(WebSecurity web) {WebSecurity and = web.ignoring().and();// 忽略 GETcustomConfig.getIgnores().getGet().forEach(url -> and.ignoring().antMatchers(HttpMethod.GET, url));// 忽略 POSTcustomConfig.getIgnores().getPost().forEach(url -> and.ignoring().antMatchers(HttpMethod.POST, url));// 忽略 DELETEcustomConfig.getIgnores().getDelete().forEach(url -> and.ignoring().antMatchers(HttpMethod.DELETE, url));// 忽略 PUTcustomConfig.getIgnores().getPut().forEach(url -> and.ignoring().antMatchers(HttpMethod.PUT, url));// 忽略 HEADcustomConfig.getIgnores().getHead().forEach(url -> and.ignoring().antMatchers(HttpMethod.HEAD, url));// 忽略 PATCHcustomConfig.getIgnores().getPatch().forEach(url -> and.ignoring().antMatchers(HttpMethod.PATCH, url));// 忽略 OPTIONScustomConfig.getIgnores().getOptions().forEach(url -> and.ignoring().antMatchers(HttpMethod.OPTIONS, url));// 忽略 TRACEcustomConfig.getIgnores().getTrace().forEach(url -> and.ignoring().antMatchers(HttpMethod.TRACE, url));// 按照请求格式忽略customConfig.getIgnores().getPattern().forEach(url -> and.ignoring().antMatchers(url));}
}

WebSecurityConfigurerAdapter 类是一个适配器,在配置的时候,需要我们自己写个配置类去继承他,然后编写自己所特殊需要的配置。里面重写的 configure 方法就是定制化的配置信息内容。每个模块配置使用 and 结尾,在这些配置字段信息中:

  • authorizeRequests()配置路径拦截,表明路径访问所对应的权限,角色,认证信息。
  • formLogin()对应表单认证相关的配置。
  • logout()对应了注销相关的配置。
  • httpBasic()可以配置 basic 登录。

5.10 创建SecurityHandlerConfig

通过这个类,我们自定义返回的响应的形式、内容,接着在这里需要自定义了一个 AccessDeniedHandler方法,把权限不足的情况返回的响应给客户端。
SecurityHandlerConfig.java

@Configuration
public class SecurityHandlerConfig {@Beanpublic AccessDeniedHandler accessDeniedHandler() {return (request, response, accessDeniedException) -> ResponseUtil.renderJson(response, Status.ACCESS_DENIED, null);}}

5.11 解决跨域问题

WebMvcConfig.java

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {private static final long MAX_AGE_SECS = 3600;@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOrigins("*").allowedMethods("HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE").maxAge(MAX_AGE_SECS);}
}

5.11 配置RedisConfig

RedisConfig.java

@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
@EnableCaching
public class RedisConfig {/*** 默认情况下的模板只能支持RedisTemplate,也就是只能存入字符串,因此支持序列化*/@Beanpublic RedisTemplate redisCacheTemplate(LettuceConnectionFactory redisConnectionFactory) {RedisTemplate template = new RedisTemplate<>();template.setKeySerializer(new StringRedisSerializer());template.setValueSerializer(new GenericJackson2JsonRedisSerializer());template.setConnectionFactory(redisConnectionFactory);return template;}
}

5.12 创建控制器

AuthController.java

@Slf4j
@RestController
@RequestMapping("/api/auth")
public class AuthController {@Autowiredprivate AuthenticationManager authenticationManager;@Autowiredprivate JwtUtil jwtUtil;/*** 登录*/@PostMapping("/login")public ApiResponse login(@Valid @RequestBody LoginRequest loginRequest) {Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsernameOrEmailOrPhone(), loginRequest.getPassword()));SecurityContextHolder.getContext().setAuthentication(authentication);String jwt = jwtUtil.createJWT(authentication,loginRequest.getRememberMe());return ApiResponse.ofSuccess(new JwtResponse(jwt));}@PostMapping("/logout")public ApiResponse logout(HttpServletRequest request) {try {// 设置JWT过期jwtUtil.invalidateJWT(request);} catch (SecurityException e) {throw new SecurityException(Status.UNAUTHORIZED);}return ApiResponse.ofStatus(Status.LOGOUT);}
}

在接口中我们注入了 Spring Security 中的认证令牌 AuthenticationManager,它获取认证请求作为参数,然后创建一个 JWT,最后返回是否成功的响应。

TestController.java

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {@GetMappingpublic ApiResponse list() {log.info("测试列表查询");return ApiResponse.ofMessage("测试列表查询");}@PostMappingpublic ApiResponse add() {log.info("测试列表添加");return ApiResponse.ofMessage("测试列表添加");}@PutMapping("/{id}")public ApiResponse update(@PathVariable Long id) {log.info("测试列表修改");return ApiResponse.ofSuccess("测试列表修改");}
}

5.13 开始测试

预留了两个用户,分别是 admin/123456 管理员和 user/123456 普通用户。启动项目后,使用postman进行登陆验证。
在这里插入图片描述
发送请求后会返回我们token字符串,我们就可以通过携带token字符串来完成后续的请求了。
在这里插入图片描述
同时也可以在redis中看到我们生成的token。
在这里插入图片描述
接下来我们先不带token,来访问test请求,出现下面情况。
在这里插入图片描述
接下来我们带上token,来访问test请求,就出现下面情况,访问成功了。
在这里插入图片描述
我们在前面的配置的 token 的过期时间是 10 分钟,当10分钟过去之后,再次带着这个 token去访问这个接口的时候,我们得到的响应结果如下。
在这里插入图片描述
同时redis中存储的token也会自动删除,再次查看就看不到了。
在这里插入图片描述
到这里这个练习就结束了,demo源码:demo下载地址

相关内容

热门资讯

监控摄像头接入GB28181平... 流程简介将监控摄像头的视频在网站和APP中直播,要解决的几个问题是:1&...
Windows10添加群晖磁盘... 在使用群晖NAS时,我们需要通过本地映射的方式把NAS映射成本地的一块磁盘使用。 通过...
protocol buffer... 目录 目录 什么是protocol buffer 1.protobuf 1.1安装  1.2使用...
在Word、WPS中插入AxM... 引言 我最近需要写一些文章,在排版时发现AxMath插入的公式竟然会导致行间距异常&#...
【PdgCntEditor】解... 一、问题背景 大部分的图书对应的PDF,目录中的页码并非PDF中直接索引的页码...
Fluent中创建监测点 1 概述某些仿真问题,需要创建监测点,用于获取空间定点的数据࿰...
educoder数据结构与算法...                                                   ...
MySQL下载和安装(Wind... 前言:刚换了一台电脑,里面所有东西都需要重新配置,习惯了所...
修复 爱普生 EPSON L4... L4151 L4153 L4156 L4158 L4163 L4165 L4166 L4168 L4...
MFC文件操作  MFC提供了一个文件操作的基类CFile,这个类提供了一个没有缓存的二进制格式的磁盘...