欢迎光临
我们一直在努力

拿捏登录安全:RS256 + 双令牌,把非法请求拦在 Redis 白名单门外

一:什么是双令牌

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 白名单的键
  • 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 安全配置

/**
* 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
  • @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 轮换检测、设备会话管理、密钥定期轮换
赞(0)
未经允许不得转载:171主机测评 » 拿捏登录安全:RS256 + 双令牌,把非法请求拦在 Redis 白名单门外
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址