一:什么是双令牌
accessToken(访问令牌)
-
作用 : 每次访问业务接口时,用来证明"我是某某用户"。 比如你
Authorization: Bearer xxx就是 accessToken。 -
特点:
-
有效期 很短(比如 15 分钟、30 分钟)。
-
内部自带用户 ID、角色、权限等信息(JWT 的 payload)。
-
后端 只需要校验签名和是否过期,一般不查数据库,性能好、扩展性强。
-
-
使用方式 : 前端每次调
/api/v1/xxx时带上它用以权限认证。111
refreshToken(刷新令牌)
-
作用 : 不直接访问业务接口,只用来 换取新的 accessToken 。 当前 accessToken 过期了,前端拿 refreshToken 调
/auth/refresh这种接口,就能拿到一对新的 accessToken + refreshToken。 -
特点:
-
有效期 较长(比如 7 天、14 天甚至 30 天)。
-
一般只在少数几个认证接口使用:
/login、/refresh。 -
可以结合 Redis / 数据库 做黑名单、版本号等控制(更容易做"踢下线""注销")。
-
二:为什么要用双令牌,而不是一个长有效期的 token?
1. 安全性更高
如果只有一个长生命周期的 JWT:
-
比如你设一个 JWT 直接 30 天不过期,一旦这个 token 被窃取,黑客可以在 30 天内随便伪装成用户操作,风险非常大。
-
又因为 JWT 一般是"无状态"的,后端默认不会存储、也不回收,因此很难"立刻让这个 token 失效"。
双令牌的改进:
-
accessToken 只活 15~30 分钟 ------ 即使被盗,黑客最多用这一小段时间。
-
refreshToken 一般只在"刷新接口"使用,你可以:
-
把 refreshToken 保存得更安全(比如 HttpOnly Cookie、只在后端存一份、只允许 HTTPS)。
-
针对 refreshToken 做更严格检查:设备信息、IP、User-Agent、单点登录等。
-
-
一旦发现泄露或用户手动"退出登录",直接把对应的 refreshToken 在 Redis 标记失效:
-
老的 refreshToken 无法再换新 token;
-
accessToken 也会因为短时过期,自然失效;
-
这样就实现"可控的强制下线"。
-
短命的 accessToken + 受控的 refreshToken,比一个长命 JWT 安全得多。
2. 提升用户体验(减少频繁登录)
只有一个短期 JWT 时:
-
你为了安全,把过期时间设得很短(例如 30 分钟);
-
那用户会频繁被"踢回登录页面",体验很差。
双令牌的玩法是:
-
accessToken 失效后,前端用 refreshToken 悄悄调一次刷新接口,换一对新的;
-
对用户来说,整个过程是"无感"的:
-
只要 refreshToken 还没过期,就能一直续命;
-
只有在 refreshToken 真正过期(比如 7 天不登录)之后,才需要重新输账号密码。
-
3. 兼顾"无状态扩展性"和"可控的登录状态"
-
accessToken 校验可以完全无状态(只靠签名+过期时间),非常适合分布式,集群部署:
- 任意一个服务实例都能根据 JWT 自己完成认证。
-
refreshToken 可以是 "轻状态" 的:
-
把 refreshToken ID 存到 Redis;
-
可以做:
-
单点登录(同一用户只允许一个刷新 token 生效);
-
踢人下线(删除某个 refreshToken 记录);
-
安全风控(记录设备、IP,异常则禁止刷新)。
-
-
这样就可以做到:
-
业务接口层(多服务)只管校验 accessToken,不查库,性能高;
-
认证中心 / 网关 少量存 refreshToken 的状态,用来集中管理登录会话,引入"可控的在线状态"。
三:整体架构设计
完整请求处理流程

四:核心实现逐层解析
4.1 RSA 密钥对与 JWK 配置
- 使用 RS256(非对称算法),私钥签名、公钥验签
- PEM 文件存储, AuthProperties 绑定配置
- AuthConfiguration 中构造 JwtEncoder (含私钥)和 JwtDecoder (仅公钥)
- JWK 设置 keyId 支持密钥轮换
4.2 JwtService 令牌签发
- issueTokenPair() 一次调用签发两个令牌
- 自定义 Claims: token_type (区分 access/refresh)、 uid (用户 ID)、 jti (令牌 ID)
- Refresh Token 的 jti 作为 Redis 白名单的键
java
public TokenPair issueTokenPair(User user) {
String refreshTokenId = UUID.randomUUID().toString();
// 签发时间
Instant issuedAt = Instant.now(clock);
Instant accessExpiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
Instant refreshExpiresAt = issuedAt.plus(properties.getJwt().getRefreshTokenTtl());
String accessToken = encodeToken(user, issuedAt, accessExpiresAt, "access", UUID.randomUUID().toString());
String refreshToken = encodeRefreshToken(user, issuedAt, refreshExpiresAt, refreshTokenId);
return new TokenPair(accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, refreshTokenId);
}
/**
* 解码 JWT 字符串为 {@link Jwt}。
*
* @param token JWT 字符串。
* @return 解析后的 JWT 对象。
*/
public Jwt decode(String token) {
return jwtDecoder.decode(token);
}
/**
* 编码访问令牌。
*
* @param user 用户实体,作为 subject 与自定义声明来源。
* @param issuedAt 签发时间。
* @param expiresAt 过期时间。
* @param tokenType 令牌类型("access")。
* @param tokenId 令牌 ID(jti)。
* @return 编码后的 JWT 字符串。
*/
private String encodeToken(User user, Instant issuedAt, Instant expiresAt, String tokenType, String tokenId) {
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.getJwt().getIssuer())
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.subject(String.valueOf(user.getId()))
.id(tokenId)
.claim(CLAIM_TOKEN_TYPE, tokenType)
.claim(CLAIM_USER_ID, user.getId())
.claim("nickname", user.getNickname())
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
4.3 Redis 白名单(RefreshTokenStore)
- 键模式: auth:rt:{userId}:{tokenId} → 值 "1" ,TTL 与 Refresh Token 对齐
- 核心操作: storeToken (写入)、 isTokenValid (校验)、 revokeToken (撤销单个)、 revokeAll (撤销全部)
- 解决 JWT 无状态无法撤销的安全漏洞
4.4 Spring Security 安全配置
java
/**
* Spring Security 安全配置。
* <p>
* - 关闭 CSRF(后端纯 API,使用 JWT 无会话);
* - 启用 CORS,当前允许所有来源(后续需替换白名单);
* - 无状态会话;
* - 公开认证相关接口与健康检查,其余接口需鉴权;
* - 资源服务器启用 JWT 校验。
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
/**
* 配置 Spring Security 过滤链。
*
* <p>主要包含:</p>
* - 关闭 CSRF;
* - 启用 CORS;
* - 使用无状态会话策略;
* - 公开认证接口与健康检查,其余接口需鉴权;
* - 启用资源服务器的 JWT 校验。
*
* @param http Spring 的 {@link HttpSecurity} 构建器。
* @return 构建完成的 {@link SecurityFilterChain}。
* @throws Exception 构建过滤链过程中可能抛出的异常。
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
// 公开内容:首页 Feed 不需要登录
.requestMatchers("/api/v1/knowposts/feed").permitAll()
// 知文详情(公开已发布内容,非公开由服务层校验)
.requestMatchers(org.springframework.http.HttpMethod.GET, "/api/v1/knowposts/detail/*").permitAll()
// 知文详情页 RAG 问答(SSE 流式输出)允许匿名访问
.requestMatchers(org.springframework.http.HttpMethod.GET, "/api/v1/knowposts/*/qa/stream").permitAll()
.requestMatchers(
"/api/v1/auth/send-code",
"/api/v1/auth/register",
"/api/v1/auth/login",
"/api/v1/auth/token/refresh",
"/api/v1/auth/logout",
"/api/v1/auth/password/reset"
).permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 定义并提供 CORS 配置源。
*
* <p>当前允许所有来源(后续建议替换为产品白名单),允许常见方法与请求头,且不携带凭证。</p>
*
* @return {@link CorsConfigurationSource},用于为所有路径注册 CORS 规则。
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
configuration.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
- 无状态会话( STATELESS ),禁用 CSRF
- 无状态会话服务端不存session,免疫CSRF攻击
- 公开认证端点(send-code、register、login、refresh、logout、password/reset)
- .oauth2ResourceServer().jwt() 自动拦截 Bearer Token,校验签名与过期
- 这个自动校验请求头里面的信息,解析,构建认证对象
- 总体流程
- 注册 验证码校验→创建用户→签发令牌对→写入 Redis 白名单→记录审计 登录 密码或验证码校验→签发令牌对→写入 Redis 白名单→记录审计 刷新 校验 Refresh Token 签名→检查 token_type=refresh →检查 Redis 白名单→撤销旧令牌→签发新令牌对→写入新白名单 登出 接收 Refresh Token→解析 JWT→从 Redis 白名单删除 重置密码 验证码校验→更新密码→调用 revokeAll 强制该用户所有设备下线
五:令牌续签机制
- 客户端检测 Access Token 即将过期 → 调用 /api/v1/auth/token/refresh
java
@Override
public void revokeToken(long userId, String tokenId) {
redisTemplate.delete(key(userId, tokenId));
}
- 服务端颁发新的令牌对(Access + Refresh),旧 Refresh Token 立即失效
- 轮换策略 :每次刷新都替换 Refresh Token,防止重放
六:即时注销机制
- 单设备登出 : logout 接口从 Redis 删除该 Refresh Token
- 全设备强制下线 : resetPassword 调用 revokeAll ,批量删除该用户所有 Refresh Token
- 被动过期 :Redis TTL 到期自动清理,无需额外处理
七:安全增强点
- BCrypt 密码加密(cost=12)
- 验证码防暴力破解(尝试次数限制 + 发送频率限制)
- 登录审计日志(记录登录渠道、IP、UA、成功/失败)
- JWT 自定义声明防篡改(RS256 + Nimbus)
八:总结与最佳实践
- 双令牌 + Redis 白名单 = 无状态 + 可撤销,兼顾性能与安全
- 生产建议:Access Token 5-15 分钟、Refresh Token 7-30 天
- 扩展方向:Refresh Token 轮换检测、设备会话管理、密钥定期轮换
