程序员Java

Spring Security 源码之 RememberMeAu

2020-11-17  本文已影响0人  AlienPaul

RememberMeAuthenticationFilter

Remember Me的功能为记住用户的登录状态并保持一定时间。在此期间内,即便是用户session已经失效,用户仍然可以免登录访问系统。

RememberMeAuthenticationFilter主要功能为实现这一逻辑,实现自动登陆功能。

我们分析下它的doFilter方法,内容如下:

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    // 如果已经有认证信息,跳过此filter
    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;
    }
    // 使用RememberMe服务自动登陆
    // autoLogin方法的逻辑在下一节介绍
    Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
    // 如果获取到了authentication
    if (rememberMeAuth != null) {
        // Attempt authenticaton via AuthenticationManager
        try {
            // 使用AuthenticationManager认证
            rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
            // Store to SecurityContextHolder
            // 储存认证信息到SecurityContext
            SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
            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()));
            }
            // 调用登陆成功handler的相关逻辑
            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);
}

AbstractRememberMeServices

Remember me相关的service封装了主要的实现逻辑,包含如何从remember me cookie中获取到用户信息和登陆状态,检查cookie内容的有效性和如何在用户允许时启用remember me功能。

autoLogin方法的功能为从cookie中获取用户信息并认证。它的内容如下:

@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
    // 获取remeber me服务保存信息的cookie,默认cookie名称为remember-me
    String rememberMeCookie = extractRememberMeCookie(request);
    // 如果没找到cookie,返回
    if (rememberMeCookie == null) {
        return null;
    }
    this.logger.debug("Remember-me cookie detected");
    // 如果cookie value长度为0
    // 让这个cookie立刻过期
    if (rememberMeCookie.length() == 0) {
        this.logger.debug("Cookie was empty");
        cancelCookie(request, response);
        return null;
    }
    try {
        // 解析cookie获取token
        // base64解码,以冒号为分隔符切分string为数组后再依次URL解码
        String[] cookieTokens = decodeCookie(rememberMeCookie);
        // 获取user详细信息,这个方法在子类中实现
        UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
        // 检查user信息
        this.userDetailsChecker.check(user);
        this.logger.debug("Remember-me cookie accepted");
        // 创建认证成功的authentication
        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;
}

createSuccessfulAuthentication方法,将用户认证信息包装为RememberMeAuthenticationToken
注意,remember me服务包含有一个key,在这里会被写入token。Token在校验的时候会拿出它的key,同remember me服务的key做比较,只有key相同才能认证通过。

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

loginSuccess方法负责检查request中是否带有remeber me参数(勾选了“记住我”复选框)或者是配置了永远启用remember me。如果启用了remember me,调用子类的onLoginSuccess方法。AbstractAuthenticationProcessingFilterBasicAuthenticationFilter中会调用该方法。

@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication successfulAuthentication) {
    // rememberMeRequested方法判断是否需要启用remember me服务
    // this.parameter为remember me的参数名,默认为remember-me
    if (!rememberMeRequested(request, this.parameter)) {
        this.logger.debug("Remember-me login not requested.");
        return;
    }
    onLoginSuccess(request, response, successfulAuthentication);
}

rememberMeRequested方法包含了用来判断是否启用remember me服务(用户是否勾选remember me复选框)的具体操作。内容如下:

protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
    // 如果永远启用,返回true
    if (this.alwaysRemember) {
        return true;
    }
    // 获取remember me请求参数的值
    String paramValue = request.getParameter(parameter);
    // 如果是true,on,yes或1,返回true
    if (paramValue != null) {
        if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
                || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
            return true;
        }
    }
    this.logger.debug(
            LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter));
    return false;
}

到这里AbstractRememberMeServices抽象类的主要逻辑已分析完毕。

下面我们重点分析下AbstractRememberMeServices两个子类:

其中onLoginSuccess方法负责在认证成功时,将用户的登陆状态记录下来,从而做到有效期内免登录。
processAutoLoginCookie负责从cookie中读取用户的登陆状态,还原为用户详细信息(UserDetails)。

TokenBasedRememberMeServices

该类将用户认证token相关信息保存在cookie中,不需要数据库等外部存储。

onLoginSuccess方法内容如下:

@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication successfulAuthentication) {
    // 从authentication获取用户名和密码
    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)) {
        this.logger.debug("Unable to retrieve username");
        return;
    }
    // 如果密码找不到,从UserDetailsService中查找用户的密码
    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;
        }
    }
    // 获取token有效时间
    int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
    long expiryTime = System.currentTimeMillis();
    // SEC-949
    // 计算token过期的时间戳,如果token有效时间小于0,设置过期时间为2周后
    expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
    // 计算token签名
    // token签名为MD5(username + ":" + tokenExpiryTime + ":" + password + ":" + getKey())
    String signatureValue = makeTokenSignature(expiryTime, username, password);
    // 设置token内容到cookie
    // Cookie内容为Base64(URLEncode(username):URLEncode(expiryTime):URLEncode(signatureValue))
    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) + "'");
    }
}

processAutoLoginCookie方法:

@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
        HttpServletResponse response) {
    // 依照上面的分析,token解析前是一个string数组,长度为3
    // 如果这里长度不为3,说明token有误,抛出异常
    if (cookieTokens.length != 3) {
        throw new InvalidCookieException(
                "Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
    }
    // 获取token过期时间,位于string数组第二个元素
    long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
    // 如果token已过期(小于系统当前时间),抛出异常
    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.
    // 检查cookie中保存的用户名对应的用户是否存在
    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.
    // 重新计算token的签名,判断是否和cookie中保存的一致
    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

该实现方式把token的内容写入到tokenRepository中,支持token内容持久化。可以防御cookie窃取攻击。

onLoginSuccess方法:

@Override
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
    // 参数中的序列号和token数据均为Base64编码的随机字节数据
    PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
            generateTokenData(), new Date());
    try {
        // 保存token
        this.tokenRepository.createNewToken(persistentToken);
        // 设置token到cookie中
        // cookie内容为Base64(URLEncode(序列号):URLEncode(token数据))
        addCookie(persistentToken, request, response);
    }
    catch (Exception ex) {
        this.logger.error("Failed to save persistent token ", ex);
    }
}

PersistentTokenRepository负责保存持久化的token信息。有两个子类:

processAutoLoginCookie方法:

@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
        HttpServletResponse response) {
    // 按照上面的分析,persistence类型的cookie内容包含2部分
    // 如果长度不为2,cookie内容无效,抛出异常
    if (cookieTokens.length != 2) {
        throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
                + Arrays.asList(cookieTokens) + "'");
    }
    // 获取序列号和token数据
    String presentedSeries = cookieTokens[0];
    String presentedToken = cookieTokens[1];
    // 从tokenRepository中读取PersistentRememberMeToken
    PersistentRememberMeToken token = this.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
    // 如果保存的token数据和cookie中的不一致
    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中保存的用户token
        this.tokenRepository.removeUserTokens(token.getUsername());
        // 抛出异常,很可能遭受到cookie窃取攻击
        throw new CookieTheftException(this.messages.getMessage(
                "PersistentTokenBasedRememberMeServices.cookieStolen",
                "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
    }
    // 判断token是否过期
    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.
    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 {
        // 更新token的内容
        this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
        // 重新设置token内容到cookie中
        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");
    }
    // 从UserDetailsService中读取UserDetails并返回
    return getUserDetailsService().loadUserByUsername(token.getUsername());
}

本文为原创内容,欢迎大家讨论、批评指正与转载。转载时请注明出处。

上一篇 下一篇

猜你喜欢

热点阅读