Spring Security是怎么实现自动登录的?

2,679 阅读16分钟

前言

前面的章节已经介绍了,我们如何quick start一个spring security,然后还有做了一些图片验证之类的功能。还有从数据库中获取我们用户的信息。研究了spring security整个认证的流程。

本章呢,主要讲解如何记录我们的认证信息在下次会话被关闭之后还能够继续使用,而不用重复登录。

本章内容

  1. remember-me自动登录怎么玩?
  2. Remember me的源码分析
  3. 持久化remember me
  4. 二次校验功能
  5. remember-me前后端分离

为什么有remember-me?

小黑: 小白啊, 问一个问题,如果让你自己实现remember me功能,你应该怎么实现呢?

小白: 为什么要实现? 直接给cookie设置过期时间不就好了? 让浏览器将jessionID保存到电脑上完事, 何必再去实现一个劳什子remember-me? 你这不是给我们后端人员增加工作量么? 你不清楚我们劳动人民的辛苦么? 你....

1

是啊? 为什么 spring security还要自己实现一个 remember-me 呢? 他有什么考量的地方是我们不知道的呢?

  1. 不安全. cookie有好多安全问题, 不安全(即使使用了spring security的remember-me也还是会有一点不安全, 但比没有好多了)
  2. session不可控. 我们无法感知session怎么样
  3. 接入spring security后会有更多的功能

quick start

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RememberApplication {
	
	public static void main(String[] args) throws Exception {
		SpringApplication.run(RememberApplication.class, args);
	}
	
}

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
public class IndexController {
	
	@GetMapping("hello")
	public String hello() {
		return "hello";
	}
	
}

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
	
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
		return httpSecurity
				.authorizeRequests()
				.anyRequest()
				.authenticated()
				.and()
				.formLogin()
				.permitAll()
				.and()
				.rememberMe()
				.key("zhazha") // 这里有点像撒盐
				.and()
				.csrf()
				.disable()
				.build();
	}
	
}

image-20221126104309912

image-20221126111717047

底层都做了什么?

要了解remember-me底层都做了什么非常简单, 只要关注RememberMeServices接口就好

public interface RememberMeServices {
   Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
    
   void loginFail(HttpServletRequest request, HttpServletResponse response);
    
   void loginSuccess(HttpServletRequest request, HttpServletResponse response,
         Authentication successfulAuthentication);
}

image-20230108233441391

小黑: "我们的quick start 就是走的最底下的那个类"

小白: "那我们的remember-me功能在哪调用的呢?"

小黑: "我们核心认证流程的最后一步骤是保存用户登录信息, 这也是我们remember-me功能使用的位置"

image-20230108233940432

// AbstractRememberMeServices
@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication successfulAuthentication) {
   if (!rememberMeRequested(request, this.parameter)) {
      this.logger.debug("Remember-me login not requested.");
      return;
   }
   onLoginSuccess(request, response, successfulAuthentication);
}

小黑: "首先会判断你是否开启了remember-me功能, 然后在调用真正的记住我功能"

// TokenBasedRememberMeServices
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication successfulAuthentication) {
   String username = retrieveUserName(successfulAuthentication);
   String password = retrievePassword(successfulAuthentication);
   if (!StringUtils.hasLength(password)) {
      UserDetails user = getUserDetailsService().loadUserByUsername(username);
      password = user.getPassword();
   }
    // 默认返回两周时间
   int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
    // 拿到当前时间
   long expiryTime = System.currentTimeMillis();
    // 默认过期时间计算是当前时间之后的两周时间
   expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
    // 下面函数中的核心代码: String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
    // 紧接着将上面的字符串使用md5加密一下
    // 其中 getKey() 就是我们配置的 key("zhazha")
   String signatureValue = makeTokenSignature(expiryTime, username, password);
    // 紧接着将用户名, 过期时间和上面计算的签名三个元素组成一个使用Base64加密的token
    // 创建一个 Cookie , 将值保存到cookie中, 并设置好过期时间, 一般是 两周
   setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request,
         response);
}

该类的注释都有这段说明

image-20230108235804929

小白: "这里是在认证时调用的remember-me功能, 怎么进行自动登录的呢? "

小黑: "在自动登录时, 通常会将在浏览器中的 cookie 中remember-me相关数据读取出来, 整个过程的开始, 就是下面这个方法"

// RememberMeAuthenticationFilter
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    // 如果已经登录, 那么就不需要remember-me功能了
   if (SecurityContextHolder.getContext().getAuthentication() != null) {
      chain.doFilter(request, response);
      return;
   }
    // 这里触发了remember-me功能
   Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
   if (rememberMeAuth != null) {
      try {
          // 认证授权, 这里明显需要
         rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
         // 将新的认证信息保存到SecurityContextHolder
         SecurityContext context = SecurityContextHolder.createEmptyContext();
         context.setAuthentication(rememberMeAuth);
         SecurityContextHolder.setContext(context);
          // 认证成功之后执行别的操作, 这个方法没有执行任何代码, 是给程序员自定义实现的
         onSuccessfulAuthentication(request, response, rememberMeAuth);
		  // 持久化SecurityContext
         this.securityContextRepository.saveContext(context, request, response);
         if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                  SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
         }
         if (this.successHandler != null) {
            this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
            return;
         }
      }
      catch (AuthenticationException ex) {
         this.rememberMeServices.loginFail(request, response);
         onUnsuccessfulAuthentication(request, response, ex);
      }
   }
   chain.doFilter(request, response);
}

进入this.rememberMeServices.autoLogin(request, response)函数我们会看到下面这段代码

// AbstractRememberMeServices
@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
    // 从 cookie 中拿到remember-me保存的数据
   String rememberMeCookie = extractRememberMeCookie(request);
   if (rememberMeCookie == null) {
      return null;
   }
    // 省略了部分代码
   try {
       // 解析出cookie中原先的数据, 也就是上面那张图片
       // username + ":" + expiryTime + ":" + Md5Hex(username + ":" + expiryTime + ":" + password + ":" + key)
       // 而下面的代码跟 setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request, response); 相对应 
       // 这里有一个长度为 3 的数组, username, Long.toString(expiryTime), signatureValue
      String[] cookieTokens = decodeCookie(rememberMeCookie);
       // 所以你进入这个函数会发现cookieTokens进行了是否 length == 3 的判断
       // 这个函数内部还进行了自动登录处理
      UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
       // 最后检测下, 是否lock, 是否过期和 账户是否 disable
      this.userDetailsChecker.check(user);
       // 认证成功的处理, 在这里面创建了 RememberMeAuthenticationToken 对象, 并且填充了 Details 扩展
      return createSuccessfulAuthentication(request, user);
   }
   // 省略了全部 catch 代码
   // 删除掉浏览器中的关于remember-me的部分属性
   cancelCookie(request, response);
   return null;
}

接着进入processAutoLoginCookie(cookieTokens, request, response)类看看

// TokenBasedRememberMeServices
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
      HttpServletResponse response) {
    // 判断是否等于三个
    // 长度为 3 的数组, username, Long.toString(expiryTime), signatureValue
   if (cookieTokens.length != 3) {
      throw new InvalidCookieException(
            "Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
   }
    // 拿到时间, 估计用于判断是否过期之类
   long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
    // 判断是否过期
   if (isTokenExpired(tokenExpiryTime)) {
      throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
            + "'; current time is '" + new Date() + "')");
   }
   // 拿着 username 去数据库中读取, 但是这里是内存中读取 username 对应的 User 对象
   UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
   // 通过现有的数据生成一个签名
   String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
         userDetails.getPassword());
    // 然后拿着我们生成签名和cookie中读取到的签名进行比较
   if (!equals(expectedTokenSignature, cookieTokens[2])) {
      throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
            + "' but expected '" + expectedTokenSignature + "'");
   }
    // 签名相等, 直接返回
   return userDetails;
}

小黑: "说白了, 这整个过程相当的简单, 从 cookie 读取的数据是一个数组, 数组中存放着 用户名, 过期时间和使用过期时间密码和key等生成的签名"

小黑: "cookie中存放的 username 被用于查找数据库中匹配的用户, 最后生成签名准备和cookie中的签名进行匹配"

小黑: "cookie中的过期时间, 主要用于判断remember-me的信息是否过期"

小黑: "cookie中的签名主要用于判断是否还是那个"

小白: "哦哦, 看起来很简单啊, 主要是判断是否过期, 接着判断username拿到的user生成的签名和cookie中保存的签名是否相同, 完事"

小黑: "是的, 非常简单, 主要复杂的地方还是加密和解密的过程, 和使用 ':' 隔离的方法, 说白了, 整个过程相当的简单"

总结

小白: "过程虽然简单, 但我觉得你这也不安全吧? 不过是换了个名字么? 照样不安全吧?"

小黑: "spring security使用默认的remember-me肯定是不安全的, 你需要开启持久化功能, 也就是下图红框框的类"

image-20230109171227821

小白: "那你要怎么修改它内部使用哪个类的呢?"

小黑: "你有两种方式"

image-20230109173344864

image-20230109173410342

小黑: "也就是这两种方式"

image-20230109173627029

小黑: "从他们前面的源码可以看出, 这两种方式有优先级区别, 明显第一种优先级高于后面的tokenRepository"

持久化remember-me

持久化令牌

@Bean
@Throws(Exception::class)
open fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
    httpSecurity
        .authorizeRequests()
        .anyRequest().authenticated() // 任何账户密码认证的用户都可以访问
        httpSecurity.formLogin()
        .defaultSuccessUrl("/hello", true)
        val tokenRepository = JdbcTokenRepositoryImpl()
        tokenRepository.setCreateTableOnStartup(false)
        tokenRepository.dataSource = dataSource
        httpSecurity.rememberMe() // 启动rememberMe功能
        .tokenRepository(tokenRepository) // 只要使用了这个, 就会使用持久化令牌方式
        httpSecurity.csrf().disable()

        // 不需要, 只要 userDetailsService 是一个 Spring Bean 就会被加载
        //    httpSecurity.userDetailsService(userDetailsService())
        return httpSecurity.build()
}

这里我使用了kotlin, 目的有三

第一: 复习 kotlin 怎么使用, kotlin学了, 发现后端基本用不到, 怕忘记了

第二: kotlin代码也看得懂, 而且代码简单(我比较懒)

第三: 各位看官可以在熟悉kotlin的同时, 将代码修改为 java, 学习最忌讳眼高手低不是?

其实还有一点, 有人跟我赌, 说kotlin真的可以完全代替java, 我不信!!!至少nacos不行, 会出现问题, 我再试试spring security行不行, 不过看spring官方给出的事例代码都会有kotlin版本, 估计spring全家桶没问题

小黑: "只要添加上面的方式, 我们就可以使用持久化类方案PersistentTokenBasedRememberMeServices"

小黑: "上面使用的是JdbcTokenRepositoryImpl方式, 将数据存储到数据库中"

image-20230110015918042

小黑: "这里面会有表结构, 注意这里的series是主键哦, 只要我们这样"

image-20230110020143667

public void setCreateTableOnStartup(boolean createTableOnStartup) {
   this.createTableOnStartup = createTableOnStartup;
}

也就是这样

@Bean
@Throws(Exception::class)
open fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
    httpSecurity.
        // 代码省略
        val tokenRepository = JdbcTokenRepositoryImpl()
        // 关键还是这行, 这样才会创建表结构
        tokenRepository.setCreateTableOnStartup(true)
        tokenRepository.dataSource = dataSource
        httpSecurity.rememberMe() // 启动rememberMe功能
        .tokenRepository(tokenRepository)
        return httpSecurity.build()
}

持久化的使用其实没什么的, 非常简单, 核心还是源码

持久化remember-me源码分析

关于remember-me持久化方式的源码分析我分为两个步骤

  1. 认证时, 启动remember-me
  2. 在浏览器或者服务器被关闭, 无法拿到用户信息时, 启动 remember-me 功能

认证时

启动的话还是围绕这个方法

AbstractRememberMeServices#loginSuccess

@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication successfulAuthentication) {
   if (!rememberMeRequested(request, this.parameter)) {
      this.logger.debug("Remember-me login not requested.");
      return;
   }
   onLoginSuccess(request, response, successfulAuthentication);
}

或者说围绕这这个方法onLoginSuccess

protected abstract void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication);

这是一个抽象方法, 实际的方法在这里实现

PersistentTokenBasedRememberMeServices#onLoginSuccess

@Override
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication successfulAuthentication) {
    // 拿到用户名
   String username = successfulAuthentication.getName();
    // 创建PersistentRememberMeToken
    // 使用 SecureRandom 生成 Series 和 Token, 这是一个随机字符串(细节就不追了)
   PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
         generateTokenData(), new Date());
   try {
       // 这里将会调用我们填写的 JdbcTokenRepositoryImpl 将生成的对象持久化到数据库中
      this.tokenRepository.createNewToken(persistentToken);
       // 最后将PersistentRememberMeToken添加到 cookie中
       // setCookie(new String[] { token.getSeries(), token.getTokenValue() }, getTokenValiditySeconds(), request, response);
       // 第一个参数就是我们生成的series和token
       // 第二个参数就是过期时间, 默认是两周
       // 第三个参数和第四个参数就不用多说了
      addCookie(persistentToken, request, response);
   }
   catch (Exception ex) {
      this.logger.error("Failed to save persistent token ", ex);
   }
}

小白: "一头雾水, 为什么持久化方式和内存方式不同? 持久化方式怎么多了两个东西 seriestoken ?"

小黑: "现在跟你说, 你可能也不懂, 不过大体的意思是, series 只要客户使用用户名和密码登录系统就会生成一个, 而 token 是每次产生一次会话就会生成一个新的"

小黑: "换句话说, 自动登录不会导致 series 改变"

小白: "这么做有什么好处么?"

小黑: "你不是说安全么? 每次都会生成一个新的, 能不安全么? 而且最关键的是 每一个 username 都会有多个 tokenseries, 那么不就实现了在多端登录时, 系统就可以判断我们的账户是否被盗"

触发自动登录

自动登录我们还是得看这里

AbstractRememberMeServices#autoLogin

@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
    // 从前端cookie的remember-me属性中拿到相对应的字符串
   String rememberMeCookie = extractRememberMeCookie(request);
   if (rememberMeCookie == null) {
      return null;
   }
   if (rememberMeCookie.length() == 0) {
      cancelCookie(request, response);
      return null;
   }
   try {
       // 解码出 series 和 token
      String[] cookieTokens = decodeCookie(rememberMeCookie);
       // 核心代码, 下面分析
      UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
       // 检测 User 是否 xx zz yy
      this.userDetailsChecker.check(user);
       // 创建RememberMeAuthenticationToken对象
      return createSuccessfulAuthentication(request, user);
   }
   cancelCookie(request, response);
   return null;
}

接下来是核心代码PersistentTokenBasedRememberMeServices

@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
      HttpServletResponse response) {
    // 需要有 series 和 token 两个
   if (cookieTokens.length != 2) {
      throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
            + Arrays.asList(cookieTokens) + "'");
   }
    // 拿到 series
   String presentedSeries = cookieTokens[0];
    // 拿到 token
   String presentedToken = cookieTokens[1];
    // 从数据库中根据 series 查询到对应的 token
   PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
    // 说明数据库中没有相对应的 token
   if (token == null) {
      // 这样就不会走rememberme功能, 直接进入认证流程
      throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
   }
   // token不匹配
   if (!presentedToken.equals(token.getTokenValue())) {
      // series 匹配, 但是 token不匹配, 则删除所有 username 的自动登录信息
      // 这时候说明了什么呢? 说明自动登录的会话被刷新了, token值不对了
      // 那么就意味着该账号的信息被hacker完完整整的复制到其他电脑上访问了, 这样hacker以为不使用用户密码就可以借助盗取的信息访问, 但会导致原先的用户在访问网站时, 出现token不匹配问题, 就会删除该用户所有remember-me的功能
      // 这样即便黑客盗用了数据, 在正版用户访问后, hacker也直接无法继续访问了, 要使用用户名和密码登录一次
      this.tokenRepository.removeUserTokens(token.getUsername());
   }
   // 判断当前时间是否已经是超时时间
   if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
      throw new RememberMeAuthenticationException("Remember-me login has expired");
   }
   // 说明没超时, 验证也通过了
   // 那么创建一个新的 PersistentRememberMeToken 对象
   // 下面的方式创建了一个新的 token, 也就意味着每次触发自动登录, 都会创建一个新的 token
   PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
         generateTokenData(), new Date());
   try {
      // 更新到数据库
      this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
      // 添加到 cookie 中, 这样每次触发自动登录都会生成一个新的值存储到cookie中
      addCookie(newToken, request, response);
   }
   catch (Exception ex) {
      throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
   }
   // 拿到用户对象
   return getUserDetailsService().loadUserByUsername(token.getUsername());
}

小白: "等下, 你返回的 User 对象最后拿来干嘛了?"

小黑: "拿出来当然是认证咯"

小白: "啊? 还要再认证一次? "

小黑: "是的, 需要认证一次, 不过这次走的不是DaoAuthenticationProvider认证器"

小白: "啊? 为什么啊? 不是需要重新认证么?"

小黑: "你忘记了? 认证器都会有一个support函数啊?"

小黑: "你还记得remember-me的过程了么? 其中它创建了一个RememberMeAuthenticationToken对象"

小黑: "这不就意味着走DaoAuthenticationProvider认证器就不支持了么? 所以肯定不会走这里, 但是走RememberMeAuthenticationProvider认证器的话, 只会做 hashcode 匹配, 其实也没什么好讲的了"

小黑: "直接看源码吧"

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
   if (!supports(authentication.getClass())) {
      return null;
   }
   if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) {
      throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey",
            "The presented RememberMeAuthenticationToken does not contain the expected key"));
   }
   return authentication;
}

小黑: "这里的key是可配置的"

小黑: "回到RememberMeAuthenticationFilter的源码"

// RememberMeAuthenticationFilter
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   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;
   }
    // 我们的自动登录在这里已经完成了
   Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
   if (rememberMeAuth != null) {
      try {
         // 在这里做了认证 RememberMeAuthenticationProvider 也就是做了 hashCode比较
         rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
         // 保存到SecurityContextHolder
         SecurityContext context = SecurityContextHolder.createEmptyContext();
         context.setAuthentication(rememberMeAuth);
         SecurityContextHolder.setContext(context);
         // 用户登录完成之后执行的代码, 这里给程序员继承实现功能用的
         onSuccessfulAuthentication(request, response, rememberMeAuth);
         // 将 SecurityContext 保存到数据库中
         this.securityContextRepository.saveContext(context, request, response);
         if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                  SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
         }
         if (this.successHandler != null) {
            // 认证成功后执行的代码
            this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
            return;
         }
      }
      catch (AuthenticationException ex) {
         // 自动登录失败
         this.rememberMeServices.loginFail(request, response);
         onUnsuccessfulAuthentication(request, response, ex);
      }
   }
   chain.doFilter(request, response);
}

总体代码分析下来还是很简单的

二次校验功能的实现

是什么?

我们经常发现, 很多网站在访问敏感操作时, 会让你重新输入密码, 这种功能便是二次校验功能

所以我们需要将资源分为普通资源和敏感资源, 这样就能够保证在普通资源时使用自动登录边可以直接访问, 但是自动登录却无法访问敏感操作, 需要输入用户名和密码认证一次才能访问敏感资源

怎么做?

httpSecurity
   .authorizeRequests()
   .antMatchers("/authentication", "/auth").fullyAuthenticated()
   .anyRequest().authenticated()
@RestController
class IndexController {
   @GetMapping("remember")
   fun remember(): String {
      return "remember"
   }
   
   @GetMapping("auth")
   fun auth(): String {
      return "auth"
   }
   
   @GetMapping("both")
   fun both(): String {
      return "both"
   }
   
   @GetMapping("authentication")
   fun authentication(): String {
      return JSONUtil.toJsonStr(SecurityContextHolder.getContext().authentication)
   }
}

这么玩就行, 非常简单

在上面的 Controller中, 我们只要保护 "/authentication" 和 "/auth" 就行了

remember-me前后端分离

思路

前后端分离的方式非常简单, 前面我们已经说了这种思路

前后端分离的方式只要保证 requestresponse 的读写都是JSON格式就好, 所以我们只要分析remember-me的过程中, 是否直接从 requestresponse 中读取或者写入的情况, 遇到这种情况直接重写该方法便可

要分析 remember-me 的源码直接看

public interface RememberMeServices {
   Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
   void loginFail(HttpServletRequest request, HttpServletResponse response);
   void loginSuccess(HttpServletRequest request, HttpServletResponse response,
         Authentication successfulAuthentication);
}

分析该方法, 最终发现只有 loginSuccess 存在直接从 request 中读取数据的情况

// AbstractRememberMeServices
@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication successfulAuthentication) {
   if (!rememberMeRequested(request, this.parameter)) {
      return;
   }
   onLoginSuccess(request, response, successfulAuthentication);
}
// AbstractRememberMeServices
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
   if (this.alwaysRemember) {
      return true;
   }
   // 关注这一行代码, 说明我们需要重写该方法了
   String paramValue = request.getParameter(parameter);
   if (paramValue != null) {
      if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
            || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
         return true;
      }
   }
   return false;
}

小白: "等等, 你没发现问题么?"

小黑: "什么问题?"

小白: "如果你需要重写上面那个方法, 那么你就必须从 request 中读取流"

小黑: "哦, 我知道你说啥了, 你是说前后端分离本身认证的地方就需要从 request 中读取前面的JSON请求, 然后 remember-me 也需要从 request 中读取流, 这样, request 流被读取了两次"

小黑: "但是流的读取只能读取一次, 明显第二次读取request流会报错"

小白: "是的, 那你应该怎么解决呢?"

小黑: "思路也很简单, 认证和remember-me功能有先后顺序, 那么我们读取request流肯定只能在认证时读取, 此时我们不仅读取 usernamepassword 还得从request流中将rememeber-me读取读取出来, 接着关键步骤将读取出来的remember-me写入到requestattribute就行了"

在认证时

String memberMeValue = (String) map.get(myRememberMeService.getParameter());
request.setAttribute(myRememberMeService.getParameter(), memberMeValue);

rememberMeRequested读取时修改成下面这样

// 这里在认证器的时候, 需要读取出来保存到 request 的 attribute 中
String paramValue = (String) request.getAttribute(parameter);

小黑: "思路完成, 还是非常简单的, 不过实现的方式有很多, 我这只是其中一种罢了"

方法

class LoginFilter(private val code: String) : UsernamePasswordAuthenticationFilter() {
	
	@Resource
	lateinit var objectMapper: ObjectMapper
	
	override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
		if (request.method != "POST") {
			throw AuthenticationServiceException("Authentication method not supported: " + request.method)
		}
		if (!LoginFilter::objectMapper.isLateinit) {
			objectMapper = ObjectMapper()
		}
		val contentType = request.contentType
		val session = request.session
		val verifyCode = session.getAttribute(VERIFY_CODE) as String
		if (StrUtil.equalsIgnoreCase(contentType, MediaType.APPLICATION_JSON_VALUE)
			|| StrUtil.equalsIgnoreCase(contentType, MediaType.APPLICATION_JSON_UTF8_VALUE)
		) {
			val map = objectMapper.readValue(request.inputStream, Map::class.java)
			// 图片验证等等
			val code = map[this.code] as String
			if (StrUtil.isBlank(verifyCode) || StrUtil.isBlank(code) || !StrUtil.equalsIgnoreCase(verifyCode, code)) {
				throw VerifyCodeException("图片验证失败")
			}
			// remember-me 功能
			val rememberMe = map[REMEMBER_ME] as? String
			request.setAttribute(REMEMBER_ME, rememberMe)
			// 账户验证
			var username = map[this.usernameParameter] as? String
			username = username?.trim() ?: ""
			var password = map[this.passwordParameter] as? String
			password = password?.trim() ?: ""
			val passwordAuthenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password)
			return this.authenticationManager.authenticate(passwordAuthenticationToken)
		}
		throw UsernameNotFoundException("请求JSON格式不对")
	}
}
class RememberMeService(
	key: String,
	userDetailsService: UserDetailsService,
	tokenRepository: PersistentTokenRepository
) : PersistentTokenBasedRememberMeServices(key, userDetailsService, tokenRepository) {
	
	private val alwaysRememberF: Boolean
	
	init {
		val clazz = AbstractRememberMeServices::class.java
		val field = clazz.getDeclaredField("alwaysRemember")
		field.isAccessible = true
		alwaysRememberF = field.getBoolean(this)
	}
	
	override fun rememberMeRequested(request: HttpServletRequest, parameter: String): Boolean {
		if (alwaysRememberF) {
			return true
		}
		val paramValue = request.getAttribute(parameter) as? String
		if (paramValue != null) {
			if (paramValue.equals("true", ignoreCase = true) || paramValue.equals("on", ignoreCase = true)
				|| paramValue.equals("yes", ignoreCase = true) || paramValue == "1"
			) {
				return true
			}
		}
		logger.debug(
			LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter)
		)
		return false
	}
}
@Configuration
open class SecurityConfig {
	@Resource
	lateinit var authenticationConfiguration: AuthenticationConfiguration
	
	@Bean
	open fun authenticationManager(): AuthenticationManager {
		return authenticationConfiguration.authenticationManager
	}
	
	@Bean
	open fun userDetailsService(): UserService {
		return UserService(authenticationManager())
	}
	
	@Bean
	open fun passwordEncoder(): PasswordEncoder {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder()
	}
	
	@Bean
	open fun objectMapper(): ObjectMapper {
		return ObjectMapper()
	}
	
	@Bean
	open fun loginFilter(): LoginFilter {
		val loginFilter = LoginFilter("verify-code")
		loginFilter.setAuthenticationManager(authenticationManager())
		// 这里需要给 认证器 执行 rememberMeService
		loginFilter.rememberMeServices = rememberMeService()
		loginFilter.setAuthenticationSuccessHandler { _, response, authentication ->
			val map = mutableMapOf<String, Any>()
			map["msg"] = "认证成功"
			map["status"] = HttpStatus.ACCEPTED.value()
			map["user"] = authentication
			response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE
			response.writer.println(JSONUtil.toJsonStr(map))
		}
		loginFilter.setAuthenticationFailureHandler { _, response, exception ->
			exception.printStackTrace()
			val map = mutableMapOf<String, Any>()
			map["msg"] = "认证失败"
			map["status"] = HttpStatus.FORBIDDEN.value()
			map["exception"] = exception.message!!
			response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE
			response.writer.println(JSONUtil.toJsonStr(map))
		}
		return loginFilter
	}
	
	@Bean
	open fun tokenRepository(): JdbcTokenRepositoryImpl {
		val tokenRepository = JdbcTokenRepositoryImpl()
		tokenRepository.setCreateTableOnStartup(false)
		tokenRepository.dataSource = dataSource
		return tokenRepository
	}
	
	@Bean
	open fun rememberMeService(): RememberMeService {
		return RememberMeService(UUID.fastUUID().toString(true), userDetailsService(), tokenRepository())
	}
	
	@Resource
	lateinit var dataSource: DataSource
	
	@Bean
	open fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
		
		httpSecurity
			.authorizeRequests()
			.antMatchers("/verify-code").permitAll()
			.antMatchers("/authentication", "/auth").fullyAuthenticated()
			.anyRequest().authenticated()
		
		httpSecurity.formLogin()
			.permitAll()
		
		httpSecurity.rememberMe() // 启动rememberMe功能
			.tokenRepository(tokenRepository())
		
		httpSecurity.csrf().disable()
		
		// 不需要, 只要 userDetailsService 是一个 Spring Bean 就会被加载
//		httpSecurity.userDetailsService(userDetailsService())
		
		httpSecurity.addFilterBefore(loginFilter(), UsernamePasswordAuthenticationFilter::class.java)
		
		httpSecurity.exceptionHandling {
			it.authenticationEntryPoint { _, response, authException ->
				authException.printStackTrace()
				val map = mutableMapOf<String, Any>()
				map["msg"] = "认证失败, 请重新认证"
				map["status"] = HttpStatus.UNAUTHORIZED.value()
				map["exception"] = authException.message!!
				response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE
				response.writer.println(JSONUtil.toJsonStr(map))
			}
			
			it.accessDeniedHandler { _, response, accessDeniedException ->
				accessDeniedException.printStackTrace()
				val map = mutableMapOf<String, Any>()
				map["msg"] = "您没有权限访问"
				map["status"] = HttpStatus.FORBIDDEN.value()
				map["exception"] = accessDeniedException.message!!
				response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE
				response.writer.println(JSONUtil.toJsonStr(map))
			}
		}
		
		httpSecurity.logout {
			it.logoutSuccessHandler { _, response, authentication ->
				val map = mutableMapOf<String, Any>()
				map["msg"] = "注销成功"
				map["status"] = HttpStatus.ACCEPTED.value()
				map["user"] = authentication
				response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE
				response.writer.println(JSONUtil.toJsonStr(map))
			}
			it.permitAll()
		}
		
		return httpSecurity.build()
	}
}
@RestController
class IndexController {
	
	@GetMapping("auth")
	fun auth(): String {
		return "auth"
	}
	
	@GetMapping("both")
	fun both(): String {
		return "both"
	}
	
	@GetMapping("authentication")
	fun authentication(): String {
		return JSONUtil.toJsonStr(SecurityContextHolder.getContext().authentication)
	}
	
	@GetMapping("/verify-code")
	fun verifyCodeImage(session: HttpSession, response: HttpServletResponse): Unit {
		val captcha = CaptchaUtil.createGifCaptcha(80, 50, 4)
		session.setAttribute(VERIFY_CODE, captcha.code)
		response.contentType = MediaType.IMAGE_GIF_VALUE
		response.outputStream.use {
			captcha.write(it)
		}
	}
	
}

代码看起来就非常简单了

完整代码可以看这里: remember_me2-spring-security