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

​用户登录中常用的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,而 TokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices 则默认实现了该接口。

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-记住我 的相关原理,上述已经进行了详细的说明。不过,文字描述终归不够直观,下面,再以图示的方式来展示一下其运行原理。

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

其它详细源码,请参考文末源码链接,可自行下载后阅读。

 

源码

github

https://github.com/liuminglei/SpringSecurityLearning/tree/master/36

gitee

https://gitee.com/xbd521/SpringSecurityLearning/tree/master/36

 

 

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

 

你可能感兴趣的:(Web安全,java,Spring,Security)