Redis - Token JWT 概念解析及双token实现分布式session存储实战
Token
- 定义:令牌,访问资源接口(API)时所需要的资源凭证
一、Access Token
-
定义
:访问资源接口(API)时所需要的资源凭证,存储在客户端 -
组成
组成部分 说明 uid 用户唯一的身份标识 time 当前时间的时间戳 sign 签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串 -
验证流程
- 登录:客户端使用 username & password 请求登录
- 验证:服务端收到请求,验证 username & password
- 验证成功 → 服务端会签发一个 token 并把这个 token 发送给客户端
- 验证失败 → 登录失败
- 存储 token:客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里
- 携带 token:客户端每次向服务端请求资源的时候需要携带服务端签发的 token(放在 HTTP 的 Header 中)
- 解析 token:服务端收到请求,解析客户端的 token 数据
- 验证成功 → 向客户端返回请求的数据
- 验证失败 → 拒绝请求,要求重新登录
二、Refresh Token
-
定义
:专用于刷新 access token 的 token -
功能
:减少重复登录操作,Access Token 失效时,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作 -
存储位置
:服务器的数据库 -
工作流程
JWT
一、概述
- 定义:JSON Web Token(简称 JWT),一种认证授权机制,是目前最流行的跨域认证解决方案
- 功能:实现跨域请求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录(session 所有数据都保存在客户端,每次请求都发回服务器)
- 存储位置:HTTP 请求的头信息
Authorization
字段中,格式为:Authorization: Bearer <token>
- 基本格式:(Header.Payload.Signature)
二、组成部分
-
Header
-
定义:描述 JWT 的元数据(配置信息),记录令牌类型、签名算法等配置,由 Base64URL 算法转为字符串
-
示例
{ "alg": "HS256", // 签名算法类型 "typ": "JWT" // Token类型 }
-
-
Payload
-
定义:记录用户信息的数据(不是加密数据,不能存敏感信息)
-
示例
{ "sub": "1234567890", "name": "John Doe", "admin": true }
-
-
Signature
-
定义:对前两部分(Header和Payload)的数字签名,用于验证消息的完整性和确保数据未被篡改。这是JWT安全性的核心保障
-
生成过程:
- 服务器持有一个密钥(secret),该密钥必须妥善保管且不能泄露
- 使用Header中指定的签名算法(默认为HMAC SHA256)
- 将编码后的Header和Payload用"."连接,再使用密钥和签名算法生成签名
-
获取签名方式:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
-
三、优缺点
- 优点
- JWT 是自包含的(内部包含了一些会话信息),因此减少了查询数据库的需要,有效使用 JWT,可以降低服务器查询数据库的次数
- JWT 不仅可以用于认证,也可以用于交换信息
- JWT 并不使用 Cookie 的,所以可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)
- 缺点
- JWT 的最大缺点:由于服务器不保存 session 状态,因此无法在使用过程中废止或更改某个 token 的权限。一旦 JWT 签发,在到期之前就会始终有效
- JWT 默认不加密,但也是可以加密(生成原始 Token 以后,可以用密钥再加密一次)
- JWT 不加密的情况下,不能将秘密数据写入 JWT
- JWT 本身包含了认证信息,一旦泄露,任何人都可以通过 JWT 获得该 JWT 的所有权限
四、工作流程
代码实现
⭐参考资料:
- https://blog.csdn.net/gitblog_09788/article/details/143407938#:~:text=1 JWT集成:生成安全的JWT令牌,用于用户身份验证。 2 Redis存储:将刷新令牌存储在Redis中,利用其高速特性进行快速校验。 3 双Token机制:,访问令牌:短寿命,用于直接的API访问。 刷新令牌:长寿命,用于在访问令牌过期时自动获取新令牌。 4 自动刷新:当访问令牌过期时,系统可自动利用有效的刷新令牌获取新的访问令牌。 5 安全性强化:通过Redis的过期策略、JWT的签名验证以及适当的访问控制,增强系统的安全性。
- GitHub - dolyw/ShiroJwt: API SpringBoot + Shiro + Java-Jwt + Redis(Jedis)
方案设计
-
方案对比
特性 JWT + Redis 白名单 JWT + Redis 存储用户会话 设计理念 无状态为主,Redis 仅辅助校验 强状态管理,Redis 为中心,JWT 辅助 Redis 存储压力 存储 jti
或少量数据,存储压力小存储完整会话信息,存储占用较高 实现复杂度 较低,直接存储 jti
和过期时间,校验简单较高,需要设计用户会话结构、处理多端登录等逻辑 主动失效能力 易实现:只需从 Redis 删除对应的 jti
即可易实现:删除会话即可失效 会话扩展能力 较弱,只适合验证 Token 是否有效 强,可以存储用户登录的扩展信息(设备、角色等) 支持多端登录 较弱,需要额外逻辑 强,天然支持多个会话实例 性能开销 性能更高(JWT 主要靠自包含验证,少量 Redis 查询) Redis 频繁交互性能略低,适合中等并发的场景 业务需求复杂度 简单业务场景 复杂业务场景 -
方案选择:JWT + Redis 存储用户会话,可以存储更多用户信息,并且没有泄露风险
校验请求
-
目标:过滤所有 token 为空或不合法的请求
-
代码(com.lloop.authcheckdemo.interceptor.UserLoginFilter)
@Slf4j @Component public class UserLoginFilter implements HandlerInterceptor { @Resource JwtUtils jwtUtils; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 获取token String token = request.getHeader(jwtUtils.header); // 2. 判断token是否有效 ThrowUtils.throwIf(StringUtils.isEmpty(token), ErrorCode.NULL_ERROR, "请登录后操作!"); ThrowUtils.throwIf(jwtUtils.isTokenExpired(token), ErrorCode.PARAMS_ERROR, "登录已过期!"); ThrowUtils.throwIf(jwtUtils.checkBlacklist(token), ErrorCode.PARAMS_ERROR, "用户已被禁止登录!"); // 3. token有效 => 记录登录用户信息 UserTokenInfo userTokenInfo = jwtUtils.getUserInfoToken(token); ThrowUtils.throwIf(ObjectUtils.isEmpty(userTokenInfo), ErrorCode.NULL_ERROR, "对不起,身份认证出现错误,请重新登录..."); UserHolder.saveUser(userTokenInfo); return true; } /** * 移除用户信息,防止内存溢出 * @param request * @param response * @param handler * @param ex * @throws Exception */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
注册拦截器
-
目标:将校验请求的拦截器注册到项目中
-
注意:只需要匹配 controller 的路径部分,server.servlet.context-path 不用管
-
代码
@Configuration public class WebConfig implements WebMvcConfigurer { @Resource private UserLoginFilter userLoginFilter; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(userLoginFilter) .addPathPatterns("/**") .excludePathPatterns("/login", "/register"); } }
创建用户信息类
-
目标:解析用户token中存储的信息,存入 UserHolder 中供 Controller 调用
-
代码
@Data public class UserTokenInfo { /** * ID,唯一 */ private Long id; /** * 账号 */ private String account; /** * 用户昵称 */ private String username; /** * 用户角色 0 - 普通用户 1 - 管理员 */ private Integer role; }
创建JWT工具类
-
目标:创建、校验、解析 token
-
代码
@Data @Component public class JwtUtils { @Value("${jwt.secret}") public String secret; @Value("${jwt.header}") public String header; @Value("${jwt.expire.accessToken}") public Integer accessTokenExpire; @Value("${jwt.expire.refreshToken}") public Integer refreshTokenExpire; @Resource RedisUtils redisUtils; private static final Gson gson = new Gson(); /** * 获取用户信息 * * @param token * @return */ public UserTokenInfo getUserInfoToken(String token) { String subject = getTokenClaim(token).getSubject(); UserTokenInfo userTokenInfo = gson.fromJson(subject, UserTokenInfo.class); return userTokenInfo; } /** * 获取 token 中注册信息 * * @param token * @return */ public Claims getTokenClaim(String token) { try { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (Exception e) { return null; } } }