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

你以为登录就是"用户名密码换一个Token"?当你的系统需要"踢人下线"、"单设备登录"、"Token无感刷新"时,一切都变了。

------ 一篇用代码流程拆解双Token认证体系的实战手册

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


📑 目录

  1. 序章:为什么需要双Token?
  2. [第一幕:JWT基础 --- Token长什么样](#第一幕:JWT基础 — Token长什么样)
  3. 第二幕:双Token架构设计
  4. 第三幕:登录流程完整拆解
  5. [第四幕:请求认证 --- Filter Chain执行流程](#第四幕:请求认证 — Filter Chain执行流程)
  6. 第五幕:Token刷新机制
  7. 第六幕:登出与Token失效
  8. 第七幕:RBAC权限模型
  9. [第八幕:Spring Security配置架构](#第八幕:Spring Security配置架构)
  10. 第九幕:安全最佳实践
  11. 参考文献

🎬 序章:为什么需要双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(头部)

json 复制代码
{
  "alg": "HS256",   // 签名算法
  "typ": "JWT"      // Token类型
}

② Payload(载荷)

Access Token的Payload:

json 复制代码
{
  "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(精简):

json 复制代码
{
  "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 Token Refresh 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完整实现

java 复制代码
@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

java 复制代码
@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:前端发送登录请求

http 复制代码
POST /auth/login
Content-Type: application/json

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

Step 2:Spring Security过滤链拦截

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

Step 3:构建Authentication对象

java 复制代码
UsernamePasswordAuthenticationToken authRequest = 
    new UsernamePasswordAuthenticationToken(username, password);

Step 4:调用AuthenticationManager.authenticate()

java 复制代码
Authentication authentication = authenticationManager.authenticate(authRequest);

Step 5:AuthenticationManager委托给AuthenticationProvider

默认使用 DaoAuthenticationProvider

Step 6:调用UserDetailsService.loadUserByUsername()

java 复制代码
@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:密码校验

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

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

java 复制代码
// 生成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

java 复制代码
redisTemplate.opsForValue().set(
    "auth:refresh:" + user.getId(),
    refreshToken,
    7, TimeUnit.DAYS
);

Step 10:设置HttpOnly Cookie(Refresh Token)

java 复制代码
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 + 用户信息(响应体)

json 复制代码
{
  "code": 200,
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "user": {
      "id": 1001,
      "username": "zhangsan",
      "roles": ["admin", "user"],
      "permissions": ["blog:read", "blog:write", "user:manage"]
    }
  }
}

Step 12:前端存储

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

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

3.3 完整AuthenticationController

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

java 复制代码
@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拦截器)

typescript 复制代码
// 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 --- 核心认证逻辑

java 复制代码
@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 刷新流程

http 复制代码
POST /auth/refresh
Cookie: refreshToken=eyJhbGciOiJIUzI1NiIs...

后端处理逻辑:

java 复制代码
@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 前端无感刷新实现

javascript 复制代码
// 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。如果每个都触发一次刷新,就会产生竞态条件。解决方案是请求队列

typescript 复制代码
// 核心逻辑(已在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即将过期 前端定时器提前刷新 无感知

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

typescript 复制代码
// 在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 单设备登出

java 复制代码
@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 全设备登出(踢人下线)

java 复制代码
@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 数据库设计

sql 复制代码
-- 用户表
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:

java 复制代码
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 前端动态路由与权限按钮

typescript 复制代码
// 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();
});
vue 复制代码
<!-- 权限按钮组件 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 方法级权限控制

java 复制代码
@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 完整配置类

java 复制代码
@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 自定义异常处理

java 复制代码
@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安全标志

java 复制代码
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 密码安全

java 复制代码
// 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认证体系看似复杂,但核心思想很简单:

  1. Access Token = "通行证",短命但快(无状态,不查库)
  2. Refresh Token = "续命符",长命但可控(有状态,存Redis)
  3. Spring Security Filter Chain = "安检系统",每个请求都要过安检
  4. Redis黑名单 = "作废公告",让已登出的Token失效
  5. RBAC权限 = "门禁卡级别",不同角色能进不同的门

"认证的本质是证明'你是你',授权的本质是决定'你能做什么'。双Token体系把这两件事分开处理:Access Token证明身份+携带权限,Refresh Token管理登录状态。分工明确,各司其职。"


📚 参考文献

  1. Internet Engineering Task Force. "JSON Web Token (JWT)." RFC 7519, 2015. https://www.rfc-editor.org/rfc/rfc7519
  2. Internet Engineering Task Force. "JSON Web Signature (JWS)." RFC 7515, 2015. https://www.rfc-editor.org/rfc/rfc7515
  3. Spring Security Reference Documentation. "Authentication Architecture." https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
  4. Spring Security Reference Documentation. "Authorization." https://docs.spring.io/spring-security/reference/servlet/authorization/index.html
  5. OAuth 2.0 RFC 6749. "The OAuth 2.0 Authorization Framework." IETF, 2012. https://www.rfc-editor.org/rfc/rfc6749
  6. Auth0 Blog. "Refresh Token Rotation." https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/
  7. OWASP. "JSON Web Token Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
  8. Spring Boot Reference Documentation. "Spring Security." https://docs.spring.io/spring-boot/reference/web/spring-security.html
  9. Redis Documentation. "EXPIRE / SETEX / TTL Commands." https://redis.io/commands/
相关推荐
海上小飞龙9 小时前
Redis 分布式锁原理:从 SET NX EX 到 Redisson 看门狗
数据库·redis·分布式
BUG指挥官9 小时前
Sa-Token和Spring Security对比
java·后端·spring
geminigoth10 小时前
Spring AI Alibaba 入门开发一(备份)
java·人工智能·spring
万物皆字节12 小时前
【避坑】使用CacheEvict需要注意的坑
redis
xbgRS12 小时前
spring整合mybaits
java·spring·mybatis
ruleslol13 小时前
静态依赖注入 VS 动态依赖查询
spring
墨雨晨曦8815 小时前
Spring AI总结
java·人工智能·spring
用户31268748772017 小时前
Spring Boot 异常处理到底怎么玩的?从 DispatcherServlet 到全局兜底的全链路拆解
spring
不才不才不不才17 小时前
Spring 源码系列(17): HandlerMapping 与 HandlerAdapter 两大体系
java·后端·spring
(轻舟已过万重山)18 小时前
第40章 Spring AI 实战:企业级 AI 应用架构
人工智能·spring·架构