云计算百科
云计算领域专业知识百科平台

双Token认证体系深度拆解:Spring Security + JWT + Redis

你以为登录就是"用户名密码换一个Token"?当你的系统需要"踢人下线"、“单设备登录”、"Token无感刷新"时,一切都变了。 —— 一篇用代码流程拆解双Token认证体系的实战手册

阅读时间:约45分钟 | 涵盖:双Token原理 / Spring Security过滤链 / JWT生成与校验 / Redis黑名单 / RBAC权限 / Token刷新 / 安全实践


📑 目录

  • 序章:为什么需要双Token?
  • 第一幕:JWT基础 — Token长什么样
  • 第二幕:双Token架构设计
  • 第三幕:登录流程完整拆解
  • 第四幕:请求认证 — Filter Chain执行流程
  • 第五幕:Token刷新机制
  • 第六幕:登出与Token失效
  • 第七幕:RBAC权限模型
  • 第八幕:Spring Security配置架构
  • 第九幕:安全最佳实践
  • 参考文献

  • 🎬 序章:为什么需要双Token?

    在这里插入图片描述

    传统认证方案的困境

    方案一:Session认证

    用户登录 -> 服务端创建Session -> 返回SessionID(Cookie)
    每次请求 -> 带Cookie -> 服务端查Session

    问题:

    • 有状态:服务端要存Session,分布式环境下要共享Session(粘性Session/Redis Session)
    • CSRF风险:Cookie会被自动携带,容易被跨站请求伪造
    • 跨域困难:Cookie有域限制,前后端分离架构不方便

    方案二:单Token(一个JWT搞定一切)

    用户登录 -> 返回一个JWT(有效期30天)
    每次请求 -> 带JWT -> 服务端验签

    问题:

    • 安全与体验的矛盾:
      • Token有效期长(如30天)→ 用户体验好,但Token泄露后30天内无法撤销
      • Token有效期短(如15分钟)→ 安全,但用户每15分钟就要重新登录
    • 无法踢人下线:JWT一旦签发,在有效期内无法使其失效

    双Token方案:两全其美

    在这里插入图片描述

    Token有效期存储方式状态用途
    Access Token 15-30分钟 内存/Header 无状态 每次API请求携带
    Refresh Token 7-30天 HttpOnly Cookie + Redis 有状态(Redis) 仅用于刷新Access Token

    核心思想:

    • Access Token短命 → 泄露危害有限(最多15分钟)
    • Refresh Token长命 → 用户体验好(7天免登录)
    • Refresh Token存在Redis → 可以随时撤销(踢人下线)
    • Access Token无状态 → 性能好(不需要查Redis)

    🎯 一句话总结: Access Token负责"快"(无状态,O(1)验证),Refresh Token负责"可控"(有状态,可撤销)。两者配合,实现安全与性能的最优平衡。


    🔑 第一幕:JWT基础 — Token长什么样

    在这里插入图片描述

    1.1 JWT的三个部分

    JWT(JSON Web Token)由三部分组成,用 . 连接:

    eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    |______Header________|______Payload________|____________Signature________________|

    ① Header(头部)

    {
    "alg": "HS256", // 签名算法
    "typ": "JWT" // Token类型
    }

    ② Payload(载荷)

    Access Token的Payload:

    {
    "sub": "1001", // 用户ID
    "username": "zhangsan", // 用户名
    "roles": ["admin", "user"], // 角色列表
    "permissions": ["blog:read", "blog:write", "user:manage"], // 权限列表
    "iat": 1720000000, // 签发时间
    "exp": 1720000900, // 过期时间(15分钟后)
    "jti": "a1b2c3d4-e5f6-7890" // JWT唯一ID(用于黑名单)
    }

    Refresh Token的Payload(精简):

    {
    "sub": "1001", // 用户ID
    "tokenVersion": 5, // Token版本(用于批量失效)
    "iat": 1720000000,
    "exp": 1720604800 // 过期时间(7天后)
    }

    ③ Signature(签名)

    HMACSHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    secret_key
    )

    签名的作用:防篡改。任何人修改了Header或Payload,签名就对不上了。

    1.2 Access Token vs Refresh Token 设计原则

    维度Access TokenRefresh Token
    包含信息 用户ID、角色、权限(尽量丰富) 用户ID、tokenVersion(尽量少)
    有效期 15-30分钟 7-30天
    传输方式 Authorization: Bearer header HttpOnly Cookie
    存储位置 前端内存/变量 HttpOnly Cookie(前端不可见)
    是否存Redis ❌ 不存(无状态) ✅ 存(有状态,可撤销)
    验证方式 验签+检查过期+检查黑名单 验签+检查Redis是否存在
    性能 极快(不查库) 较快(只查一次Redis)

    🏗️ 第二幕:双Token架构设计

    2.1 整体架构

    ┌─────────────┐ ┌──────────────────┐ ┌─────────┐
    │ Frontend │────>│ Spring Boot │────>│ Redis │
    │ Vue / React │<────│ Spring Security │<────│ │
    └─────────────┘ └──────────────────┘ └─────────┘

    v
    ┌──────────┐
    │ MySQL │
    │ (users) │
    └──────────┘

    2.2 JwtUtil完整实现

    @Component
    public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.access-token-expiry}")
    private long accessTokenExpiry; // 15 * 60 * 1000 (15分钟)

    @Value("${jwt.refresh-token-expiry}")
    private long refreshTokenExpiry; // 7 * 24 * 3600 * 1000 (7天)

    /**
    * 生成Access Token
    */

    public String generateAccessToken(Long userId, String username,
    List<String> roles, List<String> permissions) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("sub", String.valueOf(userId));
    claims.put("username", username);
    claims.put("roles", roles);
    claims.put("permissions", permissions);
    claims.put("jti", UUID.randomUUID().toString());

    return Jwts.builder()
    .setClaims(claims)
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiry))
    .signWith(SignatureAlgorithm.HS256, secret)
    .compact();
    }

    /**
    * 生成Refresh Token
    */

    public String generateRefreshToken(Long userId, int tokenVersion) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("sub", String.valueOf(userId));
    claims.put("tokenVersion", tokenVersion);
    claims.put("jti", UUID.randomUUID().toString());

    return Jwts.builder()
    .setClaims(claims)
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiry))
    .signWith(SignatureAlgorithm.HS256, secret)
    .compact();
    }

    /**
    * 解析Token(验证签名+过期)
    */

    public Claims parseToken(String token) {
    return Jwts.parser()
    .setSigningKey(secret)
    .parseClaimsJws(token)
    .getBody();
    }

    /**
    * 判断Token是否过期
    */

    public boolean isTokenExpired(String token) {
    try {
    Claims claims = parseToken(token);
    return claims.getExpiration().before(new Date());
    } catch (ExpiredJwtException e) {
    return true;
    }
    }

    public String extractUserId(String token) {
    return parseToken(token).getSubject();
    }

    public String extractJti(String token) {
    return parseToken(token).getId();
    }
    }

    2.3 LoginUser — 自定义UserDetails

    @Data
    public class LoginUser implements UserDetails {

    private Long id;
    private String username;
    private String password;
    private List<String> roles;
    private List<String> permissions;
    private Integer status;

    public LoginUser(User user, List<String> roles, List<String> permissions) {
    this.id = user.getId();
    this.username = user.getUsername();
    this.password = user.getPassword();
    this.roles = roles;
    this.permissions = permissions;
    this.status = user.getStatus();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> authorities = new ArrayList<>();
    roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
    permissions.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));
    return authorities;
    }

    @Override
    public String getPassword() { return password; }

    @Override
    public String getUsername() { return username; }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return status == 1; }

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() { return status == 1; }
    }

    2.4 核心组件清单

    表1:双Token认证体系核心组件

    组件类职责
    JwtUtil 工具类 JWT生成、解析、验证
    JwtAuthenticationFilter OncePerRequestFilter 从请求中提取JWT并认证
    SecurityConfig @Configuration 配置Spring Security过滤链
    AuthenticationController @RestController 登录、刷新、登出接口
    UserDetailsServiceImpl UserDetailsService 从数据库加载用户信息
    RedisService Service Token黑名单、Refresh Token存储
    User Entity 用户实体(含角色、权限)

    2.3 Redis Key设计

    # 1. Refresh Token存储(用户维度)
    Key: auth:refresh:{userId}
    Value: refreshTokenString
    TTL: 7天

    # 2. Access Token黑名单(Token维度,用于登出后使Token失效)
    Key: auth:blacklist:{jti}
    Value: 1
    TTL: Access Token剩余有效期(最多30分钟)

    # 3. Token版本号(用户维度,用于批量失效所有Token)
    Key: auth:token:version:{userId}
    Value: versionNumber
    TTL: 30天

    # 4. 登录失败计数(用于防暴力破解)
    Key: auth:login:fail:{username}
    Value: failCount
    TTL: 15分钟


    🔐 第三幕:登录流程完整拆解

    在这里插入图片描述

    3.1 完整登录流程(12步)

    Step 1:前端发送登录请求

    POST /auth/login
    Content-Type: application/json

    {
    "username": "zhangsan",
    "password": "***"
    }

    Step 2:Spring Security过滤链拦截

    请求经过多个Filter,最终到达 UsernamePasswordAuthenticationFilter(或者我们自定义的登录端点)。

    Step 3:构建Authentication对象

    UsernamePasswordAuthenticationToken authRequest =
    new UsernamePasswordAuthenticationToken(username, password);

    Step 4:调用AuthenticationManager.authenticate()

    Authentication authentication = authenticationManager.authenticate(authRequest);

    Step 5:AuthenticationManager委托给AuthenticationProvider

    默认使用 DaoAuthenticationProvider。

    Step 6:调用UserDetailsService.loadUserByUsername()

    @Override
    public UserDetails loadUserByUsername(String username) {
    User user = userMapper.selectByUsername(username);
    if (user == null) {
    throw new UsernameNotFoundException("用户不存在");
    }
    // 查询用户的角色和权限
    List<String> permissions = permissionMapper.selectByUserId(user.getId());
    return new LoginUser(user, permissions);
    }

    Step 7:密码校验

    // DaoAuthenticationProvider内部调用
    passwordEncoder.matches(rawPassword, encodedPassword)
    // BCrypt加密,每次结果不同(加盐),不可逆

    Step 8:认证成功,生成双Token

    // 生成Access Token(有效期15分钟)
    String accessToken = jwtUtil.generateAccessToken(user.getId(),
    user.getUsername(), roles, permissions);

    // 生成Refresh Token(有效期7天)
    String refreshToken = jwtUtil.generateRefreshToken(user.getId());

    Step 9:将Refresh Token存入Redis

    redisTemplate.opsForValue().set(
    "auth:refresh:" + user.getId(),
    refreshToken,
    7, TimeUnit.DAYS
    );

    Step 10:设置HttpOnly Cookie(Refresh Token)

    ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken)
    .httpOnly(true) // JS无法读取(防XSS)
    .secure(true) // 只在HTTPS下传输
    .sameSite("Strict") // 防CSRF
    .path("/")
    .maxAge(7 * 24 * 3600)
    .build();
    response.addHeader("Set-Cookie", refreshCookie.toString());

    Step 11:返回Access Token + 用户信息(响应体)

    {
    "code": 200,
    "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs…",
    "user": {
    "id": 1001,
    "username": "zhangsan",
    "roles": ["admin", "user"],
    "permissions": ["blog:read", "blog:write", "user:manage"]
    }
    }
    }

    Step 12:前端存储

    // Access Token存在内存变量中(刷新页面会丢失,需要重新用Refresh Token获取)
    const accessToken = response.data.accessToken;

    // Refresh Token在HttpOnly Cookie中(前端代码无法访问,但浏览器会自动携带)
    // 前端不需要做任何处理!


    3.3 完整AuthenticationController

    下面是完整的Controller代码,包含登录、刷新、登出、全设备登出、获取当前用户等所有端点:

    @RestController
    @RequestMapping("/auth")
    @Slf4j
    public class AuthenticationController {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final int MAX_LOGIN_FAIL = 5;
    private static final int LOGIN_FAIL_LOCK_MINUTES = 15;

    @PostMapping("/login")
    public Result<LoginVO> login(@RequestBody LoginDTO dto, HttpServletResponse response) {
    // 1. 检查登录失败次数
    String failKey = "auth:login:fail:" + dto.getUsername();
    String failCount = redisTemplate.opsForValue().get(failKey);
    if (failCount != null && Integer.parseInt(failCount) >= MAX_LOGIN_FAIL) {
    return Result.fail("登录失败次数过多,请稍后重试");
    }
    // 2. 认证
    Authentication authentication;
    try {
    authentication = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
    } catch (BadCredentialsException e) {
    redisTemplate.opsForValue().increment(failKey);
    redisTemplate.expire(failKey, LOGIN_FAIL_LOCK_MINUTES, TimeUnit.MINUTES);
    return Result.fail("用户名或密码错误");
    }
    redisTemplate.delete(failKey);
    // 3. 生成Token对
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    int version = getTokenVersion(loginUser.getId());
    String accessToken = jwtUtil.generateAccessToken(loginUser.getId(),
    loginUser.getUsername(), loginUser.getRoles(), loginUser.getPermissions());
    String refreshToken = jwtUtil.generateRefreshToken(loginUser.getId(), version);
    // 4. 存Redis + 设Cookie
    redisTemplate.opsForValue().set("auth:refresh:" + loginUser.getId(), refreshToken, 7, TimeUnit.DAYS);
    setRefreshTokenCookie(response, refreshToken);
    return Result.ok(new LoginVO(accessToken, loginUser.toUserInfoVO()));
    }

    @PostMapping("/refresh")
    public Result<TokenVO> refresh(
    @CookieValue(value = "refreshToken", required = false) String refreshToken,
    HttpServletResponse response) {
    if (refreshToken == null) return Result.fail(401, "缺少Refresh Token");
    Claims claims;
    try { claims = jwtUtil.parseToken(refreshToken); }
    catch (ExpiredJwtException e) { return Result.fail(401, "Refresh Token已过期"); }
    catch (Exception e) { return Result.fail(401, "Refresh Token无效"); }
    String userId = claims.getSubject();
    String stored = redisTemplate.opsForValue().get("auth:refresh:" + userId);
    if (stored == null || !stored.equals(refreshToken)) return Result.fail(401, "Token已被撤销");
    Integer currentVer = getTokenVersion(Long.parseLong(userId));
    if (!currentVer.equals(claims.get("tokenVersion", Integer.class))) return Result.fail(401, "版本不匹配");
    LoginUser user = userService.loadUserById(Long.parseLong(userId));
    String newAccess = jwtUtil.generateAccessToken(user.getId(), user.getUsername(), user.getRoles(), user.getPermissions());
    String newRefresh = jwtUtil.generateRefreshToken(user.getId(), currentVer);
    redisTemplate.opsForValue().set("auth:refresh:" + userId, newRefresh, 7, TimeUnit.DAYS);
    setRefreshTokenCookie(response, newRefresh);
    return Result.ok(new TokenVO(newAccess));
    }

    @PostMapping("/logout")
    public Result<Void> logout(HttpServletRequest request, HttpServletResponse response) {
    String header = request.getHeader("Authorization");
    if (header != null && header.startsWith("Bearer ")) {
    try {
    Claims claims = jwtUtil.parseToken(header.substring(7));
    long remaining = claims.getExpiration().getTime() System.currentTimeMillis();
    if (remaining > 0) redisTemplate.opsForValue().set("auth:blacklist:" + claims.getId(), "1", remaining, TimeUnit.MILLISECONDS);
    redisTemplate.delete("auth:refresh:" + claims.getSubject());
    } catch (Exception ignored) {}
    }
    clearRefreshTokenCookie(response);
    return Result.ok();
    }

    @PostMapping("/logout-all")
    @PreAuthorize("isAuthenticated()")
    public Result<Void> logoutAll() {
    String userId = SecurityContextHolder.getContext().getAuthentication().getPrincipal().toString();
    redisTemplate.opsForValue().increment("auth:token:version:" + userId);
    redisTemplate.expire("auth:token:version:" + userId, 30, TimeUnit.DAYS);
    redisTemplate.delete("auth:refresh:" + userId);
    return Result.ok();
    }

    private int getTokenVersion(Long userId) {
    String v = redisTemplate.opsForValue().get("auth:token:version:" + userId);
    return v != null ? Integer.parseInt(v) : 0;
    }

    private void setRefreshTokenCookie(HttpServletResponse response, String token) {
    response.addHeader("Set-Cookie",
    ResponseCookie.from("refreshToken", token)
    .httpOnly(true).secure(true).sameSite("Strict").path("/").maxAge(7*24*3600).toString());
    }

    private void clearRefreshTokenCookie(HttpServletResponse response) {
    response.addHeader("Set-Cookie",
    ResponseCookie.from("refreshToken", "")
    .httpOnly(true).secure(true).sameSite("Strict").path("/").maxAge(0).toString());
    }
    }

    3.4 前端Auth Store(Pinia + Axios拦截器)

    // stores/auth.ts
    import { defineStore } from 'pinia';
    import axios from 'axios';

    export const useAuthStore = defineStore('auth', {
    state: () => ({
    accessToken: null as string | null, // 内存变量,刷新页面丢失
    user: null as any,
    isRefreshing: false,
    failedQueue: [] as Array<{ resolve: Function; reject: Function }>,
    }),
    getters: {
    isAuthenticated: (s) => !!s.accessToken,
    hasRole: (s) => (role: string) => s.user?.roles?.includes(role) ?? false,
    hasPermission: (s) => (perm: string) => s.user?.permissions?.includes(perm) ?? false,
    },
    actions: {
    async login(username: string, password: string) {
    const res = await axios.post('/auth/login', { username, password });
    this.accessToken = res.data.data.accessToken;
    this.user = res.data.data.userInfo;
    axios.defaults.headers.common['Authorization'] = `Bearer ${this.accessToken}`;
    },
    async refreshToken() {
    const res = await axios.post('/auth/refresh');
    this.accessToken = res.data.data.accessToken;
    axios.defaults.headers.common['Authorization'] = `Bearer ${this.accessToken}`;
    return this.accessToken;
    },
    async logout() {
    try { await axios.post('/auth/logout'); } catch {}
    this.accessToken = null;
    this.user = null;
    delete axios.defaults.headers.common['Authorization'];
    },
    processQueue(error: any, token: string | null) {
    this.failedQueue.forEach(({ resolve, reject }) => error ? reject(error) : resolve(token));
    this.failedQueue = [];
    },
    },
    });

    // Axios拦截器配置(在main.ts中初始化)
    let isRefreshing = false;
    axios.interceptors.response.use(
    (res) => res,
    async (error) => {
    const original = error.config;
    if (error.response?.status === 401 && !original._retry && original.url !== '/auth/refresh') {
    if (isRefreshing) {
    return new Promise((resolve, reject) => {
    useAuthStore().failedQueue.push({ resolve, reject });
    }).then((token) => {
    original.headers.Authorization = `Bearer ${token}`;
    return axios(original);
    });
    }
    original._retry = true;
    isRefreshing = true;
    try {
    const newToken = await useAuthStore().refreshToken();
    useAuthStore().processQueue(null, newToken);
    original.headers.Authorization = `Bearer ${newToken}`;
    return axios(original);
    } catch (e) {
    useAuthStore().processQueue(e, null);
    useAuthStore().logout();
    window.location.href = '/login';
    return Promise.reject(e);
    } finally {
    isRefreshing = false;
    }
    }
    return Promise.reject(error);
    }
    );

    3.5 关键设计决策

    表3:双Token设计关键决策

    决策选择理由
    Access Token存储位置 JS内存变量 XSS无法读取(除非有XSS漏洞)
    Refresh Token存储位置 HttpOnly Cookie JS完全无法访问,防XSS
    Refresh Token是否存Redis 可撤销,可检查是否被盗用
    Access Token是否存Redis 否(仅黑名单) 无状态验证,性能最优
    密码加密方式 BCrypt(cost=12) 不可逆,加盐,抗暴力破解
    登录失败限制 5次/15分钟 防暴力破解,不影响正常用户
    Refresh Token Rotation 防止Token被盗用后持续使用
    CSRF防护 SameSite=Strict Cookie不随跨站请求发送

    🔍 第四幕:请求认证 — Filter Chain执行流程

    在这里插入图片描述

    4.1 Spring Security Filter Chain

    Spring Security的核心是一个过滤器链(Filter Chain),每个请求都要经过这个链上的所有过滤器。

    表2:Spring Security核心过滤器(按执行顺序)

    过滤器职责是否需要自定义
    SecurityContextPersistenceFilter 维护SecurityContext
    CorsFilter 处理跨域请求 ✅ 配置允许的域
    CsrfFilter CSRF防护 ❌ JWT通常禁用
    **JwtAuthenticationFilter** JWT认证(自定义) ✅ 核心
    UsernamePasswordAuthenticationFilter 表单登录 ❌ API登录不用这个
    ExceptionTranslationFilter 处理认证/授权异常 ✅ 自定义错误响应
    FilterSecurityInterceptor URL级别的权限判断 ✅ 配置URL权限

    4.2 JwtAuthenticationFilter — 核心认证逻辑

    @Component
    public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
    HttpServletResponse response,
    FilterChain filterChain)
    throws ServletException, IOException {

    // 1. 提取Token
    String authHeader = request.getHeader("Authorization");
    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
    filterChain.doFilter(request, response); // 没有Token,放行(后续过滤器处理)
    return;
    }
    String token = authHeader.substring(7);

    try {
    // 2. 解析Token(验签 + 检查过期)
    Claims claims = jwtUtil.parseToken(token);
    String jti = claims.getId();
    String userId = claims.getSubject();

    // 3. 检查Redis黑名单(是否已登出)
    if (Boolean.TRUE.equals(redisTemplate.hasKey("auth:blacklist:" + jti))) {
    throw new RuntimeException("Token已被撤销");
    }

    // 4. 提取权限信息
    List<String> roles = claims.get("roles", List.class);
    List<String> permissions = claims.get("permissions", List.class);
    List<GrantedAuthority> authorities = new ArrayList<>();
    roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
    permissions.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));

    // 5. 构建Authentication对象
    UsernamePasswordAuthenticationToken authentication =
    new UsernamePasswordAuthenticationToken(userId, null, authorities);
    authentication.setDetails(claims);

    // 6. 设置到SecurityContext
    SecurityContextHolder.getContext().setAuthentication(authentication);

    } catch (Exception e) {
    // Token无效,不设置Authentication,后续会返回401
    log.warn("JWT验证失败: {}", e.getMessage());
    }

    // 7. 继续执行过滤链
    filterChain.doFilter(request, response);
    }
    }

    关键点:

    • 此Filter在 UsernamePasswordAuthenticationFilter 之前执行
    • 没有Token时直接放行(permitAll的路径不需要Token)
    • Token无效时也放行,但不设置SecurityContext → 后续会返回401
    • 有Token且有效时,设置SecurityContext → 后续Controller可以获取当前用户

    4.3 认证流程图

    请求到达


    有Authorization头?

    ├── 否 → 直接放行(后续检查是否permitAll)

    ▼ 是
    解析JWT

    ├── 解析失败(签名错误/格式错误)→ 不设置认证,放行 → 401

    ├── Token过期 → 不设置认证,放行 → 401 TOKEN_EXPIRED

    ├── 在Redis黑名单中 → 不设置认证,放行 → 401 TOKEN_REVOKED

    ▼ 有效
    提取用户信息 + 权限


    创建Authentication对象,放入SecurityContext


    继续执行过滤链 → Controller处理请求


    @PreAuthorize检查权限 → 通过则返回数据 / 不通过则403


    🔄 第五幕:Token刷新机制

    在这里插入图片描述

    5.1 刷新流程

    POST /auth/refresh
    Cookie: refreshToken=eyJhbGciOiJIUzI1NiIs…

    后端处理逻辑:

    @PostMapping("/auth/refresh")
    public Result<TokenVO> refreshToken(
    @CookieValue("refreshToken") String refreshToken,
    HttpServletResponse response) {

    // 1. 验证Refresh Token签名和过期
    Claims claims;
    try {
    claims = jwtUtil.parseToken(refreshToken);
    } catch (ExpiredJwtException e) {
    return Result.fail(401, "Refresh Token已过期,请重新登录");
    } catch (Exception e) {
    return Result.fail(401, "Refresh Token无效");
    }

    String userId = claims.getSubject();

    // 2. 检查Redis中是否存在(是否被撤销)
    String storedToken = redisTemplate.opsForValue().get("auth:refresh:" + userId);
    if (storedToken == null || !storedToken.equals(refreshToken)) {
    return Result.fail(401, "Refresh Token已被撤销");
    }

    // 3. 检查tokenVersion(是否被批量撤销)
    Integer currentVersion = redisTemplate.opsForValue().get("auth:token:version:" + userId);
    Integer tokenVersion = claims.get("tokenVersion", Integer.class);
    if (currentVersion != null && !currentVersion.equals(tokenVersion)) {
    return Result.fail(401, "Token版本不匹配,请重新登录");
    }

    // 4. 生成新的Token对(Rotation:轮转)
    User user = userService.getById(Long.parseLong(userId));
    String newAccessToken = jwtUtil.generateAccessToken(user);
    String newRefreshToken = jwtUtil.generateRefreshToken(user);

    // 5. 更新Redis(旧的Refresh Token自动失效)
    redisTemplate.opsForValue().set(
    "auth:refresh:" + userId,
    newRefreshToken,
    7, TimeUnit.DAYS
    );

    // 6. 设置新的Cookie
    setRefreshTokenCookie(response, newRefreshToken);

    // 7. 返回新的Access Token
    return Result.ok(new TokenVO(newAccessToken));
    }

    5.2 前端无感刷新实现

    // Axios响应拦截器
    let isRefreshing = false;
    let failedQueue = [];

    const processQueue = (error, token = null) => {
    failedQueue.forEach(prom => {
    if (error) {
    prom.reject(error);
    } else {
    prom.resolve(token);
    }
    });
    failedQueue = [];
    };

    axios.interceptors.response.use(
    response => response,
    async error => {
    const originalRequest = error.config;

    // 如果是401且不是刷新请求本身,且没有重试过
    if (error.response?.status === 401
    && !originalRequest._retry
    && originalRequest.url !== '/auth/refresh') {

    if (isRefreshing) {
    // 正在刷新中,将请求加入队列
    return new Promise((resolve, reject) => {
    failedQueue.push({ resolve, reject });
    }).then(token => {
    originalRequest.headers.Authorization = 'Bearer ' + token;
    return axios(originalRequest);
    });
    }

    originalRequest._retry = true;
    isRefreshing = true;

    try {
    // 调用刷新接口
    const res = await axios.post('/auth/refresh');
    const newToken = res.data.data.accessToken;

    // 更新内存中的Token
    store.setAccessToken(newToken);

    // 重试队列中的请求
    processQueue(null, newToken);

    // 重试原始请求
    originalRequest.headers.Authorization = 'Bearer ' + newToken;
    return axios(originalRequest);

    } catch (refreshError) {
    processQueue(refreshError, null);
    // Refresh Token也过期了,跳转登录页
    store.logout();
    router.push('/login');
    return Promise.reject(refreshError);
    } finally {
    isRefreshing = false;
    }
    }

    return Promise.reject(error);
    }
    );

    5.3 Refresh Token轮转(Rotation)

    为什么每次刷新都要换一个新的Refresh Token?

    这叫"Refresh Token Rotation",防止Refresh Token被盗用:

    正常流程:
    RT1 (Redis中) -> 刷新 -> 返回AT2 + RT2 -> RT1从Redis删除,RT2存入Redis

    攻击场景:
    用户的RT1被攻击者窃取
    用户用RT1刷新 -> 得到AT2 + RT2 -> RT1失效
    攻击者用RT1刷新 -> 发现RT1已失效(已被删除)-> 刷新失败!

    如果不用Rotation,攻击者和用户可以同时使用同一个Refresh Token,直到它过期。

    5.4 并发请求的Token刷新队列

    当前端同时发出5个请求,且Access Token恰好过期时,5个请求都会收到401。如果每个都触发一次刷新,就会产生竞态条件。解决方案是请求队列:

    // 核心逻辑(已在3.4节的axios拦截器中实现)
    // 1. 第一个401到达时,isRefreshing = true,开始刷新
    // 2. 后续401到达时,不触发刷新,而是加入failedQueue等待
    // 3. 刷新成功后,用新Token重试所有排队的请求
    // 4. 刷新失败时,reject所有排队的请求,跳转登录页

    // 时序图:
    // Request1 -> 401 -> refresh() -> 新Token -> 重试Request1
    // Request2 -> 401 -> 加入队列 -> 等待 -> 用新Token重试
    // Request3 -> 401 -> 加入队列 -> 等待 -> 用新Token重试
    // Request4 -> 401 -> 加入队列 -> 等待 -> 用新Token重试
    // Request5 -> 401 -> 加入队列 -> 等待 -> 用新Token重试
    //
    // 总共只刷新了1次!避免了竞态条件。

    5.5 Token续期的用户体验优化

    场景处理方式用户感知
    Access Token未过期 直接请求 无感知
    Access Token过期,Refresh Token有效 自动刷新+重试 无感知(可能多100ms延迟)
    Access Token过期,Refresh Token也过期 跳转登录页 需要重新登录
    用户在操作中Token即将过期 前端定时器提前刷新 无感知

    前端定时刷新(可选优化):

    // 在app启动时设置定时器
    const ACCESS_TOKEN_EXPIRY = 14 * 60 * 1000; // 14分钟(Token有效期15分钟,提前1分钟刷新)
    setInterval(async () => {
    if (useAuthStore().isAuthenticated) {
    try {
    await useAuthStore().refreshToken();
    } catch (e) {
    console.warn('Token refresh failed:', e);
    }
    }
    }, ACCESS_TOKEN_EXPIRY);


    🚪 第六幕:登出与Token失效

    在这里插入图片描述

    6.1 单设备登出

    @PostMapping("/auth/logout")
    public Result<Void> logout(HttpServletRequest request, HttpServletResponse response) {
    // 1. 从当前请求中获取Access Token
    String authHeader = request.getHeader("Authorization");
    String accessToken = authHeader.substring(7);
    Claims claims = jwtUtil.parseToken(accessToken);
    String jti = claims.getId();
    String userId = claims.getSubject();

    // 2. 将Access Token的jti加入黑名单
    // TTL设为Token的剩余有效期(过期后自动清理)
    long remainingTime = claims.getExpiration().getTime() System.currentTimeMillis();
    if (remainingTime > 0) {
    redisTemplate.opsForValue().set(
    "auth:blacklist:" + jti,
    "1",
    remainingTime, TimeUnit.MILLISECONDS
    );
    }

    // 3. 删除Redis中的Refresh Token
    redisTemplate.delete("auth:refresh:" + userId);

    // 4. 清除Cookie
    ResponseCookie clearCookie = ResponseCookie.from("refreshToken", "")
    .httpOnly(true)
    .secure(true)
    .sameSite("Strict")
    .path("/")
    .maxAge(0) // 立即过期
    .build();
    response.addHeader("Set-Cookie", clearCookie.toString());

    return Result.ok();
    }

    6.2 全设备登出(踢人下线)

    @PostMapping("/auth/logout-all")
    public Result<Void> logoutAllDevices(@AuthenticationPrincipal String userId) {
    // 递增tokenVersion,所有旧版本的Token都会失效
    String key = "auth:token:version:" + userId;
    redisTemplate.opsForValue().increment(key);
    redisTemplate.expire(key, 30, TimeUnit.DAYS);

    // 删除Refresh Token
    redisTemplate.delete("auth:refresh:" + userId);

    return Result.ok();
    }

    表3:Token失效策略对比

    策略实现方式影响范围使用场景
    Access Token黑名单 Redis SET blacklist:{jti} 单个Token 本设备登出
    删除Refresh Token Redis DEL refresh:{userId} 当前设备的刷新能力 本设备登出
    递增tokenVersion Redis INCR token:version:{userId} 该用户所有设备 修改密码、管理员踢人
    清除所有Token DEL refresh + INCR version 该用户所有设备全部下线 安全事件

    👥 第七幕:RBAC权限模型

    在这里插入图片描述

    7.1 数据库设计

    — 用户表
    CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL, — BCrypt加密
    status TINYINT DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );

    — 角色表
    CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_name VARCHAR(50) NOT NULL,
    role_key VARCHAR(50) UNIQUE NOT NULL — 如 ADMIN, USER
    );

    — 权限表
    CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    perm_name VARCHAR(100) NOT NULL,
    perm_key VARCHAR(100) UNIQUE NOT NULL — 如 blog:read, blog:write
    );

    — 用户-角色关联表
    CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
    );

    — 角色-权限关联表
    CREATE TABLE sys_role_permission (
    role_id BIGINT NOT NULL,
    perm_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, perm_id)
    );

    7.2 权限嵌入JWT

    登录时,将用户的权限列表写入JWT的Payload:

    public String generateAccessToken(LoginUser loginUser) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("sub", String.valueOf(loginUser.getUser().getId()));
    claims.put("username", loginUser.getUsername());
    claims.put("roles", loginUser.getRoles()); // ["admin", "user"]
    claims.put("permissions", loginUser.getPermissions()); // ["blog:read", "blog:write"]
    claims.put("jti", UUID.randomUUID().toString());

    return Jwts.builder()
    .setClaims(claims)
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
    .signWith(SignatureAlgorithm.HS256, secret)
    .compact();
    }

    7.3 前端动态路由与权限按钮

    // router/guard.ts — 路由守卫
    import { useAuthStore } from '@/stores/auth';
    import router from '@/router';

    // 动态路由表(根据权限生成)
    const asyncRoutes = [
    {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta: { roles: ['admin'] }, // 需要admin角色
    children: [
    { path: 'users', component: () => import('@/views/admin/Users.vue'), meta: { roles: ['admin'] } },
    { path: 'roles', component: () => import('@/views/admin/Roles.vue'), meta: { roles: ['admin'] } },
    ]
    },
    {
    path: '/blog',
    component: () => import('@/layouts/MainLayout.vue'),
    children: [
    { path: 'list', component: () => import('@/views/blog/List.vue') }, // 已登录即可
    { path: 'create', component: () => import('@/views/blog/Create.vue'), meta: { permissions: ['blog:write'] } },
    { path: 'edit/:id', component: () => import('@/views/blog/Edit.vue'), meta: { permissions: ['blog:write'] } },
    ]
    },
    ];

    // 路由前置守卫
    router.beforeEach(async (to, from, next) => {
    const authStore = useAuthStore();

    // 1. 白名单直接放行
    if (['/login', '/register', '/404'].includes(to.path)) return next();

    // 2. 未登录 -> 尝试用Refresh Token恢复
    if (!authStore.isAuthenticated) {
    try {
    await authStore.refreshToken();
    } catch {
    return next('/login');
    }
    }

    // 3. 首次访问 -> 加载动态路由
    if (!router.hasRoute('admin')) {
    asyncRoutes.forEach(route => {
    if (!route.meta?.roles || authStore.hasRole(route.meta.roles[0])) {
    router.addRoute(route);
    }
    });
    return next({ to, replace: true }); // 重新进入以匹配新路由
    }

    // 4. 检查路由权限
    if (to.meta?.roles && !to.meta.roles.some((r: string) => authStore.hasRole(r))) {
    return next('/403');
    }
    if (to.meta?.permissions && !to.meta.permissions.some((p: string) => authStore.hasPermission(p))) {
    return next('/403');
    }

    next();
    });

    <!– 权限按钮组件 components/PermButton.vue –>
    <template>
    <button v-if="hasPerm" @click="$emit('click')" :class="btnClass">
    <slot />
    </button>
    </template>

    <script setup lang="ts">
    import { computed } from 'vue';
    import { useAuthStore } from '@/stores/auth';

    const props = defineProps<{
    permission?: string; // 如 'blog:write'
    role?: string; // 如 'admin'
    btnClass?: string;
    }>();

    const authStore = useAuthStore();
    const hasPerm = computed(() => {
    if (props.permission) return authStore.hasPermission(props.permission);
    if (props.role) return authStore.hasRole(props.role);
    return true;
    });
    </script>

    <!– 使用:只有有blog:write权限的用户才能看到“编辑”按钮 –>
    <!– <PermButton permission="blog:write" @click="editBlog">编辑</PermButton> –>

    7.4 方法级权限控制

    @RestController
    @RequestMapping("/api/blog")
    public class BlogController {

    // 只有admin角色才能访问
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/{id}")
    public Result<Void> deleteBlog(@PathVariable Long id) {
    blogService.delete(id);
    return Result.ok();
    }

    // 需要blog:write权限
    @PreAuthorize("hasAuthority('blog:write')")
    @PostMapping
    public Result<Void> createBlog(@RequestBody BlogDTO dto) {
    blogService.create(dto);
    return Result.ok();
    }

    // 自定义权限表达式:只能操作自己的博客
    @PreAuthorize("#blog.userId == authentication.principal")
    @PutMapping("/{id}")
    public Result<Void> updateBlog(@PathVariable Long id, @RequestBody BlogDTO dto) {
    blogService.update(id, dto);
    return Result.ok();
    }

    // 已登录即可访问
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/{id}")
    public Result<BlogVO> getBlog(@PathVariable Long id) {
    return Result.ok(blogService.getById(id));
    }
    }


    ⚙️ 第八幕:Spring Security配置架构

    在这里插入图片描述

    8.1 完整配置类

    @Configuration
    @EnableMethodSecurity // 启用方法级权限控制
    public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    private AuthenticationEntryPoint authEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
    // 禁用CSRF(JWT API不需要)
    .csrf(csrf -> csrf.disable())

    // 无状态Session(不使用Session)
    .sessionManagement(session ->
    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

    // 请求授权规则
    .authorizeHttpRequests(auth -> auth
    // 公开接口
    .requestMatchers("/auth/login", "/auth/refresh").permitAll()
    .requestMatchers("/public/**").permitAll()
    .requestMatchers("/actuator/health").permitAll()
    // 管理接口
    .requestMatchers("/admin/**").hasRole("ADMIN")
    // 其他所有请求需要认证
    .anyRequest().authenticated()
    )

    // 异常处理
    .exceptionHandling(exception -> exception
    .authenticationEntryPoint(authEntryPoint) // 401
    .accessDeniedHandler(accessDeniedHandler) // 403
    )

    // 添加JWT过滤器(在UsernamePasswordAuthenticationFilter之前)
    .addFilterBefore(jwtAuthenticationFilter,
    UsernamePasswordAuthenticationFilter.class);

    return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(); // 密码加密
    }

    @Bean
    public AuthenticationManager authenticationManager(
    AuthenticationConfiguration config) throws Exception {
    return config.getAuthenticationManager();
    }
    }

    8.2 自定义异常处理

    @Component
    public class AuthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
    HttpServletResponse response,
    AuthenticationException authException) throws IOException {
    response.setContentType("application/json;charset=UTF-8");
    response.setStatus(401);
    response.getWriter().write("{\\"code\\":401,\\"message\\":\\"未认证,请先登录\\"}");
    }
    }

    @Component
    public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
    HttpServletResponse response,
    AccessDeniedException accessDeniedException) throws IOException {
    response.setContentType("application/json;charset=UTF-8");
    response.setStatus(403);
    response.getWriter().write("{\\"code\\":403,\\"message\\":\\"权限不足\\"}");
    }
    }


    🛡️ 第九幕:安全最佳实践

    在这里插入图片描述

    9.1 Token存储安全

    表4:Token存储方案对比

    存储方式XSS风险CSRF风险推荐度说明
    localStorage ❌ 高(JS可读) ✅ 低 ⭐⭐ 不推荐存敏感Token
    sessionStorage ❌ 高(JS可读) ✅ 低 ⭐⭐ 同上
    Cookie (普通) ✅ 低 ❌ 高(自动携带) ⭐⭐⭐ 需要CSRF防护
    Cookie (HttpOnly+Secure+SameSite) ✅ 无 ✅ 低 ⭐⭐⭐⭐⭐ 推荐
    内存变量 (JS) ✅ 低(页面刷新丢失) ✅ 低 ⭐⭐⭐⭐ Access Token推荐

    最佳实践:

    • Access Token:存在JS内存变量中(页面刷新丢失 → 用Refresh Token重新获取)
    • Refresh Token:存在HttpOnly + Secure + SameSite=Strict的Cookie中

    9.2 Cookie安全标志

    ResponseCookie cookie = ResponseCookie.from("refreshToken", token)
    .httpOnly(true) // JS无法通过document.cookie读取(防XSS)
    .secure(true) // 只在HTTPS连接下传输(防中间人)
    .sameSite("Strict") // 跨站请求不携带Cookie(防CSRF)
    .path("/") // Cookie的作用路径
    .maxAge(7 * 24 * 3600) // 7天过期
    .build();

    9.3 密码安全

    // BCrypt加密(每次结果不同,因为加了随机盐)
    String encoded = passwordEncoder.encode("myPassword");
    // $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

    // 验证
    boolean matches = passwordEncoder.matches("myPassword", encoded); // true

    在这里插入图片描述

    表5:安全Checklist

    #检查项优先级说明
    1 JWT secret ≥ 256位随机密钥 P0 不要用"123456"
    2 Access Token ≤ 30分钟 P0 泄露危害有限
    3 Refresh Token存HttpOnly Cookie P0 防XSS窃取
    4 BCrypt加密密码(cost=12) P0 不要用MD5/SHA
    5 登录失败限流(5次/15分钟) P0 防暴力破解
    6 Refresh Token Rotation P1 防Token盗用
    7 HTTPS全站 P0 防中间人攻击
    8 CORS严格配置 P1 只允许信任的域
    9 审计日志(登录/登出/失败) P1 安全事件追溯
    10 Redis高可用(Sentinel/Cluster) P1 避免单点故障

    🌟 结语

    双Token认证体系看似复杂,但核心思想很简单:

  • Access Token = “通行证”,短命但快(无状态,不查库)
  • Refresh Token = “续命符”,长命但可控(有状态,存Redis)
  • Spring Security Filter Chain = “安检系统”,每个请求都要过安检
  • Redis黑名单 = “作废公告”,让已登出的Token失效
  • RBAC权限 = “门禁卡级别”,不同角色能进不同的门
  • “认证的本质是证明’你是你’,授权的本质是决定’你能做什么’。双Token体系把这两件事分开处理:Access Token证明身份+携带权限,Refresh Token管理登录状态。分工明确,各司其职。”


    📚 参考文献

  • Internet Engineering Task Force. “JSON Web Token (JWT).” RFC 7519, 2015. https://www.rfc-editor.org/rfc/rfc7519
  • Internet Engineering Task Force. “JSON Web Signature (JWS).” RFC 7515, 2015. https://www.rfc-editor.org/rfc/rfc7515
  • Spring Security Reference Documentation. “Authentication Architecture.” https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
  • Spring Security Reference Documentation. “Authorization.” https://docs.spring.io/spring-security/reference/servlet/authorization/index.html
  • OAuth 2.0 RFC 6749. “The OAuth 2.0 Authorization Framework.” IETF, 2012. https://www.rfc-editor.org/rfc/rfc6749
  • Auth0 Blog. “Refresh Token Rotation.” https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/
  • OWASP. “JSON Web Token Cheat Sheet.” https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
  • Spring Boot Reference Documentation. “Spring Security.” https://docs.spring.io/spring-boot/reference/web/spring-security.html
  • Redis Documentation. “EXPIRE / SETEX / TTL Commands.” https://redis.io/commands/
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » 双Token认证体系深度拆解:Spring Security + JWT + Redis
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!