⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/6932702419344162823 「何甜甜在吗」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

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

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分钟时才更新过期时间

拦截器配置类

@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();
}
}

文章目录
  1. 1. 技术选型
    1. 1.1. 区别
    2. 1.2. 认证流程
      1. 1.2.0.1. 基于session的认证流程
      2. 1.2.0.2. 基于JWT的认证流程
  2. 1.3. 优缺点
  3. 1.4. 安全性
  4. 1.5. 性能
  5. 1.6. 一次性
  6. 1.7. 无法废弃
  7. 1.8. 续签
  8. 1.9. 选择JWT或session
  • 2. 功能实现
    1. 2.1. Redis工具类
    2. 2.2. 业务实现
      1. 2.2.0.1. 登陆功能
      2. 2.2.0.2. 登出功能
      3. 2.2.0.3. 更新密码功能
      4. 2.2.0.4. 其他说明
      5. 2.2.0.5. 拦截器类
      6. 2.2.0.6. 说明:
      7. 2.2.0.7. 拦截器配置类