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


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

    • 简介

    • 基本使用

    • 原理分析

    • 持久化令牌

    5.1 RememberMe简介

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

           提到 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分钟后过期了。

    补充:

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

    5.3.2 RememberMe原理分析

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

    5.3.2.1 RememberMeAuthenticationFilter

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

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

    具体过程如下:

           (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:

    5.3.2.3 TokenBasedRememberMeServices

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

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

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

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

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

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

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

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

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

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

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

    总结

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

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

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

    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)登录成功时

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

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

    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信息

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

    5.3.6 自定义记住我

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

    5.3.6.1 查看记住我源码

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

    successfulAuthentication方法: 

    loginSuccess方法: 

    rememberMeRequested方法: 

     

    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());
         }
     }

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

  • 相关阅读:
    react native使用5-搭建ios环境
    Vue——属性监听器(面试)、自定义指令(面试)
    npm 重要知识
    Linux学习(4)——Linux目录结构
    ios + vue3 Teleport + inset 兼容性问题
    【工具篇】SQLite本地数据库在Unity3D的应用
    适用于多种场景功能强大的在线海报图片素材设计器源码
    react经验1:监听窗体尺寸变化
    java EE初阶 — volatile关键字保证内存可见性
    RocketMQ 顺序消息解析——图解、源码级解析
  • 原文地址:https://blog.csdn.net/xiaocui1995/article/details/126564192