SpringSecurity的记住登录实现过程———【RememberMeServices】分析过程

        在上一篇文章《SpringSecurity的认证流程分析(各个组件之间的关联)》介绍认证的基本流程时,其中的AbstractAuthenticationProcessingFilter有这样一个属性——RememberMeServices引起我的注意,虽然从字面上很容易理解这个属性是和记住登录有关。但是其中的实现过程又是如何不深入还真不清楚,所以秉着学习的态度专门了解一下Security中对这个功能的实现。

        在了解之前,先要清楚为什么会需要这个类来实现?当我们在很多网站上注册一个网站进行使用的时候,由于太简单的密码很容易被人破解,所以一般都会进行密码复杂度的验证,例如长度、特殊符号以及大小写和数字等。刚设置的一段时间肯定很容易记得住密码,但是长时间不使用就会忘了密码到底是什么,就需要重新设置密码,从而再次陷入设置密码的循环中。除此之外每次都要自己输入密码可能会显得繁琐,给用户的体验就不是很好。

        所以这个时候就需要一个叫记住我(Rember Me)的小功能来帮用户记住登录信息,当然这个登录信息肯定是存储在客户端的,浏览器一般都是存在Cookie信息中。用户下次登录的时候直接将Cookie信息放入请求,自动实现校验并建立登录状态。但是对于保存下来的登录信息中肯定包含了用户名、用户密码的信息,这种机密信息肯定就不能以明文的方式存储在浏览器中,所以就需要引入加密的机制。Cookie只存储加密后的数据,又称令牌信息。令牌中包括用户名、登录的有效期、散列盐值(包括用户名、过期时间、密码和一个Key,如下图所示)。在下一次登录时,SpringSecurity会先将加密的Cookie信息进行解密得到用户名、过期时间和加密散列值,然后使用用户名得到密码,接着重新以散列算法正向计算,将计算结果与旧的散列值进行对比,从而判断令牌是否有效。

         这时再带着上面的初步理解再来分析RememberServices,其具体的继承与实现结构如下图所示,可以清晰的看出在Security中提供了两种类型支持——散列加密和数据持久化的两种方式。也就分别对应着最终的两个实现类,我们的重点也就是在这两个实现类和一个抽象类上。

一、哪里在调用RememberMeServices  ?

         RememberMeServices的接口很简单,具体如上图所示,主要是与登录的相关操作。我们在分析UsernamePasswordAuthenticationFilter的时候,如果登录成功或失败均会调用该对象的后面两个方法,但是却没有关于调用autoLogin方法的逻辑。那这时候我们就不免心生疑惑,这个方法是在哪里调用?记住登录的功能是在哪里实现?

        一开始我也存在着这样的疑惑,之所以存在这样的想法,是因为自己主观的认为登录和记住操作都是属于登录这一过程中的步骤,都属于同一个过滤器。实际上在Security中登录和记住的这两个操作属于不同的过滤器,记住有其自己单独的过滤器——RememberMeAuthenticationFilter。所以调用自动登录也是在该过滤器中调用。

         那这个时候你就会问——过滤器不是有先后顺序吗?那在SpringSecurity里面这两个过滤器的先后顺序是怎么保证的呢?在Security里中有一个这样的类——FilterOrderRegistration。这个类就是往我们的过滤器链中注册过滤器的,所以很轻易的就看到过滤器的先后顺序,其中的Order是一个简单的实现自增的对象,默认从100开始,而且在FilterOrderRegistration的构造方法中就会执行下面各个过滤器的加载。但是这么一看,心里又难免有一个问题——很明显Remember的过滤器是晚于UsernamePassword过滤器,那是怎么控制这两个过滤器的先后顺序?

        通过断点UsernamePassword过滤器的一个方法引起了我的注意,这个方法其实是这里面控制的关键,所谓的requiresAuthentication是一个RequestMatcher即请求匹配器,即判断是当前请求路径是否进行当前过滤器的验证。在UsernamePasswordAuthenticationFIlter中属性的声明中就指定了默认的请求过滤器即AntPathRequestMatcher,对应的url和method也是指定为/login和POST。

         分析到这里我们就很容易明白在每次默认登录的时候,由于当前请求地址不是/login且请求方法不是POST,则不会进行相应的过滤验证,而在Remember过滤器中没有对应的验证,所以在记住登录信息且没有登录的条件下,就会执行RememberService中的AutoLogin。

二、AbstractRememberMeServices

         在这个抽象类中的方法有很多,但是最主要的方法其实并不多,先说下三个加密、解密、提取cookie的方法。

        extractRememberMeCookie(HttpServletRequest request):从Request的Cookie中提取RemembeMe的信息,即上面所说的令牌信息,取值字段也是在AbstractRememberMeService中提前默认的字符串——“remember-me”,这个字符串也是存在浏览器的cookie名称。

        encode(String [] cookieToken)和decode(String cookieValue):这两个方法就是令牌信息的加密和解密。相应的算法采用的是Base64,值得注意的是,由于Base64加密是以3位为一组,所以可能最后剩下1位或者两位字符,为了保证编码组的完整,会默认在后面加上“=”进行占位。但是在Security中会默认将"="先剔除,因此我们才会看到rememeber-me中的值是不含等号的。在进行解密的时候会先将剔除的“=”给添加上以便进行解密。

        此时我们再来看autoLogin方法,首先调用提取RememberMe的Cookie信息,为空则说明未记住登录信息,直接返回。反之判断Cookie信息是否有值,没有值则直接返回null,且设置Cookie失效(提取到了为空串的Cookie,所以要及时删除)。接下来再来解密Cookie信息,解析后的CookieTokens交给抽象方法processAutoLoginCookie的实现子类调用。返回结果是一个UserDetails,并校验当前用户信息,这一切成功后则返回对应的Authentication。当然如果在解密和调用子类方法过程中报错,会抛出异常,且都会清空Cookie信息。  

        其次再来分析继承自接口的loginSuccess和loginFail这两个方法。在该抽象类在对两个方法的实现外,还提供了扩展方法onLoginSuccess和onLoginFail这两个抽象方法,子类就可以进行额外的操作。不过对于loginSuccess方法中要整个环境中设置需要记住账号才会调用onLoginSuccess方法,其中的rememberMeRequest就是判断抽象类alwaysRember参数是否为true,或者在request中含有remeber-me的参数值为yes或1,这样才会调用onLoginSuccess。loginFail中其实就是清除Cookie信息,onLoginFail其实子类都未实现。

三、散列加密方案——TokenBasedRememberMeServices

        TokenBasedRememberMeServices这个类其实就是散列加密方案的代表。其中的方法我们也只针对上面提到的两个抽象方法:

        processAutoLoginCookie:在抽象类中解析好的Cookie信息会传入该方法,在使用之前会先校验参数的个数是否正确。然后再获取当前Cookie的失效时期,并判断是否失效,不失效才会进行后续操作。拿到Cookie中的用户名信息,固定为数组的第一个,然后根据用户名用UserDetailService找到用户的UserDetail对象。这样就可以拿到用户的密码,然后进行正向的加密(按照用户名,失效时间、密码、key)然后判断是否和Cookie中的加密后的值判断是否一致,一致则说明正常,反之说明登录失败。

         值得注意的一点是此处的getKey方法,由于使用的是散列的加密方法,如果不指定的话,每次都会返回一个UUID字符串,这一点在RememberMeConfigurer中就可以看到。

        接下来就分析一下onLoginSuccess方法,每次在UsernamePassword过滤器中认证成功后会调用该方法用于生成令牌信息并设置到Cookie信息中,其中的makeTokenSignature和上面验证两次散列值是否一致的方法一致,所以这里也不再加以说明。此处注意的是在Security默认登录失效时间为1209600秒,即两周的时间。

         但是你会发现使用TokenBasedRememberMeServices时为什么每次自己前面刚输入过密码并且选择了记住密码,但是为什么刷新页面或者重启服务还是要自己登录?这就是上面说的获取key时默认采用了UUID的方法随机生成字符串,这样就使得重启之前的自动登录Cookie失效。除此之外,在多实例部署的情况下,由于实例间的Key不相同,所以各个实例之间自动登录策略就会失效。当然你可以手动指定key的值。

       总体来说,这种方式不需要服务器花费空间来存储自动登录的相关数据,实现简单,安全性相对较高。但存在潜在风险,即如果该令牌在有效期内被盗取,那么用户的身份将完全暴露。

四、持久化令牌方案——PersistentTokenBasedRememberMeServices

        持久化令牌的方案和上面的散列加密的方案大体上是一致的,但是又有不一样的地方。最明显的不同的是返回的令牌格式,与散列加密的用户名+过期时间+散列值不同,持久化返回的是series+token两个值,它们都是用MD5散列过的随机字符串。两个字段不同的是,series仅在用户使用密码重新登录时更新,而token会在每一个新的session中都重新生成。为什么要这样做呢?

        自动登录不会导致series变更,而每次自动登录都要同时验证series和token的两个值,当该令牌还未使用过自动登录就被盗取,系统会在非法用户验证通过后刷新 token 值,此时在合法用户
的浏览器中,该token值已经失效。当合法用户使用自动登录时,由于该series对应的 token 不同,系统可以推断该令牌可能已被盗用,从而做一些处理。例如,清理该用户的所有自动登录令牌,并通知该用户可能已被盗号等。巧妙的解决散列加密方案中一个令牌可以同时在多端登录的问题,在持久化方案中每个token仅支持单实例登录。

        持久化肯定会将上面的series和token信息存入数据库中,这样也是为了后续的更新和查找工作的开展。实际上,在Spring Security使用PersistentRememberMeToken 来表明一个验证实体,如上图所示。对应的肯定要在数据库中建一张存储自动登录信息的表(默认的是persistent_login ),为了方便操作肯定会提供一个类似我们在操作JPA的时候使用的Repository,只不过这个并不是使用的JPA,而是原生的JDBC,即下面的接口。

public interface PersistentTokenRepository {
    // 创建新的token
    void createNewToken(PersistentRememberMeToken var1);
    // 更新token
    void updateToken(String var1, String var2, Date var3);
    // 根据series获取token
    PersistentRememberMeToken getTokenForSeries(String var1);
    // 删除token
    void removeUserTokens(String var1);
}

        PersistentTokenRepository对应有两个实现类一个是基于内存的、一个是基于JDBC的实现类。也是对其具体实现,这里就不具体展开,因为实现逻辑比较简单,关于JDBC实现的如下图所示。很明显的可以看出几个方法,其实就是调用对应的SQL并执行。

        回归正题,对于PersistentTokenBasedRememberMeServices这个类我们也只分析processAutoLoginCookie和onLoginSuccess两个方法。

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) + "'");
        } else {
            // 第一步,获取数据
            // 分别获取request传过来的series和Cookie
            String presentedSeries = cookieTokens[0];
            String presentedToken = cookieTokens[1];
            // 数据库对象中获取对应持久化对象信息
            PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);

            // 第二步,校验数据的正确性
            if (token == null) {
                throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
            } else if (!presentedToken.equals(token.getTokenValue())) {
                // 和数据token不一致,说明当前cookie信息存在被别人使用过,即盗用
                this.tokenRepository.removeUserTokens(token.getUsername());
                throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
            } else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
                // 判断是否失效
                throw new RememberMeAuthenticationException("Remember-me login has expired");
            } else {
                this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'", token.getUsername(), token.getSeries()));
                //第三步更新更新持久化对象信息,
                PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());

                try {
                    // 更新到数据库中,并将新的信息添加到Cookie中
                    this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
                    this.addCookie(newToken, request, response);
                } catch (Exception var9) {
                    this.logger.error("Failed to update token: ", var9);
                    throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
                }

                return this.getUserDetailsService().loadUserByUsername(token.getUsername());
            }
        }
    }

       processAutoLoginCookie的大体只分为三个步骤,提取数据、验证数据、更新数据。其中的验证包括是否找到、是否被人盗用、是否过期。更新数据只会更新token信息,从源码也可以看到,series和username都是直接从上一步骤获取到得,即原来的数据,只有token会调用generateTokenData()重新生成。然后更新更新数据库的同时会将新的令牌信息添加到cookie中去。

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 persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());

        try {
            // 插入数据库并添加到cookie中
            this.tokenRepository.createNewToken(persistentToken);
            this.addCookie(persistentToken, request, response);
        } catch (Exception var7) {
            this.logger.error("Failed to save persistent token ", var7);
        }

    }

public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        super.logout(request, response, authentication);
        if (authentication != null) {
            // 删除持久登录信息
            this.tokenRepository.removeUserTokens(authentication.getName());
        }

}

private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
        // 设置Cookie信息,只包括series和token
        this.setCookie(new String[]{token.getSeries(), token.getTokenValue()}, this.getTokenValiditySeconds(), request, response);
    }

       onLoginSuccess方法和退出登录时候的操作如上面代码注释一样,没有过多的操作,只是注意的是Cookie返回的令牌信息只包含了Series和Token两个信息。在setCookie的最开始会先将这两个内容进行加密再设置。

 五、总结

        记住登录的两种方案都存在cookie被盗取导致身份被暂时利用的可能,如果有更高的安全性需求,使用Spring Security提供的令牌持久化方案可能会更好一点。使用自动登录的时候,除了优质体验外,我们还应该主动考虑安全的问题,因此要么限制cookie登录时的部分执行权限——修改密码、修改邮箱、查看隐私信息等,但是这样的体验就不是很好,所以一般都是在进行这些操作钱进行登录密码或者独立密码来做二次校验,这也类似于阿里云这些网站上,当我们操作关于服务器等一些配置或重启时都会让我们输入密码或校验手机短信验证码等。

猜你喜欢

转载自blog.csdn.net/qq_35363507/article/details/121557878