本文在前文 Spring Security 入门(二):图形验证码和手机短信验证码 的基础上介绍 Remember-Me 功能和注销登录。
Remember-Me 功能
在实际开发中,为了用户登录方便常常会启用记住我(Remember-Me)功能。如果用户登录时勾选了“记住我”选项,那么在一段有效时间内,会默认自动登录,免去再次输入用户名、密码等登录操作。该功能的实现机理是根据用户登录信息生成 Token 并保存在用户浏览器的 Cookie 中,当用户需要再次登录时,自动实现校验并建立登录态的一种机制。
Spring Security
提供了两种 Remember-Me 的实现方式:
- 简单加密 Token:用散列算法加密用户必要的登录系信息并生成 Token 令牌。
- 持久化 Token:数据库等持久性数据存储机制用的持久化 Token 令牌。
基本原理
Remember-Me 功能的开启需要在configure(HttpSecurity http)
方法中通过http.rememberMe()
配置,该配置主要会在过滤器链中添加 RememberMeAuthenticationFilter 过滤器,通过该过滤器实现自动登录。该过滤器的位置在其它认证过滤器之后,其它认证过滤器没有进行认证处理时,该过滤器尝试工作:
注意:Remember-Me 功能是用于再次登录(认证)的,而不是再次请求。工作流程如下:
- 当用户成功登录认证后,浏览器中存在两个 Cookie,一个是 remember-me,另一个是 JSESSIONID。用户再次请求访问时,请求首先被 SecurityContextPersistenceFilter 过滤器拦截,该过滤器会根据 JSESSIONID 获取对应 Session 中存储的 SecurityContext 对象。如果获取到的 SecurityContext 对象中存储了认证用户信息对象 Authentiacaion,也就是说线程可以直接获得认证用户信息,那么后续的认证过滤器不需要对该请求进行拦截,remember-me 不起作用。
- 当 JSESSIONID 过期后,浏览器中只存在 remember-me 的 Cookie。用户再次请求访问时,由于请求没有携带 JSESSIONID,SecurityContextPersistenceFilter 过滤器无法获取 Session 中的 SecurityContext 对象,也就没法获得认证用户信息,后续需要进行登录认证。如果没有 remember-me 的 Cookie,浏览器会重定向到登录页面进行表单登录认证;但是 remember-me 的 Cookie 存在,RememberMeAuthenticationFilter 过滤器会将请求进行拦截,根据 remember-me 存储的 Token 值实现自动登录,并将成功登录后的认证用户信息对象 Authentiacaion 存储到 SecurityContext 中。当响应返回时,SecurityContextPersistenceFilter 过滤器会将 SecurityContext 存储在 Session 中,下次请求又通过 JSEESIONID 获取认证用户信息。
总结:remember-me 只有在 JSESSIONID 失效和前面的过滤器认证失败或者未进行认证时才发挥作用。此时,只要 remember-me 的 Cookie 不过期,我们就不需要填写登录表单,就能实现再次登录,并且 remember-me 自动登录成功之后,会生成新的 Token 替换旧的 Token,相应 Cookie 的 Max-Age 也会重置。
此处对http.rememberMe()
返回值的主要方法进行说明,这些方法涉及 Remember-Me 配置,具体如下:
rememberMeParameter(String rememberMeParameter)
:指定在登录时“记住我”的 HTTP 参数,默认为remember-me
。key(String key)
:“记住我”的 Token 中的标识字段,默认是一个随机的 UUID 值。tokenValiditySeconds(int tokenValiditySeconds)
:“记住我” 的 Token 令牌有效期,单位为秒,即对应的 cookie 的 Max-Age 值,默认时间为 2 周。userDetailsService(UserDetailsService userDetailsService)
:指定 Remember-Me 功能自动登录过程使用的 UserDetailsService 对象,默认使用 Spring 容器中的 UserDetailsService 对象.tokenRepository(PersistentTokenRepository tokenRepository)
:指定 TokenRepository 对象,用来配置持久化 Token。alwaysRemember(boolean alwaysRemember)
:是否应该始终创建记住我的 Token,默认为 false。useSecureCookie(boolean useSecureCookie)
:是否设置 Cookie 为安全,如果设置为 true,则必须通过 https 进行连接请求。
简单加密 Token(基本使用)
在用户选择“记住我”登录并成功认证后,Spring Security
将默认会生成一个名为 remember-me 的 Cookie 存储 Token 并发送给浏览器;用户注销登录后,该 Cookie 的 Max-Age 会被设置为 0,即删除该 Cookie。Token 值由下列方式组合而成:
base64(username + ":" + expirationTime + ":" +
md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))
其中,username 代表用户名;password 代表用户密码;expirationTime 表示记住我的 Token 的失效日期,以毫秒为单位;key 表示防止修改 Token 的标识,默认是一个随机的 UUID 值。具体使用如下:
☕️ 修改 login.html 和 login-mobile.html,在登录表单中添加“记住我”选项
<div><input name="remember-me" type="checkbox">记住我</div>
以 login.html 为例:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h3>表单登录</h3>
<form method="post" th:action="@{/login/form}">
<input type="text" name="name" placeholder="用户名"><br>
<input type="password" name="pwd" placeholder="密码"><br>
<input name="imageCode" type="text" placeholder="验证码"><br>
<img th:onclick="this.src=\'/code/image?\'+Math.random()" th:src="@{/code/image}" alt="验证码"/><br>
<div th:if="${param.error}">
<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
</div>
<div><input name="remember-me" type="checkbox">记住我</div>
<button type="submit">登录</button>
</form>
</body>
</html>
☕️ 修改安全配置类 SpringSecurityConfig
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启 Remember-Me 功能
http.rememberMe()
// 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
.rememberMeParameter("remember-me")
// 设置 Token 有效期为 200s,默认时长为 2 星期
.tokenValiditySeconds(200)
// 指定 UserDetailsService 对象
.userDetailsService(userDetailsService);
// 开启注销登录功能
http.logout()
// 用户注销登录时访问的 url,默认为 /logout
.logoutUrl("/logout")
// 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
.logoutSuccessUrl("/login/page?logout");
}
//...
}
☕️ 测试
访问localhost:8080/login/page
,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:
成功登录认证后,在返回的响应头中可以找到 key 为 JSESSIONID 的 Cookie,生命周期为浏览器关闭时就删除;key 为 remember-me 的 Cookie,Max-age 为 200 秒:
访问localhost:8080/logout
,注销登录,在返回的响应头中可以找到 remember-me 的 Cookie,Max-Age 被设置为 0,即删除该 Cookie:
简单加密 Token(源码分析)
首次登录
⭐️ AbstractAuthenticationProcessingFilter#successfulAuthentication
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//...
// 认证成功后的处理
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
}
//(1) 将认证成功的用户信息对象 Authentication 封装进 SecurityContext 对象中,并存入 SecurityContextHolder;
SecurityContextHolder.getContext().setAuthentication(authResult);
//(2) rememberMe 的处理
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
//(3) 发布认证成功的事件
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
//(4) 调用认证成功处理器
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
//...
}
用户登录后,在成功认证处理时,上述(2)过程会调用 AbstractRememberMeServices 的 loginSuccess() 方法进行 Remember-Me 处理。
⭐️ AbstractRememberMeServices#loginSuccess
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//...
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 判断 Request 请求中是否携带了 remember-me 参数,且参数值为 true/on/yes/1
if (!this.rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
} else {
//(2) 本类的 onLoginSuccess() 方法是个抽象方法,所以实际调用的是子类
// TokenBasedRememberMeServices 重写的 onLoginSuccess() 方法
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
}
⭐ TokenBasedRememberMeServices#onLoginSuccess
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 获取用户名、密码
String username = this.retrieveUserName(successfulAuthentication);
String password = this.retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
} else {
if (!StringUtils.hasLength(password)) {
//(2) 通过 UserDetailsService 对象从数据库中查询对应 User的信息
UserDetails user = this.getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
//(3) 获取 Token 的生命周期,默认为 1209600s(两周)
int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
//(4) 获取 Token 的过期时间
expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
//(5) 计算并获取 Token 值
String signatureValue = this.makeTokenSignature(expiryTime, username, password);
//(6) 设置 Cookie,将 Token 传递给浏览器
this.setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}, tokenLifetime, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Added remember-me cookie for user \'" + username + "\', expiry: \'" + new Date(expiryTime) + "\'");
}
}
}
//...
}
二次登陆
✏️ RememberMeAuthenticationFilter#doFilter
public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware {
//...
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
//(1) 判断当前线程的 SecurityContext 对象是否存储 Authentication 对象;
// 如果存在,意味着当前线程已经获取了用户信息,不需要再次进行登录
if (SecurityContextHolder.getContext().getAuthentication() == null) {
//(2) 当前线程没有对应用户信息,调用 AbstractRememberMeServices 类的 autoLogin() 方法进行自动登录,获取用户信息
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
// 获取用户信息成功
try {
//(3) 调用 ProviderManager 实现类的 authenticate() 方法进行身份认证
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
//(4) 认证成功后,将 Authentication 对象存储当前线程的 SecurityContext
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
//(5) 调用本类的认证成功处理,是一个空方法
this.onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder populated with remember-me token: \'" + SecurityContextHolder.getContext().getAuthentication() + "\'");
}
if (this.eventPublisher != null) {
//(6) 发布认证成功的事件
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
//(7) 调用认证成功的处理器
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
} catch (AuthenticationException var8) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: \'" + rememberMeAuth + "\'; invalidating remember-me token", var8);
}
// 认证失败后的处理
this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, var8);
}
}
chain.doFilter(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: \'" + SecurityContextHolder.getContext().getAuthentication() + "\'");
}
chain.doFilter(request, response);
}
}
}
上述的(2)过程调用 AbstractRememberMeServices 的 autoLogin() 方法实现自动登录,获取用户信息。
✏️ AbstractRememberMeServices#autoLogin
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
//...
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
//(1) 从 request 中获取 remember-me 对应的 cookie 值
String rememberMeCookie = this.extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
} else {
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
this.cancelCookie(request, response);
return null;
} else {
UserDetails user = null;
try {
//(2) 对 cookie 值进行 Base64 解码获取 series 和 token 字段
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
//(3) 获取 UserDetails,本类的 procssAutoLoginCookie() 方法是一个抽象方法,
// 所以实际调用的是子类 TokenBasedRememberMeServices 重写的方法
user = this.procssAutoLoginCookie(cookieTokens, request, response);
//(4) 检查用户账号是否锁定、是否可用、是否过期
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
//(5) 将 UserDetails 对象封装到 Authentication 对象里,并返回
return this.createSuccessfulAuthentication(request, user);
} catch (CookieTheftException var6) {
//...
}
// 获取用户信息类对象 UserDetails 失败,删除 remember-me 对应的 cookie
this.cancelCookie(request, response);
return null;
}
}
}
}
上述的(3)过程调用 TokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法获取用户信息。
✏️ TokenBasedRememberMeServices#processAutoLoginCookie
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 3) {
throw new InvalidCookieException("Cookie token did not contain 3 tokens, but contained \'" + Arrays.asList(cookieTokens) + "\'");
} else {
long tokenExpiryTime;
try {
//(1) 获取 Token 过期时间
tokenExpiryTime = new Long(cookieTokens[1]);
} catch (NumberFormatException var8) {
throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained \'" + cookieTokens[1] + "\')");
}
//(2) 判断 Token 是否过期
if (this.isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on \'" + new Date(tokenExpiryTime) + "\'; current time is \'" + new Date() + "\')");
} else {
//(3) 通过 UserDetailsService 对象获取对应用户信息 UserDetails
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> {
return "UserDetailsService " + this.getUserDetailsService() + " returned null for username " + cookieTokens[0] + ". This is an interface contract violation";
});
//(4) 比较 Token 中信息是否和预期的一样,即判断 Token 是否合法
String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature \'" + cookieTokens[2] + "\' but expected \'" + expectedTokenSignature + "\'");
} else {
//(5) 返回用户信息 UserDetails
return userDetails;
}
}
}
}
//...
}
持久化 Token(原理分析)
在用户选择“记住我”成功登录认证后,默认会生成一个名为 remember-me 的 Cookie 储存 Token,并发送给浏览器,具体实现流程如下:
- 用户选择“记住我”功能成功登录认证后,
Spring Security
会把用户名 username、序列号 series、令牌值 token 和最后一次使用自动登录的时间 last_used 作为一条 Token 记录存入数据库表中,同时生成一个名为 remember-me 的 Cookie 存储series:token
的 base64 编码,该编码为发送给浏览器的 Token。 - 当用户需要再次登录时,RememberMeAuthenticationFilter 过滤器首先会检查请求是否有 remember-me 的 Cookie。如果存在,则检查其 Token 值中的 series 和 token 字段是否与数据库中的相关记录一致,一致则通过验证,并且系统重新生成一个新 token 值替换数据库中对应记录的旧 token,该记录的序列号 series 保持不变,认证时间 last_used 更新,同时重新生成新的 Token(旧 series : 新 token)通过 Cookie 发送给浏览器,remember-me 的 Cookie 的 Max-Age 也因此重置。
- 上述验证通过后,获取数据库中对应 Token 记录的 username 字段,调用 UserDetailsService 获取用户信息。之后进行登录认证,认证成功后将认证用户信息 Authentication 对象存入 SecurityContext。
- 如果对应的 Cookie 值包含的 token 字段与数据库中对应 Token 记录的 token 字段不匹配,则有可能是用户的 Cookie 被盗用,这时将会删除数据库中与当前用户相关的所有 Token 记录,用户需要重新进行表单登录。
- 如果对应的 Cookie 不存在,或者其值包含的 series 和 token 字段与数据库中的记录不匹配,则用户需要重新进行表单登录。如果用户退出登录,则删除数据库中对应的 Token 记录,并将相应的 Cookie 的 Max-Age 设置为 0。
在实现上,Spring Security
使用 PersistentRememberMeToken 来表明一个验证实体:
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
// 最后一个使用自动登录的时间
private final Date date;
//...
}
对应的,在数据库需要有一张 persistent_logins 表(存储自动登录信息的表),表结构如下:
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) PRIMARY KEY,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL
);
由于需要使用持久化 Token 方案,所以需要定制 tokenRepository,用于与数据库表的交互。为此,我们需要创建一个 PersistentTokenRepository 实例,该实例中定义了持久化令牌的一些必要方法:
public interface PersistentTokenRepository {
void createNewToken(PersistentRememberMeToken var1);
void updateToken(String var1, String var2, Date var3);
PersistentRememberMeToken getTokenForSeries(String var1);
void removeUserTokens(String var1);
}
我们可以自定义实现 PersistentTokenRepository 接口,也可以使用Spring Security
提供的 JDBC 方案实现:
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
public static final String CREATE_TABLE_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)";
public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
private String tokensBySeriesSql = "select username,series,token,last_used from persistent_logins where series = ?";
private String insertTokenSql = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
private String updateTokenSql = "update persistent_logins set token = ?, last_used = ? where series = ?";
private String removeUserTokensSql = "delete from persistent_logins where username = ?";
//...
}