查看原文
其他

JWT实现登陆认证及Token自动续期

架构师 2023-06-18

架构师(JiaGouX)我们都是架构师!
架构未来,你来不来?


因公众号更改推送规则,请点“在看”并加“星标”第一时间获取精彩技术分享

  • 技术选型
    • 区别
    • 认证流程
    • 优缺点
    • 安全性
    • 性能
    • 一次性
    • 无法废弃
    • 续签
    • 选择JWT或session
  • 功能实现
    • Redis工具类
    • 业务实现

过去这段时间主要负责了项目中的用户管理模块,用户管理模块会涉及到加密及认证流程,加密已经在前面的文章中介绍了,可以阅读用户管理模块:

https://juejin.cn/post/6916150628955717646

今天就来讲讲认证功能的技术选型及实现。技术上没啥难度当然也没啥挑战,但是对一个原先没写过认证功能的菜鸡甜来说也是一种锻炼吧

技术选型

要实现认证功能,很容易就会想到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的多机数据共享可以通过粘性session、session共享、session复制、持久化session、terracoa实现seesion复制等多种成熟的方案来解决这个问题。但是JWT不需要额外的工作,使用JWT不香吗?且JWT一次性的缺点可以结合redis进行弥补。

扬长补短,因此在实际项目中选择的是使用JWT来进行认证

功能实现

JWT所需依赖

<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version></dependency>

JWT工具类

public class JWTUtil { private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class);
//私钥 private static final String TOKEN_SECRET = "123456";
/** * 生成token,自定义过期时间 毫秒 * * @param userTokenDTO * @return */ public static String generateToken(UserTokenDTO userTokenDTO) { try { // 私钥和加密算法 Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); // 设置头部信息 Map<String, Object> header = new HashMap<>(2); header.put("Type", "Jwt"); header.put("alg", "HS256");
return JWT.create() .withHeader(header) .withClaim("token", JSONObject.toJSONString(userTokenDTO)) //.withExpiresAt(date) .sign(algorithm); } catch (Exception e) { logger.error("generate token occur error, error is:{}", e); return null; } }
/** * 检验token是否正确 * * @param token * @return */ public static UserTokenDTO parseToken(String token) { Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); JWTVerifier verifier = JWT.require(algorithm).build(); DecodedJWT jwt = verifier.verify(token); String tokenInfo = jwt.getClaim("token").asString(); return JSON.parseObject(tokenInfo, UserTokenDTO.class); }}

说明:

  • 生成的token中不带有过期时间,token的过期时间由redis进行管理
  • UserTokenDTO中不带有敏感信息,如password字段不会出现在token中

Redis工具类

public final class RedisServiceImpl implements RedisService { /** * 过期时长 */ private final Long DURATION = 1 * 24 * 60 * 60 * 1000L;
@Resource private RedisTemplate redisTemplate;
private ValueOperations<String, String> valueOperations;
@PostConstruct public void init() { RedisSerializer redisSerializer = new StringRedisSerializer(); redisTemplate.setKeySerializer(redisSerializer); redisTemplate.setValueSerializer(redisSerializer); redisTemplate.setHashKeySerializer(redisSerializer); redisTemplate.setHashValueSerializer(redisSerializer); valueOperations = redisTemplate.opsForValue(); }
@Override public void set(String key, String value) { valueOperations.set(key, value, DURATION, TimeUnit.MILLISECONDS); log.info("key={}, value is: {} into redis cache", key, value); }
@Override public String get(String key) { String redisValue = valueOperations.get(key); log.info("get from redis, value is: {}", redisValue); return redisValue; }
@Override public boolean delete(String key) { boolean result = redisTemplate.delete(key); log.info("delete from redis, key is: {}", key); return result; }
@Override public Long getExpireTime(String key) { return valueOperations.getOperations().getExpire(key); }}

RedisTemplate简单封装

业务实现

登陆功能
public String login(LoginUserVO loginUserVO) { //1.判断用户名密码是否正确 UserPO userPO = userMapper.getByUsername(loginUserVO.getUsername()); if (userPO == null) { throw new UserException(ErrorCodeEnum.TNP1001001); } if (!loginUserVO.getPassword().equals(userPO.getPassword())) { throw new UserException(ErrorCodeEnum.TNP1001002); }
//2.用户名密码正确生成token UserTokenDTO userTokenDTO = new UserTokenDTO(); PropertiesUtil.copyProperties(userTokenDTO, loginUserVO); userTokenDTO.setId(userPO.getId()); userTokenDTO.setGmtCreate(System.currentTimeMillis()); String token = JWTUtil.generateToken(userTokenDTO);
//3.存入token至redis redisService.set(userPO.getId(), token); return token;}

说明:

  • 判断用户名密码是否正确
  • 用户名密码正确则生成token
  • 将生成的token保存至redis
登出功能
public boolean loginOut(String id) { boolean result = redisService.delete(id); if (!redisService.delete(id)) { throw new UserException(ErrorCodeEnum.TNP1001003); }
return result;}

将对应的key删除即可

更新密码功能
public String updatePassword(UpdatePasswordUserVO updatePasswordUserVO) { //1.修改密码 UserPO userPO = UserPO.builder().password(updatePasswordUserVO.getPassword()) .id(updatePasswordUserVO.getId()) .build(); UserPO user = userMapper.getById(updatePasswordUserVO.getId()); if (user == null) { throw new UserException(ErrorCodeEnum.TNP1001001); }
if (userMapper.updatePassword(userPO) != 1) { throw new UserException(ErrorCodeEnum.TNP1001005); } //2.生成新的token UserTokenDTO userTokenDTO = UserTokenDTO.builder() .id(updatePasswordUserVO.getId()) .username(user.getUsername()) .gmtCreate(System.currentTimeMillis()).build(); String token = JWTUtil.generateToken(userTokenDTO); //3.更新token redisService.set(user.getId(), token); return token;}

说明:

更新用户密码时需要重新生成新的token,并将新的token返回给前端,由前端更新保存在local storage中的token,同时更新存储在redis中的token,这样实现可以避免用户重新登陆,用户体验感不至于太差

其他说明

在实际项目中,用户分为普通用户和管理员用户,只有管理员用户拥有删除用户的权限,这一块功能也是涉及token操作的,但是我太懒了,demo工程就不写了

在实际项目中,密码传输是加密过的

拦截器类
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authToken = request.getHeader("Authorization"); String token = authToken.substring("Bearer".length() + 1).trim(); UserTokenDTO userTokenDTO = JWTUtil.parseToken(token); //1.判断请求是否有效 if (redisService.get(userTokenDTO.getId()) == null || !redisService.get(userTokenDTO.getId()).equals(token)) { return false; }
//2.判断是否需要续期 if (redisService.getExpireTime(userTokenDTO.getId()) < 1 * 60 * 30) { redisService.set(userTokenDTO.getId(), token); log.error("update token info, id is:{}, user info is:{}", userTokenDTO.getId(), token); } return true;}
说明:

拦截器中主要做两件事,一是对token进行校验,二是判断token是否需要进行续期

token校验:

  • 判断id对应的token是否不存在,不存在则token过期
  • 若token存在则比较token是否一致,保证同一时间只有一个用户操作

token自动续期:

为了不频繁操作redis,只有当离过期时间只有30分钟时才更新过期时间

拦截器配置类
@Configurationpublic class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticateInterceptor()) .excludePathPatterns("/logout/**") .excludePathPatterns("/login/**") .addPathPatterns("/**"); }
@Bean public AuthenticateInterceptor authenticateInterceptor() { return new AuthenticateInterceptor(); }}


如喜欢本文,请点击右上角,把文章分享到朋友圈
如有想了解学习的技术点,请留言给若飞安排分享

·END·

相关阅读:

往期推荐

MyBatis 的执行流程分析

Redis分布式锁真的安全吗?

架构应该如何来理解?

基于 Apache APISIX 的自动化运维平台

周边检索POI技术方案设计

优化 Nginx HTTPS 延迟 - 如何让Nginx提速 30%的?

浅谈缓存的理论与实践


作者:

来源:juejin.cn/post/6932702419344162823

版权申明:内容来源网络,仅供分享学习,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!

架构师

我们都是架构师!



关注架构师(JiaGouX),添加“星标”

获取每天技术干货,一起成为牛逼架构师

技术群请加若飞:1321113940 进架构师群

投稿、合作、版权等邮箱:admin@137x.com

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存