用户登录中常用的RememberMe-记住我功能,通俗来讲,即用户成功登录一次以后,系统自动记住该用户一段时间(可配置,Spring Security 框架默认为两周)。而在此时间段内,用户不必重新登录即可访问系统资源。本文即对 Spring Security 框架提供的 RememberMe-记住我 实现逻辑进行详细讲解,剖析其实现过程。
首先,在用户登录时,如果用户勾选了 记住我 选项,则系统会将该用户的一些信息进行处理,以便下次不必登录即可访问系统。在第一次登录时,请求会由 UsernamePasswordAuthenticationFilter 拦截处理,进行身份认证。
认证成功后,会调用 RememberMeServices 的 loginSuccess 方法,处理成功登录的逻辑。
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
......
rememberMeServices.loginSuccess(request, response, authResult);
......
}
用户身份认证成功后,便由 RememberMeServices 接口来处理后续的成功登录逻辑。
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication);
在其抽象实现类 AbstractRememberMeServices 中,进行了默认的实现。
public final void loginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
而抽象方法 onLoginSuccess 则显得尤为重要,这需要其子类去实现。Spring Security 框架默认提供了两个子类:TokenBasedRememberMeServices、PersistentTokenBasedRememberMeServices。
前面已经介绍过,PersistentTokenBasedRememberMeServices 相比于 TokenBasedRememberMeServices,采用了更加安全的实现方式,而不是如 TokenBasedRememberMeServices 一般,简单的将用户信息,如用户名、密码等按照一定的规则加密后存储在Cookie中。详情可查看文章 史上最简单的Spring Security教程(三十五):RememberMe记住我之更安全的实现方式-持久化token存储方式PersistentTokenBasedRememberMeServic。
TokenBasedRememberMeServices 中的 onLoginSuccess 逻辑如下。
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
// If unable to find a username and password, just abort as
// TokenBasedRememberMeServices is
// unable to construct a valid token in this case.
if (!StringUtils.hasLength(username)) {
logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
logger.debug("Unable to obtain password for user: " + username);
return;
}
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
// SEC-949
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
tokenLifetime, request, response);
if (logger.isDebugEnabled()) {
logger.debug("Added remember-me cookie for user '" + username
+ "', expiry: '" + new Date(expiryTime) + "'");
}
}
即按照 username + ":" + expiryTime + ":" + Md5Hex(username + ":" + expiryTime + ":" + password + ":" + key) 规则,存储到Cookie中,默认过期时间为14天。
这里就能凸显出来一个问题,用户的密码存储在了Cookie中,即便被加了密。不过,如其类注释所说,这适用于大部分的应用,并没有什么问题。
This is a basic remember-me implementation which is suitable for many applications.
PersistentTokenBasedRememberMeServices 中的 onLoginSuccess 逻辑如下。
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
logger.debug("Creating new persistent login for user " + username);
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}
同 TokenBasedRememberMeServices 一样,也存储相关信息到Cookie中,只不过,没有密码等敏感信息。除此之外,还将token存储到了 tokenRepository 中,这将在后续根据用户名查询其token。
public void createNewToken(PersistentRememberMeToken token) {
getJdbcTemplate().update(insertTokenSql, token.getUsername(), token.getSeries(),
token.getTokenValue(), token.getDate());
}
用户如果没有退出登录,当再次访问系统时,则不必再次登录(在有效期时间内)。这是由 RememberMeAuthenticationFilter 实现的。
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
如果当前用户没有登录,即 SecurityContextHolder 中当前用户上下文不存在 Authentication。此时,便会调用自动登录逻辑,即 RememberMeServices 接口的 autoLogin 方法。
同 loginSuccess 方法一样,在其抽象实现类 AbstractRememberMeServices 中,进行了默认的实现。
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException cte) {
cancelCookie(request, response);
throw cte;
}
catch (UsernameNotFoundException noUser) {
logger.debug("Remember-me login was valid but corresponding user not found.",
noUser);
}
catch (InvalidCookieException invalidCookie) {
logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage());
}
catch (AccountStatusException statusInvalid) {
logger.debug("Invalid UserDetails: " + statusInvalid.getMessage());
}
catch (RememberMeAuthenticationException e) {
logger.debug(e.getMessage());
}
cancelCookie(request, response);
return null;
}
这段逻辑看似挺多,其实也没多少内容。主要为抽取Cookie、解析Cookie、自动登录、成功登录、异常处理。
抽取Cookie、解析Cookie、异常处理 逻辑较为简单,这里不再赘述,感兴趣的可以自行查看源码分析。
自动登录,即 processAutoLoginCookie 方法,同样的由两个默认子类:TokenBasedRememberMeServices、PersistentTokenBasedRememberMeServices。
TokenBasedRememberMeServices 中的逻辑如下。
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) + "'");
}
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]).longValue();
}
catch (NumberFormatException nfe) {
throw new InvalidCookieException(
"Cookie token[1] did not contain a valid number (contained '"
+ cookieTokens[1] + "')");
}
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
+ new Date(tokenExpiryTime) + "'; current time is '" + new Date()
+ "')");
}
// Check the user exists.
// Defer lookup until after expiry time checked, to possibly avoid expensive
// database call.
UserDetails userDetails = getUserDetailsService().loadUserByUsername(
cookieTokens[0]);
// Check signature of token matches remaining details.
// Must do this after user lookup, as we need the DAO-derived password.
// If efficiency was a major issue, just add in a UserCache implementation,
// but recall that this method is usually only called once per HttpSession - if
// the token is valid,
// it will cause SecurityContextHolder population, whilst if invalid, will cause
// the cookie to be cancelled.
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;
}
还是根据之前的存储规则,进行反解析,得到用户的相关信息,校验通过后,获取用户的详细信息并返回。
PersistentTokenBasedRememberMeServices 中的逻辑如下。
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) + "'");
}
final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
if (token == null) {
// No series match, so we can't authenticate using this cookie
throw new RememberMeAuthenticationException(
"No persistent token found for series id: " + presentedSeries);
}
// We have a match for this user/series combination
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(
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");
}
// Token also matches, so login is valid. Update the token value, keeping the
// *same* series number.
if (logger.isDebugEnabled()) {
logger.debug("Refreshing persistent login token for user '"
+ token.getUsername() + "', series '" + token.getSeries() + "'");
}
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());
}
这里的逻辑也比较简单,不过,有一点为重中之重:全部校验通过后,生成新token存储到 tokenRepository 中;同时,将新的token存储到Cookie中。
关于退出登录时的后续操作,其实,Spring Security 框架采用了比较巧的方式来解决了。LogoutFilter 会存在一系列的 LogoutHandler,而 TokenBasedRememberMeServices、PersistentTokenBasedRememberMeServices 则默认实现了该接口。
public abstract class AbstractRememberMeServices implements RememberMeServices,
InitializingBean, LogoutHandler {
因此,无论最后 RememberMeServices 最后使用了那个实现,都会被初始化到 LogoutFilter 中,在用户退出登录时,会自动执行 logout 方法。
public void init(H http) throws Exception {
validateInput();
String key = getKey();
RememberMeServices rememberMeServices = getRememberMeServices(http, key);
http.setSharedObject(RememberMeServices.class, rememberMeServices);
LogoutConfigurer logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
if (logoutConfigurer != null && this.logoutHandler != null) {
logoutConfigurer.addLogoutHandler(this.logoutHandler);
}
RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(
key);
authenticationProvider = postProcess(authenticationProvider);
http.authenticationProvider(authenticationProvider);
initDefaultLoginFilter(http);
}
那么,logout 方法到底都执行了哪些逻辑呢?
首先,TokenBasedRememberMeServices 中的退出逻辑如下。
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
if (logger.isDebugEnabled()) {
logger.debug("Logout of user "
+ (authentication == null ? "Unknown" : authentication.getName()));
}
cancelCookie(request, response);
}
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
logger.debug("Cancelling cookie");
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
cookie.setPath(getCookiePath(request));
if (cookieDomain != null) {
cookie.setDomain(cookieDomain);
}
response.addCookie(cookie);
}
由于 TokenBasedRememberMeServices 主要就是将用户信息按照一定规则加密后存储到Cookie中,所以,退出登录时,也只需简单的清除Cookie即可。
由于 TokenBasedRememberMeServices 主要就是将用户信息按照一定规则加密后存储到Cookie中,所以,退出登录时,也只需简单的清除Cookie即可。
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
super.logout(request, response, authentication);
if (authentication != null) {
tokenRepository.removeUserTokens(authentication.getName());
}
}
关于 RememberMe-记住我 的相关原理,上述已经进行了详细的说明。不过,文字描述终归不够直观,下面,再以图示的方式来展示一下其运行原理。
其它详细源码,请参考文末源码链接,可自行下载后阅读。
github
https://github.com/liuminglei/SpringSecurityLearning/tree/master/36
gitee
https://gitee.com/xbd521/SpringSecurityLearning/tree/master/36