美文网首页程序员Java
Spring Security 源码之 RememberMeAu

Spring Security 源码之 RememberMeAu

作者: AlienPaul | 来源:发表于2020-11-17 20:32 被阅读0次

    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两个子类:

    • TokenBasedRememberMeServices
    • PersistentTokenBasedRememberMeServices
      onLoginSuccess方法和processAutoLoginCookie方法。

    其中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信息。有两个子类:

    • InMemoryTokenRepositoryImpl:保存token信息到内存中
    • JdbcTokenRepositoryImpl:保存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());
    }
    

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

    相关文章

      网友评论

        本文标题:Spring Security 源码之 RememberMeAu

        本文链接:https://www.haomeiwen.com/subject/jmcfiktx.html