1、需求来源

很多网站在登录时都会看到类似”记住我”的选项,毕竟总让用户输入用户名和密码是一件很麻烦的事。
自动登录功能就是,用户在登录成功后,在某一段时间内,如果用户关闭了浏览器并重新打开,或者服务器重启了,都不需要用户重新登录,用户依然可以直接访问接口数据。

2、实战代码

在我们的自定义配置SecurityConfig中添加remember-me的配置:

  1. @Override
  2. protected void configure(HttpSecurity http) throws Exception {
  3. http
  4. ...
  5. .rememberMe()
  6. .key("security")
  7. .and()
  8. .formLogin()
  9. .and()
  10. ...
  11. }

除了添加以上配置之外,还得在表单登录中添加一个remember-me的参数并且设置为”true”或者”on”或者”1”或者”yes”都行,类似这样:
📍为什么要添加一个remember-me参数并且值可以是这些值???后面在源码分析的时候会解释清楚。
image.png
登录成功之后,在返回的reopnese中会携带一个remember-mecookie
image.png
接下来,当我们关闭浏览器再重新打开浏览器。正常情况下,浏览器关闭重新打开,如果再次访问受保护的接口,就需要我们重新登录。但是此时,我们访问的时候并不需要再次登录,直接就能访问成功,这就说明我们的rememer-me配置已经生效。

3、源码分析🎨

经过前面这么多篇源码分析的洗礼,相信大家都已经知道一个认证过滤器是怎样生成的了吧?
我们在SecurityConfig自定义配置中加入http.rememberMe().key("security"),即我们在HttpSecurity对象中的cofigurers集合中添加RememberMeConfigurer配置,然后在httpSecurity构建过滤器链的时候,会依次执行RememberMeConfigurer配置类的init方法和configure方法。

3.1、init方法

  1. public void init(H http) throws Exception {
  2. validateInput();
  3. String key = getKey();
  4. RememberMeServices rememberMeServices = getRememberMeServices(http, key);
  5. http.setSharedObject(RememberMeServices.class, rememberMeServices);
  6. LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
  7. if (logoutConfigurer != null && this.logoutHandler != null) {
  8. logoutConfigurer.addLogoutHandler(this.logoutHandler);
  9. }
  10. RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(key);
  11. authenticationProvider = postProcess(authenticationProvider);
  12. http.authenticationProvider(authenticationProvider);
  13. initDefaultLoginFilter(http);
  14. }
  1. 获取key:在接下来去计算散列值的时候会用到,这个散列值就是我们在上面看到cookieremember-me对应的value值,这个key默认值是一个UUID字符串,这样在服务端重启之后,这个Key会变,这样就会导致之前派发出去的所有remember-me自动登录令牌失效,所以我在自定义配置中指定了这个key,这样就算服务端重启对用户来说也不需要重新登录。

    1. private String getKey() {
    2. if (this.key == null) {
    3. if (this.rememberMeServices instanceof AbstractRememberMeServices) {
    4. this.key = ((AbstractRememberMeServices) this.rememberMeServices).getKey();
    5. }
    6. else {
    7. this.key = UUID.randomUUID().toString();
    8. }
    9. }
    10. return this.key;
    11. }
  2. 获取RememeberMeServices:创建RememeberMeServices,如果没有配置过tokenRepository,那么创建的是TokenBasedRememberMeServices,否则创建的是PersistentTokenBasedRememberMeServices。 ```java private RememberMeServices getRememberMeServices(H http, String key) throws Exception { if (this.rememberMeServices != null) {

    1. if (this.rememberMeServices instanceof LogoutHandler && this.logoutHandler == null) {
    2. this.logoutHandler = (LogoutHandler) this.rememberMeServices;
    3. }
    4. return this.rememberMeServices;

    } AbstractRememberMeServices tokenRememberMeServices = createRememberMeServices(http, key); tokenRememberMeServices.setParameter(this.rememberMeParameter); tokenRememberMeServices.setCookieName(this.rememberMeCookieName); if (this.rememberMeCookieDomain != null) {

    1. tokenRememberMeServices.setCookieDomain(this.rememberMeCookieDomain);

    } if (this.tokenValiditySeconds != null) {

    1. tokenRememberMeServices.setTokenValiditySeconds(this.tokenValiditySeconds);

    } if (this.useSecureCookie != null) {

    1. tokenRememberMeServices.setUseSecureCookie(this.useSecureCookie);

    } if (this.alwaysRemember != null) {

    1. tokenRememberMeServices.setAlwaysRemember(this.alwaysRemember);

    } tokenRememberMeServices.afterPropertiesSet(); this.logoutHandler = tokenRememberMeServices; this.rememberMeServices = tokenRememberMeServices; return tokenRememberMeServices; }

private AbstractRememberMeServices createRememberMeServices(H http, String key) { return (this.tokenRepository != null) ? createPersistentRememberMeServices(http, key) : createTokenBasedRememberMeServices(http, key); }

  1. 3. 将创建好的`RememeberMeServices`放到`httpSecurity`的共享对象中,这样其他的`xxxConfigurer`配置类也能拿到该`RememeberMeServices`,在`AbstractAuthenticationFilterConfigurer#cofigure`方法中就用到了从共享对象中获取`RememeberMeServices`对象,然后设置到`UsernamePasswordAuthenticationFilter``rememberMeServices`属性中。
  2. 3. 新建一个`RememberMeAuthenticationProvider`对象,将获取到的key当做参数传入,然后将其使用`postProcess`方法放到`spring`容器中,然后将其添加到`HttpSecuity`对象中的`providers`集合中,后续在进行`remember-me`认证的时候会使用。
  3. 3. 最后在初始化默认登录页面生成过滤器中设置它的`remember-me`参数。
  4. <a name="RYT7v"></a>
  5. ## 3.2、configure方法
  6. 就像我们之前分析过的其他过滤器一样,在该阶段的主要任务就是将配置好的过滤器加入到`HttpSecurity``filters`集合中,即加入到过滤器链中。
  7. ```java
  8. public void configure(H http) {
  9. RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter(
  10. http.getSharedObject(AuthenticationManager.class), this.rememberMeServices);
  11. if (this.authenticationSuccessHandler != null) {
  12. rememberMeFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
  13. }
  14. rememberMeFilter = postProcess(rememberMeFilter);
  15. http.addFilter(rememberMeFilter);
  16. }

新建一个RememberMeAuthenticationFilter过滤器对象,将从共享对象中取出来的AuthenticationManager对象和上面创建好的RememeberMeServices作为参数传入,将创建好的RememberMeAuthenticationFilter过滤器注入到spring容器中以及添加到HttpSecurityfilters集合中(即将该过滤器添加到过滤器链中)。

3.3、生成令牌

我们先来看看remember-me的令牌是什么时候生成的,以及怎么生成的呢?
在我们使用表单登录成功后,会调用UsernamePasswordAuthenticationFilter的父类AbstractAuthenticationProcessingFilter中的的successfulAuthentication

  1. protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
  2. Authentication authResult) throws IOException, ServletException {
  3. SecurityContext context = SecurityContextHolder.createEmptyContext();
  4. context.setAuthentication(authResult);
  5. SecurityContextHolder.setContext(context);
  6. if (this.logger.isDebugEnabled()) {
  7. this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
  8. }
  9. this.rememberMeServices.loginSuccess(request, response, authResult);
  10. if (this.eventPublisher != null) {
  11. this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
  12. }
  13. this.successHandler.onAuthenticationSuccess(request, response, authResult);
  14. }

在这个方法里面,就调用了rememberMeServices类(在3.2章节的configure中被赋值)中的loginSuccess方法。这个方法会先判断参数中给是否存在remember-me参数以及remember-me的值是否是”true” | “on” | “yes” | “1” 这四个值中的一个,这也就解释了上面的疑问,为什么要添加remember-me这个参数以及参数的值可以是这4种,如果正确的话,就会调用onLoginSuccess方法,这个方法是个抽象方法,在子类中被实现。经过上面的分析,因为我们没有配置tokenRepository,所以使用的就是TokenBasedRememberMeServices

  1. public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
  2. Authentication successfulAuthentication) {
  3. if (!rememberMeRequested(request, this.parameter)) {
  4. this.logger.debug("Remember-me login not requested.");
  5. return;
  6. }
  7. onLoginSuccess(request, response, successfulAuthentication);
  8. }
  9. protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
  10. if (this.alwaysRemember) {
  11. return true;
  12. }
  13. String paramValue = request.getParameter(parameter);
  14. if (paramValue != null) {
  15. if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
  16. || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
  17. return true;
  18. }
  19. }
  20. this.logger.debug(
  21. LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter));
  22. return false;
  23. }

onLoginSuccess方法是生成令牌的核心方法。

  1. @Override
  2. public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
  3. Authentication successfulAuthentication) {
  4. String username = retrieveUserName(successfulAuthentication);
  5. String password = retrievePassword(successfulAuthentication);
  6. if (!StringUtils.hasLength(password)) {
  7. UserDetails user = getUserDetailsService().loadUserByUsername(username);
  8. password = user.getPassword();
  9. }
  10. int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
  11. long expiryTime = System.currentTimeMillis();
  12. expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
  13. String signatureValue = makeTokenSignature(expiryTime, username, password);
  14. setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
  15. tokenLifetime, request, response);
  16. }
  17. protected String makeTokenSignature(long tokenExpiryTime, String username,
  18. String password) {
  19. String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
  20. MessageDigest digest;
  21. digest = MessageDigest.getInstance("MD5");
  22. return new String(Hex.encode(digest.digest(data.getBytes())));
  23. }
  1. 首先从登录成功的Authentication中取出用户名和密码。
  2. 由于登录成功之后,密码可能会被擦除,所以,如果一开始没有拿到密码,就再从UserDetailsService中重新加载用户并重新获取密码。
  3. 再接下来就是去获取令牌的有效期,令牌有效期如果没有配置的话默认就是两周。
  4. 再接下来就是调用makeTokenSignature方法去计算散列值,实际上就是根据username,令牌有效期以及password、key一起计算出一个散列值。
  5. 最后,将用户名、令牌有效期以及计算得到的散列值放入Cookie中。

    3.4、解析令牌

    当用户登录成功之后,关掉再打开浏览器,访问受保护的接口,居然可以访问成功!那么这是怎么做到的呢?
    经过上面的initconfigure方法,在我们的Spring Security过滤器链中已经存在一个专门用于remember-me认证的过滤器RememberMeAuthenticationFilter

    1. public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    2. throws IOException, ServletException {
    3. HttpServletRequest request = (HttpServletRequest) req;
    4. HttpServletResponse response = (HttpServletResponse) res;
    5. if (SecurityContextHolder.getContext().getAuthentication() == null) {
    6. Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
    7. response);
    8. if (rememberMeAuth != null) {
    9. rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
    10. SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
    11. onSuccessfulAuthentication(request, response, rememberMeAuth);
    12. if (this.eventPublisher != null) {
    13. eventPublisher
    14. .publishEvent(new InteractiveAuthenticationSuccessEvent(
    15. SecurityContextHolder.getContext()
    16. .getAuthentication(), this.getClass()));
    17. }
    18. if (successHandler != null) {
    19. successHandler.onAuthenticationSuccess(request, response,
    20. rememberMeAuth);
    21. return;
    22. }
    23. }
    24. chain.doFilter(request, response);
    25. }
    26. else {
    27. chain.doFilter(request, response);
    28. }
    29. }

    这个方法最关键的地方在于,如果从SecurityContextHolder中无法获取到当前登录用户实例,那么就调用rememberMeService.autoLogin方法进行登录。

    1. public final Authentication autoLogin(HttpServletRequest request,
    2. HttpServletResponse response) {
    3. String rememberMeCookie = extractRememberMeCookie(request);
    4. if (rememberMeCookie == null) {
    5. return null;
    6. }
    7. logger.debug("Remember-me cookie detected");
    8. if (rememberMeCookie.length() == 0) {
    9. logger.debug("Cookie was empty");
    10. cancelCookie(request, response);
    11. return null;
    12. }
    13. UserDetails user = null;
    14. try {
    15. String[] cookieTokens = decodeCookie(rememberMeCookie);
    16. user = processAutoLoginCookie(cookieTokens, request, response);
    17. userDetailsChecker.check(user);
    18. logger.debug("Remember-me cookie accepted");
    19. return createSuccessfulAuthentication(request, user);
    20. }
    21. catch (CookieTheftException cte) {
    22. throw cte;
    23. }
    24. cancelCookie(request, response);
    25. return null;
    26. }

    可以看到,这里就是提取Cookie信息,并对Cookie信息进行解码,解码之后,再调用processAutoLoginCookie方法去做校验。判断令牌是否过期,如果过期则直接抛出异常,如果没有过期则根据用户名查询出用户及其密码,然后通过md5散列函数计算出散列值,再将计算出来的散列值与浏览器传递过来的散列值进行比对,如果不一致则抛出异常,如果一致则返回获取到的userDetails。然后用RememberMeAuthenticationProvider去认证该rememberMeAuth,比对其中的key值是否相等,不相等则抛出异常,相等则将认证成功的rememberMeAuth放入SecurityContextHolder中。

    1. @Override
    2. protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
    3. HttpServletResponse response) {
    4. if (cookieTokens.length != 3) {
    5. throw new InvalidCookieException(
    6. "Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
    7. }
    8. long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
    9. if (isTokenExpired(tokenExpiryTime)) {
    10. throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
    11. + "'; current time is '" + new Date() + "')");
    12. }
    13. UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
    14. Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
    15. + " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
    16. String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
    17. userDetails.getPassword());
    18. if (!equals(expectedTokenSignature, cookieTokens[2])) {
    19. throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
    20. + "' but expected '" + expectedTokenSignature + "'");
    21. }
    22. return userDetails;
    23. }

    4、总结

    看了上面的源码分析,大家可能发现,如果我们开启了RememberMe功能,最最核心的东西就是放在Cookie中的令牌了,这个令牌突破了session的限制,即使浏览器关闭后重新打开亦或者服务器重新,只要这个令牌没有过期,那么用户就无需登录都可以访问受保护的接口。
    所以一旦令牌丢失,别人就可以拿着这个令牌随意登录我们的系统,这是一个非常危险的操作。
    但是实际上这是一段悖论,为了提高用户体验(少登录),我们的系统不可避免的引出了一些安全问题,不过我们可以通过技术将安全风险降低到最小。
    如何让我们的RememberMe功能更加安全呢?请看下篇文章—-持久化令牌方案。