Skip to content

Remember-Me 功能:Token 持久化与安全

你有没有注意过,很多网站登录页面都有一个「记住我」复选框?

勾选之后,即使关闭浏览器再打开,也不需要重新登录。这个功能是怎么实现的?背后又有哪些安全风险?

今天,我们就来深入了解 Spring Security 的 Remember-Me 功能。


Remember-Me 的工作原理

┌──────────────────────────────────────────────────────────────────────────┐
│                         Remember-Me 工作原理                              │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  登录时:                                                                │
│                                                                          │
│  用户 ──► 勾选"记住我" ──► 登录成功                                       │
│                              │                                            │
│                              ▼                                            │
│                      Spring Security 生成 Token                          │
│                              │                                            │
│                              ▼                                            │
│                      Token = username + expiryTime +                     │
│                              signature(使用密钥签名)                      │
│                              │                                            │
│                              ▼                                            │
│                      保存到 Cookie                                       │
│                      ┌────────────────────────────────────────┐         │
│                      │ remember-me = base64(token);           │         │
│                      │ Cookie: remember-me=xxx                 │         │
│                      └────────────────────────────────────────┘         │
│                                                                          │
│  ────────────────────────────────────────────────────────────────────  │
│                                                                          │
│  后续访问(无 Session 时):                                             │
│                                                                          │
│  用户 ──► 请求带上 Cookie                                               │
│                    │                                                    │
│                    ▼                                                    │
│            Spring Security 读取 Token                                   │
│                    │                                                    │
│                    ▼                                                    │
│            验证签名 + 检查过期时间                                       │
│                    │                                                    │
│            ┌───────┴───────┐                                           │
│            │ 验证通过        │ 验证失败                                  │
│            ▼                ▼                                          │
│      恢复用户认证状态      忽略 Token                                    │
│      相当于自动登录        回到登录页面                                 │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

两种实现方式

Spring Security 支持两种 Remember-Me 实现:

方式Token 存储安全性
简单校验SimpleUrlPersistentRememberMeServicesCookie⚠️ 仅签名防篡改
持久化PersistentTokenRepository数据库/Redis✅ 更安全

方式一:基于签名(默认)

Token 结构:

username:expiryTime:signature

例如:
admin:1677235200:signature(使用密钥加密)
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .rememberMe(remember -> remember
                // Token 有效期(秒),默认 2 周
                .tokenValiditySeconds(1209600)
                // 记住我 Cookie 的名称
                .rememberMeCookieName("remember-me")
                // 登录表单中"记住我"字段的名称
                .rememberMeParameter("remember-me")
                // 用于签名的密钥
                .key("mySecretKey")
            );
        
        return http.build();
    }
}

方式二:持久化存储(推荐生产环境)

Token 存储在数据库或 Redis 中,即使密钥泄露也无法伪造 Token。

数据库存储

sql
-- 创建 persistent_logins 表
CREATE TABLE persistent_logins (
    username VARCHAR(64) NOT NULL,
    series VARCHAR(64) PRIMARY KEY,  -- Token 系列号
    token VARCHAR(64) NOT NULL,       -- 当前 Token
    last_used TIMESTAMP NOT NULL      -- 最后使用时间
);
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Autowired
    private DataSource dataSource;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .rememberMe(remember -> remember
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(1209600)  // 14 天
                .key("mySecretKey")
            );
        
        return http.build();
    }
    
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        // 第一次运行设为 true,自动创建表
        // 之后改为 false
        tokenRepository.setCreateTableOnStartup(false);
        return tokenRepository;
    }
}

Redis 存储

xml
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .rememberMe(remember -> remember
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(1209600)
                .key("mySecretKey")
            );
        
        return http.build();
    }
    
    @Bean
    public PersistentTokenRepository persistentTokenRepository(
            RedisTemplate<String, Object> redisTemplate) {
        return new RedisTokenRepositoryImpl(redisTemplate);
    }
}

登录页面配置

Thymeleaf 表单

html
<form th:action="@{/login}" method="post">
    <div>
        <label>用户名:</label>
        <input type="text" name="username" required/>
    </div>
    <div>
        <label>密码:</label>
        <input type="password" name="password" required/>
    </div>
    <div>
        <!-- "记住我"复选框,name 必须与 rememberMeParameter 一致 -->
        <input type="checkbox" name="remember-me"/> 记住我
    </div>
    <button type="submit">登录</button>
</form>
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .rememberMe(remember -> remember
                .rememberMeCookieName("auto-login")  // 默认是 remember-me
            );
        
        return http.build();
    }
}
html
<input type="checkbox" name="remember-me"/> 下次自动登录

Remember-Me 的安全机制

Token 安全验证流程

┌─────────────────────────────────────────────────────────────────────┐
│                      Remember-Me 安全验证                          │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. 解析 Cookie                                                      │
│     remember-me = base64(series + ":" + token)                       │
│                                                                      │
│  2. Base64 解码                                                      │
│     series = "abc123"                                                │
│     token = "xyz789"                                                 │
│                                                                      │
│  3. 查询数据库                                                       │
│     SELECT * FROM persistent_logins WHERE series = 'abc123'          │
│                                                                      │
│  4. 验证 Token                                                        │
│     ┌────────────────────────────────────────────────────────────┐   │
│     │ if (stored_token == token) {                               │   │
│     │     // Token 有效                                           │   │
│     │     update last_used = NOW();                               │   │
│     │ } else {                                                    │   │
│     │     // Token 被盗用!整个系列的所有 Token 失效               │   │
│     │     delete FROM persistent_logins WHERE series = 'abc123'; │   │
│     │ }                                                            │   │
│     └────────────────────────────────────────────────────────────┘   │
│                                                                      │
│  5. 用户认证                                                          │
│     加载用户信息,构建 Authentication                                 │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

安全性保障措施

措施说明作用
签名防篡改Token 包含 HMAC 签名防止客户端修改 Token
系列号(Series)每个登录设备唯一追踪 Token 来源
Token 轮换每次使用生成新 Token防止 Token 重放攻击
自动作废Token 不匹配时整个系列失效防止 Token 被盗用

Token 被盗用的处理

java
@Service
public class TokenTheftService {
    
    @Autowired
    private PersistentTokenRepository tokenRepository;
    
    @Autowired
    private LoginAttemptService loginAttemptService;
    
    // 当检测到 Token 不匹配时调用
    public void handleTokenTheft(String series, String username) {
        // 1. 删除该用户的所有 Token(强制所有设备重新登录)
        tokenRepository.removeUserTokens(username);
        
        // 2. 记录安全事件
        log.warn("Token theft detected for user: {}, series: {}", username, series);
        
        // 3. 可选:发送告警邮件
        notifyUserAboutSecurityEvent(username);
        
        // 4. 可选:锁定账户一段时间
        loginAttemptService.lockAccount(username, Duration.ofHours(1));
    }
}

持久化 Token 的数据结构

数据库表结构

sql
CREATE TABLE persistent_logins (
    username VARCHAR(64) NOT NULL,
    series VARCHAR(64) NOT NULL,
    token VARCHAR(64) NOT NULL,
    last_used TIMESTAMP NOT NULL,
    PRIMARY KEY (series)
);

-- 为查询添加索引
CREATE INDEX idx_persistent_logins_username ON persistent_logins(username);

Token 的生命周期

用户登录(勾选记住我)


生成唯一的 Series(系列号,绑定到设备)


生成随机 Token


保存到数据库
┌─────────────────────────────────────────┐
│ username  │  series      │  token       │
│ admin     │  abc123      │  token1      │
└─────────────────────────────────────────┘


返回给浏览器(Cookie)

下次访问(Token 有效)


验证 Token 成功后,更新 Token
┌─────────────────────────────────────────┐
│ username  │  series      │  token       │ ← 换成新 token
│ admin     │  abc123      │  token2      │
└─────────────────────────────────────────┘


Cookie 更新(Series 不变,Token 变化)

高级配置

仅在特定条件下启用

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .rememberMe(remember -> remember
                .tokenValiditySeconds(1209600)
                .key("mySecretKey")
                // 仅在用户明确勾选时启用
                .useSecureCookie(false)  // 生产环境应为 true
                // 或者自定义启用条件
                // .rememberMeServices(rememberMeServices())
            );
        
        return http.build();
    }
}

自定义 Token 生成逻辑

java
@Service
public class CustomPersistentTokenRepository implements PersistentTokenRepository {
    
    @Autowired
    private TokenStore tokenStore;
    
    @Override
    public void createNewToken(PersistentRememberMeToken token) {
        tokenStore.save(token);
    }
    
    @Override
    public void updateToken(String series, String tokenValue, Date lastUsed) {
        PersistentRememberMeToken token = tokenStore.get(series);
        if (token != null) {
            tokenStore.save(new PersistentRememberMeToken(
                token.getUsername(),
                series,
                tokenValue,
                lastUsed
            ));
        }
    }
    
    @Override
    public PersistentRememberMeToken getTokenForSeries(String seriesId) {
        return tokenStore.get(seriesId);
    }
    
    @Override
    public void removeUserTokens(String username) {
        tokenStore.deleteByUsername(username);
    }
}

禁用 Remember-Me

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .rememberMe(remember -> remember
                .disable()  // 完全禁用 Remember-Me
            );
        
        return http.build();
    }
}

Remember-Me 与 CSRF

Remember-Me Cookie 同样受 CSRF 攻击威胁:

攻击场景:
1. 攻击者诱导已登录用户访问恶意页面
2. 恶意页面发起请求到 /logout(可能清除 Remember-Me Token)
3. 攻击者获取用户的 Remember-Me Cookie
4. 攻击者使用该 Cookie 登录用户账户

防护措施

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 确保 CSRF 保护开启
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            )
            // Remember-Me 配置
            .rememberMe(remember -> remember
                .key("mySecretKey")
            );
        
        return http.build();
    }
}
html
<!-- 登录表单需要包含 CSRF Token -->
<form th:action="@{/login}" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    <!-- ... -->
</form>

面试追问方向

问题考察点延伸阅读
Remember-Me 的工作原理?原理理解本篇
简单签名和持久化存储的区别?实现差异本篇
Token 被盗用后如何处理?安全机制本篇
Remember-Me 如何防止 CSRF?安全机制本篇
如何自定义 Remember-Me 的 Token 生成逻辑?扩展能力本篇

总结

Remember-Me 功能的核心:

  1. 两种实现:简单签名(仅防篡改)vs 持久化存储(更安全)
  2. Token 结构:Series(设备绑定)+ Token(随机值)
  3. 安全机制:签名验证、Token 轮换、自动作废
  4. 被盗处理:删除该用户所有 Token,强制重新登录
  5. 配合 CSRF:Remember-Me Cookie 同样需要 CSRF 保护

生产环境推荐使用持久化存储方式,可以追踪 Token 使用情况,也便于用户管理自己的登录设备。


下一步

基于 VitePress 构建