乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

RememberMe

定义与作用

RememberMe(记住我) 是一种自动登录机制:用户在首次登录时勾选"记住我"选项,服务器在认证成功后生成持久化 Token 存入浏览器 Cookie。当用户关闭浏览器后再次访问,即使 HttpSession 已过期,系统也能通过 Cookie 中的 Token 自动完成认证,避免用户频繁输入用户名和密码。

没有安全框架时,开发者需要手写完整的 Token 生成、存储、校验、过期、刷新、盗用检测逻辑。任何疏漏(如 Token 明文传输、无过期时间、无密钥签名、不刷新 Token)都会导致严重的安全问题:攻击者窃取 Cookie 后可长期冒充用户,且用户毫不知情。

Spring Security 提供两种 RememberMe 实现,覆盖从简单到高安全的完整需求:

实现方式机制安全性适用场景
简单 Hash TokenTokenBasedRememberMeServices:Cookie 中存储 Base64(username + expiry + md5Hash)中小型应用、无数据库依赖
持久 TokenPersistentTokenBasedRememberMeServices:随机 Token 存入数据库,每次使用自动刷新高生产环境、需要 Token 盗用检测

核心原理

两种实现对比

简单 Hash Token(TokenBasedRememberMeServices)
  → Cookie 格式: Base64(username:expiry:md5Signature)
  → md5Signature = md5(username + ":" + expiry + ":" + password + ":" + key)
  → 依赖用户密码,密码修改后所有 RememberMe Token 失效
  → 无服务端存储,纯 Cookie 验证

持久 Token(PersistentTokenBasedRememberMeServices)
  → Cookie 格式: Base64(series:token)
  → series 为固定标识(同一用户的系列号不变)
  → token 为每次使用自动刷新的随机值
  → 服务端数据库存储 (username, series, token, last_used)
  → 支持 Token 盗用检测:如果 Cookie 中的 series 正确但 token 不匹配,说明 Cookie 被盗用

持久 Token 的盗用检测机制

这是持久 Token 相比简单 Token 的核心安全优势:

// 简化逻辑
public void loginSuccess(...) {
    // 1. 从 Cookie 解析 series 和 token
    String series = cookieSeries;
    String presentedToken = cookieToken;

    // 2. 从数据库查询该 series 对应的记录
    PersistentRememberMeToken token = tokenRepository.getTokenForSeries(series);

    if (token == null) {
        throw new CookieTheftException("未找到对应 series");
    }

    // 3. 比对 token
    if (!token.getTokenValue().equals(presentedToken)) {
        // ❗ 发现盗用:series 正确但 token 不匹配
        // 说明攻击者窃取了旧 Cookie,而合法用户已使用并刷新了 token
        tokenRepository.removeUserTokens(token.getUsername());  // 清除该用户所有 Token
        throw new CookieTheftException("Cookie 可能已被盗用");
    }

    // 4. 验证通过,生成新 token 并更新数据库
    String newToken = generateTokenData();
    tokenRepository.updateToken(series, newToken, new Date());
    // 将新 Cookie 写回浏览器
}

RememberMe 认证流程


示例一:简单 Hash Token(无数据库依赖)

场景说明

内部工具系统,用户量小、无数据库或不想维护 RememberMe Token 表。使用 TokenBasedRememberMeServices 实现基于 Hash 的记住我,密码修改后自动失效。

操作前配置

未启用 RememberMe,用户每次关闭浏览器后都需要重新登录:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/tool/**").hasRole("USER")
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll();
        // 未配置 rememberMe,Session 过期后必须重新登录
    }
}

操作后配置

启用 TokenBasedRememberMeServices,配置密钥和 Token 有效期:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SimpleRememberMeConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/tool/**").hasRole("USER")
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .rememberMe()
                .key("myInternalToolSecretKey")           // 签名密钥
                .tokenValiditySeconds(604800)             // 7 天有效期
                .rememberMeParameter("remember-me")       // 表单参数名
                .userDetailsService(userDetailsService()); // 必须提供 UserDetailsService
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("dev")
            .password("{bcrypt}$2a$10$...")
            .roles("USER")
            .build());
        return manager;
    }
}

登录表单:

<form action="/login" method="post">
    <input type="text" name="username" placeholder="用户名" />
    <input type="password" name="password" placeholder="密码" />
    <label>
        <input type="checkbox" name="remember-me" value="true" /> 记住我(7 天)
    </label>
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
    <button type="submit">登录</button>
</form>

结果分析

  • 用户登录时勾选"记住我",TokenBasedRememberMeServices 生成 Cookie:remember-me=Base64(dev:1718000000:md5Hash)
  • 其中 md5Hash = md5(dev + ":" + 1718000000 + ":" + password + ":" + key)
  • 用户关闭浏览器,Session 过期,再次访问 /tool/dashboard 时 RememberMeAuthenticationFilter 拦截
  • 从 Cookie 解析用户名、过期时间、签名,重新计算 MD5 比对,验证通过后从 UserDetailsService 加载用户并自动认证
  • 管理员修改用户密码后,MD5 签名中的密码因子改变,旧 Cookie 失效,用户必须重新登录

示例二:持久 Token(生产级安全)

场景说明

生产级 SaaS 平台,需要 RememberMe 功能,同时要求:① 支持 Token 盗用检测;② 用户可在"账号安全"页面查看和撤销已记住的登录设备;③ 数据库持久化便于集群共享。

操作前配置

使用简单 Hash Token,无法实现 Token 刷新和盗用检测,且集群环境下各节点 Cookie 验证独立:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .rememberMe()
            .key("saPlatformKey")
            .tokenValiditySeconds(2592000);  // 30 天
    // 问题:无数据库,无盗用检测,密码修改后全部失效,集群不共享
}

操作后配置

使用 PersistentTokenBasedRememberMeServices + JdbcTokenRepositoryImpl,数据库表 persistent_logins 存储 Token:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class PersistentRememberMeConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/app/**").authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .rememberMe()
                .tokenRepository(persistentTokenRepository())  // 持久化存储
                .tokenValiditySeconds(1209600)                  // 14 天有效期
                .rememberMeParameter("remember-me")
                .userDetailsService(userDetailsService())
                .alwaysRemember(false);                         // 必须用户勾选才生效
    }

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
        repo.setDataSource(dataSource);
        // repo.setCreateTableOnStartup(true);  // 首次运行时自动建表,生产环境建议手动执行 SQL
        return repo;
    }

    // 建表 SQL(手动执行):
    // CREATE TABLE persistent_logins (
    //     username VARCHAR(64) NOT NULL,
    //     series VARCHAR(64) PRIMARY KEY,
    //     token VARCHAR(64) NOT NULL,
    //     last_used TIMESTAMP NOT NULL
    // );
}

账号安全页面:查看和撤销已记住设备:

@RestController
@RequestMapping("/account/security")
public class RememberMeDeviceController {

    @Autowired
    private JdbcTokenRepositoryImpl tokenRepository;

    @GetMapping("/devices")
    @PreAuthorize("isAuthenticated()")
    public List<DeviceInfo> listRememberedDevices() {
        String username = SecurityContextHolder.getContext().getAuthentication().getName();

        // 从 persistent_logins 表查询当前用户的所有记录
        String sql = "SELECT series, last_used FROM persistent_logins WHERE username = ?";
        List<DeviceInfo> devices = new ArrayList<>();

        // 使用 JdbcTemplate 或 Repository 查询
        // ...

        return devices;
    }

    @PostMapping("/devices/{series}/revoke")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<?> revokeDevice(@PathVariable String series) {
        String username = SecurityContextHolder.getContext().getAuthentication().getName();

        // 验证该 series 是否属于当前用户,然后删除
        tokenRepository.removeUserTokens(username);  // 或者按 series 精确删除
        return ResponseEntity.ok().build();
    }
}

结果分析

  • 用户勾选"记住我"登录后,PersistentTokenBasedRememberMeServices 生成随机 series 和 token,存入数据库 persistent_logins 表
  • Cookie 中存储 Base64(series:token),有效期 14 天
  • 用户每次自动登录时,token 被刷新为新随机值并更新数据库,旧 token 失效
  • 如果攻击者窃取 Cookie 并在合法用户再次登录前使用,两者 token 一致,攻击者成功访问
  • 但如果合法用户先使用(刷新 token),攻击者再次使用旧 token 时,series 匹配但 token 不匹配,触发 CookieTheftException,Spring Security 清除该用户所有 RememberMe Token,要求全部重新登录
  • 用户在"账号安全"页面可查看 persistent_logins 表中的记录,主动撤销可疑设备

易错场景:RememberMe 配置遗漏 key 导致集群异常

问题描述

使用 TokenBasedRememberMeServices 时,在单节点环境正常,但部署到集群后,用户在节点 A 登录后,请求被负载均衡到节点 B 时,RememberMe 自动登录失败。

根本原因

TokenBasedRememberMeServices 的签名使用 key 参数进行 MD5 计算:md5(username + ":" + expiry + ":" + password + ":" + key)。如果未显式配置 key,Spring Security 默认生成一个随机密钥(每次启动不同)。

集群中:

  • 节点 A 启动时生成 key=A,Cookie 签名基于 key=A
  • 节点 B 启动时生成 key=B,验证 Cookie 时使用 key=B 重新计算 MD5
  • 签名不匹配,验证失败,用户被重定向到登录页

错误配置

http.rememberMe()
    // .key("...")  // ❌ 遗漏!集群各节点使用不同随机 key
    .tokenValiditySeconds(604800);

正确配置

显式配置统一的 key,所有节点使用相同密钥:

http.rememberMe()
    .key("clusterWideRememberMeKey2024")  // ✅ 集群统一密钥
    .tokenValiditySeconds(604800);

进阶方案(生产环境推荐)

使用持久 Token 方案(PersistentTokenBasedRememberMeServices),验证逻辑完全依赖数据库存储,不依赖 key 进行 Cookie 签名,天然支持集群:

http.rememberMe()
    .tokenRepository(persistentTokenRepository())  // 数据库存储,集群共享
    .tokenValiditySeconds(1209600);

面试考点

问:Spring Security RememberMe 的"简单 Token"和"持久 Token"有什么区别?生产环境应该选择哪种?

答:简单 Token(TokenBasedRememberMeServices)将签名后的 Token 直接存入 Cookie,格式为 Base64(username:expiry:md5Signature),验证时通过 key 和密码重新计算 MD5 比对。优点是无需数据库,缺点是:① 密码修改后所有 RememberMe 失效;② 无法检测 Cookie 盗用;③ 集群环境必须显式配置统一 key,否则节点间验证失败。

持久 Token(PersistentTokenBasedRememberMeServices)将随机 series 和 token 存入数据库,Cookie 只存 series 和 token。每次使用自动刷新 token,并支持盗用检测:如果 series 正确但 token 不匹配,说明 Cookie 被盗用,系统清除该用户所有 RememberMe Token。生产环境强烈推荐使用持久 Token,因为它提供了更高的安全性、集群共享能力和用户可控的设备管理(可从数据库表撤销特定设备)。


版本说明:本章节基于 Spring Security 5.x(WebSecurityConfigurerAdapter + antMatchers + @EnableGlobalMethodSecurity(prePostEnabled=true) 风格)。

上一页
SessionCreationPolicy