6.5 原理分析
从 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 所示。
图 6-7 RememberMe 继承关系图
NullRememberMeServices 是一个空的实现,这里不做讨论,我们来重点分析另外三个实现类。
AbstractRememberMeServices
AbstractRememberMeServices 对于 RememberMeServices 接口中定义的方法提供了基本的实现,这里就以接口中定义的方法为思路,分析 AbstractRememberMeServices 中的具体实现。
首先我们来看 autoLogin 及其相关方法:
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 进行验证,如果验证通过,则返回登录用户对象,然后对用户状态进行检验(账户是否可用、账户是否锁定等)。processAutoLogin Cookie 方法是一个抽象方法,具体实现在 AbstractRememberMeServices 的子类中。
(4)最后调用 createSuccessfulAuthentication 方法创建登录成功的用户对象,不同于使用用户名/密码登录,本次登录成功后创建的用户对象类型是 RememberMeAuthenticationToken。
接下来我们再来看一下自动登录成功和自动登录失败的回调:
(1)登录失败时,首先取消 Cookie 的设置,然后调用 onLoginFail 方法完成失败处理,onLoginFail 方法是一个空方法,如果有需要,开发者可以自行重写该方法,一般来说不需要重写。
(2)登录成功时,会首先调用 rememberMeRequested 方法,判断当前请求是否开启了自动登录。开发者可以在服务端配置 alwaysRemember,这样无论前端参数是什么,都会开启自动登录,如果开发者没有配置 alwaysRemember,则根据前端传来的 remember-me 参数进行判断,remember-me 参数的值如果是 true、on(默认)、yes 或者 1,表示开启自动登录。如果开启了自动登录,则调用 onLoginSuccess 方法进行登录成功的处理。onLoginSuccess 是一个抽象方法,具体实现在 AbstractRememberMeServices 的子类中。
最后再来看 AbstractRememberMeServices 中一个比较重要的方法 setCookie,在自动登录成功后,将调用该方法把令牌信息放入响应头中并最终返回到前端:
(1)首先调用 encodeCookie 方法对要返回到前端的数据进行 Base64 编码,具体方式是将数组中的数据拼接成一个字符串并用“:”隔开,然后对其进行 Base64 编码。
(2)将编码后的字符串放入 Cookie 中,并配置 Cookie 的过期时间、path、domain、secure、httpOnly 等属性,最终将配置好的 Cookie 对象放入响应头中。
这便是 AbstractRememberMeServices 中的几个主要方法,还有其他一些辅助的方法都比较简单,读者可以自行研究。
TokenBasedRememberMeServices
TokenBasedRememberMeServices 是 AbstractRememberMeServices 的实现类之一,在 6.2 节中,我们讲解 RememberMe 的基本用法时,最终起作用的就是 TokenBasedRemember MeServices。作为 AbstractRememberMeServices 的子类,TokenBasedRememberMeServices 中最重要的方法就是对 AbstractRememberMeServices 中所定义的两个抽象方法 processAuto LoginCookie 和 onLoginSuccess 的实现。
我们先来看 processAutoLoginCookie 方法:
processAutoLoginCookie 方法主要用来验证 Cookie 中的令牌信息是否合法:
(1)首先判断 cookieTokens 长度是否为 3,不为 3 说明格式不对,则直接抛出异常。
(2)从 cookieTokens 数组中提取出第 1 项,也就是过期时间,判断令牌是否过期,如果已经过期,则抛出异常。
(3)根据用户名(cookieTokens 数组的第 0 项)查询出当前用户对象。
(4)调用 makeTokenSignature 方法生成一个签名,签名的生成过程如下:首先将用户名、令牌过期时间、用户密码以及 key 组成一个字符串,中间用“:”隔开,然后通过 MD5 消息摘要算法对该字符串进行加密,并将加密结果转为一个字符串返回。
(5)判断第 4 步生成的签名和通过 Cookie 传来的签名是否相等(即 cookieTokens 数组的第 2 项),如果相等,表示令牌合法,则直接返回用户对象,否则抛出异常。
再来看登录成功的回调函数 onLoginSuccess:
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = retrieveUserName(successfulAuthentication); String password = retrievePassword(successfulAuthentication); if (!StringUtils.hasLength(username)) { return; } if (!StringUtils.hasLength(password)) { UserDetails user = getUserDetailsService().loadUserByUsername(username); password = user.getPassword(); if (!StringUtils.hasLength(password)) { return; } } int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication); long expiryTime = System.currentTimeMillis(); 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); }
(1)在这个回调中,首先获取用户名和密码信息,如果用户密码在用户登录成功后已经从 successfulAuthentication 对象中擦除了,则从数据库中重新加载出用户密码。
(2)计算出令牌的过期时间,令牌默认有效期是两周。
(3)根据令牌的过期时间、用户名以及用户密码,计算出一个签名。
(4)调用 setCookie 方法设置 Cookie,第一个参数是一个数组,数组中一共包含三项:用户名、过期时间以及签名,在 setCookie 方法中会将数组转为字符串,并进行 Base64 编码后响应给前端。
看完 processAutoLoginCookie 和 onLoginSuccess 两个方法的实现,相信读者对于令牌的生成和校验已经非常清楚了,这里再总结一下:
当用户通过用户名/密码的形式登录成功后,系统会根据用户的用户名、密码以及令牌的过期时间计算出一个签名,这个签名使用 MD5 消息摘要算法生成,是不可逆的。然后再将用户名、令牌过期时间以及签名拼接成一个字符串,中间用“:”隔开,对拼接好的字符串进行 Base64 编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。当用户关闭浏览器再次打开,访问系统资源时会自动携带上 Cookie 中的令牌,服务端拿到 Cookie 中的令牌后,先进行 Base64 解码,解码后分别提取出令牌中的三项数据;接着根据令牌中的数据判断令牌是否已经过期,如果没有过期,则根据令牌中的用户名查询出用户信息;接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示令牌是合法令牌,自动登录成功,否则自动登录失败。
PersistentTokenBasedRememberMeServices
PersistentTokenBasedRememberMeServices 类作为 AbstractRememberMeServices 的另一个实现类,在 6.3 节的案例中,使用的就是 PersistentTokenBasedRememberMeServices。
在持久化令牌中,存储在数据库中的数据被封装成了一个对象 PersistentRememberMe Token,其定义如下:
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 方法:
(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(); PersistentRememberMeToken persistentToken = new PersistentRememberMeToken( username, generateSeriesData(), generateTokenData(), new Date()); try { tokenRepository.createNewToken(persistentToken); addCookie(persistentToken, request, response); } catch (Exception 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 实例是 PersistentTokenBasedRememberMe Services,否则获取到 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,创建时传入 Remember MeServices 实例,最后将创建好的 RememberMeAuthenticationFilter 加入到过滤器链中。最后我们再来看一下 RememberMeAuthenticationFilter 中的 doFilter 是如何“运筹帷幄”的:
(1)请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值,没值的话表示用户尚未登录,此时调用 autoLogin 方法进行自动登录。
(2)当自动登录成功后返回的 rememberMeAuth 不为 null 时,表示自动登录成功,此时调用 authenticate 方法对 key 进行校验,并且将登录成功的用户信息保存到 SecurityContext Holder 对象中,然后调用登录成功回调,并发布登录成功事件。需要注意的是,登录成功的回调并不包含 RememberMeServices 中的 loginSuccess 方法。
(3)如果自动登录失败,则调用 rememberMeServices.loginFail 方法处理登录失败回调。onUnsuccessfulAuthentication 和 onSuccessfulAuthentication 都是该过滤器中定义的空方法,并没有任何实现。
这就是 RememberMeAuthenticationFilter 过滤器所做的事情,成功将 RememberMeServices 的服务集成进来。
最后再额外说一下 RememberMeServices#loginSuccess 方法的调用位置。该方法是在 AbstractAuthenticationProcessingFilter#successfulAuthentication 中触发的,也就是说,无论你是否开启了 RememberMe 功能,该方法都会被调用。只不过在 RememberMeServices#loginSuccess 方法的具体实现中,会去判断是否开启了 RememberMe,进而决定是否在响应中添加对应的 Cookie。
至此,整个 RememberMe 的用法还有原理就介绍完了。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论