# security_jwt_demo **Repository Path**: shiruixian/security_jwt_demo ## Basic Information - **Project Name**: security_jwt_demo - **Description**: 一个spring security整合jwt实现登录,认证,以及单账号登录,多账号登录,同端互斥登录等的demo - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 3 - **Created**: 2023-12-09 - **Last Updated**: 2024-12-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 本篇文章主要是 springboot 整合 jwt 实现用户的登录认证与授权,并且还有单用户共享 token、单设备登录、多设备登录、同端互斥登录与临时 token 等。git 地址:[点击前往](https://gitee.com/shiruixian/security_jwt_demo) 阅读需要熟悉 spring security 与 jwt 相关知识,也可前往链接学习: [Spring Security入门](https://bk.gggd.club/archives/1700205412343) [十分钟学会JWT](https://bk.gggd.club/archives/1701446539189) # 一、准备阶段 ## 1、表 ### 1.用户表 ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for sys_user -- ---------------------------- DROP TABLE IF EXISTS `sys_user`; CREATE TABLE `sys_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `account` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '账号', `user_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名', `password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户密码', `last_login_time` datetime(0) NULL DEFAULT NULL COMMENT '上一次登录时间', `enabled` tinyint(1) NULL DEFAULT 1 COMMENT '账号是否可用。默认为1(可用)', `account_not_expired` tinyint(1) NULL DEFAULT 1 COMMENT '是否过期。默认为1(没有过期)', `account_not_locked` tinyint(1) NULL DEFAULT 1 COMMENT '账号是否锁定。默认为1(没有锁定)', `credentials_not_expired` tinyint(1) NULL DEFAULT 1 COMMENT '证书(密码)是否过期。默认为1(没有过期)', `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间', `update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间', `deleted` tinyint(1) NULL DEFAULT 0, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_user -- ---------------------------- INSERT INTO `sys_user` VALUES (1, 'zhangsan', '张三', '$2a$10$47lsFAUlWixWG17Ca3M/r.EPJVIb7Tv26ZaxhzqN65nXVcAhHQM4i', '2019-09-04 20:25:36', 1, 1, 1, 1, '2019-08-29 06:28:36', '2019-09-04 20:25:36', 0); INSERT INTO `sys_user` VALUES (2, 'lisi', '李四', '$2a$10$uSLAeON6HWrPbPCtyqPRj.hvZfeM.tiVDZm24/gRqm4opVze1cVvC', '2019-09-05 00:07:12', 1, 1, 1, 1, '2019-08-29 06:29:24', '2019-09-05 00:07:12', 0); SET FOREIGN_KEY_CHECKS = 1; ``` ### 2.角色表 ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for sys_role -- ---------------------------- DROP TABLE IF EXISTS `sys_role`; CREATE TABLE `sys_role` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id', `role_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色代码', `role_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色名', `role_description` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色说明', `create_time` datetime(0) NULL DEFAULT NULL, `update_time` datetime(0) NULL DEFAULT NULL, `deleted` tinyint(1) NULL DEFAULT 0, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_role -- ---------------------------- INSERT INTO `sys_role` VALUES (1, 'admin', '管理员', '管理员,拥有所有权限', '2023-12-11 10:15:29', NULL, 0); INSERT INTO `sys_role` VALUES (2, 'user', '普通用户', '普通用户,拥有部分权限', '2023-12-11 10:15:31', NULL, 0); SET FOREIGN_KEY_CHECKS = 1; ``` ### 3.权限表 ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for sys_permission -- ---------------------------- DROP TABLE IF EXISTS `sys_permission`; CREATE TABLE `sys_permission` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id', `permission_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限code', `permission_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限名', `create_time` datetime(0) NULL DEFAULT NULL, `update_time` datetime(0) NULL DEFAULT NULL, `deleted` tinyint(1) NULL DEFAULT 0, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '权限表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_permission -- ---------------------------- INSERT INTO `sys_permission` VALUES (1, 'create_user', '创建用户', '2023-12-11 10:17:23', NULL, 0); INSERT INTO `sys_permission` VALUES (2, 'query_user', '查看用户', '2023-12-11 10:17:25', NULL, 0); INSERT INTO `sys_permission` VALUES (3, 'delete_user', '删除用户', '2023-12-11 10:17:27', NULL, 0); INSERT INTO `sys_permission` VALUES (4, 'modify_user', '修改用户', '2023-12-11 10:17:30', NULL, 0); SET FOREIGN_KEY_CHECKS = 1; ``` ### 4.用户角色关系表 ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for sys_user_role_relation -- ---------------------------- DROP TABLE IF EXISTS `sys_user_role_relation`; CREATE TABLE `sys_user_role_relation` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id', `user_id` int(11) NULL DEFAULT NULL COMMENT '用户id', `role_id` int(11) NULL DEFAULT NULL COMMENT '角色id', `create_time` datetime(0) NULL DEFAULT NULL, `update_time` datetime(0) NULL DEFAULT NULL, `deleted` tinyint(1) NULL DEFAULT 0, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色关联关系表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_user_role_relation -- ---------------------------- INSERT INTO `sys_user_role_relation` VALUES (1, 1, 1, '2023-12-11 10:18:23', NULL, 0); INSERT INTO `sys_user_role_relation` VALUES (2, 2, 2, '2023-12-11 10:18:26', NULL, 0); SET FOREIGN_KEY_CHECKS = 1; ``` ### 5.角色权限关系表 ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for sys_role_permission_relation -- ---------------------------- DROP TABLE IF EXISTS `sys_role_permission_relation`; CREATE TABLE `sys_role_permission_relation` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id', `role_id` int(11) NULL DEFAULT NULL COMMENT '角色id', `permission_id` int(11) NULL DEFAULT NULL COMMENT '权限id', `create_time` datetime(0) NULL DEFAULT NULL, `update_time` datetime(0) NULL DEFAULT NULL, `deleted` tinyint(1) NULL DEFAULT 0, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色-权限关联关系表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_role_permission_relation -- ---------------------------- INSERT INTO `sys_role_permission_relation` VALUES (1, 1, 1, '2023-12-11 10:19:19', NULL, 0); INSERT INTO `sys_role_permission_relation` VALUES (2, 1, 2, '2023-12-11 10:19:21', NULL, 0); INSERT INTO `sys_role_permission_relation` VALUES (3, 1, 3, '2023-12-11 10:19:23', NULL, 0); INSERT INTO `sys_role_permission_relation` VALUES (4, 1, 4, '2023-12-11 10:19:25', NULL, 0); INSERT INTO `sys_role_permission_relation` VALUES (5, 2, 1, '2023-12-11 10:19:29', NULL, 0); INSERT INTO `sys_role_permission_relation` VALUES (6, 2, 2, '2023-12-11 10:19:31', NULL, 0); SET FOREIGN_KEY_CHECKS = 1; ``` **对应的实体类可前往我的 git 的 pojo.entity 包 里查看,这里因篇幅原因就不贴出来了** ## 2、项目框架 ### 1.maven 依赖 ```xml org.springframework.boot spring-boot-starter-web com.mysql mysql-connector-j runtime org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test com.baomidou mybatis-plus-boot-starter 3.4.2 cn.hutool hutool-all 5.8.18 org.springframework.boot spring-boot-starter-data-redis org.springframework.boot spring-boot-starter-validation com.alibaba fastjson 1.2.83 ``` ### 2.配置 ```yaml spring: application: name: security_jwt_demo datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security_jwt_demo?serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 database: 3 server: port: 8080 servlet: context-path: /v1/api mybatis-plus: mapper-locations: classpath:/mapper/*Mapper.xml typeAliasesPackage: club.gggd.security_jwt_demo.pojo global-config: id-type: 0 field-strategy: 1 db-column-underline: true refresh-mapper: true logic-delete-value: 0 logic-not-delete-value: 1 sql-parser-cache: true configuration: map-underscore-to-camel-case: true cache-enabled: false jwt: #JWT存储的请求头 requestHeader: Authorization #JWT加解密使用的密钥 secret: symx.club #JWT的有效时间(60*60*24*7) expiration: 604800 #JWT负载中的开头 tokenStartWith: 'Bearer ' ``` ## 3、其他类 ### 1.跨域配置类 ```java @Configuration(proxyBeanMethods = false) @EnableWebMvc public class ConfigurerAdapter implements WebMvcConfigurer { @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); // 允许cookies跨域 config.setAllowCredentials(true); // #允许向该服务器提交请求的URI,*表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin config.addAllowedOriginPattern("*"); // #允许访问的头信息,*表示全部 config.addAllowedHeader("*"); // 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了 config.setMaxAge(18000L); // 允许提交请求的方法类型,*表示全部允许 config.addAllowedMethod("OPTIONS"); config.addAllowedMethod("HEAD"); config.addAllowedMethod("GET"); config.addAllowedMethod("PUT"); config.addAllowedMethod("POST"); config.addAllowedMethod("DELETE"); config.addAllowedMethod("PATCH"); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 静态资源过滤,需同时在WebSecurityConfig放行 } } ``` ### 2.Redis 序列化配置 ```java @Configuration public class RedisConfig { @Bean @SuppressWarnings("all") public RedisTemplate redisTemplate(RedisConnectionFactory factory){ //使用 类型 RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(factory); //序列化配置 Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(om); //String 的序列化 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); //key 采用String 的序列化方式 template.setKeySerializer(stringRedisSerializer); //hash 的key 也采用String 的序列化方式 template.setHashKeySerializer(stringRedisSerializer); //value 序列化方式采用jackson template.setValueSerializer(jackson2JsonRedisSerializer); //hash 的value序列化方式采用Jackson template.setHashValueSerializer(jackson2JsonRedisSerializer); template.afterPropertiesSet(); return template; } } ``` ### 3.实体类基类 ```java @Data public class BaseEntity { @TableField("create_time") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createTime; @TableField("update_time") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date updateTime; @TableField("deleted") @TableLogic private Integer deleted; } ``` ### 4.自定义异常 ```java @Data public class BusinessException extends RuntimeException{ private int code; private String message; public BusinessException(){} public BusinessException(ResultCode resultCode) { this.code = resultCode.getCode(); this.message = resultCode.getMessage(); } public BusinessException(int code, String mgs) { this.code = code; this.message = mgs; } public BusinessException(String mgs) { this.code = 400; this.message = mgs; } } ``` ```java @Data public class TokenException extends RuntimeException{ public TokenException(String message) { super(message); } public TokenException(ResultCode resultCode) { super(resultCode.getMessage()); } } ``` ### 5.返回状态码 ```java @Getter @NoArgsConstructor @AllArgsConstructor public enum ResultCode { SUCCESS(200, "成功"), BAD_REQUEST(400, "请求错误"), UNAUTHORIZED(401, "用户未认证,请登录"), FORBIDDEN(403, "禁止访问"), NOT_FOUND(404, "内容未找到"), METHOD_NOT_ALLOW(405, "不支持的方法"), SERVER_ERROR(500, "服务器内部发生错误"), /* 参数错误 */ PARAM_NOT_VALID(1001, "参数无效"), PARAM_IS_BLANK(1002, "参数为空"), PARAM_TYPE_ERROR(1003, "参数类型错误"), PARAM_NOT_COMPLETE(1004, "参数缺失"), /* 用户错误 */ USER_NOT_LOGIN(2001, "用户未登录"), USER_ACCOUNT_EXPIRED(2002, "账号已过期"), USER_CREDENTIALS_ERROR(2003, "密码错误"), USER_CREDENTIALS_EXPIRED(2004, "密码过期"), USER_ACCOUNT_DISABLE(2005, "账号不可用"), USER_ACCOUNT_LOCKED(2006, "账号被锁定"), USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"), USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"), USER_ACCOUNT_USE_BY_OTHERS(2009, "账号下线"), USER_NOT_FOUND(2010, "用户不存在"), TOKEN_INVALID(3001, "token无效"), TOKEN_TIMEOUT(3002, "token已过期"), CAPTCHA_ERR(4001, "验证码错误"); /** * 返回码 */ private Integer code; /** * 返回信息 */ private String message; } ``` ### 6.统一返回体 ```java @Data @NoArgsConstructor @AllArgsConstructor public class WebResult { private Boolean success; private Integer code; private String message; private T data; public static WebResult success() { WebResult result = new WebResult(); result.success = true; result.code = 200; result.message = "成功"; return result; } public static WebResult success(String message) { WebResult result = new WebResult(); result.success = true; result.code = 200; result.message = message; return result; } public static WebResult success(Object data) { WebResult result = new WebResult(); result.success = true; result.code = 200; result.message = "成功"; result.data = data; return result; } public static WebResult error() { WebResult result = new WebResult(); result.success = false; result.code = 400; result.message = "失败"; return result; } public static WebResult error(String message) { WebResult result = new WebResult(); result.success = false; result.code = 400; result.message = message; return result; } public static WebResult error(int code, String message) { WebResult result = new WebResult(); result.success = false; result.code = code; result.message = message; return result; } public static WebResult error(ResultCode resultCode) { WebResult result = new WebResult(); result.success = false; result.code = resultCode.getCode(); result.message = resultCode.getMessage(); return result; } } ``` ### 7.全局异常捕获 ```java @ControllerAdvice @Slf4j public class GlobalExceptionHandle { /** * 拦截业务异常 * @param e * @param request * @param response * @return */ @ResponseBody @ExceptionHandler(BusinessException.class) public WebResult handlerBusinessException(Exception e, HttpServletRequest request, HttpServletResponse response) { log.error("业务异常信息:{}", e.getMessage()); e.printStackTrace(); // 不同异常返回不同状态码 WebResult result = WebResult.error(((BusinessException) e).getCode(), e.getMessage()); return result; } @ResponseBody @ExceptionHandler(MethodArgumentNotValidException.class) public WebResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request, HttpServletResponse response) { log.error("请求参数异常:{}", e.getMessage()); List allErrors = e.getBindingResult().getAllErrors(); // 不同异常返回不同状态码 WebResult result = WebResult.error(ResultCode.BAD_REQUEST.getCode(), allErrors.get(0).getDefaultMessage()); return result; } /** * 处理其他异常 * @param e * @param request * @param response * @return */ @ResponseBody @ExceptionHandler(Exception.class) public WebResult handleException(Exception e, HttpServletRequest request, HttpServletResponse response){ log.error("根异常信息:{}", e.getMessage()); e.printStackTrace(); // 不同异常返回不同状态码 WebResult result = WebResult.error(ResultCode.SERVER_ERROR.getCode(), ResultCode.SERVER_ERROR.getMessage()); return result; } } ``` ### 8.Redis 工具类 网上有很多,也可以用我的:[Redis工具类](https://bk.gggd.club/archives/1700204833071) # 二、开始整合 首先把上面 maven 依赖中的 spring security 和 jwt 依赖导入。 然后写一个 jwt 工具类: ```java @Data @Component // 引入配置类中jwt相关配置 @ConfigurationProperties("jwt") public class JwtUtil { private String requestHeader; private String secret; private int expiration; private String tokenStartWith; @Autowired private RedisUtil redisUtil; /** * 获取用户名 * @return */ public String getAccountByToken(String token) { Claims claims = getClaimsByToken(token); return claims != null ? claims.getSubject() : null; } /** * 获取过期时间 * @param token * @return Date */ public Date getExpiredByToken(String token) { Claims claims = getClaimsByToken(token); return claims != null ? claims.getExpiration() : null; } /** * 获取Claims * @param token * @return */ private Claims getClaimsByToken(String token) { Claims claims; try { claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } catch (Exception e) { // 签名不一致异常 if (e instanceof SignatureException) { throw new TokenException(ResultCode.TOKEN_INVALID); } // token过期异常 if (e instanceof ExpiredJwtException) { throw new TokenException(ResultCode.TOKEN_TIMEOUT); } // 如果都不是上面的则弹出token无效异常 throw new TokenException(ResultCode.TOKEN_INVALID); } return claims; } /** * 计算过期时间 * @return */ private Date generateExpired() { return new Date(System.currentTimeMillis() + expiration * 1000); } /** * 判断 Token 是否过期 * @param token * @return */ private Boolean isTokenExpired(String token) { Date expirationDate = getExpiredByToken(token); return expirationDate.before(new Date()); } /** * 生成 Token * @param user 用户信息 * @return */ public String generateToken(SysUser user) { String token = Jwts.builder() .setSubject(user.getAccount()) .setExpiration(generateExpired()) .signWith(SignatureAlgorithm.HS512, secret) .compact(); String key = "login:" + user.getAccount() + ":" + token; redisUtil.set(key, token, expiration); return token; } /** * 验证 Token * @param token * @return */ public Boolean validateToken(String token) { String account = getAccountByToken(token); String key = "login:" + account+ ":" + token; Object data = redisUtil.get(key); String redisToken = data == null ? null : data.toString(); return StrUtil.isNotEmpty(token) && !isTokenExpired(token) && token.equals(redisToken); } /** * 移除 Token * @param token */ public void removeToken(String token) { String account = getAccountByToken(token); String key = "login:" + account+ ":" + token; redisUtil.del(key); delUserDetail(account); } /** * 获取token * @param request * @return */ public String getToken(HttpServletRequest request) { String auth = request.getHeader(requestHeader); if (StrUtil.isBlank(auth)) { return null; } String token = auth.replace(tokenStartWith, ""); return token; } /** * 退出登录 */ public void logout(HttpServletRequest request) { String token = getToken(request); removeToken(token); } /** * 获取userDetail * @param token * @return */ public JwtUser getUserDetail(String token) { String account = getAccountByToken(token); String s = (String) redisUtil.get("user:userDetail:" + account); JwtUser jwtUser = JSON.parseObject(s, JwtUser.class); return jwtUser; } /** * 删除userDetail * @param account */ public void delUserDetail(String account) { redisUtil.del("user:userDetail:" + account); } } ``` ## 1、登录接口 首先用户需要进行登录,然后后台生成一个 token 返回给前端,之后前端就需要在请求头带上这个 token,否则会根据 Spring Security 的 配置判断用户是否可以访问这个接口。 在登录前需要先实现 UserDetailsService 接口,这个接口是登录的重要部分,用于构造并保存登录用户信息,而 UserDetails 接口则是用来记录用户信息的类,我们需要写一个类来实现 UserDetails 接口,这个类主要是一些用户的基本信息,以及权限等。 ```java @Getter @AllArgsConstructor public class JwtUser implements UserDetails { private final Integer id; private final String account; private final String userName; @JsonIgnore private final String password; @JsonIgnore private final Collection authorities; private final boolean enabled; @JsonIgnore @Override public boolean isAccountNonExpired() { return true; } @JsonIgnore @Override public boolean isAccountNonLocked() { return true; } @JsonIgnore @Override public boolean isCredentialsNonExpired() { return true; } @JsonIgnore @Override public String getPassword() { return password; } @Override public String getUsername() { return account; } @Override public boolean isEnabled() { return enabled; } public Collection getRoles() { return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); } } ``` UserDetails 写完了,接下来就是 UserDetailsService 的实现了,实现这个接口,在登录时会调用这个接口,将用户信息存到 Redis 中。**userService.getAuthList(user.getId())方法后面有说**。 ```java @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private ApplicationContext applicationContext; @Autowired private RedisUtil redisUtil; @Override public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException { // 获取用户信息 UserService userService = (UserService) applicationContext.getBean("userServiceImpl"); SysUser user = userService.getUserInfo(account); if (user == null) { throw new BusinessException(ResultCode.USER_NOT_FOUND); } // 组装成userDetails JwtUser jwtUser = new JwtUser( user.getId(), user.getAccount(), user.getUserName(), user.getPassword(), userService.getAuthList(user.getId()), user.getEnabled() == 1 ? true : false ); // 存入Redis String s = JSON.toJSONString(jwtUser); redisUtil.set("user:userDetail:" + user.getAccount(), s); return jwtUser; } } ``` 接下来就是登录接口了,下面是一个简单的登录接口: ```java @PostMapping("/login") public WebResult login(@RequestBody @Validated LoginVo vo) { // 解密密码,所有的密码都需要进行一个加密,不能明文传输,我这里就不写了 // 判断验证码,写死,不要学 if (!"1234".equals(vo.getCode())) { return WebResult.error("验证码错误"); } String token = userService.login(vo); return WebResult.success((Object) token); } ``` login 方法: ```java public String login(LoginVo vo) { // 判断用户 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getAccount, vo.getAccount()); SysUser user = userMapper.selectOne(wrapper); Optional.ofNullable(user).orElseThrow(() -> new BusinessException(ResultCode.USER_NOT_FOUND)); boolean matches = passwordEncoder.matches(vo.getPassword(), user.getPassword()); if (!matches) { throw new BusinessException(ResultCode.USER_CREDENTIALS_ERROR); } // 组装Authentication,在这里会调用到UserDetailsService的loadUserByUsername方法 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(vo.getAccount(), vo.getPassword()); Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); // 生成token String token = jwtUtil.generateToken(user); return token; } ``` 用户进行登录后,后台就会判断验证码,密码是否正确,然后通过 Spring Security 的loadUserByUsername 方法保存用户信息,这样我们就可以通过 token 解析到用户账号,然后拿到用户信息,就不需要实时查询数据库了。 ## 2、jwt 过滤器 用户登录后,后台会生成一个 token 返回给前端,至此前端每次进行请求都需要带上这个 token,这样后台才能确认到究竟是哪个用户进行访问。而接下来就是要进行 token 的解析了,通过解析 token,我们可以验证 token 是否正确和构造出当前请求的用户,然后将其保存到 Spring Security 的SecurityContext 中去。 ```java @Slf4j public class JwtFilter extends BasicAuthenticationFilter{ private final JwtUtil jwtUtil; public JwtFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) { super(authenticationManager); this.jwtUtil = jwtUtil; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // 获取token String token = jwtUtil.getToken(request); // 如果token为空,则进入下一个过滤器,因为有些接口是允许匿名访问的 if (token == null) { chain.doFilter(request, response); return; } String account = jwtUtil.getAccountByToken(token); if (account != null && SecurityContextHolder.getContext().getAuthentication() == null && jwtUtil.validateToken(token)) { // 将用户信息存入SecurityContext UserDetails userDetails = jwtUtil.getUserDetail(token); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } else { jwtUtil.removeToken(token); } chain.doFilter(request, response); } } ``` ## 3、认证失败过滤器 当用户带上 token 请求某个接口时,会经过 jwt 过滤器对 token 进行一个验证,假如验证不通过,例如有人篡改了 token 或者 token 过期了,就会执行认证失败过滤器,这个过滤器的作用主要是提示前端该用户登录过期了需要重新登录,需要实现接口AuthenticationEntryPoint。 ```java @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 当用户尝试访问安全的REST资源而不提供任何凭据时,将调用此方法发送401 响应 WebResult result = WebResult.error(ResultCode.UNAUTHORIZED); response.setContentType("text/json;charset=utf-8"); response.getWriter().write(JSON.toJSONString(result)); } } ``` ## 4、无权限访问过滤器 有些接口可能需要某些特定的权限才能访问,这时候如果用户没有这个权限但仍然访问了这个接口,这时候我们就需要进行一个拦截并且返回提示消息,就需要到了无权限访问过滤器了,这个过滤器同样需要实现一个接口AccessDeniedHandler ```java @Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { //当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应 WebResult result = WebResult.error(ResultCode.FORBIDDEN); response.setContentType("text/json;charset=utf-8"); response.getWriter().write(JSON.toJSONString(result)); } } ``` 需要注意的是,如果要实现接口的权限控制,需要在 Spring Security 的配置类中加上注解`@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)`,这个注解允许接口级别的权限控制,稍后我会贴出完整的配置类,这里稍作了解即可。加上注解后,我们就可以在接口方法上加上`@PreAuthorize`注解进行接口级的权限判断了。 一般的项目中都会有一个超级管理员角色,这个角色掌握所有的权限,如果仅通过 Spring Security 自带的注解,那么每个注解上都需要加上超级管理员,这样也是非常麻烦的,所以我们可以自己写一个验证权限的类,在里面把管理员给放行。 ```java @Service(value = "el") public class ElPermissionHandle { @Autowired private UserService userService; @Autowired private UserProvider userProvider; public Boolean check(String ...permissions){ // 获取当前用户的所有权限 Integer userId = userProvider.getUserId(); List list = userService.getAuthList(userId).stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()); return list.contains("admin") || Arrays.stream(permissions).anyMatch(list::contains); } } ``` 获取用户权限的方法userService.getAuthList(userId) ```java public List getAuthList(Integer userId) { // 获取该用户所有权限 List list = userMapper.getUserPermissionList(userId); Set permission = list.stream().map(UserPermission::getPermissionCode).collect(Collectors.toSet()); // 获取该用户所有角色 List userRoleList = roleMapper.getUserRoleList(userId); for (UserRoleDto ur : userRoleList) { permission.add(ur.getRoleCode()); } List authorities = permission.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()); return authorities; } ``` 以及 SQL: ```xml ``` 使用的话如下例子: ```java @GetMapping("/security") @PreAuthorize("@el.check('create_user')") public WebResult getSecurityInfo() { return WebResult.success("获取成功"); } ``` 最后还有一点要注意的,这也是我做的时候遇到的坑,就是一切都配置好了之后发现没有权限时直接抛出了异常AccessDeniedException,而没有走我配置的过滤器,后面才发现原来是被全局异常捕获给捕获到了,所以我们需要在GlobalExceptionHandle 这个类中先捕获到AccessDeniedException 异常,再把异常继续抛出去让无权限访问过滤器给处理掉。 ```java /** * 解决被全局异常捕获的问题 * @param e * @throws AccessDeniedException */ @ExceptionHandler(AccessDeniedException.class) public void accessDeniedException(AccessDeniedException e) throws AccessDeniedException { throw e; } ``` ## 5、用户工具类 很多情况下我们都只需要到用户 id 或者用户账号,所以可以写一个工具类来获取当前请求的用户信息。 ```java @Component public class UserProvider { /** * 获取当前用户信息 * @return */ public JwtUser curUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); JwtUser jwtUser = (JwtUser) authentication.getPrincipal(); return jwtUser; } /** * 获取用户id * @return */ public Integer getUserId() { return curUser().getId(); } /** * 获取用户账号 * @return */ public String getAccount() { return curUser().getAccount(); } } ``` ## 6、整合配置 最后将上面的配置都整合到 Spring Security 配置类中 ```java @Configuration(proxyBeanMethods = false) @EnableWebSecurity // 加上该注解可实现接口级的权限认证 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtUtil jwtUtil; @Autowired private JwtAccessDeniedHandler jwtAccessDeniedHandler; @Autowired private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Autowired private CorsFilter corsFilter; @Autowired private UserDetailsServiceImpl userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //配置认证方式 auth.userDetailsService(userDetailsService); } @Override protected void configure(HttpSecurity http) throws Exception { //http相关的配置,包括登入登出、异常处理、会话管理等 http.csrf().disable() // 跨域过滤器 .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) // jwt过滤器 .addFilter(jwtFilter()) // 授权异常 .exceptionHandling() .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) // 防止iframe 造成跨域 .and() .headers() .frameOptions() .disable() // 不创建会话 .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 放行与拦截接口 .and().authorizeRequests() // 放行登录接口 .antMatchers("/auth/login").permitAll() // 拦截其余接口 .anyRequest().authenticated(); } /** * 设置密码加密方式,密码必须要加密保存 * @return */ @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * token过滤器 * @return */ private JwtFilter jwtFilter() throws Exception{ return new JwtFilter(authenticationManager(), jwtUtil); } /** * 去除 ROLE_ 前缀 * @return */ @Bean GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults(""); } } ``` 到这里就大致整合完毕了,大家可以自己测试一下效果。 # 三、其他功能的实现 上面只是整合了 Spring Security 和 JWT,已经可以实现简单的登录了,但是实际上可能业务没那么简单,而是需要不同的登录功能,比如有些项目只允许一个设备端登录,在某个设备登录后之前登录的设备就要下线了,我这里也做了几个简单的登录类型:单设备登录,多设备登录和同端互斥登录。 - 单设备登录:只允许一个设备登录,后登录的会把前登录的给踢掉 - 多设备登录:允许多个设备同时登录,不做限制 - 同端互斥登录:同一种设备类型只允许一次登录,类似 QQ,电脑和安卓可以同时登录,但是不允许两台电脑同时登录 在开始之前,我们需要确认一下要如何保存 token 信息,我们需要把 token 信息保存在 redis,需要通过 redis 来确认用户是否登录,下面是我设计的一种 token 保存方式。 session 信息:这里主要是保存这个账号下的所有 token 的,key 为 `login:session:account` token 信息:这里是保存这个 token 所关联的账号,`key 为 login:token:token` 两者结构如下: ![](https://api.gggd.club/v1/api/kodo/download?code=657ea5c7e4b01e34ea26161f#id=rfWI6&originHeight=258&originWidth=597&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=) session 信息的结构,主要是保存了这个账号下的所有 token ,以及登录的设备和 token 到期时间: ![](https://api.gggd.club/v1/api/kodo/download?code=657ea612e4b01e34ea261621#id=mJujw&originHeight=822&originWidth=1609&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=) token 信息的结构: ![](https://api.gggd.club/v1/api/kodo/download?code=657ea673e4b01e34ea261623#id=ikzwM&originHeight=193&originWidth=1084&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=) 通过这个结构,我们可以根据账号来获取到这个账号下的所有 token,也可以仅根据 token 拿到这个 token 所对应的账号。接下来所谓的各种登录方式也就是将这些 token 按规定删除就可以实现各种类型的登录了。 ## 1、准备 ### 1.配置文件 ```yaml login: # 是否共享token,仅当login-model为multi时有效 is-share: false # 登录模式,分为alone,multi和same-device-exclusion三种 login-model: alone ``` ### 2.登录模式枚举 ```java @Getter @NoArgsConstructor @AllArgsConstructor public enum LoginModel { ALONE("alone", "单设备登录"), MULTI("multi", "多设备登录"), SAME_DEVICE_EXCLUSION("same-device-exclusion", "同端互斥登录"); private String value; private String desc; } ``` ### 3.设备类型枚举 ```java @Getter @AllArgsConstructor public enum DeviceType { DEFAULT("DEFAULT", "默认设备"), ANDROID("ANDROID", "安卓设备"), IOS("IOS", "苹果设备"), PC("PC", "Windows设备"); private String value; private String desc; } ``` ### 4.token 常量 ```java // 这个是保存到redis里key的前缀 @Data public class TokenConstant { public static final String TOKEN = "login:token:"; public static final String SESSION = "login:session:"; } ``` ### 5.token 信息 ```java @Data public class TokenInfo { /** * key的值 */ private String id; /** * 创建时间 */ private Long createTime; /** * token集合 */ private List tokenSignList; } ``` ```java @Data public class TokenSign { /** * token */ private String token; /** * 设备 */ private String device; /** * 有效截止期 */ private Long deadline; } ``` ### 6.UserAgent 工具类 ```java public class UserAgentUtil { /** * 获取当前请求的User-Agent * @return */ private static String getUserAgent() { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = attributes.getRequest(); String header = request.getHeader("User-Agent"); return header; } return null; } /** * 获取当前请求的设备 * @return */ public static String getDevice() { String userAgent = getUserAgent(); if (userAgent == null) { return DeviceType.DEFAULT.getValue(); } if (userAgent.toLowerCase().contains(DeviceType.ANDROID.getValue())) { return DeviceType.ANDROID.getValue(); } if (userAgent.toLowerCase().contains("iphone") || userAgent.toLowerCase().contains("ipad")) { return DeviceType.IOS.getValue(); } if (userAgent.toLowerCase().contains("windows nt") && !userAgent.toLowerCase().contains("windows phone")) { return DeviceType.PC.getValue(); } return DeviceType.DEFAULT.getValue(); } } ``` ## 2、功能实现 接下来就是对登录模式的实现了,主要就是在用户登录时判断登录模式,根据登录模式的不同实现不同的登录效果即可。 下面的改动都是在 **JwtUtil** 类中的改动。 ### 1.引入配置 ```java @Value("${login.is-share}") private Boolean isShare; @Value("${login.login-model}") private String loginModel; ``` ### 2.功能实现 #### 1.原方法改造 因为登录方式变了,所以我们需要重新改造之前的生成 token,验证 token 与移除 token 方法 因为生成 token 要改动的内容比较多,所以先说一下验证 token 与移除 token 方法 验证 token,这个改动不大,只是改了下 key: ```java public Boolean validateToken(String token) { // 调用这个方法主要是用来验证签名和有效期 getClaimsByToken(token); String key = TokenConstant.TOKEN + token; return StrUtil.isNotEmpty(token) && !isTokenExpired(token) && redisUtil.hasKey(key); } ``` 移除 token,相比于之前,现在需要把 session 和 token 都要同时移除: ```java public void removeToken(String token) { // 获取session信息 String account = getAccountByToken(token); String key = TokenConstant.TOKEN + token; TokenInfo tokenInfo = (TokenInfo) redisUtil.get(TokenConstant.SESSION + account); // 删除该token tokenInfo.getTokenSignList().removeIf(e -> e.getToken().equals(token)); redisUtil.set(TokenConstant.SESSION + account, tokenInfo); redisUtil.del(key); // 如果此时该账号没有token了,则删除UserDetails if (CollectionUtils.isEmpty(tokenInfo.getTokenSignList())) { delUserDetail(account); } } ``` 最后是生成 token,这个是关键,相比与之前,就是根据不同登录方式来执行不同的方法: ```java public String generateToken(SysUser user) { String token = Jwts.builder() .setSubject(user.getAccount()) .setExpiration(generateExpired()) .signWith(SignatureAlgorithm.HS512, secret) .compact(); String device = UserAgentUtil.getDevice(); // 判断登录模式 if (LoginModel.ALONE.getValue().equals(loginModel)) { // 单设备登录 aloneLogin(token, user.getAccount(), device); } else if (LoginModel.MULTI.getValue().equals(loginModel)) { // 多设备登录 String newToken = multiLogin(token, user.getAccount(), device); return newToken; } else { // 同端互斥登录 sameDeviceExclusionLogin(token, user.getAccount(), device); } return token; } ``` #### 2.通用方法 在编写登录模式的方法之前,先写好一些通用的方法 - 获取用户 session 信息:因为 session 信息包含了这个用户的所有 token,所以我们可以写一个方法用来获取,需要注意的是,因为 session 只是一个键值对,所以不能设置有效期,我们需要手动比对 token 的有效期,把过期的删掉 - 设置token和session到redis:这里主要是把当前的 token 和更新后的 token 都重新保存到 redis 中 ```java /** * 获取用户session信息 * @param account * @return */ private TokenInfo getSessionInfo(String account) { String key = TokenConstant.SESSION + account; TokenInfo tokenInfo = (TokenInfo) redisUtil.get(key); if (tokenInfo == null) { tokenInfo = new TokenInfo(); tokenInfo.setId(key); tokenInfo.setCreateTime(System.currentTimeMillis()); List tokenSignList = new ArrayList<>(); tokenInfo.setTokenSignList(tokenSignList); } // 删除过期token long cur = System.currentTimeMillis(); tokenInfo.getTokenSignList().removeIf(e -> e.getDeadline() < cur); return tokenInfo; } /** * 设置token和session到redis * @param token * @param device * @param account * @param sessionInfo */ private void setTokenAndSessionHandle(String token, String device, String account, TokenInfo sessionInfo) { // 将token存入 redisUtil.set(TokenConstant.TOKEN + token, account); // 组装新的token TokenSign tokenSign = new TokenSign(); tokenSign.setToken(token); tokenSign.setDevice(device); tokenSign.setDeadline(System.currentTimeMillis() + expiration * 1000); sessionInfo.getTokenSignList().add(tokenSign); // 把session存入 redisUtil.set(TokenConstant.SESSION + account, sessionInfo); } ``` #### 3.新方法 接下来就是三种登录模式了。 ##### 1. 单设备登录 单设备登录需要删除原来的 token,并保存新的 token ```java /** * 单设备登录 * @param token * @param account * @param device */ private void aloneLogin(String token, String account, String device) { // 拿到当前用户的session信息 TokenInfo sessionInfo = getSessionInfo(account); List tokenSignList = sessionInfo.getTokenSignList(); // token不为空时清空所有token if (CollectionUtil.isNotEmpty(tokenSignList)) { for (TokenSign t : tokenSignList) { redisUtil.del(TokenConstant.TOKEN + t.getToken()); } tokenSignList.clear(); } setTokenAndSessionHandle(token, device, account, sessionInfo); } ``` ##### 2.多设备登录 多设备登录需要注意是否是共享 token ```java /** * 多设备登录 * @param token * @param account * @param device * @return token */ private String multiLogin(String token, String account, String device) { // 拿到当前用户的session信息 TokenInfo sessionInfo = getSessionInfo(account); List tokenSignList = sessionInfo.getTokenSignList(); // 如果是共享token且session里面存在token if (isShare && CollectionUtil.isNotEmpty(tokenSignList)) { // 则直接返回原来的token return tokenSignList.get(0).getToken(); } // 没有开启共享token,或开启了共享token但是是第一次登录 setTokenAndSessionHandle(token, device, account, sessionInfo); return token; } ``` ##### 3.同端互斥登录 需要注意同一个设备只允许一个登录 ```java /** * 同端互斥登录 * @param token * @param account * @param device */ private void sameDeviceExclusionLogin(String token, String account, String device) { TokenInfo sessionInfo = getSessionInfo(account); List tokenSignList = sessionInfo.getTokenSignList(); // 判断是否存在当前设备的登录信息 Iterator iterator = tokenSignList.iterator(); while (iterator.hasNext()) { TokenSign next = iterator.next(); if (next.getDevice().equals(device)) { // 存在则删除 redisUtil.del(TokenConstant.TOKEN + next.getToken()); iterator.remove(); break; } } // 录入当前登录信息 setTokenAndSessionHandle(token, device, account, sessionInfo); } ``` ##### 4.踢出用户 踢出用户本质上就是把 redis 里面相应的 token 删除就可以了,这个踢出用户的方法我们可以写在 **UserProvider** 中。 ```java /** * 踢出该账号的所有登录信息 * @param account */ public void kickOutUserByAccount(String account) { // 拿到session信息 TokenInfo sessionInfo = (TokenInfo) redisUtil.get(TokenConstant.SESSION + account); if (sessionInfo != null) { List tokenSignList = sessionInfo.getTokenSignList(); // 删除全部token for (TokenSign t : tokenSignList) { redisUtil.del(TokenConstant.TOKEN + t.getToken()); } // 删除session里的token tokenSignList.clear(); redisUtil.set(TokenConstant.SESSION + account, sessionInfo); } } /** * 踢出该token的登录信息 * @param token */ public void kickOutUserByToken(String token) { // 拿到账号 String account = (String) redisUtil.get(TokenConstant.TOKEN + token); // 拿到session TokenInfo sessionInfo = (TokenInfo) redisUtil.get(TokenConstant.SESSION + account); List tokenSignList = sessionInfo.getTokenSignList(); // 删除session中的token tokenSignList.removeIf(e -> e.getToken().equals(token)); // 删除token redisUtil.del(TokenConstant.TOKEN + token); redisUtil.set(TokenConstant.SESSION + account, sessionInfo); } ``` 到这里就结束了,大家可以自己测试一下,有什么不懂了可以去 git 查看源码:[点击前往](https://gitee.com/shiruixian/security_jwt_demo)