前言
- SpringSecurity支持记住我登录,点击下面的复选框即可完成记住我认证
- RememberMeConfigurer作为RememberMeAuthenticationFilter的过滤器
- 在SpringSecurity中默认不会填充,通过以下代码开启基础配置
java
复制代码
@Override
protected void configure(HttpSecurity http) throws Exception {
...
http.rememberMe();
...
}
1.1 rememberMeServices(...)
- RememberMeServices(...):填充RememberMeServices
java
复制代码
public RememberMeConfigurer<H> rememberMeServices(RememberMeServices rememberMeServices) {
this.rememberMeServices = rememberMeServices;
return this;
}
- RememberMeServices:SpringSecurity中的负责认证的过滤器将调用其实现类来完成记住我机制
java
复制代码
public interface RememberMeServices {
/**
* 将记住我令牌转为认证对象
*/
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
/**
* 记住我认证失败调用的方法
* <ul>
* <li>
* 比如说执行autoLogin方法创建的认证对象,认证失败后,清除记住我令牌
* </li>
* </ul>
*/
void loginFail(HttpServletRequest request, HttpServletResponse response);
/**
* 认证成功调用的方法
* <ul>
* <li>
* 比如说使用表单或者基本认证,认证成功后,可能需要创建一个记住我令牌
* </li>
* </ul>
*/
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication);
}
- 其中有两个重要的实现
- TokenBasedRememberMeServices:不依赖于外部数据库
- PersistentTokenBasedRememberMeServices:支持持久化(数据库)
- 最后再介绍这两个类
1.2 tokenRepository(...)
- tokenRepository(...):使用持久化方式来保持记住我令牌
- 这个只有在PersistentTokenBasedRememberMeServices中会用到
java
复制代码
public RememberMeConfigurer<H> tokenRepository(PersistentTokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
return this;
}
- 然后再看PersistentTokenRepository的源码:很明显是一个支持增删改查的类
java
复制代码
public interface PersistentTokenRepository {
void createNewToken(PersistentRememberMeToken token);
void updateToken(String series, String tokenValue, Date lastUsed);
PersistentRememberMeToken getTokenForSeries(String seriesId);
void removeUserTokens(String username);
}
- 我们就看他的一个基于内存的实现类:
- JdbcTokenRepositoryImpl:基于JDBC的持久登录令牌
- InMemoryTokenRepositoryImpl:由Map支持的简单PersistentTokenRepository实现。仅用于测试
- 这两个类也是最后再来介绍
1.3 init(...)
- init(...):这里都是填充记住我机制的必要参数
- 但是这里为认证管理器注册了一个新的类:RememberMeAuthenticationProvider
- 因为记住我认证对象和用 用户名密码认证的对象不一样,需要特殊的认证提供者
java
复制代码
public void init(H http) throws Exception {
validateInput();
//获取秘钥
String key = getKey();
//获得记住我服务
RememberMeServices rememberMeServices = getRememberMeServices(http, key);
//将记住我服务放入SharedObject中,这样表单登录时候就能够创建记住我令牌了
http.setSharedObject(RememberMeServices.class, rememberMeServices);
//记住我服务,通常都实现了登出处理器,提供登出的时候,删除记住我令牌的功能
LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
if (logoutConfigurer != null && this.logoutHandler != null) {
logoutConfigurer.addLogoutHandler(this.logoutHandler);
}
//创建一个记住我用户的认证提供者
RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(key);
authenticationProvider = postProcess(authenticationProvider);
//添加到httpSecurity中
http.authenticationProvider(authenticationProvider);
//如果有登录页的话,给他设置开启记住我登录的参数名
initDefaultLoginFilter(http);
}
1.3.1 RememberMeAuthenticationProvider
- RememberMeAuthenticationProvider是SpringSecurity其中的一种认证方式
- 记住我的认证规则很简单,只比较了秘钥
java
复制代码
public class RememberMeAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
...
/**
* 记住我的认证规则很简单,只比较了秘钥
* <p>我理解是因为在通过记住我过滤器生成记住我认证对象的时候,已经比较过签名了</p>
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!supports(authentication.getClass())) {
return null;
}
//比较秘钥是否相同
if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) {
throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey",
"The presented RememberMeAuthenticationToken does not contain the expected key"));
}
return authentication;
}
...
}
java
复制代码
public void configure(H http) {
//创建对应过滤器
RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter(
http.getSharedObject(AuthenticationManager.class), this.rememberMeServices);
//设置认证成功处理器
if (this.authenticationSuccessHandler != null) {
rememberMeFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
}
rememberMeFilter = postProcess(rememberMeFilter);
http.addFilter(rememberMeFilter);
}
2. RememberMeAuthenticationFilter
java
复制代码
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
//如果HttpSession级别的安全上下文中有认证对象的话,那就说明已经认证过了,就不需要进行记住我方式认证了
//通常情况是因为Session过期了
//注意:匿名认证过滤器在这个过滤器的后面
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
//获得认证对象
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
try {
//通过局部认证管理器进行认证操作
//局部认证管理器通常有匿名和记住我的认证提供者,而全局认证管理器才是表单的认证提供者
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
//将认证对象保存到线程级别的安全上下文策略中
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
//执行认证成功的方法,默认是空方法
onSuccessfulAuthentication(request, response, rememberMeAuth);
this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
//推送交互式认证成功事件
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
//执行认证成功处理器
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
}
catch (AuthenticationException ex) {
this.logger.debug(LogMessage
.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
+ "rejected Authentication returned by RememberMeServices: '%s'; "
+ "invalidating remember-me token", rememberMeAuth),
ex);
//执行认证失败操作
this.rememberMeServices.loginFail(request, response);
//默认空方法
onUnsuccessfulAuthentication(request, response, ex);
}
}
chain.doFilter(request, response);
}
- 大部分代码我已经加入了注释,并且都是以前介绍过的类,这里唯一的陌生代码就是第十四行
- 这里是通过RememberMeServices获取认证对象,我们就看看他的两个实现是怎么操作的
- TokenBasedRememberMeServices
- PersistentTokenBasedRememberMeServices
3. RememberMeServices
3.1 TokenBasedRememberMeServices
- TokenBasedRememberMeServices和PersistentTokenBasedRememberMeServices都有相同的父类:AbstractRememberMeServices
- 我们接下来就看看RememberMeServices中的三大方法在TokenBasedRememberMeServices中是如何实现的
3.1.1 loginSuccess(...)
- 此方法是当认证完成后才会被调用,下面是他的调用情况
- BasicAuthenticationFilter.doFilterInternal(...)
- UsernamePasswordAuthenticationFilter.successfulAuthentication(...)
- 接下来我们直接看其源码
java
复制代码
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
//获得用户名和密码
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
//如若无法找到用户名和密码就终止创建记住我令牌
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
//尝试通过用户详情服务获取密码
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
//获得记住我令牌有效时间
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
//过期时间 = 令牌有效时间 + 当前时间
expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
//生成签名
String signatureValue = makeTokenSignature(expiryTime, username, password);
//将记住我令牌添加到Cookie中
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) + "'");
}
}
- 别看源码很长,其实重点就在于如何生成的令牌,我们看makeTokenSignature(...)方法
java
复制代码
/**
* 生成签名,并通过MD5进行加密
*/
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No MD5 algorithm available!");
}
}
- 很明显就是通过 (过期时间 + 用户名 + 密码 + key) 再通过MD5加密为签名
- 最后将用户名、过期时间、签名通过Base64加密保存到Cookie中
3.1.2 autoLogin(...)
- autoLogin(...)是在通过记住我认证后SecurityContext中没有认证对象才会被调用的方法
- 两个实现类并没有重写核心方法autoLogin(...),而是在其父类中有代码
java
复制代码
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
//获取记住我令牌
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
//记住我令牌不能为空
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
//将生存时间设置为0,以禁用记住我认证
cancelCookie(request, response);
return null;
}
try {
//将记住我令牌进行Base64解码
String[] cookieTokens = decodeCookie(rememberMeCookie);
//记住我令牌转换为用户对象
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
//进行检查
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
//创建记住我认证对象
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException ex) {
cancelCookie(request, response);
throw ex;
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
}
catch (InvalidCookieException ex) {
this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
}
catch (AccountStatusException ex) {
this.logger.debug("Invalid UserDetails: " + ex.getMessage());
}
catch (RememberMeAuthenticationException ex) {
this.logger.debug(ex.getMessage());
}
cancelCookie(request, response);
return null;
}
- autoLogin(...)方法的核心就在于通过processAutoLoginCookie(...)获取了UserDetails,而这个方法两个实现类都重写了
java
复制代码
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
//使用当前记住我服务只会生成长度为3的记住我令牌
if (cookieTokens.length != 3) {
throw new InvalidCookieException(
"Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
//获得过期时间
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
//判断记住我令牌是否已经过期
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
+ "'; current time is '" + new Date() + "')");
}
//通过用户名加载UserDetails
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
+ " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
//以固定的参数重新生成签名
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword());
//如果不一样,就抛出异常
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
- 分析源码就能得出结论:
- 由于记住我令牌最外面一层是通过Base64加密的所以说可以直接进行解密,变成下面的样子
- 然后判断是否过期
- 未过期就通过用户名获取用户对象
- 在用 用户名 + 密码 + 过期时间 + key 重新生成签名
- 只有Cookie中的令牌和重新生成的一样才认为此令牌有效
3.1.3 loginFail(...)
- 此方法是退出登录才会被调用的,其代码很简单,就是清除记住我令牌而已
java
复制代码
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Interactive login attempt was unsuccessful.");
cancelCookie(request, response);
onLoginFail(request, response);
}
/**
* 将生存时间设置为0,以禁用记住我认证
*/
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Cancelling cookie");
Cookie cookie = new Cookie(this.cookieName, null);
cookie.setMaxAge(0);
cookie.setPath(getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}
cookie.setSecure((this.useSecureCookie != null) ? this.useSecureCookie : request.isSecure());
response.addCookie(cookie);
}
3.2 PersistentTokenBasedRememberMeServices
- 与TokenBasedRememberMeServices不一样,此类支持持久化,并且令牌的生成方式不一样
3.2.1 loginSuccess(...)
java
复制代码
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));
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
generateTokenData(), new Date());
try {
//保存起来,一般情况是数据库
this.tokenRepository.createNewToken(persistentToken);
//添加记住我令牌到响应的Cookie中
addCookie(persistentToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to save persistent token ", ex);
}
}
- 这里生成的令牌就和TokenBasedRememberMeServices不一样了
- 其中的series和tokenValue都是随机数
java
复制代码
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
/**
* 创建日期
*/
private final Date date;
}
- 这里保存令牌是通过PersistentTokenRepository的实现类,我们就看个简单的例子
java
复制代码
public class InMemoryTokenRepositoryImpl implements PersistentTokenRepository {
private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();
@Override
public synchronized void createNewToken(PersistentRememberMeToken token) {
PersistentRememberMeToken current = this.seriesTokens.get(token.getSeries());
if (current != null) {
throw new DataIntegrityViolationException("Series Id '" + token.getSeries() + "' already exists!");
}
this.seriesTokens.put(token.getSeries(), token);
}
@Override
public synchronized void updateToken(String series, String tokenValue, Date lastUsed) {
PersistentRememberMeToken token = getTokenForSeries(series);
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), series, tokenValue,
new Date());
// Store it, overwriting the existing one.
this.seriesTokens.put(series, newToken);
}
...
}
- 这里保存和更新都是以series作为键,这里是重点
3.2.2 processAutoLoginCookie(...)
- 此方法的重点就在于一个令牌的Series是固定的,而TokenValue是会随着请求不断的更新的,一旦发现TokenValue值不对就说明此令牌已经发生了泄露
java
复制代码
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
//使用当前记住我服务只会生成长度为2的记住我令牌
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
+ Arrays.asList(cookieTokens) + "'");
}
//生成记住我令牌的时候就已经固定了第一位是Series,第二位是Token
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
//先通过持久化策略获得 保存的持久化记住我令牌
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
//没有保存,不能使用此cookie进行身份认证
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}
//当token值不等的时候,说明此记住我令牌已经泄露了
if (!presentedToken.equals(token.getTokenValue())) {
//删除用此用户名登录的所有 持久化记住我令牌
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."));
}
//判断是否过期
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()));
//此记住我令牌是有效的,更新token值和时间
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());
}
3.2.3 loginFail(...)
- 登出的时候就和TokenBasedRememberMeServices一样了
java
复制代码
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
super.logout(request, response, authentication);
if (authentication != null) {
this.tokenRepository.removeUserTokens(authentication.getName());
}
}
4. 总结
- 最后总结下记住我的认证逻辑:
- 以其他表单认证或者基本认证等认证方式进行认证后会通过RememberMeServices创建记住我令牌,并添加在Cookie中
- 等过一段时间后HttpSession过期了,SecurityContextRepository中的认证对象为空了
- 此时就来到了RememberMeAuthenticationFilter,解析出记住我令牌
- 通过RememberMeServices校验记住我令牌,然后进行认证
- 认证完成后创建记住我认证对象,并将其放入SecurityContext中