Spring Security 入门(三):Remember-Me 和注销登录
本文在前文 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 = ?";
//...
}
持久化 Token(基本使用)
📚 创建数据库表 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
);
📚 修改安全配置类 SpringSecurityConfig,使用持久化 Token 方式
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private DataSource dataSource; // 数据源
/**
* 配置 JdbcTokenRepositoryImpl,用于 Remember-Me 的持久化 Token
*/
@Bean
public JdbcTokenRepositoryImpl tokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次启动的时候可以使用以下语句自动建表(可以不用这句话,自己手动建表,源码中有语句的)
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启 Remember-Me 功能
http.rememberMe()
// 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
.rememberMeParameter("remember-me")
// 设置 Token 有效期为 200s,默认时长为 2 星期
.tokenValiditySeconds(200)
// 设置操作数据表的 Repository
.tokenRepository(tokenRepository())
// 指定 UserDetailsService 对象
.userDetailsService(userDetailsService);
// 开启注销登录功能
http.logout()
// 用户注销登录时访问的 url,默认为 /logout
.logoutUrl("/logout")
// 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
.logoutSuccessUrl("/login/page?logout");
}
//...
}
完整的安全配置类 SpringSecurityConfig 如下:
package com.example.config;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import javax.sql.DataSource;
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
@Autowired
private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类
@Autowired
private DataSource dataSource; // 数据源
/**
* 配置 JdbcTokenRepositoryImpl,用于 Remember-Me 的持久化 Token
*/
@Bean
public JdbcTokenRepositoryImpl tokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次启动的时候可以使用以下语句自动建表(可以不用这句话,自己手动建表,源码中有语句的)
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
/**
* 密码编码器,密码不能明文存储
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用户认证管理器来实现用户认证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 采用内存存储方式,用户认证信息存储在内存中
// auth.inMemoryAuthentication()
// .withUser("admin").password(passwordEncoder()
// .encode("123456")).roles("ROLE_ADMIN");
// 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 启动 form 表单登录
http.formLogin()
// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
.loginPage("/login/page")
// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
.loginProcessingUrl("/login/form")
// 设置登录表单中的用户名参数,默认为 username
.usernameParameter("name")
// 设置登录表单中的密码参数,默认为 password
.passwordParameter("pwd")
// 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
//.defaultSuccessUrl("/index")
// 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
//.failureUrl("/login/page?error");
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
// 使用自定义的认证成功和失败处理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
// 关闭 csrf 防护
http.csrf().disable();
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
// 将手机短信验证码认证的配置与当前的配置绑定
http.apply(mobileAuthenticationConfig);
// 开启 Remember-Me 功能
http.rememberMe()
// 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
.rememberMeParameter("remember-me")
// 设置 Token 有效期为 200s,默认时长为 2 星期
.tokenValiditySeconds(200)
// 设置操作数据库表的 Repository
.tokenRepository(tokenRepository())
// 指定 UserDetailsService 对象
.userDetailsService(userDetailsService);
// 开启注销登录功能
http.logout()
// 用户注销登录时访问的 url,默认为 /logout
.logoutUrl("/logout")
// 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
.logoutSuccessUrl("/login/page?logout");
}
/**
* 定制一些全局性的安全配置,例如:不拦截静态资源的访问
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 静态资源的访问不需要拦截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
📚 测试
访问localhost:8080/login/page
,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:

首次登录
成功登录认证后,可以在对应的数据表中找到相关 Token 记录:

在浏览器返回的响应头中可以找到 key 为 JSESSIONID 的 Cookie,生命周期为浏览器关闭时就删除;key 为 remember-me 的 Cookie,Max-age 为 200 秒:

上图中,如果对 remember-me 的 Cookie 值进行 base64 解码,可以发现解码后的字符串就是series:token
。
二次登录
将浏览器保存的 JSESSIONID 删除,只保留 remember-me 的 Cookie。访问localhost:8080
,查看请求头和响应头:


从上图可以看出,请求头中只携带 remember-me 的 Cookie,响应头返回新的 JSESSIONID 和 remember-me 的 Cookie。对比上面两张图,明显可以发现 remember-me 的 Cookie 值改变,并且该 Cookie 的 Max-Age 重置。查看数据库表,可以发现 token 字段改变,last_used 字段更新:

注销登录
访问localhost:8080/logout
,注销登录,数据库表中对应的 Token 记录会被删除:

在返回的响应头中可以找到 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() 方法是个抽象方法,所以实际调用的是子类
// PersistentTokenBasedRememberMeServices 重写的 onLoginSuccess() 方法
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
}
⭐ PersistentTokenBasedRememberMeServices#onLoginSuccess
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//(1) 获取用户名
String username = successfulAuthentication.getName();
this.logger.debug("Creating new persistent login for user " + username);
//(2) 创建要插入的 Token 记录对应实例 persistentToken
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
//(3) 使用 tokenRepository 往数据库表中插入 Token 记录
this.tokenRepository.createNewToken(persistentToken);
//(4) 将 Token 记录的 series:token 字段通过 Cookie 发送给用户浏览器
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}
//...
}
二次登陆
✏️ 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() 方法是一个抽象方法,
// 所以实际调用的是子类 PersistentTokenBasedRememberMeServices 重写的方法
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)过程调用 PersistentTokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法获取用户信息。
✏️ PersistentTokenBasedRememberMeServices#processAutoLoginCookie
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
//(1) 获取 Cookie 中的 series 和 token 字段
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
//(2) 根据 series 字段从数据库中查询 Token 记录
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
//(3) 没有查询到 Token 记录的处理,抛出异常
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) {
//(3) 查询到的 Token 记录中的 token 字段和 Cookie 中的字段不同,可能是 Cookie
// 被盗用,所以删除数据库表的该用户的所有 Token 记录,并抛出异常
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
//(3) Token 过期的处理,抛出异常
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
//(3) 查询到正常 Token 记录
if (this.logger.isDebugEnabled()) {
this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
}
//(3.1) 生成新的 Token(token 字段改变,series 字段不变,last_used 更新)
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
try {
//(3.2) 更新数据库中的 Token 记录
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
//(3.3) 将新 Token 的 series:token 通过 Cookie 发送给用户浏览器
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
//(3.4) 通过 UserDetailsService 对象查询用户信息 UserDetails,并返回
return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
}
//...
}
注销登录
注销登录需要在安全配置类的configure(HttpSecurity http)
里使用http.logout()
配置,该配置主要会在过滤器链中加入 LogoutFilter 过滤器,Spring Security
通过该过滤器实现注销登录功能。
此处对http.logout()
返回值的主要方法进行介绍,这些方法设计注销登录的配置,具体如下:
logoutUrl(String outUrl)
:指定用户注销登录时请求访问的地址,默认为 POST 方式的/logout
。logoutSuccessUrl(String logoutSuccessUrl)
:指定用户成功注销登录后的重定向地址,默认为/登录页面url?logout
。logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler)
:指定用户成功注销登录后使用的处理器。deleteCookies(String ...cookieNamesToClear)
:指定用户注销登录后删除的 Cookie。invalidateHttpSession(boolean invalidateHttpSession)
:指定用户注销登录后是否立即清除用户的 Session,默认为 true。clearAuthentication(boolean clearAuthentication)
:指定用户退出登录后是否立即清除用户认证信息对象 Authentication,默认为 true。addLogoutHandler(LogoutHandler logoutHandler)
:指定用户注销登录时使用的处理器。
需要注意,Spring Security
默认以 POST 方式请求访问/logout
注销登录,以 POST 方式请求的原因是为了防止 csrf(跨站请求伪造),如果想使用 GET 方式的请求,则需要关闭 csrf 防护。前面我们能以 GET 方式的请求注销登录,是因为我们在configure(HttpSecurity http)
方法中关闭了 csrf 防护:
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...
http.csrf().disable(); // 关闭 csrf 防护
// ...
}
默认配置下,成功注销登录后会进行如下三个操作:
- 删除用户浏览器中的指定 Cookie。
- 将用户浏览器中 remember-me 的 Cookie 删除,并清除用户在数据库中 remember-me 的 Token 记录;
- 当前用户的 Session 删除,并清除当前 SecurityContext 中的用户认证信息对象 Authentication。
- 通知用户浏览器重定向到
/登录页面url?logout
。
基本使用
📚 自定义成功注销登录处理器
package com.example.config.security;
import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 继承 SimpleUrlLogoutSuccessHandler 处理器,该类是 logoutSuccessUrl() 方法使用的成功注销登录处理器
*/
@Component
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
String xRequestedWith = request.getHeader("x-requested-with");
// 判断前端的请求是否为 ajax 请求
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 成功注销登录,响应 JSON 数据
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(0, "注销登录成功!")));
}else {
// 以下配置等同于在 http.logout() 后配置 logoutSuccessUrl("/login/page?logout")
// 设置默认的重定向路径
super.setDefaultTargetUrl("/login/page?logout");
// 调用父类的 onLogoutSuccess() 方法
super.onLogoutSuccess(request, response, authentication);
}
}
}
📚 修改安全配置类 SpringSecurityConfig
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private CustomLogoutSuccessHandler logoutSuccessHandler; // 自定义成功注销登录处理器
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启注销登录功能
http.logout()
// 用户注销登录时访问的 url,默认为 /logout
.logoutUrl("/logout")
// 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
//.logoutSuccessUrl("/login/page?logout")
// 不再使用 logoutSuccessUrl() 方法,使用自定义的成功注销登录处理器
.logoutSuccessHandler(logoutSuccessHandler)
// 指定用户注销登录时删除的 Cookie
.deleteCookies("JSESSIONID")
// 用户注销登录时是否立即清除用户的 Session,默认为 true
.invalidateHttpSession(true)
// 用户注销登录时是否立即清除用户认证信息 Authentication,默认为 true
.clearAuthentication(true);
}
//...
}
完整的安全配置类 SpringSecurityConfig 如下:
package com.example.config;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.CustomLogoutSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import javax.sql.DataSource;
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
@Autowired
private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类
@Autowired
private CustomLogoutSuccessHandler logoutSuccessHandler; // 自定义成功注销登录处理器
@Autowired
private DataSource dataSource; // 数据源
/**
* 配置 JdbcTokenRepositoryImpl,用于 Remember-Me 的持久化 Token
*/
@Bean
public JdbcTokenRepositoryImpl tokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次启动的时候可以使用以下语句自动建表(可以不用这句话,自己手动建表,源码中有语句的)
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
/**
* 密码编码器,密码不能明文存储
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用户认证管理器来实现用户认证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 启动 form 表单登录
http.formLogin()
// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
.loginPage("/login/page")
// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
.loginProcessingUrl("/login/form")
// 设置登录表单中的用户名参数,默认为 username
.usernameParameter("name")
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
// 使用自定义的认证成功和失败处理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
// 关闭 csrf 防护
http.csrf().disable();
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
// 将手机短信验证码认证的配置与当前的配置绑定
http.apply(mobileAuthenticationConfig);
// 开启 Remember-Me 功能
http.rememberMe()
// 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
.rememberMeParameter("remember-me")
// 设置 Token 有效期为 200s,默认时长为 2 星期
//.tokenValiditySeconds(200)
// 设置操作数据库表的 Repository
.tokenRepository(tokenRepository())
// 指定 UserDetailsService 对象
.userDetailsService(userDetailsService);
// 开启注销登录功能
http.logout()
// 用户注销登录时访问的 url,默认为 /logout
.logoutUrl("/logout")
// 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
//.logoutSuccessUrl("/login/page?logout")
// 不再使用 logoutSuccessUrl() 方法,使用自定义的成功注销登录处理器
.logoutSuccessHandler(logoutSuccessHandler)
// 指定用户注销登录时删除的 Cookie
.deleteCookies("JSESSIONID")
// 用户注销登录时是否立即清除用户的 Session,默认为 true
.invalidateHttpSession(true)
// 用户注销登录时是否立即清除用户认证信息 Authentication,默认为 true
.clearAuthentication(true);
}
/**
* 定制一些全局性的安全配置,例如:不拦截静态资源的访问
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 静态资源的访问不需要拦截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
📚 测试
访问localhost:8080/login/page
,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:

访问localhost:8080/logout
,注销登录,查看请求头和响应头:


由上图可以看出,注销登录后,用户浏览器的 JSESSIONID 和 remember-me 的 Cookie 被删除。
源码分析
✌ LogoutFilter#doFilter
public class LogoutFilter extends GenericFilterBean {
//...
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
//(1) 判断请求的 URL 是否为指定的注销登录路径,默认为 /logout
if (this.requiresLogout(request, response)) {
//(2) 获取认证用户信息 Authentication
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (this.logger.isDebugEnabled()) {
this.logger.debug("Logging out user '" + auth + "' and transferring to logout destination");
}
//(3) 注销登录的处理,调用 CompositeLogoutHandler 处理器的 logout 方法
this.handler.logout(request, response, auth);
//(4) 成功注销登录后的处理,调用成功注销登录处理器的 onLogoutSuccess() 方法,
// 默认重定向到 /登录页面url?logout
this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
} else {
chain.doFilter(request, response);
}
}
//...
}
上述(3)过程调用 CompositeLogoutHandler 处理器的 logout 方法进行注销登录的处理。
✌ CompositeLogoutHandler#logout
public final class CompositeLogoutHandler implements LogoutHandler {
private final List<LogoutHandler> logoutHandlers;
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Iterator var4 = this.logoutHandlers.iterator();
// 此处有多个处理器操作,主要有三个:
// 1. 清除指定 Cookie:CookieClearingLogoutHandler#logout
// 2. 清除 remember-me:PersistentTokenBasedRememberMeServices#logout
// 3. 使当前 Session无效,清空当前的 SecurityContext 中认证用户信息 Authentication:
// SecurityContextLogoutHandler#logout
while(var4.hasNext()) {
LogoutHandler handler = (LogoutHandler)var4.next();
handler.logout(request, response, authentication);
}
}
}
下面会对这三个处理器的 logout()
✍ CookieClearingLogoutHandler#logout
public final class CookieClearingLogoutHandler implements LogoutHandler {
private final List<Function<HttpServletRequest, Cookie>> cookiesToClear;
// 此处传入的 cookiesToClear 就是我们在安全配置类中使用 deleteCookies() 方法传入的参数
public CookieClearingLogoutHandler(String... cookiesToClear) {
Assert.notNull(cookiesToClear, "List of cookies cannot be null");
List<Function<HttpServletRequest, Cookie>> cookieList = new ArrayList();
String[] var3 = cookiesToClear;
int var4 = cookiesToClear.length;
for(int var5 = 0; var5 < var4; ++var5) {
String cookieName = var3[var5];
// 创建 Cookie 和删除逻辑的 Lambda 表达式
Function<HttpServletRequest, Cookie> f = (request) -> {
Cookie cookie = new Cookie(cookieName, (String)null);
// 此处的 cookiePath 设置存在问题,如果 contextPath 不为空,后缀不需要加 "/"
// cookiePath = contextPath.length() > 0 ? contextPath : "/"
String cookiePath = request.getContextPath() + "/";
cookie.setPath(cookiePath);
cookie.setMaxAge(0);
cookie.setSecure(request.isSecure());
return cookie;
};
cookieList.add(f);
}
this.cookiesToClear = cookieList;
}
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 将指定的 cookiesToClear 列表中的 Cookie 的 Max-Age 设置为 0,删除该列表中的 Cookie
this.cookiesToClear.forEach((f) -> {
response.addCookie((Cookie)f.apply(request));
});
}
//...
}
✍ PersistentTokenBasedRememberMeServices#logout
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//(1) 调用父类 AbstractRememberMeServices 的同名方法,将 remember-me 的 Cookie 清除
super.logout(request, response, authentication);
if (authentication != null) {
//(2) 使用 tokenRepository 将数据库表中对应用户的 Token 记录删除
this.tokenRepository.removeUserTokens(authentication.getName());
}
}
//...
}
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Logout of user " + (authentication == null ? "Unknown" : authentication.getName()));
}
// 将 remember-me 的 Cookie 清除
this.cancelCookie(request, response);
}
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Cancelling cookie");
Cookie cookie = new Cookie(this.cookieName, (String)null);
cookie.setMaxAge(0);
cookie.setPath(this.getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}
if (this.useSecureCookie == null) {
cookie.setSecure(request.isSecure());
} else {
cookie.setSecure(this.useSecureCookie);
}
response.addCookie(cookie);
}
private String getCookiePath(HttpServletRequest request) {
String contextPath = request.getContextPath();
return contextPath.length() > 0 ? contextPath : "/";
}
//
}
✍ SecurityContextLogoutHandler#logout
public class SecurityContextLogoutHandler implements LogoutHandler {
//...
private boolean invalidateHttpSession = true;
private boolean clearAuthentication = true;
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Assert.notNull(request, "HttpServletRequest required");
if (this.invalidateHttpSession) {
//(1) 将当前 Session 强制失效
HttpSession session = request.getSession(false);
if (session != null) {
this.logger.debug("Invalidating session: " + session.getId());
session.invalidate();
}
}
if (this.clearAuthentication) {
//(2) 移除当前 SecurityContext 中的用户认证用户信息 Authentication
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication((Authentication)null);
}
//(3) 清空当前的 SecurityContextHolder
SecurityContextHolder.clearContext();
}
//...
}