赞
踩
Spring Security的核心功能就是认证、授权、攻击防护,Spring Boot项目启动之后会自动进行配置,其核心就是一组链式过滤器。
如下图所示,对于一个用户请求,Username Password Authentication Filter验证用户名和密码是否正确,通过就放行,然后Basic Authentication Filter就实现了去验证请求中是否包含有权限认证的basic信息。
FilterSecurityInterceptor验证请求是否能够访问REST API,如果不能够访问即被拒绝了的话就会抛出不同类型的异常,这些异常由Exception Translation Filter来捕获。成功走完这条链式过滤器的请求才会返回成功的响应数据给客户端。
RBAC即Role-Based-Access-Control基于角色的访问控制,核心就是一个用户可以拥有若干的角色,每个角色拥有若干的权限。所以就会建立起用户-角色-权限之间的关系图,这就是一个授权模型。在这种关系模型中,用户和角色,角色和权限之间往往是多对多的关系。
一个用户拥有了不同的角色,不同的角色拥有不同的权限,只要拥有了某个角色就会实现某些功能,这也是权限管理及设计的关键所在。
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 = InnoDB DEFAULT 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 = InnoDB DEFAULT 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 = InnoDB DEFAULT 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 = InnoDB DEFAULT 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 = InnoDB DEFAULT 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模型的思想来建立用户-角色-权限之间的关联。
JWT即Json WEB Token的简称,是一种采用Json的方式安全传输信息交互的方式。 JWT 最大的一个特点就是存取用户的信息在客户端,它属于是一种认证协议,主要应用在前后端分离的项目中,针对于对后台的Api保护时使用。
在JWT的认证过程中,它的验证消息主要包含三个主要的部分,头部(header)、载荷(payload)、签名(signature)。头部用于保存用户的一些基本的信息,比如其类型和签名所用的算法,而在载荷这一部分主要保存是一些不太敏感核心的数据信息,比如 JWT的签发者,以及其面向的客户是哪些等。头部、载荷、签名都是JSON字符串的格式信息,在前两部分完成了拼接之后,第三部分的签名算法就会对这一部分的信息内容进行加密处理,加密的时候会提供一个密钥,加密后呈现的结果是一串字符串,最后的加密的这个字符串就是签名,我们把这个签名拼接在上面已经拼接好的字符串后面就可以得到一个完整的JWT,篡改者想要修改头和载荷部分的话必须得知道密钥是什么,也没有办法生成新的签名,那么在服务端这边就无法验证通过,在JWT中,消息体是透明的,但是使用签名可以保证消息不被篡改,这也是验证过程中,这个认证安全协议的一个很重要的特点。
JWT 的主要工作流程
创建一个初始的Spring Boot项目,添加上必要依赖即可。
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> <jjwt.veersion>0.9.1</jjwt.veersion> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> <version>2.1.0.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> <version>2.1.0.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 对象池,使用redis时必须引入 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>${jjwt.veersion}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.4.5</version> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>29.0-jre</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>
这里主要配置mysql数据库连接信息;jpa配置信息,用于操作持久层;redis数据库连接信息与配置信息;jwt配置信息;配置一些需要绕过校验的客户端发起的请求,比如 /api/auth/login , /api/auth/logout 和 /test/* 请求。
server: port: 8080 spring: datasource: hikari: username: root password: 12345678 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/spring-security?useUnicode=true&characterEncoding=UTF-8&useSSL=false&autoReconnect=true&failOverReadOnly=false&serverTimezone=GMT%2B8 jpa: show-sql: true generate-ddl: false hibernate: ddl-auto: validate open-in-view: true properties: hibernate: dialect: org.hibernate.dialect.MySQL57InnoDBDialect resources: add-mappings: false mvc: throw-exception-if-no-handler-found: true redis: host: localhost port: 6379 # 连接超时时间(记得添加单位,Duration) timeout: 10000ms # Redis默认情况下有16个分片,这里配置具体使用的分片 # database: 0 lettuce: pool: # 连接池最大连接数(使用负值表示没有限制) 默认 8 max-active: 8 # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1 max-wait: -1ms # 连接池中的最大空闲连接 默认 8 max-idle: 8 # 连接池中的最小空闲连接 默认 0 min-idle: 0 jwt: config: key: picacho ttl: 600000 remember: 604800000 logging: level: com.example.rbac.security: debug custom: config: ignores: # 需要过滤的 post 请求 post: - "/api/auth/login" - "/api/auth/logout" # 需要过滤的请求,不限方法 pattern: - "/test/*"
创建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; } @Override public 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 {
/**
* 雪花生成器
*/
@Bean
public 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<T> implements Serializable { private static final long serialVersionUID = 3420391142991247367L; /** * 当前页数据 */ private List<T> rows; /** * 总条数 */ private Long total; public static <T> PageResult of(List<T> 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 <T> {@link BaseException} 的子类 * @return ApiResponse */ public static <T extends BaseException> ApiResponse ofException(T t) { return of(t.getCode(), t.getMessage(), t.getData()); } }
创建entity包,在该包下创建实体类。
User.java
@Data @Entity @Table(name = "tb_user") public class User { @Id private 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 { @Id private 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 { @Id private 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 {
/**
* 主键
*/
@EmbeddedId
private UserRoleKey id;
}
RolePermission.java
@Data
@Entity
@Table(name = "tb_role_permission")
public class RolePermission {
/**
* 主键
*/
@EmbeddedId
private RolePermissionKey id;
}
在使用Spring Security时,该类需要实现UserDetails,用于从数据库获取用户信息,进而进行身份认证等。创建vo包,在该包下创建UserPrincipal.java
@Data @NoArgsConstructor @AllArgsConstructor public class UserPrincipal implements UserDetails { /** * 主键 */ private Long id; /** * 用户名 */ private String username; /** * 密码 */ @JsonIgnore private 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<String> roles; /** * 用户权限列表 */ private Collection<? extends GrantedAuthority> authorities; public static UserPrincipal create(User user, List<Role> roles, List<Permission> permissions) { List<String> roleNames = roles.stream() .map(Role::getName) .collect(Collectors.toList()); List<GrantedAuthority> 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); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return Objects.equals(this.status, Consts.ENABLE); } }
create()方法中通过传入用户基本信息和角色权限信息创建UserPrincipal对象,后面可以通过此对象获取登陆用户信息,进而进行用户认证。
创建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); } }
首先需要创建创建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 { @Autowired private JwtConfig jwtConfig; @Autowired private 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<String> roles, Collection<? extends GrantedAuthority> 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保存至Redis stringRedisTemplate.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中清除JWT stringRedisTemplate.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<String> pattern = Lists.newArrayList(); /** * 需要忽略的 GET 请求 */ private List<String> get = Lists.newArrayList(); /** * 需要忽略的 POST 请求 */ private List<String> post = Lists.newArrayList(); /** * 需要忽略的 DELETE 请求 */ private List<String> delete = Lists.newArrayList(); /** * 需要忽略的 PUT 请求 */ private List<String> put = Lists.newArrayList(); /** * 需要忽略的 HEAD 请求 */ private List<String> head = Lists.newArrayList(); /** * 需要忽略的 PATCH 请求 */ private List<String> patch = Lists.newArrayList(); /** * 需要忽略的 OPTIONS 请求 */ private List<String> options = Lists.newArrayList(); /** * 需要忽略的 TRACE 请求 */ private List<String> 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 { @Autowired private UserDao userDao; @Autowired private RoleDao roleDao; @Autowired private PermissionDao permissionDao; @Override public UserDetails loadUserByUsername(String usernameOrEmailOrPhone) throws UsernameNotFoundException { User user = userDao.findByUsernameOrEmailOrPhone(usernameOrEmailOrPhone, usernameOrEmailOrPhone, usernameOrEmailOrPhone) .orElseThrow(() -> new UsernameNotFoundException("未找到用户信息 : " + usernameOrEmailOrPhone)); List<Role> roles = roleDao.selectByUserId(user.getId()); List<Long> roleIds = roles.stream() .map(Role::getId) .collect(Collectors.toList()); List<Permission> permissions = permissionDao.selectByRoleIdList(roleIds); return UserPrincipal.create(user, roles, permissions); } }
创建dao包,在该包下分别创建对应的dao。
UserDao
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { /** * 根据用户名、邮箱、手机号查询用户 * * @param username 用户名 * @param email 邮箱 * @param phone 手机号 * @return 用户信息 */ Optional<User> findByUsernameOrEmailOrPhone(String username, String email, String phone); /** * 根据用户名列表查询用户列表 * * @param usernameList 用户名列表 * @return 用户列表 */ List<User> findByUsernameIn(List<String> usernameList); }
roleDao.java
public interface RoleDao extends JpaRepository<Role, Long>, JpaSpecificationExecutor<Role> {
/**
* 根据用户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<Role> selectByUserId(@Param("userId") Long userId);
}
permissionDao.java
public interface PermissionDao extends JpaRepository<Permission, Long>, JpaSpecificationExecutor<Permission> {
/**
* 根据角色列表查询权限列表
*
* @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<Permission> selectByRoleIdList(@Param("ids") List<Long> ids);
}
最后实现JwtAuthenticationFilter过滤器。
JwtAuthenticationFilter.java
@Component @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private CustomUserDetailsService customUserDetailsService; @Autowired private JwtUtil jwtUtil; @Autowired private CustomConfig customConfig; @Override protected 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<String> 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 等。
RbacAuthorityService类主要的功能是校验每次客户端发起的请求的合法性,根据当前请求路径与该用户的可访问的资源做匹配,通过则可以访问,否则将不允许被访问,这属于一个路由动态鉴权类。
RbacAuthorityService.java
@Component public class RbacAuthorityService { @Autowired private RoleDao roleDao; @Autowired private PermissionDao permissionDao; @Autowired private 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<Role> roles = roleDao.selectByUserId(userId); List<Long> roleIds = roles.stream() .map(Role::getId) .collect(Collectors.toList()); List<Permission> permissions = permissionDao.selectByRoleIdList(roleIds); //获取资源,前后端分离,所以过滤页面权限,只保留按钮权限 List<Permission> 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<String, String> 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<String, String> allUrlMapping() { Multimap<String, String> urlMapping = ArrayListMultimap.create(); // 获取url与类和方法的对应信息 Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods(); handlerMethods.forEach((k, v) -> { // 获取当前 key 下的获取所有URL Set<String> 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 的信息。
SecurityConfig.java
@Configuration @EnableWebSecurity @EnableConfigurationProperties(CustomConfig.class) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomConfig customConfig; @Autowired private AccessDeniedHandler accessDeniedHandler; @Autowired private CustomUserDetailsService customUserDetailsService; @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public BCryptPasswordEncoder encoder() { return new BCryptPasswordEncoder(); } @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService).passwordEncoder(encoder()); } @Override protected void configure(HttpSecurity http) throws Exception { // @formatter:off http.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()} */ @Override public void configure(WebSecurity web) { WebSecurity and = web.ignoring().and(); // 忽略 GET customConfig.getIgnores().getGet().forEach(url -> and.ignoring().antMatchers(HttpMethod.GET, url)); // 忽略 POST customConfig.getIgnores().getPost().forEach(url -> and.ignoring().antMatchers(HttpMethod.POST, url)); // 忽略 DELETE customConfig.getIgnores().getDelete().forEach(url -> and.ignoring().antMatchers(HttpMethod.DELETE, url)); // 忽略 PUT customConfig.getIgnores().getPut().forEach(url -> and.ignoring().antMatchers(HttpMethod.PUT, url)); // 忽略 HEAD customConfig.getIgnores().getHead().forEach(url -> and.ignoring().antMatchers(HttpMethod.HEAD, url)); // 忽略 PATCH customConfig.getIgnores().getPatch().forEach(url -> and.ignoring().antMatchers(HttpMethod.PATCH, url)); // 忽略 OPTIONS customConfig.getIgnores().getOptions().forEach(url -> and.ignoring().antMatchers(HttpMethod.OPTIONS, url)); // 忽略 TRACE customConfig.getIgnores().getTrace().forEach(url -> and.ignoring().antMatchers(HttpMethod.TRACE, url)); // 按照请求格式忽略 customConfig.getIgnores().getPattern().forEach(url -> and.ignoring().antMatchers(url)); } }
WebSecurityConfigurerAdapter 类是一个适配器,在配置的时候,需要我们自己写个配置类去继承他,然后编写自己所特殊需要的配置。里面重写的 configure 方法就是定制化的配置信息内容。每个模块配置使用 and 结尾,在这些配置字段信息中:
通过这个类,我们自定义返回的响应的形式、内容,接着在这里需要自定义了一个 AccessDeniedHandler方法,把权限不足的情况返回的响应给客户端。
SecurityHandlerConfig.java
@Configuration
public class SecurityHandlerConfig {
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> ResponseUtil.renderJson(response, Status.ACCESS_DENIED, null);
}
}
WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private static final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE")
.maxAge(MAX_AGE_SECS);
}
}
RedisConfig.java
@Configuration @AutoConfigureAfter(RedisAutoConfiguration.class) @EnableCaching public class RedisConfig { /** * 默认情况下的模板只能支持RedisTemplate<String, String>,也就是只能存入字符串,因此支持序列化 */ @Bean public RedisTemplate<String, Serializable> redisCacheTemplate(LettuceConnectionFactory redisConnectionFactory) { RedisTemplate<String, Serializable> template = new RedisTemplate<>(); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setConnectionFactory(redisConnectionFactory); return template; } }
AuthController.java
@Slf4j @RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private 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 { @GetMapping public ApiResponse list() { log.info("测试列表查询"); return ApiResponse.ofMessage("测试列表查询"); } @PostMapping public ApiResponse add() { log.info("测试列表添加"); return ApiResponse.ofMessage("测试列表添加"); } @PutMapping("/{id}") public ApiResponse update(@PathVariable Long id) { log.info("测试列表修改"); return ApiResponse.ofSuccess("测试列表修改"); } }
预留了两个用户,分别是 admin/123456 管理员和 user/123456 普通用户。启动项目后,使用postman进行登陆验证。
发送请求后会返回我们token字符串,我们就可以通过携带token字符串来完成后续的请求了。
同时也可以在redis中看到我们生成的token。
接下来我们先不带token,来访问test请求,出现下面情况。
接下来我们带上token,来访问test请求,就出现下面情况,访问成功了。
我们在前面的配置的 token 的过期时间是 10 分钟,当10分钟过去之后,再次带着这个 token去访问这个接口的时候,我们得到的响应结果如下。
同时redis中存储的token也会自动删除,再次查看就看不到了。
到这里这个练习就结束了,demo源码:demo下载地址
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。