你以为登录就是"用户名密码换一个Token"?当你的系统需要"踢人下线"、"单设备登录"、"Token无感刷新"时,一切都变了。
------ 一篇用代码流程拆解双Token认证体系的实战手册
阅读时间:约45分钟 | 涵盖:双Token原理 / Spring Security过滤链 / JWT生成与校验 / Redis黑名单 / RBAC权限 / Token刷新 / 安全实践
📑 目录
- 序章:为什么需要双Token?
- [第一幕:JWT基础 --- Token长什么样](#第一幕:JWT基础 — Token长什么样)
- 第二幕:双Token架构设计
- 第三幕:登录流程完整拆解
- [第四幕:请求认证 --- Filter Chain执行流程](#第四幕:请求认证 — Filter Chain执行流程)
- 第五幕:Token刷新机制
- 第六幕:登出与Token失效
- 第七幕:RBAC权限模型
- [第八幕:Spring Security配置架构](#第八幕: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(头部)
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认证体系看似复杂,但核心思想很简单:
- 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/