Token持久化
Token 持久化实际上用到的最多的场景就是登录时候的记住我
这是 Spring Security 最经典、最常用的 Token
持久化场景。它允许用户在关闭浏览器后(会话失效),下次访问时无需重新登录,依然允许用户自动登录。
它的核心机制有两种形式,现在基本上用的最多的就是持久化令牌方案,因为它更安全,它通过在数据库中存储一个随机生成的“系列号(Series)”和“令牌(Token)”来验证身份。
工作原理:
- 用户登录时勾选“记住我”。
- 服务器生成一对随机数:Series(固定,代表该设备登录会话)和
Token(动态,每次自动登录都会更新)。
- 这两者与用户名、过期时间一起存入数据库,并以 Cookie
形式发给浏览器。
- 下次访问时,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 中
其中,在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) { String username = retrieveUserName(successfulAuthentication); String password = retrievePassword(successfulAuthentication); if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) { logger.debug("无法获取用户名或密码,跳过Remember me"); return; }
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication); long expiryTime = System.currentTimeMillis() + 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password, this.encodingAlgorithm);
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) { if (!isValidCookieTokensLength(cookieTokens)) { throw new InvalidCookieException("Cookie格式错误"); }
long tokenExpiryTime = getTokenExpiryTime(cookieTokens); if (isTokenExpired(tokenExpiryTime)) { throw new InvalidCookieException("Token已过期"); }
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]); if (userDetails == null) { throw new InvalidCookieException("用户不存在"); }
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
中,通过双向验证确保安全性。
持久化令牌方案的工作流程是这样的
- 用户登录后,系统生成一个
seriesId 和
tokenValue,并存储到数据库;
- 服务器将
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));
String series = generateSeriesData(); String token = generateTokenData();
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, series, token, new Date());
try { this.tokenRepository.createNewToken(persistentToken); 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
| 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)); }
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) { if (cookieTokens.length != 2) { throw new InvalidCookieException("Cookie token did not contain 2 tokens"); } String presentedSeries = cookieTokens[0]; String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries); if (token == null) { throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries); }
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."); }
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) { throw new RememberMeAuthenticationException("Remember-me login has expired"); }
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 { this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate()); 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"); }
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); 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;
@Id @Column(name = "series", length = 64, nullable = false) private String series;
@Column(name = "username", length = 64, nullable = false) private String username;
@Column(name = "token", length = 64, nullable = false) private String 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(); } }
@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
|
@Repository public interface PersistentRememberMeTokenRepository extends JpaRepository<RememberMeTokenEntity, String> {
Optional<RememberMeTokenEntity> findBySeries(String series);
List<RememberMeTokenEntity> findByUsername(String username);
@Modifying @Query("DELETE FROM RememberMeTokenEntity t WHERE t.username = :username") void deleteByUsername(@Param("username") String username);
@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;
@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()); }
@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) ); }
@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); }
@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); }
@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
| .rememberMe(remember -> remember .key("mySecretKey-2024-Database-Persistent") .tokenValiditySeconds(604800) .userDetailsService(userDetailsService) .tokenRepository(persistentTokenRepository) .rememberMeParameter("remember-me") .rememberMeCookieName("REMEMBER_ME") )
|
测试
实际上,因为在过滤器链中配置了 Remember me
相关内容,登录的认证控制器是不需要修改的
确保登录页面中包含 Remember Me 复选框就可以
我好像登陆前面写的有问题
就先不测试了
大概是这样的
img
会发现数据库中多了一条Token数据,同时关闭浏览器重新访问,系统会自动完成认证,无需重新输入用户名/密码。