配套视频:38.RememberMe简介_哔哩哔哩_bilibili
简介
基本使用
原理分析
持久化令牌
RememberMe (记住我、记住密码下次自动登录) 这个功能非常常见,下图就是QQ邮箱登录时的“记住我” 选项。
提到 RememberMe,一些初学者往往会有一些误解,认为RememberMe功能就是把用户名/密码用Cookie保存在浏览器中,下次登录时不用再次输入用户名/密码,这个理解显然是不对的。我们这里所说的 RememberMe是一种服务器端的行为,传统的登录方式基于 Session会话,一旦用户的会话超时过期(一般为会话时间为30分钟),就要再次登录,这样太过于烦琐。如果能有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多,RememberMe 就是为了解决这一需求而生的。
具体的实现思路就是通过 Cookie 来记录当前用户身份,当用户登录成功之后,会通过一定算法,将用户信息、时间戳等进行加密,加密完成后,通过响应头带回前端存储在cookie中,当浏览器会话过期之后,如果再次访问该网站,会自动将 Cookie 中的信息发送给服务器,服务器对 Cookie中的信息进行校验分析,进而确定出用户的身份,Cookie中所保存的用户信息也是有时效的,例如三天、一周等(时间越长,风险越大,没有绝对的安全)。
创建Spring Initializr项目spring-security-08,引入Spring Web、Spring Security依赖,新建config、controller包。
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(); } }
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"; } }
以debug方式启动项目,访问:http://localhost:8080/index ,输入用户名root、密码123进行等登录,服务器会话默认失效时间为30分钟,30分钟后需要再次登录,此处为演示此过程,将服务器端会话失效时间设置为1分钟,具体配置在application.properties中:
# 修改服务器会话过期时间(单位:分钟) server.servlet.session.timeout=1
重启服务后,按照上述方式进行登录后,等待1分钟,刷新页面,发现此时需要再次登录,说明之前登录的用户名密码已经失效。
配套视频:39.RememberMe的基本使用_哔哩哔哩_bilibili
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 me on this computer.
配套视频:40.RememberMe 原理分析_哔哩哔哩_bilibili
在上图中我们在SecurityConfig配置中开启了"记住我"功能之后,在进行认证时如果勾选了"记住我"选项,此时打开浏览器控制台,进而分析整个登录过程。首先当我们登录时,在登录请求中多了一个 RememberMe 的参数。
很显然,这个参数就是告诉服务器应该开启 RememberMe功能的。如果自定义登录页面开启 RememberMe功能应该多加入一个一样的请求参数就可以啦,该请求会被 RememberMeAuthenticationFilter
进行拦截然后自动登录具体参见源码:
具体过程如下:
(1)请求到达过滤器之后,首先判断 SecurityContextHolder中是否有值,没值的话表示用户尚未登录,此时调用 autoLogin方法进行自动登录。
(2)当自动登录成功后返回的rememberMeAuth不为null时,表示自动登录成功,此时调用authenticate方法对 key 进行校验,并且将登录成功的用户信息保存到 SecurityContextHolder 对象中,然后调用登录成功回调,并发布登录成功事件。需要注意的是,登录成功的回调并不包含 RememberMeServices 中的 loginSuccess 方法。
(3)如果自动登录失败,则调用 remenberMeServices.loginFail方法处理登录失败回调。onUnsuccessfulAuthentication 和 onSuccessfulAuthentication 都是该过滤器中定义的空方法,并没有任何实现,这就是 RememberMeAuthenticationFilter 过滤器所做的事情,成功将 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); }
这里一共定义了三个方法:
autoLogin 方法:可以从请求中提取出需要的参数,完成自动登录功能。
loginFail 方法:自动登录失败的回调。
1oginSuccess 方法:自动登录成功的回调。
RememberMeServices的实现类为AbstractRememberMeServices,AbstractRememberMeServices的子类为PersistentTokenBasedRememberMeServices和TokenBasedRememberMeServices,默认实现的是TokenBasedRememberMeServices:
在开启RememberMe后,如果没有加入额外配置,默认实现就是由TokenBasedRememberMeServices进行的实现。查看这个类源码中 processAutoLoginCookie 方法实现:
processAutoLoginCookie 方法主要用来验证 Cookie 中的令牌信息是否合法:
首先判断cookieTokens的长度是否为3,不为3时说明格式不对,直接抛出异常。
从cookieTokens 数组中提取出第 1项,也就是过期时间,判断令牌是否过期,如果己经过期,则拋出异常。
根据用户名 (cookieTokens 数组的第0项)查询出当前用户对象。
调用 makeTokenSignature 方法生成一个签名,签名的生成过程如下:首先将用户名、令牌过期时间、用户密码以及 key 组成一个宇符串,中间用“:”隔开,然后通过 MD5 消息摘要算法对该宇符串进行加密,并将加密结果转为一个字符串返回。
判断第4 步生成的签名和通过 Cookie 传来的签名是否相等(即 cookieTokens 数组的第2项),如果相等,表示令牌合法,则直接返回用户对象,否则拋出异常。
在这个回调中,首先获取用户经和密码信息,如果用户密码在用户登录成功后从successfulAuthentication对象中擦除,则从数据库中重新加载出用户密码。
计算出令牌的过期时间,令牌默认有效期是两周。
根据令牌的过期时间、用户名以及用户密码,计算出一个签名。
调用 setCookie 方法设置 Cookie, 第一个参数是一个数组,数组中一共包含三项:用户名、过期时间以及签名,在setCookie 方法中会将数组转为字符串,并进行 Base64编码后响应给前端。
总结
当用户通过用户名/密码的形式登录成功后,系统会根据用户的用户名、密码以及令牌的过期时间计算出一个签名,这个签名使用 MD5 消息摘要算法生成,是不可逆的。然后再将用户名、令牌过期时间以及签名拼接成一个字符串,中间用“:” 隔开,对拼接好的字符串进行Base64 编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。当会话过期之后,访问系统资源时会自动携带上Cookie中的令牌,服务端拿到 Cookie中的令牌后,先进行 Bae64解码,解码后分别提取出令牌中的三项数据;接着根据令牌中的数据判断令牌是否已经过期,如果没有过期,则根据令牌中的用户名查询出用户信息;接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示会牌是合法令牌,自动登录成功,否则自动登录失败。
以上过程中可以在浏览器的cookies中获得token信息,说明还不安全,下面会围绕此进行展开。
配套视频:41.RememberMe 原理分析图解_哔哩哔哩_bilibili
配套视频:42.Remember-Me 提高安全性_哔哩哔哩_bilibili
基于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); } }
源码分析:
不同于 TokonBasedRemornberMeServices 中的 processAutologinCookie 方法,这里cookieTokens 数组的长度为2,第一项是series,第二项是 token。
从cookieTokens数组中分到提取出 series和token,然后根据 series 去内存中查询出一个 PersistentRememberMeToken对象。如果查询出来的对象为null,表示内存中并没有series对应的值,本次自动登录失败。如果查询出来的 token 和从 cookieTokens 中解析出来的token不相同,说明自动登录会牌已经泄漏(恶意用户利用令牌登录后,内存中的token变了),此时移除当前用户的所有自动登录记录并抛出异常。
根据数据库中查询出来的结果判断令牌是否过期,如果过期就抛出异常。
生成一个新的 PersistentRememberMeToken 对象,用户名和series 不变,token 重新生成,date 也使用当前时间。newToken生成后,根据 series 去修改内存中的 token和 date(即每次自动登录后都会产生新的 token和 date)。
调用 addCookie 方法添加 Cookie, 在addCookie 方法中,会调用到我们前面所说的setCookie 方法,但是要注意第一个数组参数中只有两项:series 和 token(即返回到前端的令牌是通过对 series 和 token 进行 Base64 编码得到的)。
最后将根据用户名查询用户对象并返回。
@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分钟会话过期后,刷新页面
由于以上令牌都是保存在内存中的,内存中的令牌在应用程序重启之后,即使之前做过“记住我”操作,之后也无法再实现记住我功能。
配套视频: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信息记录。
com.alibaba druid 1.2.8 mysql mysql-connector-java 5.1.38 org.mybatis.spring.boot mybatis-spring-boot-starter 2.2.0
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
方式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); // } }
注意:启动项目后会自动在数据库中创建一个表persistent_logins,用来保存记住我的token信息
再次测试记住我:在测试发现即使服务器重新启动,依然可以自动登录。
配套视频:44.传统web 开发自定义记住我功能_哔哩哔哩_bilibili
AbstractUserDetailsAuthenticationProvider类中authenticate方法在最后认证成功之后实现了记住我功能,但是查看源码得知,如果开启记住我,必须进行相关的设置 :
successfulAuthentication方法:
loginSuccess方法:
rememberMeRequested方法:
通过源码分析得知,必须在认证请求中加入参数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(); } }
配套视频: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 { MapuserInfo = 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) -> { Mapresult = 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()); } }
前后端分离记住我实现有待完善!!!