史上最简单的Spring Security教程(三十七):RememberMe记住我原理剖析

it2023-07-24  65

​用户登录中常用的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()); }

 

token使用

 

用户如果没有退出登录,当再次访问系统时,则不必再次登录(在有效期时间内)。这是由 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<H> 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

 

 

 

最新回复(0)