Spring Boot 4.0 JWT 登录:访问令牌、刷新令牌与退出登录
前言
只返回一个 JWT 很容易,难的是回答三个生产问题:访问令牌泄露后如何缩短风险窗口,刷新令牌被重复使用怎么办,用户退出后旧令牌何时失效。
本章使用短期 JWT 访问令牌和随机会话式刷新令牌。刷新令牌只保存在服务端,使用一次就轮换;退出时撤销当前 JWT 的 jti 和刷新令牌。
代码接在上一章的安全链上。读者需要理解 HTTP 状态码、Bearer Token 和 BCrypt,完整源码位于 dvsusan/spring-boot4-demo。
最终结果
本章新增:
POST /api/v1/auth/login
POST /api/v1/auth/refresh
POST /api/v1/auth/logout登录返回访问令牌、刷新令牌和有效期。访问令牌带用户、租户、部门、角色、权限和唯一 jti;刷新令牌是不透明随机值,不在浏览器中携带业务权限。

图 1:访问令牌负责短期授权,刷新令牌负责重新取得会话。
第 1 步:检查 JWT 配置
application-chapter6.yml 中设置签名密钥与访问时长:
demo:
chapter6:
jwt:
issuer: spring-boot4-demo
# Access Token 只保留 15 分钟,降低泄露后的可利用时间。
access-ttl: 15m
token:
# 至少 32 字节;生产环境必须由密钥管理系统注入。
signing-key: ${DEMO_SIGNING_KEY:chapter6-demo-signing-key-change-before-production}
# 不透明刷新令牌在服务端保存 7 天。
refresh-ttl: 7d演示默认密钥只能用于本地。HS256 的密钥一旦泄露,攻击者可以签发任意用户令牌。
第 2 步:签发访问令牌
JwtTokenService 使用 Spring Security 的 JwtEncoder:
public IssuedAccessToken issue(Chapter6UserDetails user) {
Instant issuedAt = clock.instant();
Instant expiresAt = issuedAt.plus(accessTtl);
String tokenId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.subject(user.getUsername())
.id(tokenId)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.claim("token_use", "access")
.claim("tenant_id", user.getTenantId())
.claim("department_id", user.getDepartmentId())
.claim("roles", user.getRoles())
.claim("permissions", user.getPermissions())
.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(MacAlgorithm.HS256).build(),
claims
)).getTokenValue();
return new IssuedAccessToken(token, tokenId, expiresAt);
}token_use=access 防止其他用途令牌被当成 API 凭证。jti 是退出撤销的索引。角色和权限放进访问令牌后,资源服务器不必每次请求都查用户表,但权限变更要等旧令牌过期,敏感权限仍建议短 TTL。
第 3 步:保存并轮换刷新令牌
TokenStore 使用随机 32 字节生成刷新令牌:
public String createRefreshToken(String username) {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
refreshSessions.put(token, new RefreshSession(
username,
clock.instant().plus(refreshTtl)
));
return token;
}
public RefreshRotation rotateRefreshToken(String token) {
RefreshSession session = refreshSessions.remove(token);
if (session == null || session.expiresAt().isBefore(clock.instant())) {
throw new IllegalArgumentException("刷新令牌无效或已过期");
}
String next = createRefreshToken(session.username());
return new RefreshRotation(session.username(), next);
}删除动作是原子的消费过程。生产环境还要保存令牌哈希、设备指纹、签发时间和复用检测;如果旧令牌第二次出现,应撤销同一会话的全部令牌。
第 4 步:实现登录与刷新闭环
AuthService 先校验密码,再签发一对令牌:
public TokenPair login(String username, String password) {
Chapter6UserDetails user = (Chapter6UserDetails)
userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("用户名或密码错误");
}
return issuePair(user);
}
public TokenPair refresh(String refreshToken) {
TokenStore.RefreshRotation rotation =
tokenStore.rotateRefreshToken(refreshToken);
Chapter6UserDetails user = (Chapter6UserDetails)
userDetailsService.loadUserByUsername(rotation.username());
JwtTokenService.IssuedAccessToken access =
jwtTokenService.issue(user);
return new TokenPair(
"Bearer",
access.value(),
rotation.nextRefreshToken(),
accessTtl.toSeconds()
);
}登录错误只返回统一的“用户名或密码错误”,不能泄露账号是否存在。
第 5 步:启动并登录
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=chapter6 \
--server.port=18106
curl -sS -X POST http://localhost:18106/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}'响应结构如下,三个令牌字段会变化:
{
"code": "0",
"data": {
"tokenType": "Bearer",
"accessToken": "<jwt>",
"refreshToken": "<opaque-random-token>",
"expiresIn": 900
}
}把访问令牌放入 Authorization 头:
curl -i http://localhost:18106/api/v1/security/me \
-H 'Authorization: Bearer <accessToken>'接口返回 200,并能看到 ADMIN 角色和租户声明。
第 6 步:验证刷新令牌只能使用一次
curl -sS -X POST http://localhost:18106/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"<refreshToken>"}'成功后服务端删除旧值并返回新值。再次提交同一个旧刷新令牌,预期返回 401:
{
"code": "INVALID_TOKEN",
"message": "刷新令牌无效或已过期"
}这正是“轮换”与“重复使用”的区别。只返回同一个刷新令牌,会让泄露令牌长期有效。
第 7 步:退出并确认访问令牌失效
curl -i -X POST http://localhost:18106/api/v1/auth/logout \
-H 'Authorization: Bearer <accessToken>' \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"<refreshToken>"}'退出会把访问令牌的 jti 写入撤销表,并删除刷新令牌。随后再用旧访问令牌请求 /api/v1/security/me,资源服务器会返回 401。
JWT 本身无法修改,所谓“JWT 退出”依赖服务端记录撤销状态。当前撤销表在内存中,适合单实例教学;生产环境应放入 Redis 并设置到访问令牌过期时间为止。
自动化验证
mvn -pl demo-web -am \
-Dtest=Chapter6SecurityIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false \
test实际结果覆盖:
- 登录后携带 JWT 访问
/me返回 200。 - 第一次刷新成功,旧刷新令牌再次使用返回 401。
- 退出后旧访问令牌返回 401。
- 无权限删除操作返回 403。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 所有 Bearer 请求都返回 401 | 签名密钥、issuer 或算法不一致 | 对比签发和验证配置 |
| 刷新令牌可以重复使用 | 没有在刷新时删除旧记录 | 使用原子消费并轮换 |
| 退出后 JWT 仍能访问 | 只删了刷新令牌,没有撤销 jti | 增加访问令牌撤销校验 |
| 刷新接口返回 401 | 令牌已过期或已被轮换 | 要求用户重新登录 |
| 权限变更没有立即生效 | 权限固化在未过期 JWT 中 | 缩短 TTL 或引入权限版本号 |
练习题
给刷新会话增加 deviceId 和 lastUsedAt。当某个旧刷新令牌被重复使用时,撤销该用户同一设备的全部刷新令牌,并记录安全审计事件。验收标准是并发刷新只能有一个成功,重复使用会触发整组撤销。
总结
短期 JWT 负责减少数据库查询,不透明刷新令牌负责延续会话,服务端撤销表负责退出和高风险失效。三者组合起来,才是可运营的登录体系。
下一篇接入企业 OIDC,让员工使用公司统一身份登录,并把企业用户组映射成本项目的角色和权限。
上一节:Security 7 企业级配置
下一节:OAuth2 与 SSO
相关阅读:Redis 缓存设计
返回课程目录
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
