【编程不良人】SpringSecurity实战学习笔记04---RememberMe

配套视频:38.RememberMe简介_哔哩哔哩_bilibili

  • 简介

  • 基本使用

  • 原理分析

  • 持久化令牌

5.1 RememberMe简介

       RememberMe (记住我、记住密码下次自动登录) 这个功能非常常见,下图就是QQ邮箱登录时的“记住我” 选项。

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第1张图片

       提到 RememberMe,一些初学者往往会有一些误解,认为RememberMe功能就是把用户名/密码用Cookie保存在浏览器中,下次登录时不用再次输入用户名/密码,这个理解显然是不对的。我们这里所说的 RememberMe是一种服务器端的行为,传统的登录方式基于 Session会话,一旦用户的会话超时过期(一般为会话时间为30分钟),就要再次登录,这样太过于烦琐。如果能有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多,RememberMe 就是为了解决这一需求而生的。

       具体的实现思路就是通过 Cookie 来记录当前用户身份,当用户登录成功之后,会通过一定算法,将用户信息、时间戳等进行加密,加密完成后,通过响应头带回前端存储在cookie中,当浏览器会话过期之后,如果再次访问该网站,会自动将 Cookie 中的信息发送给服务器,服务器对 Cookie中的信息进行校验分析,进而确定出用户的身份,Cookie中所保存的用户信息也是有时效的,例如三天、一周等(时间越长,风险越大,没有绝对的安全)。

5.2 前期环境搭建

       创建Spring Initializr项目spring-security-08,引入Spring Web、Spring Security依赖,新建config、controller包。

5.2.1 编写Security配置类

  • SecurityConfig.java

 package com.study.config;
 ​
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 ​
 /**
  * @ClassName SecurityConfig
  * @Description TODO
  * @Author Jiangnan Cui
  * @Date 2022/8/27 20:07
  * @Version 1.0
  */
 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     //使用内存中的数据源
     @Bean
     public UserDetailsService userDetailsService() {
         InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
         inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("ADMIN").build());
         return inMemoryUserDetailsManager;
     }
 ​
     //使用全局自定义配置AuthenticationManager
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         auth.userDetailsService(userDetailsService());
     }
 ​
     //重写认证登录默认配置
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 .anyRequest().authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .csrf().disable();
     }
 }
 ​

5.2.2 编写测试Controller

  • IndexController.java

 package com.study.controller;
 ​
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RestController;
 ​
 /**
  * @ClassName IndexController
  * @Description TODO
  * @Author Jiangnan Cui
  * @Date 2022/8/27 20:05
  * @Version 1.0
  */
 @RestController
 public class IndexController {
     @GetMapping("/index")
     public String index() {
         System.out.println("index is ok");
         return "Index is Ok";
     }
 }

5.2.3 测试

       以debug方式启动项目,访问:http://localhost:8080/index ,输入用户名root、密码123进行等登录,服务器会话默认失效时间为30分钟,30分钟后需要再次登录,此处为演示此过程,将服务器端会话失效时间设置为1分钟,具体配置在application.properties中:

 # 修改服务器会话过期时间(单位:分钟)
 server.servlet.session.timeout=1

       重启服务后,按照上述方式进行登录后,等待1分钟,刷新页面,发现此时需要再次登录,说明之前登录的用户名密码已经失效。

5.3 基本使用

配套视频:39.RememberMe的基本使用_哔哩哔哩_bilibili

5.3.1 开启RememberMe

SecurityConfig的configure方法添加rememberMe()即可开启RememberMe功能:

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     ......
     //重写认证登录默认配置
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 .anyRequest().authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .rememberMe()//开启RememberMe功能,重启服务之后登录页面出现Remember me on this computer.选择框
                 .and()
                 .csrf().disable();
     }
 }

       重启服务之后,访问:http://localhost:8080/index,发现登录页面中会多出一个 RememberMe 选项,勾选此选项后进行登录,登录成功后就不会在1分钟后过期了。

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第2张图片

补充:

  • 自定义认证页面添加Remember功能:
  Remember me on this computer.

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第3张图片

5.3.2 RememberMe原理分析

配套视频:40.RememberMe 原理分析_哔哩哔哩_bilibili

5.3.2.1 RememberMeAuthenticationFilter

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第4张图片

       在上图中我们在SecurityConfig配置中开启了"记住我"功能之后,在进行认证时如果勾选了"记住我"选项,此时打开浏览器控制台,进而分析整个登录过程。首先当我们登录时,在登录请求中多了一个 RememberMe 的参数。

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第5张图片

       很显然,这个参数就是告诉服务器应该开启 RememberMe功能的。如果自定义登录页面开启 RememberMe功能应该多加入一个一样的请求参数就可以啦,该请求会被 RememberMeAuthenticationFilter进行拦截然后自动登录具体参见源码:

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第6张图片

具体过程如下:

       (1)请求到达过滤器之后,首先判断 SecurityContextHolder中是否有值,没值的话表示用户尚未登录,此时调用 autoLogin方法进行自动登录。

       (2)当自动登录成功后返回的rememberMeAuth不为null时,表示自动登录成功,此时调用authenticate方法对 key 进行校验,并且将登录成功的用户信息保存到 SecurityContextHolder 对象中,然后调用登录成功回调,并发布登录成功事件。需要注意的是,登录成功的回调并不包含 RememberMeServices 中的 loginSuccess 方法。

       (3)如果自动登录失败,则调用 remenberMeServices.loginFail方法处理登录失败回调。onUnsuccessfulAuthentication 和 onSuccessfulAuthentication 都是该过滤器中定义的空方法,并没有任何实现,这就是 RememberMeAuthenticationFilter 过滤器所做的事情,成功将 RememberMeServices的服务集成进来。

5.3.2.2 RememberMeServices

 package org.springframework.security.web.authentication;
 ​
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.springframework.security.core.Authentication;
 ​
 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. 1oginSuccess 方法:自动登录成功的回调。

       RememberMeServices的实现类为AbstractRememberMeServices,AbstractRememberMeServices的子类为PersistentTokenBasedRememberMeServices和TokenBasedRememberMeServices,默认实现的是TokenBasedRememberMeServices:

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第7张图片

5.3.2.3 TokenBasedRememberMeServices

       在开启RememberMe后,如果没有加入额外配置,默认实现就是由TokenBasedRememberMeServices进行的实现。查看这个类源码中 processAutoLoginCookie 方法实现:

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第8张图片

processAutoLoginCookie 方法主要用来验证 Cookie 中的令牌信息是否合法:

  1. 首先判断cookieTokens的长度是否为3,不为3时说明格式不对,直接抛出异常。

  2. 从cookieTokens 数组中提取出第 1项,也就是过期时间,判断令牌是否过期,如果己经过期,则拋出异常。

  3. 根据用户名 (cookieTokens 数组的第0项)查询出当前用户对象。

  4. 调用 makeTokenSignature 方法生成一个签名,签名的生成过程如下:首先将用户名、令牌过期时间、用户密码以及 key 组成一个宇符串,中间用“:”隔开,然后通过 MD5 消息摘要算法对该宇符串进行加密,并将加密结果转为一个字符串返回。

  5. 判断第4 步生成的签名和通过 Cookie 传来的签名是否相等(即 cookieTokens 数组的第2项),如果相等,表示令牌合法,则直接返回用户对象,否则拋出异常。

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第9张图片

  1. 在这个回调中,首先获取用户经和密码信息,如果用户密码在用户登录成功后从successfulAuthentication对象中擦除,则从数据库中重新加载出用户密码。

  2. 计算出令牌的过期时间,令牌默认有效期是两周。

  3. 根据令牌的过期时间、用户名以及用户密码,计算出一个签名。

  4. 调用 setCookie 方法设置 Cookie, 第一个参数是一个数组,数组中一共包含三项:用户名、过期时间以及签名,在setCookie 方法中会将数组转为字符串,并进行 Base64编码后响应给前端。

总结

       当用户通过用户名/密码的形式登录成功后,系统会根据用户的用户名、密码以及令牌的过期时间计算出一个签名,这个签名使用 MD5 消息摘要算法生成,是不可逆的。然后再将用户名、令牌过期时间以及签名拼接成一个字符串,中间用“:” 隔开,对拼接好的字符串进行Base64 编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。当会话过期之后,访问系统资源时会自动携带上Cookie中的令牌,服务端拿到 Cookie中的令牌后,先进行 Bae64解码,解码后分别提取出令牌中的三项数据;接着根据令牌中的数据判断令牌是否已经过期,如果没有过期,则根据令牌中的用户名查询出用户信息;接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示会牌是合法令牌,自动登录成功,否则自动登录失败。

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第10张图片

以上过程中可以在浏览器的cookies中获得token信息,说明还不安全,下面会围绕此进行展开。

配套视频:41.RememberMe 原理分析图解_哔哩哔哩_bilibili

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第11张图片

5.3.4 内存令牌

配套视频:42.Remember-Me 提高安全性_哔哩哔哩_bilibili

5.3.4.1 PersistentTokenBasedRememberMeServices

       基于TokenBasedRememberMeServices生成的Cookie信息是固定的,容易被不法分子拦截,而基于PersistentTokenBasedRememberMeServices生成的Cookie信息是不断更新的,生成新的Cookie信息后,之前的Cookie信息会过期,不能再利用。

PersistentTokenBasedRememberMeServices源码:

 package org.springframework.security.web.authentication.rememberme;
 ​
 import java.security.SecureRandom;
 import java.util.Arrays;
 import java.util.Base64;
 import java.util.Date;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.springframework.core.log.LogMessage;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.util.Assert;
 ​
 public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
     private PersistentTokenRepository tokenRepository = new InMemoryTokenRepositoryImpl();
     private SecureRandom random = new SecureRandom();
     public static final int DEFAULT_SERIES_LENGTH = 16;
     public static final int DEFAULT_TOKEN_LENGTH = 16;
     private int seriesLength = 16;
     private int tokenLength = 16;
 ​
     public PersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
         super(key, userDetailsService);
         this.tokenRepository = tokenRepository;
     }
 ​
     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 {
             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())) {
                 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 {
                     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());
             }
         }
     }
 ​
     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 {
             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());
         }
 ​
     }
 ​
     protected String generateSeriesData() {
         byte[] newSeries = new byte[this.seriesLength];
         this.random.nextBytes(newSeries);
         return new String(Base64.getEncoder().encode(newSeries));
     }
 ​
     protected String generateTokenData() {
         byte[] newToken = new byte[this.tokenLength];
         this.random.nextBytes(newToken);
         return new String(Base64.getEncoder().encode(newToken));
     }
 ​
     private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
         this.setCookie(new String[]{token.getSeries(), token.getTokenValue()}, this.getTokenValiditySeconds(), request, response);
     }
 ​
     public void setSeriesLength(int seriesLength) {
         this.seriesLength = seriesLength;
     }
 ​
     public void setTokenLength(int tokenLength) {
         this.tokenLength = tokenLength;
     }
 ​
     public void setTokenValiditySeconds(int tokenValiditySeconds) {
         Assert.isTrue(tokenValiditySeconds > 0, "tokenValiditySeconds must be positive for this implementation");
         super.setTokenValiditySeconds(tokenValiditySeconds);
     }
 }

源码分析:

  1. 不同于 TokonBasedRemornberMeServices 中的 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. 最后将根据用户名查询用户对象并返回。

5.3.4.2 内存令牌具体实现

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     ......
     //重写认证登录默认配置
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 //.mvcMatchers("/index").rememberMe()//指定资源开启记住我功能,其它不开启,需要认证
                 .anyRequest().authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .rememberMe()//开启RememberMe功能,重启服务之后登录页面出现Remember me on this computer.选择框
                 //.rememberMeParameter("RememberMe")//修改RememberMe名称
                 .rememberMeServices(rememberMeServices())
                 .and()
                 .csrf().disable();
     }
 ​
     //使用PersistentTokenBasedRememberMeServices更新Cookie,提高安全性
     @Bean
     public RememberMeServices rememberMeServices() {
         return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), new InMemoryTokenRepositoryImpl());
     }
 }
 ​

       重新启动服务,访问:http://localhost:8080/index ,输入root、123进行登录。等待1分钟后在进行刷新,发现两次得到的Cookie信息不一样,说明Cookie在会话过期后会更新,且前后Cookie信息不一致,相对地提高了安全性。

(1)登录成功时

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第12张图片

(2)1分钟会话过期后,刷新页面

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第13张图片

       由于以上令牌都是保存在内存中的,内存中的令牌在应用程序重启之后,即使之前做过“记住我”操作,之后也无法再实现记住我功能。

5.3.5 持久化令牌(基于数据库实现)

配套视频:43.Remember-Me 令牌数据库的持久化_哔哩哔哩_bilibili

基于数据库实现持久化令牌操作时要用到PersistentTokenRepository,源码如下:

 package org.springframework.security.web.authentication.rememberme;
 ​
 import java.util.Date;
 ​
 public interface PersistentTokenRepository {
     void createNewToken(PersistentRememberMeToken token);
 ​
     void updateToken(String series, String tokenValue, Date lastUsed);
 ​
     PersistentRememberMeToken getTokenForSeries(String seriesId);
 ​
     void removeUserTokens(String username);
 }
 ​

       该接口主要实现类为InMemoryTokenRepositoryImpl(基于内存)和JdbcTokenRepositoryImpl(基于数据库),接下来主要使用JdbcTokenRepositoryImpl实现代替InMemoryTokenRepositoryImpl,JdbcTokenRepositoryImpl源码如下:

 package org.springframework.security.web.authentication.rememberme;
 ​
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.util.Date;
 import org.springframework.core.log.LogMessage;
 import org.springframework.dao.DataAccessException;
 import org.springframework.dao.EmptyResultDataAccessException;
 import org.springframework.dao.IncorrectResultSizeDataAccessException;
 import org.springframework.jdbc.core.support.JdbcDaoSupport;
 ​
 public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
     public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)";
     public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
     public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
     public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
     public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
     private String tokensBySeriesSql = "select username,series,token,last_used from persistent_logins where series = ?";
     private String insertTokenSql = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
     private String updateTokenSql = "update persistent_logins set token = ?, last_used = ? where series = ?";
     private String removeUserTokensSql = "delete from persistent_logins where username = ?";
     private boolean createTableOnStartup;
 ​
     public JdbcTokenRepositoryImpl() {
     }
 ​
     protected void initDao() {
         if (this.createTableOnStartup) {
             this.getJdbcTemplate().execute("create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)");
         }
 ​
     }
 ​
     public void createNewToken(PersistentRememberMeToken token) {
         this.getJdbcTemplate().update(this.insertTokenSql, new Object[]{token.getUsername(), token.getSeries(), token.getTokenValue(), token.getDate()});
     }
 ​
     public void updateToken(String series, String tokenValue, Date lastUsed) {
         this.getJdbcTemplate().update(this.updateTokenSql, new Object[]{tokenValue, lastUsed, series});
     }
 ​
     public PersistentRememberMeToken getTokenForSeries(String seriesId) {
         try {
             return (PersistentRememberMeToken)this.getJdbcTemplate().queryForObject(this.tokensBySeriesSql, this::createRememberMeToken, new Object[]{seriesId});
         } catch (EmptyResultDataAccessException var3) {
             this.logger.debug(LogMessage.format("Querying token for series '%s' returned no results.", seriesId), var3);
         } catch (IncorrectResultSizeDataAccessException var4) {
             this.logger.error(LogMessage.format("Querying token for series '%s' returned more than one value. Series should be unique", seriesId));
         } catch (DataAccessException var5) {
             this.logger.error("Failed to load token for series " + seriesId, var5);
         }
 ​
         return null;
     }
 ​
     private PersistentRememberMeToken createRememberMeToken(ResultSet rs, int rowNum) throws SQLException {
         return new PersistentRememberMeToken(rs.getString(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4));
     }
 ​
     public void removeUserTokens(String username) {
         this.getJdbcTemplate().update(this.removeUserTokensSql, new Object[]{username});
     }
 ​
     public void setCreateTableOnStartup(boolean createTableOnStartup) {
         this.createTableOnStartup = createTableOnStartup;
     }
 }
 ​

       在该实现类中自动定义了表(persistent_logins)及其表结构,后续连接数据库后会创建此表进行Token信息记录。

5.3.5.1 pom.xml引入依赖

 
   com.alibaba
   druid
   1.2.8
 
 ​
 
   mysql
   mysql-connector-java
   5.1.38
 
 ​
 
   org.mybatis.spring.boot
   mybatis-spring-boot-starter
   2.2.0
 

5.3.5.2 配置数据源

 spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
 spring.datasource.driver-class-name=com.mysql.jdbc.Driver
 spring.datasource.url=jdbc:mysql://localhost:3306/security?characterEncoding=UTF-8&useSSL=false
 spring.datasource.username=root
 spring.datasource.password=root
 mybatis.mapper-locations=classpath:com/study/mapper/*.xml
 mybatis.type-aliases-package=com.study.entity

5.3.5.3 配置持久化令牌

  • 方式1:使用RememberMeServices

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     //注入数据源
     private final DataSource dataSource;
 ​
     @Autowired
     public SecurityConfig(DataSource dataSource) {
         this.dataSource = dataSource;
     }
 ​
     //使用内存中的数据源
     @Bean
     public UserDetailsService userDetailsService() {
         InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
         inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("ADMIN").build());
         return inMemoryUserDetailsManager;
     }
 ​
     //使用全局自定义配置AuthenticationManager
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         auth.userDetailsService(userDetailsService());
     }
 ​
     //重写认证登录默认配置
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 //.mvcMatchers("/index").rememberMe()//指定资源开启记住我功能,其它不开启,需要认证
                 .anyRequest().authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .rememberMe()//开启RememberMe功能,重启服务之后登录页面出现Remember me on this computer.选择框
                 //.rememberMeParameter("RememberMe")//修改RememberMe名称
                 .rememberMeServices(rememberMeServices())
                 .and()
                 .csrf().disable();
     }
 ​
 ​
     //使用PersistentTokenBasedRememberMeServices更新Cookie,提高安全性
     //方式1:
     @Bean
     public RememberMeServices rememberMeServices() {
         //return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), new InMemoryTokenRepositoryImpl());
         //基于数据库实现,使用JdbcTokenRepository
         JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
         //指定数据源
         jdbcTokenRepository.setDataSource(dataSource);
         //使用rememberMeServices时第一次需要手动创建表结构,数据库直接使用security即可,启动服务进行登录后,会存储此次登录认证信息
         //jdbcTokenRepository.setCreateTableOnStartup(true);
         /**
          * create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
          */
         return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), jdbcTokenRepository);
     }
 }
 ​
  • 方式2:直接指定tokenRepository

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     //注入数据源
     private final DataSource dataSource;
 ​
     @Autowired
     public SecurityConfig(DataSource dataSource) {
         this.dataSource = dataSource;
     }
 ​
     //使用内存中的数据源
     @Bean
     public UserDetailsService userDetailsService() {
         InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
         inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("ADMIN").build());
         return inMemoryUserDetailsManager;
     }
 ​
     //使用全局自定义配置AuthenticationManager
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         auth.userDetailsService(userDetailsService());
     }
 ​
     //重写认证登录默认配置
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 //.mvcMatchers("/index").rememberMe()//指定资源开启记住我功能,其它不开启,需要认证
                 .anyRequest().authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .rememberMe()//开启RememberMe功能,重启服务之后登录页面出现Remember me on this computer.选择框
                 .tokenRepository(persistentTokenRepository())//方式2
                 //.rememberMeParameter("RememberMe")//修改RememberMe名称
                 //.rememberMeServices(rememberMeServices())//方式1
                 //.alwaysRemember(true)//总是记住我
                 .and()
                 .csrf().disable();
     }
     
     //方式2:指定数据库持久化
     @Bean
     public PersistentTokenRepository persistentTokenRepository() {
         JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
         jdbcTokenRepository.setDataSource(dataSource);
         jdbcTokenRepository.setCreateTableOnStartup(false);//第一次新建表结构时需要设置为true,第二次之后表已经存在需要设置为false,需要手动改一下
         return jdbcTokenRepository;
     }
     
     //使用PersistentTokenBasedRememberMeServices更新Cookie,提高安全性
     //方式1:
 //    @Bean
 //    public RememberMeServices rememberMeServices() {
 //        //return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), new InMemoryTokenRepositoryImpl());
 //        //基于数据库实现,使用JdbcTokenRepository
 //        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
 //        //指定数据源
 //        jdbcTokenRepository.setDataSource(dataSource);
 //        //使用rememberMeServices时第一次需要手动创建表结构,数据库直接使用security即可,启动服务进行登录后,会存储此次登录认证信息
 //        //jdbcTokenRepository.setCreateTableOnStartup(true);
 //        /**
 //         * create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
 //         */
 //        return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), jdbcTokenRepository);
 //    }
 }
 ​

5.3.5.4 启动项目登录+查看数据库

注意:启动项目后会自动在数据库中创建一个表persistent_logins,用来保存记住我的token信息

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第14张图片

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第15张图片

再次测试记住我:在测试发现即使服务器重新启动,依然可以自动登录。

5.3.6 自定义记住我

配套视频:44.传统web 开发自定义记住我功能_哔哩哔哩_bilibili

5.3.6.1 查看记住我源码

        AbstractUserDetailsAuthenticationProvider类中authenticate方法在最后认证成功之后实现了记住我功能,但是查看源码得知,如果开启记住我,必须进行相关的设置 :

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第16张图片successfulAuthentication方法: 

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第17张图片loginSuccess方法: 

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第18张图片

rememberMeRequested方法: 

【编程不良人】SpringSecurity实战学习笔记04---RememberMe_第19张图片 

5.3.6.2 传统 web 开发记住我实现

       通过源码分析得知,必须在认证请求中加入参数remember-me值为"true,on,yes,1"其中任意一个才可以完成记住我功能,这个时候修改认证界面:

  • 引入Thymeleaf依赖,配置Thymeleaf,新建登录页面引入remember-me

 
 
 
     
     登录
 
 
 

用户登录

 
    用户名:
    密码:
  记住我:
       
   
  • SecurityConfig配置中开启记住我

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
         @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 .....
                 .and()
                 .rememberMe() //开启记住我
                 //.alwaysRemember(true) 总是记住我
                 .and()
                 .csrf().disable();
     }
 }

5.3.6.3 前后端分离开发记住我实现

配套视频:45.前后端分离开发记住我实现_哔哩哔哩_bilibili

  • 自定义认证类 LoginFilter

 /**
  * 自定义前后端分离认证 Filter
  */
 public class LoginFilter extends UsernamePasswordAuthenticationFilter {
 ​
     @Override
     public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
         System.out.println("========================================");
         //1.判断是否是 post 方式请求
         if (!request.getMethod().equals("POST")) {
             throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
         }
         //2.判断是否是 json 格式请求类型
         if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
             //3.从 json 数据中获取用户输入用户名和密码进行认证 {"uname":"xxx","password":"xxx","remember-me":true}
             try {
                 Map userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
                 String username = userInfo.get(getUsernameParameter());
                 String password = userInfo.get(getPasswordParameter());
                 String rememberValue = userInfo.get(AbstractRememberMeServices.DEFAULT_PARAMETER);
                 if (!ObjectUtils.isEmpty(rememberValue)) {
                     request.setAttribute(AbstractRememberMeServices.DEFAULT_PARAMETER, rememberValue);
                 }
                 System.out.println("用户名: " + username + " 密码: " + password + " 是否记住我: " + rememberValue);
                 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                 setDetails(request, authRequest);
                 return this.getAuthenticationManager().authenticate(authRequest);
             } catch (IOException e) {
                 e.printStackTrace();
             }
         }
         return super.attemptAuthentication(request, response);
     }
 }
  • 自定义 RememberMeService

 /**
  * 自定义记住我 services 实现类
  */
 public class MyPersistentTokenBasedRememberMeServices extends PersistentTokenBasedRememberMeServices {
     public MyPersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
         super(key, userDetailsService, tokenRepository);
     }
     /**
      * 自定义前后端分离获取 remember-me 方式
      */
     @Override
     protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
         String paramValue = request.getAttribute(parameter).toString();
         if (paramValue != null) {
             if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
                     || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
                 return true;
             }
         }
         return false;
     }
 }
  • 配置记住我SecurityConfig

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     @Bean
     public UserDetailsService userDetailsService() {
         //.....
         return inMemoryUserDetailsManager;
     }
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         auth.userDetailsService(userDetailsService());
     }
 ​
     @Override
     @Bean
     public AuthenticationManager authenticationManagerBean() throws Exception {
         return super.authenticationManagerBean();
     }
 ​
     //自定义 filter 交给工厂管理
     @Bean
     public LoginFilter loginFilter() throws Exception {
         LoginFilter loginFilter = new LoginFilter();
         loginFilter.setFilterProcessesUrl("/doLogin");//指定认证 url
         loginFilter.setUsernameParameter("uname");//指定接收json 用户名 key
         loginFilter.setPasswordParameter("passwd");//指定接收 json 密码 key
         loginFilter.setAuthenticationManager(authenticationManagerBean());
         loginFilter.setRememberMeServices(rememberMeServices()); //设置认证成功时使用自定义rememberMeService
         //认证成功处理
         loginFilter.setAuthenticationSuccessHandler((req, resp, authentication) -> {
             Map result = new HashMap();
             result.put("msg", "登录成功");
             result.put("用户信息", authentication.getPrincipal());
             resp.setContentType("application/json;charset=UTF-8");
             resp.setStatus(HttpStatus.OK.value());
             String s = new ObjectMapper().writeValueAsString(result);
             resp.getWriter().println(s);
         });
         //认证失败处理
         loginFilter.setAuthenticationFailureHandler((req, resp, ex) -> {
             Map result = new HashMap();
             result.put("msg", "登录失败: " + ex.getMessage());
             resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
             resp.setContentType("application/json;charset=UTF-8");
             String s = new ObjectMapper().writeValueAsString(result);
             resp.getWriter().println(s);
         });
         return loginFilter;
     }
 ​
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeHttpRequests()
                 .anyRequest().authenticated()//所有请求必须认证
                 .and()
                 .formLogin()
                 .and()
                 .rememberMe() //开启记住我功能  cookie 进行实现  1.认证成功保存记住我 cookie 到客户端   2.只有 cookie 写入客户端成功才能实现自动登录功能
                 .rememberMeServices(rememberMeServices())  //设置自动登录使用哪个 rememberMeServices
                 .and()
                 .exceptionHandling()
                 .authenticationEntryPoint((req, resp, ex) -> {
                     resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                     resp.setStatus(HttpStatus.UNAUTHORIZED.value());
                     resp.getWriter().println("请认证之后再去处理!");
                 })
                 .and()
                 .logout()
                 .logoutRequestMatcher(new OrRequestMatcher(
                         new AntPathRequestMatcher("/logout", HttpMethod.DELETE.name()),
                         new AntPathRequestMatcher("/logout", HttpMethod.GET.name())
                 ))
                 .logoutSuccessHandler((req, resp, auth) -> {
                     Map result = new HashMap();
                     result.put("msg", "注销成功");
                     result.put("用户信息", auth.getPrincipal());
                     resp.setContentType("application/json;charset=UTF-8");
                     resp.setStatus(HttpStatus.OK.value());
                     String s = new ObjectMapper().writeValueAsString(result);
                     resp.getWriter().println(s);
                 })
                 .and()
                 .csrf().disable();
 ​
 ​
         // at: 用来某个 filter 替换过滤器链中哪个 filter
         // before: 放在过滤器链中哪个 filter 之前
         // after: 放在过滤器链中那个 filter 之后
         http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
     }
 ​
     @Bean
     public RememberMeServices rememberMeServices() {
         return new MyPersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), new InMemoryTokenRepositoryImpl());
     }
 }

前后端分离记住我实现有待完善!!!

你可能感兴趣的:(Spring,spring)