2.1 自动登录

2.1.1 实现思路

  1. 用户勾选记住我登录成功;
  2. Spring Security 生成一个 token 标识,然后将该 token 标识持久化到数据库;
  3. 同时生成一个与该 token 相对应的 cookie 返回给浏览器;
  4. 当用户过段时间再次访问系统时,如果该 cookie 没有过期,Spring Security 便会根据 cookie 包含的信息从数据库中获取相应的 token 信息;
  5. 从 token 信息中获取用户身份,帮用户自动完成登录操作。

    2.1.2 源码分析

    PersistentRememberMeToken

    该类用来存储记住我用户的 token 信息,配合 PersistentTokenRepository 持久化接口,将 token 信息存入内存或持久化到数据库。
    源码如下:

    1. public class PersistentRememberMeToken {
    2. // 登录用户的用户名
    3. private final String username;
    4. // token 的唯一标识
    5. private final String series;
    6. // token 的内容
    7. private final String tokenValue;
    8. // token 的最后访问时间
    9. private final Date date;
    10. public PersistentRememberMeToken(String username, String series, String tokenValue, Date date) {
    11. this.username = username;
    12. this.series = series;
    13. this.tokenValue = tokenValue;
    14. this.date = date;
    15. }
    16. public String getUsername() {
    17. return this.username;
    18. }
    19. public String getSeries() {
    20. return this.series;
    21. }
    22. public String getTokenValue() {
    23. return this.tokenValue;
    24. }
    25. public Date getDate() {
    26. return this.date;
    27. }
    28. }

    PersistentTokenRepository

    PersistentTokenRepository 是 Spring Security 提供的持久化记住我 token 的接口。接口源码如下:

    1. public interface PersistentTokenRepository {
    2. /**
    3. * 创建 token
    4. * @param token
    5. */
    6. void createNewToken(PersistentRememberMeToken token);
    7. /**
    8. * 更新 token
    9. * @param series token 的唯一表示
    10. * @param tokenValue token 的内容
    11. * @param lastUsed token 的最后使用时间
    12. */
    13. void updateToken(String series, String tokenValue, Date lastUsed);
    14. /**
    15. * 根据 token 唯一表示获取 token
    16. * @param series
    17. * @return
    18. */
    19. PersistentRememberMeToken getTokenForSeries(String series);
    20. /**
    21. * 删除 token
    22. * @param username 登录用户的用户名
    23. */
    24. void removeUserTokens(String username);
    25. }

    该接口有两个实现类:JdbcTokenRepositoryImpl 和 InMemoryTokenRepositoryImpl。

  • JdbcTokenRepositoryImpl 使用 JdbcTemplate 模板操作 token 持久化到数据库。
  • InMemoryTokenRepositoryImpl 是基于内存的 token 持久化方案,该方案服务器重启,记住的用户会丢失。

我们不使用以上两种实现,我们扩展 PersistentTokenRepository 接口,将 token 信息持久化到 Redis 数据库。

2.1.3 具体实现

RedisTemplate

定义一个 Redis 的操作模版,来完成对 PersistentRememberMeToken 的操作:
image.png

  1. @Bean
  2. public RedisTemplate<String, PersistentRememberMeToken> redisTemplate(
  3. RedisConnectionFactory redisConnectionFactory) {
  4. RedisTemplate<String, PersistentRememberMeToken>
  5. redisTemplate = new RedisTemplate<>();
  6. redisTemplate.setConnectionFactory(redisConnectionFactory);
  7. return redisTemplate;
  8. }
  • RedisConnectionFactory 不需要再创建,spring-boot-starter-data-redis 已经帮助我们创建了 Redis 连接工厂对象并注入到了 Spring IOC 容器。

    实现 PersistentTokenRepository 接口

    image.png
    实现 PersistentTokenRepository 接口:

    public class RedisTokenRepositoryImpl implements PersistentTokenRepository {
    
      private static final String REMEBERME_TOKEN_KEY_PREFIX = "remember-me:";
    
      @Autowired
      private RedisTemplate<String, PersistentRememberMeToken> redisTemplate;
    
      /**
       * 创建 token
       * @param token
       */
      @Override
      public void createNewToken(PersistentRememberMeToken token) {
          this.removeUserTokens(token.getUsername());
          redisTemplate.opsForValue().set(REMEBERME_TOKEN_KEY_PREFIX + token.getSeries(), token);
      }
    
      /**
       * 根据 token 唯一表示获取 token
       * @param series
       * @return
       */
      @Override
      public PersistentRememberMeToken getTokenForSeries(String series) {
          return redisTemplate.opsForValue().get(REMEBERME_TOKEN_KEY_PREFIX + series);
      }
    
      /**
       * 更新 token
       * @param series token 的唯一表示
       * @param tokenValue  token 的内容
       * @param lastUsed    token 的最后使用时间
       */
      @Override
      public synchronized void updateToken(String series, String tokenValue, Date lastUsed) {
          PersistentRememberMeToken token = this.getTokenForSeries(series);
          PersistentRememberMeToken newToken =
                  new PersistentRememberMeToken(token.getUsername(), series, tokenValue, new Date());
          this.createNewToken(newToken);
      }
    
      /**
       * 删除 token
       * @param username 登录用户的用户名
       */
      @Override
      public synchronized void removeUserTokens(String username) {
          Set<String> keys = redisTemplate.keys(REMEBERME_TOKEN_KEY_PREFIX + "*");
          for (String key : keys) {
              PersistentRememberMeToken token = redisTemplate.opsForValue().get(key);
              if (username.equals(token.getUsername())) {
                  redisTemplate.delete(key);
              }
          }
      }
    }
    

    配置 rememberMe

    image.png

    @Configuration
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
      @Resource
      private UserServiceImpl userDetailsService;
    
      @Override
      protected void configure(HttpSecurity http) throws Exception {
          http
                  ......
    
                  .and()
                   // 记住我配置
                  .rememberMe()
                  .tokenRepository(rememberMeTokenRepository()) // 配置token持久化仓库
                  .tokenValiditySeconds(60 * 60 * 24 * 7)  // remember 过期时间7天内有效单为秒
                  .userDetailsService(userDetailsService)  // 自动登录调用
    
                  .and()
                  // 禁用跨域请求伪造防护
                  .csrf().disable();
      }
    
      @Bean
      public RedisTemplate<String, PersistentRememberMeToken> redisTemplate(
              RedisConnectionFactory redisConnectionFactory) {
          RedisTemplate<String, PersistentRememberMeToken>
                  redisTemplate = new RedisTemplate<>();
          redisTemplate.setConnectionFactory(redisConnectionFactory);
          return redisTemplate;
      }
    
      @Bean
      public RedisTokenRepositoryImpl rememberMeTokenRepository() {
          return new RedisTokenRepositoryImpl();
      }
    }
    
  • 注入 UserServiceImpl 对象,配置到记住我的配置里,记住我功能要读取用户信息。

  • 将 RedisTokenRepositoryImpl 对象交给 Spring 容器管理,并注入给记住我功能使用。

    登录页面

    在登录页面添加一个记住我的复选框表单,该表单名称为 remember-me。
    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
      <meta charset="UTF-8">
      <title>系统登录</title>
    </head>
    <body>
    <h1>登录页面</h1>
    <form method="post" th:action="@{/login}">
      用户名:<input type="text" name="username"/><br/>
      密 码:<input type="password" name="password"/><br/>
      <input type="checkbox" name="remember-me"/> 记住我 <br>
      <input type="submit" value="登录"/>
    </form>
    </body>
    </html>
    

    记住我的表单名称必须是 remember-me

测试验证

登录的时候,选择记住我测试
image.png
后台报序列化异常错误:
org.springframework.data.redis.serializer.SerializationException:
Cannot serialize; nested exception is org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer; nested exception is java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken]
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:96) ~[spring-data-redis-2.4.3.jar:2.4.3]

错误分析

image.png
我们定义的 RedisTemplate key 和 value 默认使用的是 JDK 的序列化,JDK 序列化需要被序列化的类实现 Serializable 接口。
查看 PersistentRememberMeToken 源码:
image.png
发现该类没有实现 Serializable 接口。并且该类也没有提供无参构造方法。这样反序列化的过程也会有问题。

解决方案

  • 自己再实现一个 PersistentRememberMeToken 类,提供无参构造和实现序列化接口。
  • 使用 JSON 序列化和反序列化
    • 解决了 PersistentRememberMeToken 没有实现 Serializable 接口的问题;
    • 反序列化可能不成功,反序列化过程创建对象可能会用到无参构造;
      • 使用 Jackson 类库的 MixIn(混入)可以解决该问题。

具体实现很简单,过程如下:

2.1.4 解决序列化问题

创建 MixIn 类

image.png

package com.imcode.rememberme;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Date;
public class RedisTokeMixIn {
    public RedisTokeMixIn(
            @JsonProperty("username") String username,
            @JsonProperty("series") String series,
            @JsonProperty("tokenValue") String tokenValue,
            @JsonProperty("date") Date date) {
    }
}

配置

image.png

@Bean
public RedisTemplate<String, PersistentRememberMeToken> redisTemplate(
    RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<String, PersistentRememberMeToken>
        redisTemplate = new RedisTemplate<>();

    Jackson2JsonRedisSerializer<PersistentRememberMeToken>
        jsonSerializer = new Jackson2JsonRedisSerializer<>(PersistentRememberMeToken.class);
    ObjectMapper objectMapper = new ObjectMapper();
    // 使用MixIn
    objectMapper.addMixIn(PersistentRememberMeToken.class, RedisTokeMixIn.class);
    jsonSerializer.setObjectMapper(objectMapper);
    // value 使用 Jackson 序列化成 json
    redisTemplate.setValueSerializer(jsonSerializer);

    // key 使用 String 序列化,方便查看和维护
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    return redisTemplate;
}

测试

image.png
登录成功以后,浏览器cookie中增加了名称为 remember-me 的cookie,有效期7天。
image.png
查看 Redis 数据库:
image.png
image.png
image.png
登录成功以后,浏览器 cookie 中增加了名称为 remember-me 的 cookie,有效期7天。

查看数据库:
image.png