JWT 登录认证 + Token 自动续期方案,写得太好了!

打印 上一主题 下一主题

主题 887|帖子 887|积分 2661

作者:何甜甜在吗
链接:https://juejin.cn/post/6932702419344162823
过去这段时间主要负责了项目中的用户管理模块,用户管理模块会涉及到加密及认证流程,加密已经在前面的文章中介绍了,可以阅读用户管理模块:如何保证用户数据安全。
今天就来讲讲认证功能的技术选型及实现。技术上没啥难度当然也没啥挑战,但是对一个原先没写过认证功能的菜鸡甜来说也是一种锻炼吧
技术选型

要实现认证功能,很容易就会想到JWT或者session,但是两者有啥区别?各自的优缺点?应该Pick谁?夺命三连
区别

基于session和基于JWT的方式的主要区别就是用户的状态保存的位置,session是保存在服务端的,而JWT是保存在客户端
认证流程

基于session的认证流程


  • 用户在浏览器中输入用户名和密码,服务器通过密码校验后生成一个session并保存到数据库
  • 服务器为用户生成一个sessionId,并将具有sesssionId的cookie放置在用户浏览器中,在后续的请求中都将带有这个cookie信息进行访问
  • 服务器获取cookie,通过获取cookie中的sessionId查找数据库判断当前请求是否有效
基于JWT的认证流程


  • 用户在浏览器中输入用户名和密码,服务器通过密码校验后生成一个token并保存到数据库
  • 前端获取到token,存储到cookie或者local storage中,在后续的请求中都将带有这个token信息进行访问
  • 服务器获取token值,通过查找数据库判断当前token是否有效
优缺点


  • JWT保存在客户端,在分布式环境下不需要做额外工作。而session因为保存在服务端,分布式环境下需要实现多机数据共享
  • session一般需要结合Cookie实现认证,所以需要浏览器支持cookie,因此移动端无法使用session认证方案
安全性


  • JWT的payload使用的是base64编码的,因此在JWT中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全

如果在JWT中存储了敏感信息,可以解码出来非常的不安全
性能


  • 经过编码之后JWT将非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以JWT一般放在local storage里面。并且用户在系统中的每一次http请求都会把JWT携带在Header里面,HTTP请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用JWT的HTTP请求比使用session的开销大得多
一次性

无状态是JWT的特点,但也导致了这个问题,JWT是一次性的。想修改里面的内容,就必须签发一个新的JWT

  • 无法废弃
    一旦签发一个JWT,在到期之前就会始终有效,无法中途废弃。若想废弃,一种常用的处理手段是结合redis
  • 续签
    如果使用JWT做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变JWT的有效时间,就要签发新的JWT。最简单的一种方式是每次请求刷新JWT,即每个HTTP请求都返回一个新的JWT。这个方法不仅暴力不优雅,而且每次请求都要做JWT的加密解密,会带来性能问题。另一种方法是在redis中单独为每个JWT设置过期时间,每次访问时刷新JWT的过期时间
选择JWT或session

我投JWT一票,JWT有很多缺点,但是在分布式环境下不需要像session一样额外实现多机数据共享,虽然seesion的多机数据共享可以通过粘性sessionsession共享session复制持久化sessionterracoa实现seesion复制等多种成熟的方案来解决这个问题。但是JWT不需要额外的工作,使用JWT不香吗?且JWT一次性的缺点可以结合redis进行弥补。扬长补短,因此在实际项目中选择的是使用JWT来进行认证
功能实现

JWT所需依赖
  1. <dependency>
  2.     <groupId>com.auth0</groupId>
  3.     <artifactId>java-jwt</artifactId>
  4.     <version>3.10.3</version>
  5. </dependency>
复制代码
JWT工具类
  1. public class JWTUtil {
  2.     private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class);
  3.     //私钥
  4.     private static final String TOKEN_SECRET = "123456";
  5.     /**
  6.      * 生成token,自定义过期时间 毫秒
  7.      *
  8.      * @param userTokenDTO
  9.      * @return
  10.      */
  11.     public static String generateToken(UserTokenDTO userTokenDTO) {
  12.         try {
  13.             // 私钥和加密算法
  14.             Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
  15.             // 设置头部信息
  16.             Map<String, Object> header = new HashMap<>(2);
  17.             header.put("Type", "Jwt");
  18.             header.put("alg", "HS256");
  19.             return JWT.create()
  20.                     .withHeader(header)
  21.                     .withClaim("token", JSONObject.toJSONString(userTokenDTO))
  22.                     //.withExpiresAt(date)
  23.                     .sign(algorithm);
  24.         } catch (Exception e) {
  25.             logger.error("generate token occur error, error is:{}", e);
  26.             return null;
  27.         }
  28.     }
  29.     /**
  30.      * 检验token是否正确
  31.      *
  32.      * @param token
  33.      * @return
  34.      */
  35.     public static UserTokenDTO parseToken(String token) {
  36.         Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
  37.         JWTVerifier verifier = JWT.require(algorithm).build();
  38.         DecodedJWT jwt = verifier.verify(token);
  39.         String tokenInfo = jwt.getClaim("token").asString();
  40.         return JSON.parseObject(tokenInfo, UserTokenDTO.class);
  41.     }
  42. }
复制代码
说明:

  • 生成的token中不带有过期时间,token的过期时间由redis进行管理
  • UserTokenDTO中不带有敏感信息,如password字段不会出现在token中
Redis工具类
  1. public final class RedisServiceImpl implements RedisService {
  2.     /**
  3.      * 过期时长
  4.      */
  5.     private final Long DURATION = 1 * 24 * 60 * 60 * 1000L;
  6.     @Resource
  7.     private RedisTemplate redisTemplate;
  8.     private ValueOperations<String, String> valueOperations;
  9.     @PostConstruct
  10.     public void init() {
  11.         RedisSerializer redisSerializer = new StringRedisSerializer();
  12.         redisTemplate.setKeySerializer(redisSerializer);
  13.         redisTemplate.setValueSerializer(redisSerializer);
  14.         redisTemplate.setHashKeySerializer(redisSerializer);
  15.         redisTemplate.setHashValueSerializer(redisSerializer);
  16.         valueOperations = redisTemplate.opsForValue();
  17.     }
  18.     @Override
  19.     public void set(String key, String value) {
  20.         valueOperations.set(key, value, DURATION, TimeUnit.MILLISECONDS);
  21.         log.info("key={}, value is: {} into redis cache", key, value);
  22.     }
  23.     @Override
  24.     public String get(String key) {
  25.         String redisValue = valueOperations.get(key);
  26.         log.info("get from redis, value is: {}", redisValue);
  27.         return redisValue;
  28.     }
  29.     @Override
  30.     public boolean delete(String key) {
  31.         boolean result = redisTemplate.delete(key);
  32.         log.info("delete from redis, key is: {}", key);
  33.         return result;
  34.     }
  35.     @Override
  36.     public Long getExpireTime(String key) {
  37.         return valueOperations.getOperations().getExpire(key);
  38.     }
  39. }
复制代码
RedisTemplate简单封装
业务实现

登陆功能
  1. public String login(LoginUserVO loginUserVO) {
  2.     //1.判断用户名密码是否正确
  3.     UserPO userPO = userMapper.getByUsername(loginUserVO.getUsername());
  4.     if (userPO == null) {
  5.         throw new UserException(ErrorCodeEnum.TNP1001001);
  6.     }
  7.     if (!loginUserVO.getPassword().equals(userPO.getPassword())) {
  8.         throw new UserException(ErrorCodeEnum.TNP1001002);
  9.     }
  10.     //2.用户名密码正确生成token
  11.     UserTokenDTO userTokenDTO = new UserTokenDTO();
  12.     PropertiesUtil.copyProperties(userTokenDTO, loginUserVO);
  13.     userTokenDTO.setId(userPO.getId());
  14.     userTokenDTO.setGmtCreate(System.currentTimeMillis());
  15.     String token = JWTUtil.generateToken(userTokenDTO);
  16.     //3.存入token至redis
  17.     redisService.set(userPO.getId(), token);
  18.     return token;
  19. }
复制代码
说明:

  • 判断用户名密码是否正确
  • 用户名密码正确则生成token
  • 将生成的token保存至redis
登出功能
  1. public boolean loginOut(String id) {
  2.      boolean result = redisService.delete(id);
  3.      if (!redisService.delete(id)) {
  4.         throw new UserException(ErrorCodeEnum.TNP1001003);
  5.      }
  6.      return result;
  7. }
复制代码
将对应的key删除即可
更新密码功能
  1. public String updatePassword(UpdatePasswordUserVO updatePasswordUserVO) {
  2.     //1.修改密码
  3.     UserPO userPO = UserPO.builder().password(updatePasswordUserVO.getPassword())
  4.             .id(updatePasswordUserVO.getId())
  5.             .build();
  6.     UserPO user = userMapper.getById(updatePasswordUserVO.getId());
  7.     if (user == null) {
  8.         throw new UserException(ErrorCodeEnum.TNP1001001);
  9.     }
  10.     if (userMapper.updatePassword(userPO) != 1) {
  11.         throw new UserException(ErrorCodeEnum.TNP1001005);
  12.     }
  13.     //2.生成新的token
  14.     UserTokenDTO userTokenDTO = UserTokenDTO.builder()
  15.             .id(updatePasswordUserVO.getId())
  16.             .username(user.getUsername())
  17.             .gmtCreate(System.currentTimeMillis()).build();
  18.     String token = JWTUtil.generateToken(userTokenDTO);
  19.     //3.更新token
  20.     redisService.set(user.getId(), token);
  21.     return token;
  22. }
复制代码
说明:
更新用户密码时需要重新生成新的token,并将新的token返回给前端,由前端更新保存在local storage中的token,同时更新存储在redis中的token,这样实现可以避免用户重新登陆,用户体验感不至于太差
其他说明


  • 在实际项目中,用户分为普通用户和管理员用户,只有管理员用户拥有删除用户的权限,这一块功能也是涉及token操作的,但是我太懒了,demo工程就不写了
  • 在实际项目中,密码传输是加密过的
拦截器类
  1. public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
  2.                              Object handler) throws Exception {
  3.     String authToken = request.getHeader("Authorization");
  4.     String token = authToken.substring("Bearer".length() + 1).trim();
  5.     UserTokenDTO userTokenDTO = JWTUtil.parseToken(token);
  6.     //1.判断请求是否有效
  7.     if (redisService.get(userTokenDTO.getId()) == null
  8.             || !redisService.get(userTokenDTO.getId()).equals(token)) {
  9.         return false;
  10.     }
  11.     //2.判断是否需要续期
  12.     if (redisService.getExpireTime(userTokenDTO.getId()) < 1 * 60 * 30) {
  13.         redisService.set(userTokenDTO.getId(), token);
  14.         log.error("update token info, id is:{}, user info is:{}", userTokenDTO.getId(), token);
  15.     }
  16.     return true;
  17. }
复制代码
说明:
拦截器中主要做两件事,一是对token进行校验,二是判断token是否需要进行续期
token校验:

  • 判断id对应的token是否不存在,不存在则token过期
  • 若token存在则比较token是否一致,保证同一时间只有一个用户操作
token自动续期:  为了不频繁操作redis,只有当离过期时间只有30分钟时才更新过期时间
拦截器配置类
  1. @Configuration
  2. public class InterceptorConfig implements WebMvcConfigurer {
  3.     @Override
  4.     public void addInterceptors(InterceptorRegistry registry) {
  5.         registry.addInterceptor(authenticateInterceptor())
  6.                 .excludePathPatterns("/logout/**")
  7.                 .excludePathPatterns("/login/**")
  8.                 .addPathPatterns("/**");
  9.     }
  10.     @Bean
  11.     public AuthenticateInterceptor authenticateInterceptor() {
  12.         return new AuthenticateInterceptor();
  13.     }
  14. }
复制代码
写在最后

若有纰漏不足,欢迎指出
近期热文推荐:
1.1,000+ 道 Java面试题及答案整理(2022最新版)
2.劲爆!Java 协程要来了。。。
3.Spring Boot 2.x 教程,太全了!
4.别再写满屏的爆爆爆炸类了,试试装饰器模式,这才是优雅的方式!!
5.《Java开发手册(嵩山版)》最新发布,速速下载!
觉得不错,别忘了随手点赞+转发哦!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

万有斥力

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表