qrcode

马上订阅,开启修仙之旅

Spring Security 实现 Remember Me

一、什么是 Remember Me

Remember Me 即记住我,常用于 Web 应用的登录页目的是让用户选择是否记住用户的登录状态。当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中,而无需重新执行登录操作。相信国内很多开发者都使用过或听过一个 云端软件开发协作平台 —— 码云,下图是它的登录页:

gitee

由上图可知,登录页除了输入用户名和密码之外,还多了一个 记住我 的复选框,用于实现前面提到的 Remember Me 功能,接下来本文将重点介绍如何基于 Spring Security 实现 Remember Me 功能。

二、Remember Me 处理流程

在 Spring Security 中要实现 Remember Me 功能很简单,因为它内置的过滤器 RememberMeAuthenticationFilter 已经提供了该功能。在开始实战前,我们先来看一下 Remember Me 的运行流程。

remember-me-flow

三、Remember Me 实战

3.1 配置数据源

1
2
3
4
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 添加项目依赖

1
2
3
4
5
6
7
8
<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 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 对象注入到 JdbcTokenRepositoryImpldataSource 属性中。

3.4 创建 persistent_logins 数据表

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
)

3.5 添加 remember me 复选框

打开 resources/templates 路径下的 login.html 登录页,添加 Remember Me 复选框:

1
2
3
<div class="form-field">
Remember Me:<input type="checkbox" name="remember-me" value="true"/>
</div>

注意:Remember Me 复选框的 name 属性的值必须为 “remember-me”

3.6 新增 remember me 配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 方法的定义如下:

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
//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 方法,即执行认证成功回调函数:

1
2
3
4
5
6
7
8
9
10
11
// 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 所对应的实例,该实现类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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 进行持久化处理。

1
2
3
4
5
6
7
8
9
10
@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 数据表中:

1
2
3
4
5
6
7
8
// 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());
}
}

相应的数据库插入语句如下:

1
insert into persistent_logins (username, series, token, last_used) values(?,?,?,?);

成功执行插入语句后,在数据库 persistent_logins 表中会新增一条记录:

persistent-logins-record

除此之外,在 onLoginSuccess 方法中还会调用 addCookie 添加相应的 Cookie。为了更加直观的感受 addCookie 方法最终达到的效果,我们来看一下实战部分勾选 Remember Me 复选框后登录成功后返回的响应体:

remember-me-cookie

通过上图可知,在勾选 Remember Me 复选框成功登录之后,除了设置常见的 JSESSIONID Cookie 之外,还会进一步设置 remember-me Cookie。

在成功设置 remember-me Cookie 之后,当前站点下所发起的 HTTP 请求的请求头都会默认带上 Cookie 信息,它包含两部分信息,即 JSESSIONID 和 remember-me Cookie 信息。

request-remember-me-cookie

这里 remember-me Cookie 的认证处理也会交由 Spring Security 内部的 RememberMeAuthenticationFilter

过滤器来处理。与分析 UsernamePasswordAuthenticationFilter 过滤器一样,我们也先来看一下该过滤器的 doFilter 方法:

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
// 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 方法来实现:

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
// 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 字符串数组,我本机的解码结果如下:

split-remember-me-cookies

在完成 cookie 解码之后,会尝试使用该 cookie 进行自动登录,即调用内部的 processAutoLoginCookie 方法,该方法内部的执行流程如下:

  1. 使用 presentedSeries(series) 作为参数调用 tokenRepository 对象的 getTokenForSeries 方法获取 token (PersistentRememberMeToken) 对象,然后对返回的 token 执行校验,比如判空或有效期验证;

  2. 验证通过后重新生成新的 newToken (PersistentRememberMeToken)并更新数据库中相应的记录值;

  3. 使用前面从数据库中获得的 token 对象,并以 token 的用户名作为参数调用 UserDetailsService 对象的 loadUserByUsername 方法加载用户的详细信息。

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
// 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

五、参考资源


欢迎小伙伴们订阅前端全栈修仙之路,及时阅读 Angular、TypeScript、Node.js/Java和Spring技术栈最新文章。

qrcode