原理分析

从 RememberMeServices 接口开始介绍。

RememberMeServices 接定义如下:

public interface RememberMeServices {

	Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

	void loginFail(HttpServletRequest request, HttpServletResponse response);

	void loginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication);
}

这里一共定义了三个方法:

  1. autoLogin 方法可以从请求中提取出需要的参数,完成自动登录功能。

  2. loginFail 方法是自动登录失败的回调。

  3. loginSuccess方法是自动登录成功的回调。

RememberMeServices 接口的继承关系如图 6-7 所示。

image 2024 04 13 12 12 04 168
Figure 1. 图 6-7 RememberMe 继承关系图

NullRememberMeServices 是一个空的实现,这里不做讨论,我们来重点分析另外三个实现类。

AbstractRememberMeServices

AbstractRememberMeServices 对于 RememberMeServices 接口中定义的方法提供了基本的实现,这里就以接口中定义的方法为思路,分析 AbstractRememberMeServices 中的具体实现。

首先我们来看 autoLogin 及其相关方法:

@Override
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;
}

protected String extractRememberMeCookie(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();

    if ((cookies == null) || (cookies.length == 0)) {
        return null;
    }

    for (Cookie cookie : cookies) {
        if (cookieName.equals(cookie.getName())) {
            return cookie.getValue();
        }
    }

    return null;
}

protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
    for (int j = 0; j < cookieValue.length() % 4; j++) {
        cookieValue = cookieValue + "=";
    }

    try {
        Base64.getDecoder().decode(cookieValue.getBytes());
    }
    catch (IllegalArgumentException e) {
        throw new InvalidCookieException(
                "Cookie token was not Base64 encoded; value was '" + cookieValue
                        + "'");
    }

    String cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes()));

    String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText,
            DELIMITER);

    for (int i = 0; i < tokens.length; i++)
    {
        try
        {
            tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8.toString());
        }
        catch (UnsupportedEncodingException e)
        {
            logger.error(e.getMessage(), e);
        }
    }

    return tokens;
}

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);
    }
    if (useSecureCookie == null) {
        cookie.setSecure(request.isSecure());
    }
    else {
        cookie.setSecure(useSecureCookie);
    }
    response.addCookie(cookie);
}

protected Authentication createSuccessfulAuthentication(HttpServletRequest request,
        UserDetails user) {
    RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(key, user,
            authoritiesMapper.mapAuthorities(user.getAuthorities()));
    auth.setDetails(authenticationDetailsSource.buildDetails(request));
    return auth;
}

autoLogin 方法主要功能就是从当前请求中提取出令牌信息,根据令牌信息完成自动登录功能,登录成功之后会返回一个认证后的 Authentication 对象,我们来看一下该方法的具体实现:

  1. 首先调用 extractRememberMeCookie 方法从当前请求中提取出需要的 Cookie 信息,即 remember-me 对应的值。如果这个值为 null,表示本次请求携带的 Cookie 中没有 remember-me,这次不需要自动登录,直接返回 null 即可。如果 remember-me 对应的值长度为 0,则在返回 null 之前,执行一下 cancelCookie 函数,将 Cookie 中 remember-me 的值置为 null。

  2. 接下来调用 decodeCookie 方法对获取到的令牌进行解析。具体方式是,先用 Base64 对令牌进行还原(如果令牌字符串长度不是 4 的倍数,则在令牌末尾补上一个或者多个 “=”,以使其长度变为 4 的倍数,之所以要是 4 的倍数,这和 Base64 编解码的原理有关,感兴趣的读者可以自行学习 Base64 编解码的原理,并不难),还原之后的字符串分为三部分,三部分之间用 “:” 隔开,第一部分是当前登录用户名,第二部分是时间戳,第三部分是一个签名。也就是说,我们一开始在浏览器中看到的 remember-me令牌,其实是一个 Base64 编码后的字符串,解码后的信息包含三部分,读者可以根据 decodeCookie 中的方法自行尝试对令牌进行解码。最后将这三部分分别提取出来组成一个数组返回。

  3. 调用 processAutoLoginCookie 方法对 Cookie 进行验证,如果验证通过,则返回登录用户对象,然后对用户状态进行检验(账户是否可用、账户是否锁定等)。processAutoLoginCookie 方法是一个抽象方法,具体实现在 AbstractRememberMeServices 的子类中。

  4. 最后调用 createSuccessfulAuthentication 方法创建登录成功的用户对象,不同于使用用户名/密码登录,本次登录成功后创建的用户对象类型是 RememberMeAuthenticationToken。

接下来我们再来看一下自动登录成功和自动登录失败的回调:

@Override
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
    logger.debug("Interactive login attempt was unsuccessful.");
    cancelCookie(request, response);
    onLoginFail(request, response);
}

protected void onLoginFail(HttpServletRequest request, HttpServletResponse response) {
}

@Override
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);
}

protected abstract void onLoginSuccess(HttpServletRequest request,
        HttpServletResponse response, Authentication successfulAuthentication);

protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
    if (alwaysRemember) {
        return true;
    }

    String paramValue = request.getParameter(parameter);

    if (paramValue != null) {
        if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
                || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
            return true;
        }
    }

    if (logger.isDebugEnabled()) {
        logger.debug("Did not send remember-me cookie (principal did not set parameter '"
                + parameter + "')");
    }

    return false;
}
  1. 登录失败时,首先取消 Cookie 的设置,然后调用 onLoginFail 方法完成失败处理,onLoginFail 方法是一个空方法,如果有需要,开发者可以自行重写该方法,一般来说不需要重写。

  2. 登录成功时,会首先调用 rememberMeRequested 方法,判断当前请求是否开启了自动登录。开发者可以在服务端配置 alwaysRemember,这样无论前端参数是什么,都会开启自动登录,如果开发者没有配置 alwaysRemember,则根据前端传来的 remember-me 参数进行判断,remember-me 参数的值如果是 true、on(默认)、yes 或者 1,表示开启自动登录。如果开启了自动登录,则调用 onLoginSuccess 方法进行登录成功的处理。onLoginSuccess 是一个抽象方法,具体实现在 AbstractRememberMeServices 的子类中。

最后再来看 AbstractRememberMeServices 中一个比较重要的方法 setCookie,在自动登录成功后,将调用该方法把令牌信息放入响应头中并最终返回到前端:

protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request,
        HttpServletResponse response) {
    String cookieValue = encodeCookie(tokens);
    Cookie cookie = new Cookie(cookieName, cookieValue);
    cookie.setMaxAge(maxAge);
    cookie.setPath(getCookiePath(request));
    if (cookieDomain != null) {
        cookie.setDomain(cookieDomain);
    }
    if (maxAge < 1) {
        cookie.setVersion(1);
    }

    if (useSecureCookie == null) {
        cookie.setSecure(request.isSecure());
    }
    else {
        cookie.setSecure(useSecureCookie);
    }

    cookie.setHttpOnly(true);

    response.addCookie(cookie);
}

protected String encodeCookie(String[] cookieTokens) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < cookieTokens.length; i++) {
        try
        {
            sb.append(URLEncoder.encode(cookieTokens[i], StandardCharsets.UTF_8.toString()));
        }
        catch (UnsupportedEncodingException e)
        {
            logger.error(e.getMessage(), e);
        }

        if (i < cookieTokens.length - 1) {
            sb.append(DELIMITER);
        }
    }

    String value = sb.toString();

    sb = new StringBuilder(new String(Base64.getEncoder().encode(value.getBytes())));

    while (sb.charAt(sb.length() - 1) == '=') {
        sb.deleteCharAt(sb.length() - 1);
    }

    return sb.toString();
}
  1. 首先调用 encodeCookie 方法对要返回到前端的数据进行 Base64 编码,具体方式是将数组中的数据拼接成一个字符串并用 “:” 隔开,然后对其进行 Base64 编码。

  2. 将编码后的字符串放入 Cookie 中,并配置 Cookie 的过期时间、path、domain、secure、httpOnly 等属性,最终将配置好的 Cookie 对象放入响应头中。

这便是 AbstractRememberMeServices 中的几个主要方法,还有其他一些辅助的方法都比较简单,读者可以自行研究。

TokenBasedRememberMeServices

TokenBasedRememberMeServices 是 AbstractRememberMeServices 的实现类之一,在 6.2 节中,我们讲解 RememberMe 的基本用法时,最终起作用的就是 TokenBasedRememberMeServices。 作为 AbstractRememberMeServices 的子类,TokenBasedRememberMeServices 最重要的方法就是对 AbstractRememberMeServices 中所定义的两个抽象方法 processAutoLoginCookie 和 onLoginSuccess 的实现。

我们先来看 processAutoLoginCookie 方法:

@Override
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]);
    }
    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]);

    Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
            + " returned null for username " + cookieTokens[0] + ". "
            + "This is an interface contract violation");

    // 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;
}

protected String makeTokenSignature(long tokenExpiryTime, String username,
        String password) {
    String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
    MessageDigest digest;
    try {
        digest = MessageDigest.getInstance("MD5");
    }
    catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("No MD5 algorithm available!");
    }

    return new String(Hex.encode(digest.digest(data.getBytes())));
}

processAutoLoginCookie 方法主要用来验证 Cookie 中的令牌信息是否合法:

  1. 首先判断 cookieTokens 长度是否为 3,不为 3 说明格式不对,则直接抛出异常。

  2. 从 cookieTokens 数组中提取出第 1 项,也就是过期时间,判断令牌是否过期,如果已经过期,则抛出异常。

  3. 根据用户名(cookieTokens 数组的第 0 项)查询出当前用户对象。

  4. 调用 makeTokenSignature 方法生成一个签名,签名的生成过程如下:首先将用户名、令牌过期时间、用户密码以及 key 组成一个字符串,中间用 “:” 隔开,然后通过 MD5 消息摘要算法对该符串进行加密,并将加密结果转为一个字符串返回。

  5. 判断第 4 步生成的签名和通过 Cookie 传来的签名是否相等(即 cookieTokens 数组的第 2 项),如果相等,表示令牌合法,则直接返回用户对象,否则抛出异常。

再来看登录成功的回调函数 onLoginSuccess:

@Override
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) + "'");
    }
}
  1. 在这个回调中,首先获取用户名和密码信息,如果用户密码在用户登录成功后已经从 successfulAuthentication 对象中擦除了,则从数据库中重新加载出用户密码。

  2. 计算出令牌的过期时间,令牌默认有效期是两周。

  3. 根据令牌的过期时间、用户名以及用户密码,计算出一个签名。

  4. 调用 setCookie 方法设置 Cookie,第一个参数是一个数组,数组中一共包含三项:用户名、过期时间以及签名,在 setCookie 方法中会将数组转为字符串,并进行 Base64 编码后响应给前端。

看完 processAutoLoginCookie 和 onLoginSuccess 两个方法的实现,相信读者对于令牌的生成和校验己经非常清楚了,这里再总结一下:

当用户通过用户名/密码的形式登录成功后,系统会根据用户的用户名、密码以及令牌的,过期时间计算出一个签名,这个签名使用 MD5 消息摘要算法生成,是不可逆的。然后再将用户名、令牌过期时间以及签名拼接成一个字符串,中间用 “:” 隔开,对拼接好的字符串进行 Base64 编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。当用户关闭浏览器再次打开,访问系统资源时会自动携带上 Cookie 中的令牌,服务端拿到 Cookie 中的令牌后,先进行 Base64 解码,解码后分别提取出令牌中的三项数据:接着根据令牌中的数据判断令牌是否己经过期,如果没有过期,则根据令牌中的用户名查询出用户信息:接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示令牌是合法令牌,自动登录成功,否则自动登录失败。

PersistentTokenBasedRememberMeServices

PersistentTokenBasedRememberMeServices 类作为 AbstractRememberMeServices 的另一个实现类,在 6.3 节的案例中,使用的就是 PersistentTokenBasedRememberMeServices。

在持久化令牌中,存储在数据库中的数据被封装成了一个对象 PersistentRememberMeToken,其定义如下:

public class PersistentRememberMeToken {
	private final String username;
	private final String series;
	private final String tokenValue;
	private final Date date;

    //省略 getter/setter
}

username 表示登录用户名,series 和 tokenValue 则是自动生成的,date 表示上次使用时间。

PersistentTokenBasedRememberMeServices 里边重要的方法也是processAutoLoginCookie 和 onLoginSuccess,我们分别来看一下。

先来看 processAutoLoginCookie 方法:

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());
}

private void addCookie(PersistentRememberMeToken token, HttpServletRequest request,
        HttpServletResponse response) {
    setCookie(new String[] { token.getSeries(), token.getTokenValue() },
            getTokenValiditySeconds(), request, response);
}
  1. 不同于 TokenBasedRememberMeServices 中的 processAutoLoginCookie 方法,这里 cookieTokens 数组的长度为 2,第一项是 series,第二项是token。

  2. 从 cookieTokens 数组中分别提取出 series 和 token,然后根据 series 去数据库中查询出一个 PersistentRememberMeToken 对象。如果查询出来的对象为 null,表示数据库中并没有 series 对应的值,本次自动登录失败;如果查询出来的 token 和从 cookieTokens 中解析出来的 token 不相同,说明自动登录令牌已经泄漏(恶意用户利用令牌登录后,数据库中的token 变了),此时移除当前用户的所有自动登录记录并抛出异常。

  3. 根据数据库中查询出来的结果判断令牌是否过期,如果过期就抛出异常。

  4. 生成一个新的 PersistentRememberMeToken 对象,用户名和 series 不变,token 重新生成,date 也使用当前时间。newToken 生成后,根据 series 去修改数据库中的 token 和 date(即每次自动登录后都会产生新的token 和 date)。

  5. 调用 addCookie 方法添加 Cookie,在 addCookie 方法中,会调用到我们前面所说的 setCookie 方法,但是要注意第一个数组参数中只有两项:series 和 token(即返回到前端的令是通过对 series 和 token 进行 Base64 编码得到的)。

  6. 最后将根据用户名香询用户对象并返回。

再来看登录成功的回调函数 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);
    }
}

登录成功后,构建一个 PersistentRememberMeToken 对象,对象中的 series 和 token 参数都是随机生成的,然后将生成的对象存入数据库中,再调用 addCookie 方法添加相关的 Cookie 信息。

PersistentTokenBasedRememberMeServices 和 TokenBasedRememberMeServices 还是有些明显的区别的:前者返回给前端的令牌是将 series 和 token 组成的字符串进行 Base64 编码后返回给前端:后者返回给前端的令牌则是将用户名、过期时间以及签名组成的字符串进行 Base64 编码后返回给端。

那么 RememberMeServices 是在何时被调用的?这就要回到我们一开始的配置中了。

当开发者配置 .rememberMe().key("javaboy") 时,实际上是引入了配置类 RememberMeConfigurer,根据第 4 章的介绍,我们知道对于 RememberMeConfigurer 而言最重要的就是 init 和 configure 方法,我们先来看其 init 方法:

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);
}

在这里首先获取了一个 key,这个 key 就是开发者一开始配置的 key,如果没有配置,则会自动生成一个 UUID 字符串。如果开发者使用普通的 RememberMe,即没有使用持久化令牌,则建议开发者自行配置该 key,因为使用默认的 UUID 字符串,系统每次重启都会生成新的 key,会导致之前下发的 remember-me 失效。

有了 key 之后,接下来再去获取 RememberMeServices 实例,如果开发者配置了 tokenRepository,则获取到的 RememberMeServices 实例是 PersistentTokenBasedRememberMeServices,否则获取到 TokenBasedRememberMeServices,即系统通过有没有配置 tokenRepository 来确定使用哪种类型的 RememberMeServices。

同时,init 方法中还配置了一个 RememberMeAuthenticationProvider,该实例主要用来校验 key。

再来看 RememberMeConfigurer 的 configure 方法:

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);
}

configure 方法中主要创建了一个 RememberMeAuthenticationFilter,创建时传入 RememberMeServices 实例,最后将创建好的 RememberMeAuthenticationFilter 加入到过滤器链中。最后我们再来看一下 RememberMeAuthenticationFilter 中的 doFilter 是如何 “运筹惟” 的:

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);

        if (rememberMeAuth != null) {
            // Attempt authenticaton via AuthenticationManager
            try {
                rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

                // Store to SecurityContextHolder
                SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);

                onSuccessfulAuthentication(request, response, rememberMeAuth);

                if (logger.isDebugEnabled()) {
                    logger.debug("SecurityContextHolder populated with remember-me token: '"
                            + SecurityContextHolder.getContext().getAuthentication()
                            + "'");
                }

                // Fire event
                if (this.eventPublisher != null) {
                    eventPublisher
                            .publishEvent(new InteractiveAuthenticationSuccessEvent(
                                    SecurityContextHolder.getContext()
                                            .getAuthentication(), this.getClass()));
                }

                if (successHandler != null) {
                    successHandler.onAuthenticationSuccess(request, response,
                            rememberMeAuth);

                    return;
                }

            }
            catch (AuthenticationException authenticationException) {
                if (logger.isDebugEnabled()) {
                    logger.debug(
                            "SecurityContextHolder not populated with remember-me token, as "
                                    + "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
                                    + rememberMeAuth
                                    + "'; invalidating remember-me token",
                            authenticationException);
                }

                rememberMeServices.loginFail(request, response);

                onUnsuccessfulAuthentication(request, response,
                        authenticationException);
            }
        }

        chain.doFilter(request, response);
    }
    else {
        if (logger.isDebugEnabled()) {
            logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
                    + SecurityContextHolder.getContext().getAuthentication() + "'");
        }

        chain.doFilter(request, response);
    }
}
  1. 请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值,没值的话表示用户尚未登录,此时调用 autoLogin 方法进行自动登录。

  2. 当自动登录成功后返回的 rememberMeAuth 不为 null 时,表示自动登录成功,此时调用 authenticate 方法对 key 进行校验,并且将登录成功的用户信息保存到 SecurityContextHolder 对象中,然后调用登录成功回调,并发布登录成功事件。需要注意的是,登录成功的回调并不包含 RememberMeServices 中的 loginSuccess 方法。

  3. 如果自动登录失败,则调用 rememberMeServices.loginFail 方法处理登录失败回调。onUnsuccessfulAuthentication 和 onSuccessfulAuthentication 都是该过滤器中定义的空方法,并没有任何实现。

这就是 RememberMeAuthenticationFilter 过滤器所做的事情,成功将 RememberMeServices 的服务集成进来。

最后再额外说一下 RememberMeServices#loginSuccess 方法的调用位置。该方法是在 AbstractAuthenticationProcessingFilter#successfulAuthentication 中触发的,也就是说,无论你是否开启了 RememberMe 功能,该方法都会被调用。只不过在 RememberMeServices#loginSuccess 方法的具体实现中,会去判断是否开启了 RememberMe,进而决定是否在响应中添加对应的 Cookie。

至此,整个 RememberMe 的用法还有原理就介绍完了。