拿捏登录安全: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 白名单的键
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 轮换检测、设备会话管理、密钥定期轮换
相关推荐
thisiszdy1 小时前
<C++&C#> lambda表达式
java·c++·c#
咖啡八杯1 小时前
GoF设计模式——外观模式
java·设计模式·外观模式
郝学胜-神的一滴1 小时前
系统设计 014:缓存深度实战:如何用 Cache 优雅优化数据库读写?
java·数据库·python·缓存·oracle·php·软件构建
xuankuxiaoyao1 小时前
阶段案例——后台管理系统
java·linux·前端
liana87441 小时前
政企专属的私有化安全协作平台,构建金融级全链路安全防护体系
安全·金融
摇滚侠1 小时前
JavaWeb 全套教程 Tomcat 53-62
java·tomcat
隔窗听雨眠2 小时前
ORM框架选型指南:MyBatis与Hibernate的全面对比
java·开发语言·数据库
qq_452396232 小时前
第十六篇:《Docker 安全基础:容器隔离与权限控制》
安全·docker·容器
j7~2 小时前
【C++】类和对象(上)--带你全面理解类和对象的概念,以及this指针的理解和相关面试题
java·开发语言·封装·this指针·类的实例化·访问限定符·类的命名