一、什么是 Remember Me
Remember Me 即记住我,常用于 Web 应用的登录页目的是让用户选择是否记住用户的登录状态。当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中,而无需重新执行登录操作。相信国内很多开发者都使用过或听过一个 云端软件开发协作平台 —— 码云,下图是它的登录页:
由上图可知,登录页除了输入用户名和密码之外,还多了一个 记住我 的复选框,用于实现前面提到的 Remember Me 功能,接下来本文将重点介绍如何基于 Spring Security 实现 Remember Me 功能。
阅读更多关于 Angular、TypeScript、Node.js/Java 、Spring 等技术文章,欢迎访问我的个人博客 —— 全栈修仙之路
二、Remember Me 处理流程
在 Spring Security 中要实现 Remember Me 功能很简单,因为它内置的过滤器 RememberMeAuthenticationFilter 已经提供了该功能。在开始实战前,我们先来看一下 Remember Me 的运行流程。
三、Remember Me 实战
3.1 配置数据源
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/security?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=
3.2 添加项目依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
3.3 配置 PersistentTokenRepository 对象
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Bean
UserDetailsService myUserDetailService() {
return new MyUserDetailsService();
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
persistentTokenRepository.setDataSource(dataSource);
return persistentTokenRepository;
}
}
PersistentTokenRepository
为一个接口类,这里我们用的是数据库持久化,所以实际使用的 PersistentTokenRepository 实现类是 JdbcTokenRepositoryImpl
,使用它的时候需要指定数据源,所以我们需要将已配置的 dataSource
对象注入到 JdbcTokenRepositoryImpl
的 dataSource
属性中。
3.4 创建 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
)
3.5 添加 remember me 复选框
打开 resources/templates
路径下的 login.html
登录页,添加 Remember Me 复选框:
<div class="form-field">
Remember Me:<input type="checkbox" name="remember-me" value="true"/>
</div>
注意:Remember Me 复选框的 name 属性的值必须为 "remember-me"
3.6 新增 remember me 配置项
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login")
.and()
.authorizeRequests()
.antMatchers("/authentication/require", "/login").permitAll()
.anyRequest().authenticated()
.and().csrf().disable()
// 新增remember me配置信息
.rememberMe()
.tokenRepository(persistentTokenRepository()) // 配置token持久化仓库
.tokenValiditySeconds(3600) // 过期时间,单位为秒
.userDetailsService(myUserDetailService()); // 处理自动登录逻辑
}
四、Remember Me 原理分析
4.1 首次登录过程
当我们首次在登录页执行登录时,登录的请求会由 UsernamePasswordAuthenticationFilter 过滤器进行处理,对于过滤器来说,它核心功能会定义在 doFilter 方法中,但该方法并不是定义在 UsernamePasswordAuthenticationFilter 过滤器中,而是定义在它的父类 AbstractAuthenticationProcessingFilter
中,doFilter
方法的定义如下:
//org/springframework/security/web/authentication/
// AbstractAuthenticationProcessingFilter.java(已省略部分代码)
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// 若不需要认证,则执行下一个过滤器
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
Authentication authResult;
try {
// 基于用户名和密码进行认证操作
authResult = attemptAuthentication(request, response);
if (authResult == null) {
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (AuthenticationException failed) {
// 处理认证失败的逻辑
unsuccessfulAuthentication(request, response, failed);
return;
}
successfulAuthentication(request, response, chain, authResult);
}
在认证成功后,会调用 successfulAuthentication
方法,即执行认证成功回调函数:
// org/springframework/security/web/authentication/
// AbstractAuthenticationProcessingFilter.java
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
// 设置 SecurityContext 对象中的 authentication 属性
SecurityContextHolder.getContext().setAuthentication(authResult);
rememberMeServices.loginSuccess(request, response, authResult);
successHandler.onAuthenticationSuccess(request, response, authResult);
}
在 successfulAuthentication 方法中,除了设置 SecurityContext 对象中的 authentication 属性之外,还会调用 rememberMeServices 对象的 loginSuccess 方法。这里的 rememberMeServices 是 RememberMeServices 接口实现类 PersistentTokenBasedRememberMeServices 所对应的实例,该实现类的定义如下:
// org/springframework/security/web/authentication/rememberme/
// PersistentTokenBasedRememberMeServices.java
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
// 使用数据库持久化保存 persistentToken 并返回 remember-me Cookie
tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}
在 onLoginSuccess 方法内部,会利用认证成功返回的对象创建 persistentToken,然后利用 tokenRepository 对象(在 Remember Me 实战部分中配置的 PersistentTokenRepository Bean 对象)对 token 进行持久化处理。
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 已省略部分代码
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
persistentTokenRepository.setDataSource(dataSource);
return persistentTokenRepository;
}
}
而 JdbcTokenRepositoryImpl 类中 createNewToken 方法的实现逻辑也很简单,就是利用 JdbcTemplate 把生成的 token 插入到 persistent_logins
数据表中:
// org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
PersistentTokenRepository {
public void createNewToken(PersistentRememberMeToken token) {
getJdbcTemplate().update(insertTokenSql, token.getUsername(), token.getSeries(),
token.getTokenValue(), token.getDate());
}
}
相应的数据库插入语句如下:
insert into persistent_logins (username, series, token, last_used) values(?,?,?,?);
成功执行插入语句后,在数据库 persistent_logins 表中会新增一条记录:
除此之外,在 onLoginSuccess 方法中还会调用 addCookie 添加相应的 Cookie。为了更加直观的感受 addCookie
方法最终达到的效果,我们来看一下实战部分勾选 Remember Me 复选框后登录成功后返回的响应体:
通过上图可知,在勾选 Remember Me 复选框成功登录之后,除了设置常见的 JSESSIONID Cookie 之外,还会进一步设置 remember-me Cookie。
4.2 Remember Me Cookie 校验流程
在成功设置 remember-me Cookie 之后,当前站点下所发起的 HTTP 请求的请求头都会默认带上 Cookie 信息,它包含两部分信息,即 JSESSIONID 和 remember-me Cookie 信息。
这里 remember-me Cookie 的认证处理也会交由 Spring Security 内部的 RememberMeAuthenticationFilter
过滤器来处理。与分析 UsernamePasswordAuthenticationFilter 过滤器一样,我们也先来看一下该过滤器的 doFilter 方法:
// org/springframework/security/web/authentication/rememberme/
// RememberMeAuthenticationFilter.java(已省略部分代码)
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// 若SecurityContext上下文对象的认证信息为null,则执行自动登录操作
if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null) {
try {
// 调用authenticationManager对象进行认证,最终调用RememberMeAuthenticationProvider
// 对象的authenticate方法进行认证
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (successHandler != null) {
successHandler.onAuthenticationSuccess(request, response,
rememberMeAuth);
return;
}
}
catch (AuthenticationException authenticationException) {
rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response,
authenticationException);
}
}
chain.doFilter(request, response);
}
else {
chain.doFilter(request, response);
}
}
在 doFilter 方法中,若发现 SecurityContext 上下文对象的认证信息为 null,则执行自动登录操作就是通过调用rememberMeServices 对象的 autoLogin
方法来实现:
// org/springframework/security/web/authentication/rememberme/
// AbstractRememberMeServices.java
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
// 从请求中抽取remember-me Cookie
// SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
// 若remember-me Cookie长度为零,则在响应头中设置它的maxAge属性为0
// 用于禁用持久化登录
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
// 执行解码操作,使用":"分隔符进行切割,转换成token字符串数组
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
// 创建RememberMeAuthenticationToken对象
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException cte) {
cancelCookie(request, response);
throw cte;
}
// 省略UsernameNotFoundException、InvalidCookieException和AccountStatusException
// 异常处理逻辑
catch (RememberMeAuthenticationException e) {
logger.debug(e.getMessage());
}
cancelCookie(request, response);
return null;
}
在 autoLogin 方法中,会使用 decodeCookie 方法对 remember-me Cookie 执行解码操作,然后使用 :
分隔符进行切割拆分为 tokens 字符串数组,我本机的解码结果如下:
在完成 cookie 解码之后,会尝试使用该 cookie 进行自动登录,即调用内部的 processAutoLoginCookie 方法,该方法内部的执行流程如下:
- 使用 presentedSeries(series) 作为参数调用 tokenRepository 对象的 getTokenForSeries 方法获取 token (PersistentRememberMeToken) 对象,然后对返回的 token 执行校验,比如判空或有效期验证;
- 验证通过后重新生成新的 newToken (PersistentRememberMeToken)并更新数据库中相应的记录值;
- 使用前面从数据库中获得的 token 对象,并以 token 的用户名作为参数调用 UserDetailsService 对象的 loadUserByUsername 方法加载用户的详细信息。
// org/springframework/security/web/authentication/rememberme/
// PersistentTokenBasedRememberMeServices.java
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
// 省略token判空校验、presentedToken与数据库token相等校验和token有效期校验逻辑
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
try {
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception e) {
logger.error("Failed to update token: ", e);
throw new RememberMeAuthenticationException(
"Autologin failed due to data access problem");
}
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
rememberMeServices 对象的 autoLogin 方法,在登录成功后会返回 RememberMeAuthenticationToken 对象,之后 RememberMeAuthenticationFilter 过滤器会继续调用 authenticationManager 对象执行认证,而最终调用 RememberMeAuthenticationProvider 对象的 authenticate 方法进行认证,认证成功后会前往下一个过滤器进行处理。
本文项目地址:Github - remember-me
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。