双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/
相关推荐
Ethan01079 小时前
spring事务隔离级别,如果数据库设置为RC,spring可以改为RR吗
spring
Ethan01079 小时前
详解Spring 事务三大核心接口,以及我们能用它来做什么
spring
zhouhui0019 小时前
AI帮我写了个Spring Boot校验,线上漏掉了这组边界条件
java·spring boot·redis·ai编程
吃饱了得干活10 小时前
扫码登录如何实现?从原理到实战
java·spring boot·redis
liuxiaowei310 小时前
Winform+WPF双框架实战:喷涂工艺SCADA上位机从0到1搭建(附采集监控源码+车间踩坑实录)
大数据·hadoop·wpf
架构源启21 小时前
文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取
java·人工智能·spring
熊猫钓鱼>_>1 天前
Redis 突发缓存穿透:一次完整的定位复盘
数据库·人工智能·redis·缓存·ai·agent·智能
Ethan01071 天前
redis 热key问题如何解决+场景
redis
风起洛阳@不良使1 天前
springIOC创建对象的方式--spring容器中的注入
java·spring