开源仓库:https://gitee.com/youlaiorg/youlai-boot

在这里插入图片描述

前言

本系列文章基于 youlai-boot 开源项目实践,一个开箱即用的 Spring Boot 后台管理系统。

记得刚入行那会儿,认证就是 Session 一把梭,简单粗暴。后来微服务火了,分布式部署成了标配,Session 共享又成了头疼事。JWT 天生无状态,看起来完美解决,但 Token 撤销又是个坑——用户改密码了,Token 还在有效期怎么办?

youlai-boot 的认证模块支持两种模式自由切换,算是把这个问题想透了。今天就来聊聊这套方案的设计思路。

JWT vs Redis-Token:怎么选?

先说结论:

场景 推荐方案
微服务架构、追求性能 JWT
需要在线用户管理、精细化会话控制 Redis-Token
传统单体应用 都行

JWT 的优势:无状态,服务端不存储会话,天生支持分布式;性能好,省去每次请求查 Redis。

JWT 的硬伤:签发出去的 Token 在过期前无法主动撤销,除非额外维护黑名单或版本号。

Redis-Token 的优势:会话完全可控,随时踢下线,支持在线用户管理。

Redis-Token 的劣势:每次请求都要查 Redis,有网络开销;依赖 Redis 高可用。

所以核心问题变成:你需要"主动让 Token 失效"吗?需要,选 Redis-Token 或 JWT+Redis 辅助;不需要,纯 JWT 够用。

认证流程:两种模式大同小异

整体流程就这几步:

  1. 用户提交账号密码
  2. 后端验证凭证
  3. 查询用户信息和权限
  4. 生成 Token 返回
  5. 后续请求携带 Token,后端校验

JWT 模式下,Token 里直接编码用户信息;Redis-Token 模式,Token 只是个 UUID,用户信息存 Redis。

JWT 的 Token 结构设计

Payload 里放什么,这是个设计问题。youlai-boot 的方案:

{
  "sub": "admin",
  "jti": "abc123",
  "iat": 1700000000,
  "exp": 1700003600,
  "userId": 1,
  "deptId": 10,
  "tokenVersion": 5,
  "dataScopes": [...],
  "authorities": ["ROLE_ADMIN", "user:add"]
}

关键字段说明:

  • jti:Token 唯一标识,用于单端退出(加入黑名单)
  • tokenVersion:Token 版本号,用于全端踢下线
  • dataScopes:数据权限列表,多角色合并后的结果

这个 tokenVersion 是个巧妙的设计,下面细说。

会话失效:两种场景的处理

场景一:用户主动退出

JWT 模式:把 Token 的 jti 加入 Redis 黑名单,有效期设为 Token 剩余有效期。后续请求校验时查黑名单,在黑名单里就拒绝。

Redis-Token 模式:直接删除 Redis 中对应的会话数据。

场景二:全端踢下线

用户改密码、被封禁、管理员强制踢出等场景,需要让该用户所有设备全部下线。

JWT 模式:用 tokenVersion 解决。Redis 中维护一个 key:auth:user:token_version:{userId},存储当前版本号。用户改密码时,递增这个版本号。校验 Token 时,比对 Token 里的版本号和 Redis 版本号,小于就意味着 Token 已失效。

// 校验逻辑
int redisVersion = redis.get("auth:user:token_version:" + userId) ?: 0;
if (token.tokenVersion < redisVersion) {
    return false; // Token 版本过低,全部失效
}

Redis-Token 模式:直接删除该用户所有相关的 Redis key。

这套机制用下来体验不错,改密码后全端下线,安全性到位。

Token 刷新:无感续期

为什么需要双 Token 机制?

想象一下这个场景:

你正在填写一个复杂的表单,花了20分钟。提交时,系统提示"登录已过期",所有填写的内容都丢了…

这就是 Access Token 有效期过长的问题——安全但用户体验差。解决方案是 双 Token 机制

Token 类型 有效期 作用 比喻
Access Token 1小时 访问资源 身份证,随时掏出来用
Refresh Token 7天 换取新 Access Token 户口本,身份证过期时用它补办

整体架构流程

Token存储 后端 响应拦截器 前端 Token存储 后端 响应拦截器 前端 并发请求共享同一次刷新 用户全程无感知 发起请求 (携带过期 Token) 校验 Token 失败 返回 401 + ACCESS_TOKEN_INVALID 响应拦截器捕获错误 检测 code === ACCESS_TOKEN_INVALID 获取 Refresh Token POST /auth/refresh-token (单飞模式) 校验 Refresh Token 生成新 Access Token 返回新 Token 对 更新存储的 Token 用新 Token 重试原请求 返回业务数据

后端刷新接口实现

@Operation(summary = "刷新令牌")
@PostMapping("/refresh-token")
public Result<?> refreshToken(
    @RequestParam String refreshToken
) {
    AuthenticationToken token = authService.refreshToken(refreshToken);
    return Result.success(token);
}

JWT 模式刷新逻辑

@Override
public AuthenticationToken refreshToken(String refreshToken) {
    // 1. 校验刷新令牌(签名、过期、类型、版本号)
    boolean isValid = validateRefreshToken(refreshToken);
    if (!isValid) {
        throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
    }

    // 2. 解析用户信息
    Authentication authentication = parseToken(refreshToken);

    // 3. 生成新的 Access Token
    String newAccessToken = generateToken(authentication, accessTokenTTL);

    // 4. 返回新 Token(Refresh Token 保持不变)
    return AuthenticationToken.builder()
            .accessToken(newAccessToken)
            .refreshToken(refreshToken)
            .expiresIn(accessTokenTTL)
            .build();
}

Redis-Token 模式刷新逻辑

@Override
public AuthenticationToken refreshToken(String refreshToken) {
    // 1. 从 Redis 获取用户会话
    UserSession session = redis.get("auth:token:refresh:" + refreshToken);
    if (session == null) {
        throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
    }

    // 2. 删除旧的 Access Token
    redis.delete("auth:token:access:" + oldAccessToken);

    // 3. 生成并存储新的 Access Token
    String newAccessToken = UUID.randomUUID();
    redis.set("auth:token:access:" + newAccessToken, session, accessTokenTTL);

    return new AuthenticationToken(newAccessToken, refreshToken);
}

前端自动刷新实现

核心机制:单飞模式防止并发刷新

let refreshPromise: Promise<void> | null = null;

/**
 * 刷新 Token(单飞模式)
 * 多个并发请求遇到 Token 过期时,共享同一次刷新请求
 */
function refreshTokenOnce() {
  // 如果已有刷新请求在进行,直接返回该 Promise
  if (refreshPromise) return refreshPromise;

  // 创建新的刷新请求
  refreshPromise = refreshToken().finally(() => {
    refreshPromise = null;  // 完成后清空
  });

  return refreshPromise;
}

响应拦截器自动刷新

http.interceptors.response.use(
  (response) => response,
  async (error) => {
    const { config, response } = error;
    const { code } = response.data;

    // Token 过期:刷新后重试
    if (code === 'ACCESS_TOKEN_INVALID') {
      await userStore.refreshTokenOnce();  // 单飞刷新
      config.headers.Authorization = `Bearer ${getAccessToken()}`;
      return http(config);  // 重试原请求
    }

    // Refresh Token 也过期:跳转登录
    if (code === 'REFRESH_TOKEN_INVALID') {
      router.push('/login');
    }

    return Promise.reject(error);
  }
);
后端 refreshTokenOnce() 请求3 请求2 请求1 后端 refreshTokenOnce() 请求3 请求2 请求1 par [并发请求到达] 多个请求共享同一个 Promise par [共享刷新结果] 发现 Token 过期 发现 Token 过期 发现 Token 过期 创建 refreshPromise POST /refresh-token (只发一次) 返回新 Token 新 Token 新 Token 新 Token 重试原请求 重试原请求 重试原请求 业务数据 业务数据 业务数据

两种模式对比

特性 JWT 模式 Redis-Token 模式
Access Token 自包含用户信息 UUID,用户信息存 Redis
刷新方式 解析 Token 生成新 Token 用 Token 查 Redis 获取用户信息
撤销机制 jti 黑名单 + tokenVersion 直接删除 Redis key
性能 无需查库 每次请求查 Redis

配置说明

security:
  session:
    type: jwt  # 或 redis-token
    access-token-time-to-live: 3600      # Access Token 1小时
    refresh-token-time-to-live: 604800   # Refresh Token 7天

权限缓存:Read-Through 模式

为什么需要权限缓存?

每次请求都查数据库获取权限会有几个问题:

  1. 性能瓶颈:数据库查询是 IO 密集型操作,高并发下会成为系统瓶颈
  2. 资源浪费:权限数据变更频率很低,但查询频率极高,重复查询不合理
  3. 响应延迟:每次请求多一次数据库查询,增加接口响应时间

Read-Through 模式原理

Read-Through 是一种缓存策略:应用只和缓存交互,缓存未命中时由缓存层负责从数据库加载数据

应用请求权限

缓存是否存在?

返回缓存数据

从数据库加载

写入缓存

Redis Hash 存储结构

youlai-boot 用 Redis Hash 缓存角色权限,结构如下:

Key: system:role:perms
Field: ADMIN    Value: ["sys:user:add", "sys:user:edit", "sys:role:list", ...]
Field: USER     Value: ["sys:user:view", ...]
Field: GUEST    Value: ["sys:notice:list", ...]

为什么用 Hash 而不是 String?

方案 结构 优势 劣势
String role:ADMIN:perms → JSON 数组 简单直观 多角色查询需多次请求
Hash system:role:perms → {ADMIN: […], USER: […]} 一次请求获取所有角色权限 需要解析 Field

选择 Hash 是因为用户通常拥有多个角色,一次 HGETALLHMGET 即可获取所有角色的权限。

核心代码实现

@Service
public class PermissionService {

    private static final String CACHE_KEY = "system:role:perms";

    /**
     * 获取角色权限列表(带缓存)
     */
    public Set<String> getRolePerms(String roleCode) {
        // 1. 先查缓存
        Object cached = redisTemplate.opsForHash().get(CACHE_KEY, roleCode);
        if (cached != null) {
            return (Set<String>) cached;
        }

        // 2. 缓存未命中,查数据库
        Set<String> perms = roleMapper.selectPermsByRoleCode(roleCode);
        if (CollUtil.isEmpty(perms)) {
            perms = Collections.emptySet();
        }

        // 3. 写入缓存
        redisTemplate.opsForHash().put(CACHE_KEY, roleCode, perms);
        return perms;
    }

    /**
     * 刷新角色权限缓存(权限变更时调用)
     */
    public void refreshRolePermsCache(String roleCode) {
        Set<String> perms = roleMapper.selectPermsByRoleCode(roleCode);
        redisTemplate.opsForHash().put(CACHE_KEY, roleCode, perms);
    }

    /**
     * 清除角色权限缓存(角色删除时调用)
     */
    public void clearRolePermsCache(String roleCode) {
        redisTemplate.opsForHash().delete(CACHE_KEY, roleCode);
    }
}

缓存更新策略

权限数据变更场景及处理方式:

场景 操作 说明
给角色分配权限 刷新该角色缓存 refreshRolePermsCache(roleCode)
删除角色权限 刷新该角色缓存 同上
删除角色 清除该角色缓存 clearRolePermsCache(roleCode)
删除菜单/按钮 清除所有角色缓存 最简单粗暴,也可精确清除

注意事项

  1. 缓存雪崩:不要给 Hash 设置 TTL,权限数据是热点数据,永久存储即可
  2. 缓存穿透:空权限也要缓存(存空集合),避免频繁查库
  3. 数据一致性:权限变更后必须同步更新缓存,事务中更新数据库成功后再更新缓存

多登录方式扩展

除了用户名密码,youlai-boot 还实现了短信验证码登录。核心是 Spring Security 的 AuthenticationProvider 机制:

TokenManager SmsProvider (短信验证码) DaoProvider (用户名密码) AuthenticationManager 客户端 TokenManager SmsProvider (短信验证码) DaoProvider (用户名密码) AuthenticationManager 客户端 alt [用户名密码登录] [短信验证码登录] 登录请求(用户名+密码) 匹配 UsernamePasswordAuthenticationToken 校验密码 认证成功 返回 Token 登录请求(手机号+验证码) 匹配 SmsAuthenticationToken 校验验证码 认证成功 返回 Token

扩展登录方式只需三步:

  1. 定义 SmsAuthenticationToken,存放手机号和验证码
  2. 实现 SmsAuthenticationProvider,校验验证码并返回用户信息
  3. 注册到 AuthenticationManager

想扩展微信登录、邮箱登录?照这个套路来就行。

配置项说明

security:
  session:
    type: jwt  # 或 redis-token
    access-token-time-to-live: 3600
    refresh-token-time-to-live: 604800
    jwt:
      secret-key: SecretKey012345678901234567890123456789  # 至少32字符

生产环境记得换密钥,JWT 密钥泄露是安全事故。

结语

认证方案没有银弹,选型关键是看场景。需要精细化会话管理,Redis-Token 更省心;追求性能和分布式友好,JWT 更合适。youlai-boot 两种模式都实现了,配置切换就行,灵活性拉满。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐