Token持久化

Token 持久化实际上用到的最多的场景就是登录时候的记住我

这是 Spring Security 最经典、最常用的 Token 持久化场景。它允许用户在关闭浏览器后(会话失效),下次访问时无需重新登录,依然允许用户自动登录。

它的核心机制有两种形式,现在基本上用的最多的就是持久化令牌方案,因为它更安全,它通过在数据库中存储一个随机生成的“系列号(Series)”和“令牌(Token)”来验证身份。

工作原理

  1. 用户登录时勾选“记住我”。
  2. 服务器生成一对随机数:Series(固定,代表该设备登录会话)和 Token(动态,每次自动登录都会更新)。
  3. 这两者与用户名、过期时间一起存入数据库,并以 Cookie 形式发给浏览器。
  4. 下次访问时,Spring Security 检查 Cookie,检查 Cookie 中的 Token 用于请求自动登录,如果 Series 匹配但 Token 不匹配,说明 Token 可能被盗用(重放攻击),系统会立即失效该用户的所有持久化令牌,则用户需要重新认证。
img

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

  • 基于 Token(默认方案):在 Cookie 中存储加密 Token(默认使用 TokenBasedRememberMeServices)
  • 持久化令牌方案(更安全):将 Token 存储在数据库中,并在每次 Remember-Me 认证时进行更新(使用 PersistentTokenBasedRememberMeServices

加密 Token 方案

Spring Security 默认提供 TokenBasedRememberMeServices,而TokenBasedRememberMeServices无状态的、不依赖数据库的 Remember me 实现,其基本原理如下:

  • 当用户登录时,系统生成一个 Token,并将其存储在 Cookie 中:

    而源码注释中明确了 Cookie 的编码格式:

    1
    username + ":" + expiryTime + ":" + algorithmName + ":" + algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)
    • username:用户账号(明文存储,这是该方案的一个安全短板)
    • expiryTime:Token 过期时间戳(毫秒)
    • algorithmName:签名算法名称(如 SHA256)
    • algorithmHex:对username:expiryTime:password:key进行哈希后的十六进制字符串(核心签名,防止 Token 被篡改)

可以看到,Token 被系统加密,存储在 Cookie 中

其中,在TokenBasedRememberMeServices中有个方法,是onLoginSuccess,这是登录成功后触发的方法,负责生成 Token 并写入 Cookie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
// 1. 获取用户名和密码(优先从Authentication中取,取不到则从UserDetails中加载)
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
logger.debug("无法获取用户名或密码,跳过Remember me");
return;
}

// 2. 计算Token有效期(默认14天,可通过setTokenValiditySeconds修改)
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis() + 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);

// 3. 生成签名(核心:使用指定算法对用户名、过期时间、密码、密钥进行哈希)
String signatureValue = makeTokenSignature(expiryTime, username, password, this.encodingAlgorithm);

// 4. 设置Cookie(包含用户名、过期时间、算法名、签名)
setCookie(new String[] { username, Long.toString(expiryTime), this.encodingAlgorithm.name(), signatureValue },
tokenLifetime, request, response);
}

processAutoLoginCookie自动登录时验证 Cookie 的合法性的方法,就算当用户下次访问系统时,会从 Cookie 中读取 Token 并验证,这个方法是核心验证逻辑

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
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
// 1. 验证Cookie的Token片段数量(必须是3或4段)
if (!isValidCookieTokensLength(cookieTokens)) {
throw new InvalidCookieException("Cookie格式错误");
}

// 2. 验证Token是否过期
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Token已过期");
}

// 3. 加载用户信息(从UserDetailsService中根据用户名获取)
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
if (userDetails == null) {
throw new InvalidCookieException("用户不存在");
}

// 4. 验证签名(核心:重新计算签名并与Cookie中的签名对比)
String actualTokenSignature = (cookieTokens.length == 4) ? cookieTokens[3] : cookieTokens[2];
RememberMeTokenAlgorithm actualAlgorithm = (cookieTokens.length == 4) ? RememberMeTokenAlgorithm.valueOf(cookieTokens[2]) : this.matchingAlgorithm;
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword(), actualAlgorithm);
if (!equals(expectedTokenSignature, actualTokenSignature)) {
throw new InvalidCookieException("Token签名验证失败");
}

// 验证通过,返回用户信息(用于自动登录)
return userDetails;
}

其中,makeTokenSignature就是生成 Token 签名的方法,对指定内容进行哈希,生成签名,其中默认编码算法为 SHA256

1
2
3
4
5
6
7
8
9
10
11
12
13
protected String makeTokenSignature(long tokenExpiryTime, String username, @Nullable String password,
RememberMeTokenAlgorithm algorithm) {
// 拼接签名原始内容:用户名:过期时间:密码:密钥
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
// 使用指定算法生成哈希值
MessageDigest digest = MessageDigest.getInstance(algorithm.getDigestAlgorithm());
// 将哈希值转为十六进制字符串返回
return new String(Hex.encode(digest.digest(data.getBytes())));
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("算法不存在:" + algorithm.name());
}
}

持久化令牌方案

TokenBasedRememberMeServices的最大问题是:Token 存储在 Cookie 中,且包含用户名明文,同时无法主动失效(除非用户改密码或系统改密钥)。而持久化方案通过将 Token 存储在数据库中,解决了这些问题,是生产环境的推荐方案。

持久化方案的核心是:将 Remember me Token 的一部分存储在数据库中,另一部分存储在 Cookie 中,通过双向验证确保安全性

持久化令牌方案的工作流程是这样的

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

那么,数据库和表的字段就要这样设计

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
);

而其中,持久化令牌方案涉及到的核心类就是PersistentTokenBasedRememberMeServices

而其中的关键实体就是PersistentRememberMeToken

image-20251220170526343

这是令牌的核心数据模型,包含 4 个字段,和上面说的一模一样

而且负责持久化令牌的接口就是其中的PersistentTokenRepository,提供了核心的 CRUD 方法:

image-20251220170642202

方法不多,但是都很有用

来看PersistentTokenBasedRememberMeServices中的内容

登陆成功会发生什么,onLoginSuccess,当用户登录时勾选了 Remember me,该方法会触发,生成新的令牌并持久化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = successfulAuthentication.getName(); // 获取登录用户名
this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));

// 1. 生成随机的系列号和令牌值
String series = generateSeriesData();
String token = generateTokenData();

// 2. 创建令牌实体(关联用户名、系列号、令牌值、当前时间)
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, series, token, new Date());

try {
// 3. 持久化令牌到数据库
this.tokenRepository.createNewToken(persistentToken);
// 4. 将系列号和令牌值写入Cookie
addCookie(persistentToken, request, response);
} catch (Exception ex) {
this.logger.error("Failed to save persistent token ", ex);
}
}

其中,是这样生成随机字符串的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 生成系列号(Base64编码的随机字节数组)
protected String generateSeriesData() {
byte[] newSeries = new byte[this.seriesLength];
this.random.nextBytes(newSeries);
return new String(Base64.getEncoder().encode(newSeries));
}

// 生成令牌值(逻辑与系列号一致)
protected String generateTokenData() {
byte[] newToken = new byte[this.tokenLength];
this.random.nextBytes(newToken);
return new String(Base64.getEncoder().encode(newToken));
}

// 将令牌写入Cookie
private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
setCookie(new String[] { token.getSeries(), token.getTokenValue() }, getTokenValiditySeconds(), request, response);
}

当用户下次访问系统时,会从 Cookie 中读取系列号和令牌值,processAutoLoginCookie方法是核心验证逻辑,它是实现了自动验证并且刷新令牌

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
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
// 1. 验证Cookie格式:必须包含系列号和令牌值两个部分
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain 2 tokens");
}
String presentedSeries = cookieTokens[0]; // 从Cookie读取的系列号
String presentedToken = cookieTokens[1]; // 从Cookie读取的令牌值

// 2. 根据系列号从数据库查询令牌
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
// 无匹配的系列号,说明令牌无效
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}

// 3. 验证令牌值:如果不匹配,说明Cookie可能被盗用
if (!presentedToken.equals(token.getTokenValue())) {
// 立即删除该用户的所有令牌,防止进一步攻击
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException("Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack.");
}

// 4. 验证令牌是否过期
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}

// 5. 令牌验证通过,生成新的令牌值(核心:刷新令牌,防止复用)
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'",
token.getUsername(), token.getSeries()));
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), // 系列号保持不变
generateTokenData(), // 生成新的令牌值
new Date() // 更新时间
);

try {
// 6. 更新数据库中的令牌值和时间
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
// 7. 将新的令牌值写入Cookie
addCookie(newToken, request, response);
} catch (Exception ex) {
this.logger.error("Failed to update token: ", ex);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}

// 8. 加载用户信息,完成自动登录
return getUserDetailsService().loadUserByUsername(token.getUsername());
}

这部分是整个方案的核心,包含了防盗用、过期验证、令牌刷新三个关键安全机制。

清理令牌就是logout方法

当用户手动退出登录时,会删除该用户的所有令牌,确保 Cookie 失效

1
2
3
4
5
6
7
8
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, @Nullable Authentication authentication) {
super.logout(request, response, authentication); // 父类会清除Cookie
if (authentication != null) {
// 删除数据库中该用户的所有令牌
this.tokenRepository.removeUserTokens(authentication.getName());
}
}

Remember Me 持久化实际演示

实体类修改

首先,既然添加了新业务,需要往数据库中添加新字段,所以说,就需要修改实体类

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Entity
@Table(name = "persistent_logins",
indexes = {
@Index(name = "idx_series", columnList = "series", unique = true),
@Index(name = "idx_username", columnList = "username")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RememberMeTokenEntity implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 系列号(主键)
* 每个用户每次启用 Remember Me 会生成一个唯一的 series
* 当用户使用 Remember Me Token 登录时,会更新 token 和 lastUsed
* 但 series 保持不变,直到用户主动退出或 Token 过期
*/
@Id
@Column(name = "series", length = 64, nullable = false)
private String series;

/**
* 用户名
*/
@Column(name = "username", length = 64, nullable = false)
private String username;

/**
* Token 值
* 每次 Remember Me 登录时会更新为新的随机值
* 这样可以检测 Token 被盗用的情况(如果发现 Token 不匹配,说明可能被盗用)
*/
@Column(name = "token", length = 64, nullable = false)
private String token;

/**
* 最后使用时间
* 用于记录 Token 的最后使用时间,可以用于清理过期 Token
*/
@Column(name = "last_used", nullable = false)
@Temporal(TemporalType.TIMESTAMP)
private Date lastUsed;

/**
* 创建时间(可选,用于审计)
*/
@Column(name = "created_at")
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;

/**
* 预持久化回调:设置创建时间
*/
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = new Date();
}
if (lastUsed == null) {
lastUsed = new Date();
}
}

/**
* 预更新回调:更新最后使用时间,实际上,上面这些字段完全可以通过jpa审计管理
*/
@PreUpdate
protected void onUpdate() {
lastUsed = new Date();
}
}

Repository持久化层

模仿着上面源码中提到的PersistentTokenRepository,我们自己也需要编写一个持久化层,用于访问自己数据库中 Token 的数据,这个和下面的还是不太一样,这个只是作为演示实际上

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
/**
* Remember Me Token 数据访问接口
*
* 提供 Token 的 CRUD 操作,用于 Spring Security Remember Me 功能
*/
@Repository
public interface PersistentRememberMeTokenRepository extends JpaRepository<RememberMeTokenEntity, String> {

/**
* 根据系列号查找 Token
* @param series 系列号
* @return Token 实体
*/
Optional<RememberMeTokenEntity> findBySeries(String series);

/**
* 根据用户名查找所有 Token
* @param username 用户名
* @return Token 列表
*/
List<RememberMeTokenEntity> findByUsername(String username);

/**
* 根据用户名删除所有 Token
* 用于用户退出登录时清除所有 Remember Me Token
* @param username 用户名
*/
@Modifying
@Query("DELETE FROM RememberMeTokenEntity t WHERE t.username = :username")
void deleteByUsername(@Param("username") String username);

/**
* 删除过期的 Token
* 用于定期清理任务
* @param expiryDate 过期时间
*/
@Modifying
@Query("DELETE FROM RememberMeTokenEntity t WHERE t.lastUsed < :expiryDate")
void deleteExpiredTokens(@Param("expiryDate") Date expiryDate);
}

下面才是真正的 基于 JPA 的 Remember Me Token 仓库实现,它实现 Spring Security 的 PersistentTokenRepository 接口,将 Remember Me Token 持久化到数据库,核心用处就是用户登录的时候创建新的 Token,更新,删除,还有验证

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
@Component
@RequiredArgsConstructor
@Slf4j
public class JpaPersistentTokenRepository implements PersistentTokenRepository {

private final PersistentRememberMeTokenRepository tokenRepository;

/**
* 创建新的 Remember Me Token
*/
@Override
@Transactional
public void createNewToken(PersistentRememberMeToken token) {
log.debug("创建新的 Remember Me Token: series={}, username={}",
token.getSeries(), token.getUsername());

RememberMeTokenEntity entity = new RememberMeTokenEntity();
entity.setSeries(token.getSeries());
entity.setUsername(token.getUsername());
entity.setToken(token.getTokenValue());
entity.setLastUsed(token.getDate());

tokenRepository.save(entity);
log.info("成功创建 Remember Me Token: username={}, series={}",
token.getUsername(), token.getSeries());
}

/**
* 更新 Token
*/
@Override
@Transactional
public void updateToken(String series, String tokenValue, Date lastUsed) {
log.debug("更新 Remember Me Token: series={}", series);

tokenRepository.findBySeries(series)
.ifPresentOrElse(
token -> {
token.setToken(tokenValue);
token.setLastUsed(lastUsed);
tokenRepository.save(token);
log.debug("成功更新 Token: series={}", series);
},
() -> log.warn("未找到要更新的 Token: series={}", series)
);
}

/**
* 根据系列号获取 Token
*
* Spring Security 在验证 Remember Me Cookie 时调用
*/
@Override
@Transactional(readOnly = true)
public PersistentRememberMeToken getTokenForSeries(String seriesId) {
log.debug("查找 Remember Me Token: series={}", seriesId);

return tokenRepository.findBySeries(seriesId)
.map(entity -> {
PersistentRememberMeToken token = new PersistentRememberMeToken(
entity.getUsername(),
entity.getSeries(),
entity.getToken(),
entity.getLastUsed()
);
log.debug("找到 Token: username={}, series={}",
entity.getUsername(), entity.getSeries());
return token;
})
.orElse(null);
}

/**
* 移除用户的 Token
*
* 当用户退出登录时调用,清除该用户的所有 Remember Me Token
*/
@Override
@Transactional
public void removeUserTokens(String username) {
log.info("移除用户的所有 Remember Me Token: username={}", username);

long count = tokenRepository.countByUsername(username);
tokenRepository.deleteByUsername(username);

log.info("成功移除 {} 个 Token: username={}", count, username);
}

/**
* 清理过期的 Token(用于定期清理任务)
*/
@Transactional
public int removeExpiredTokens(Date expiryDate) {
log.info("清理过期的 Remember Me Token: expiryDate={}", expiryDate);

List<RememberMeTokenEntity> expiredTokens =
tokenRepository.findAll().stream()
.filter(token -> token.getLastUsed().before(expiryDate))
.toList();

int count = expiredTokens.size();
tokenRepository.deleteAll(expiredTokens);

log.info("成功清理 {} 个过期 Token", count);
return count;
}
}

配置修改

需要在 SecurityConfig 配置文件中,在安全过滤器链中,加入 Remeber me 相关配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 记住我功能 - 使用数据库持久化 Token
.rememberMe(remember -> remember
// 密钥:用于签名和验证 Remember Me Cookie
.key("mySecretKey-2024-Database-Persistent")
// Token 有效期:7天(604800秒)
.tokenValiditySeconds(604800)
// 用户详情服务:用于加载用户信息
.userDetailsService(userDetailsService)
// Token 仓库:使用数据库持久化存储
.tokenRepository(persistentTokenRepository)
// Remember Me 参数名:前端表单中的 checkbox name
.rememberMeParameter("remember-me")
// Remember Me Cookie 名称
.rememberMeCookieName("REMEMBER_ME")
)

测试

实际上,因为在过滤器链中配置了 Remember me 相关内容,登录的认证控制器是不需要修改的

确保登录页面中包含 Remember Me 复选框就可以

我好像登陆前面写的有问题

就先不测试了

大概是这样的

img

会发现数据库中多了一条Token数据,同时关闭浏览器重新访问,系统会自动完成认证,无需重新输入用户名/密码。