JWT 实现登录认证 + Token 自动续期方案,这才是正确的使用姿势!
项目中基本都有用户管理模块,而用户管理模块会涉及到加密及认证流程。
今天就来讲讲认证功能的技术选型及实现。技术上没啥难度当然也没啥挑战,但是对一个原先没写过认证功能的人来说也是一种锻炼吧
技术选型
要实现认证功能,很容易就会想到 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 所需依赖
com.auth0
java-jwt
3.10.3
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 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 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 分钟时才更新过期时间
拦截器配置类
@Configuration
public 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();
}
}
源码附件已经打包好上传到百度云了,大家自行下载即可~
链接: https://pan.baidu.com/s/14G-b...
提取码: yu27
百度云链接不稳定,随时可能会失效,大家抓紧保存哈。
如果百度云链接失效了的话,请留言告诉我,我看到后会及时更新~
开源地址
码云地址:
https://gitee.com/ZhongBangKe...
Github 地址:
https://gitee.com/ZhongBangKe...