最新Spring Security实战教程(八):Remember-Me实现原理——持久化令牌与安全存储方案

1. 前言

在我们日常开发的后台系统中,”Remember-Me“(记住我)功能是一种常见的安全增强机制,允许用户在关闭浏览器后仍然保持登录状态,而无需重新输入用户名和密码。Spring Security提供了多种Remember-Me方案,最常用的是基于哈希的Token方案持久化令牌方案

本章节博主将详细讲解这两种方案的实现,带大家快速入门!在实际开发中,小伙伴们可根据各自需求进行改造。


2. Remember-Me机制概述

Spring Security中,Remember-Me的核心作用是在会话失效后依然允许用户自动登录。其基本工作流程(以更常用的持久化令牌方案为例)如下:

Remember-Me工作流程

  • 用户登录成功后,如果勾选了”记住我”,服务器会创建一个Remember-Me Token,并存储在客户端的Cookie中。
  • 当用户的会话失效后,系统会检查Cookie是否存在并有效:
    • 如果有效,则自动完成登录;
    • 如果无效或过期,则用户需要重新认证。

Spring Security主要提供两种Remember-Me方案:

方案 实现类 特点
基于Token(默认方案) TokenBasedRememberMeServices 在Cookie中存储加密Token,简单但安全性较低
持久化令牌方案(更安全) PersistentTokenBasedRememberMeServices 将Token存储在数据库中,每次认证时更新,更安全

3. 基于Token的Remember-Me机制

Spring Security默认提供TokenBasedRememberMeServices,其基本原理如下:

  • 当用户登录时,系统生成一个Token,并将其存储在Cookie中:
1
Base64(username + ":" + expirationTime + ":" + MD5(username + ":" + expirationTime + ":" + password + ":" + key))
  • 后续每次请求时,系统从Cookie读取Token,并验证其正确性:
    • 检查Token是否未过期;
    • 重新计算MD5哈希值,并与Token中的值进行对比;
    • 验证通过后自动完成登录。

开始配置Token方案

为了快速演示,这里我们用第三章节中基于内存的用户认证模块代码来追加演示,复用Maven项目中memory-spring-security子模块代码,新建一个remember-spring-security子模块。如果小伙伴没了解基于内存的用户认证的相关知识,可以访问最新Spring Security实战教程(三)Spring Security的底层原理解析进行学习。

配置Spring Security:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Configuration
public class RememberSecurityConfig {

// 手动配置用户信息
@Bean
public UserDetailsService users() {
UserDetails user = User.withUsername("user")
.password("{noop}user") // {noop}表示不加密
.roles("USER")
.build();

UserDetails admin = User.withUsername("admin")
.password("{noop}admin") // {noop}表示不加密
.roles("ADMIN")
.build();

UserDetails anonymous = User.withUsername("anonymous")
.password("{noop}anonymous")
.roles("ANONYMOUS")
.build();

return new InMemoryUserDetailsManager(user, admin, anonymous);
}

// 配置安全策略
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.logout(withDefaults())
.rememberMe(rememberMe -> rememberMe
.key("mySecretKey") // 服务器端密钥
.tokenValiditySeconds(7 * 24 * 60 * 60) // 7天有效期
.userDetailsService(users()) // 认证用户信息
);
return http.build();
}
}

代码解析:

  • rememberMe.key("mySecretKey"):服务器端密钥,防止Token被伪造。
  • tokenValiditySeconds(7 * 24 * 60 * 60):Token 7天有效。
  • userDetailsService(users()):Remember-Me认证时使用的UserDetailsService。

配置测试Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Controller
public class DemoRememberController {

@GetMapping("/")
public ResponseEntity<Map<String, Object>> index(Authentication authentication) {

String username = authentication.getName(); // 用户名
Object principal = authentication.getPrincipal(); // 身份

// 获取用户拥有的权限列表
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());

// 返回用户信息
return ResponseEntity.ok(Map.of(
"username", username,
"principal", principal,
"roles", roles));
}

@GetMapping("/admin/view")
public ResponseEntity<String> admin() {
return ResponseEntity.ok("管理员ADMIN角色访问ok");
}
}

测试步骤:

  1. 访问/login并输入管理员用户名/密码(admin),勾选”Remember-Me”选项。
  2. 登录成功后,查看浏览器Cookie。
  3. 关闭浏览器后重新访问/admin/view,系统会自动完成认证,无需重新输入用户名/密码。

安全提示:

  • 使用Base64简单加密,令牌无状态、易预测。
  • 由于Token直接存储在Cookie中,一旦被盗,攻击者可直接伪造登录。
  • 仅适用于低安全性要求的系统,不推荐在金融、政府、企业级应用中使用。

4. 持久化令牌方案

持久化令牌方案相比Token方案更安全:

  • Token存储在数据库,而非Cookie,避免被轻易伪造。
  • 每次Remember-Me认证时,生成新的Token并存入数据库,防止Token重放攻击。

❶ 持久化令牌方案的工作流程

  1. 用户登录后,系统生成一个seriesId和tokenValue,并存储到数据库。
  2. 服务器将seriesId存入Cookie,tokenValue仅存储在数据库。
  3. 下次用户访问时:
    • 服务器从Cookie获取seriesId;
    • 从数据库查找对应的tokenValue;
    • 验证成功后,生成新的tokenValue并更新数据库(防止Token被重放攻击)。

❷ 数据库表设计 + SpringBoot配置

1
2
3
4
5
6
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
);

因为涉及使用数据源,这里pom文件配置博主就复用之前章节的配置内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<dependencies>
<!--使用 HikariCP 连接池-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.9</version>
</dependency>
<!-- jdk 11+ 引入可选模块 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.9</version>
</dependency>
</dependencies>

yml配置文件,只需要追加datasource数据源配置即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8086

spring:
application:
name: remember-db-spring-security # 最新Spring Security实战教程(八)Remember-Me实现原理 - 持久化令牌与安全存储方案
datasource:
url: jdbc:mysql://localhost:3306/slave_db?useSSL=false&serverTimezone=UTC
username: root
password: toher888
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5

❸ Spring Security配置

这里博主就不演示从数据库获取用户信息认证了,直接使用手动配置用户信息。需要了解数据库认证的小伙伴可以访问最新Spring Security实战教程(五)基于数据库的动态用户认证传统RBAC角色模型实战开发进行学习。

注入数据源,添加PersistentTokenRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

// 注入数据源
private final DataSource dataSource;

// 手动配置用户信息
@Bean
public UserDetailsService users() {
UserDetails user = User.withUsername("user")
.password("{noop}user") // {noop}表示不加密
.roles("USER")
.build();

UserDetails admin = User.withUsername("admin")
.password("{noop}admin") // {noop}表示不加密
.roles("ADMIN")
.build();

UserDetails anonymous = User.withUsername("anonymous")
.password("{noop}anonymous")
.roles("ANONYMOUS")
.build();

return new InMemoryUserDetailsManager(user, admin, anonymous);
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.rememberMe(rememberMe -> rememberMe
.key("myPersistentKey")
.tokenRepository(persistentTokenRepository()) // 采用数据库存储
.tokenValiditySeconds(14 * 24 * 60 * 60) // 14天有效期
.userDetailsService(users())
);
return http.build();
}

@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
return repo;
}
}

关键配置说明:

  • tokenRepository(persistentTokenRepository()):使用数据库存储Token。
  • JdbcTokenRepositoryImpl:Spring提供的默认数据库Token存储实现。
  • tokenValiditySeconds(14 * 24 * 60 * 60):Token有效期设定为14天。

启动运行测试进行登录,会发现数据库中多了一条Token数据:

数据库Token记录

同时关闭浏览器重新访问/admin/view,系统会自动完成认证,无需重新输入用户名/密码。


5. 持久化令牌方案的安全增强实现

上述讲解中使用官方默认的配置其实已经能满足我们大部分需求,但是我们也会遇到需要自定义令牌生成、验证的情况,这里博主就简单编写两个供大家参考。

令牌生成策略优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CustomPersistentTokenRepository extends JdbcTokenRepositoryImpl {

@Override
public void createNewToken(PersistentRememberMeToken token) {
String encryptedToken = BCrypt.hashpw(token.getTokenValue(), BCrypt.gensalt());
super.createNewToken(
new PersistentRememberMeToken(
token.getUsername(),
token.getSeries(),
encryptedToken,
token.getDate()
)
);
}
}

令牌验证逻辑强化

1
2
3
4
5
6
7
@Component
public class TokenValidationService {

public boolean validateToken(PersistentRememberMeToken token, String presentedToken) {
return BCrypt.checkpw(presentedToken, token.getTokenValue());
}
}

自动清理过期令牌

使用定时器定期清理过期的令牌:

1
2
3
4
5
6
7
@Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每日清理
public void purgeExpiredTokens() {
jdbcTemplate.update(
"DELETE FROM persistent_logins WHERE last_used < ?",
Date.from(Instant.now().minus(60, ChronoUnit.DAYS))
);
}

实时吊销机制

1
2
3
4
@PostMapping("/revoke-remember-me")
public void revokeTokens(@AuthenticationPrincipal User user) {
tokenRepository.removeUserTokens(user.getUsername());
}

多维度安全防护

攻击类型 防护措施 实现方法
令牌窃取 令牌绑定IP+UA PersistentToken中存储用户特征,验证时比对
暴力破解 增加BCrypt计算成本 BCrypt.hashpw(token, BCrypt.gensalt(12))
重放攻击 单次使用令牌 每次验证后更新令牌
CSRF 启用SameSite Cookie .rememberMe().cookie().samesite(SameSite.STRICT)

最佳实践

  • 生产环境建议使用持久化令牌方案,避免Token被伪造。
  • Token存储应使用加密存储(如BCrypt)。
  • persistent_logins定期清理,避免Token长期滞留。
  • 结合设备指纹技术,增强Token安全性。

6. 两种方案对比总结

对比维度 基于Token方案 持久化令牌方案
安全性 较低,Token易被伪造 较高,数据库存储+动态更新
性能 无需数据库查询,性能好 需要数据库操作,有一定开销
可扩展性 无法实现吊销等高级功能 支持吊销、审计等高级功能
适用场景 低安全性要求的简单应用 金融、政务、企业级应用
实现复杂度 简单 中等

结语

至此我们就完成了Spring SecurityRemember-Me机制的深入解析,包含Token方案持久化令牌方案的完整实现及源码讲解。通过本章节的学习,相信大家已经能够根据实际业务需求选择合适的Remember-Me方案,并在需要时进行安全增强定制。

在后续的章节中,我们将继续深入探讨Spring Security的其他高级特性,敬请期待!