RememberMe
定义与作用
RememberMe(记住我) 是一种自动登录机制:用户在首次登录时勾选"记住我"选项,服务器在认证成功后生成持久化 Token 存入浏览器 Cookie。当用户关闭浏览器后再次访问,即使 HttpSession 已过期,系统也能通过 Cookie 中的 Token 自动完成认证,避免用户频繁输入用户名和密码。
没有安全框架时,开发者需要手写完整的 Token 生成、存储、校验、过期、刷新、盗用检测逻辑。任何疏漏(如 Token 明文传输、无过期时间、无密钥签名、不刷新 Token)都会导致严重的安全问题:攻击者窃取 Cookie 后可长期冒充用户,且用户毫不知情。
Spring Security 提供两种 RememberMe 实现,覆盖从简单到高安全的完整需求:
| 实现方式 | 机制 | 安全性 | 适用场景 |
|---|---|---|---|
| 简单 Hash Token | TokenBasedRememberMeServices:Cookie 中存储 Base64(username + expiry + md5Hash) | 中 | 小型应用、无数据库依赖 |
| 持久 Token | PersistentTokenBasedRememberMeServices:随机 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)风格)。