Spring Security 实现 Remember Me
source link: https://semlinker.com/spring-security-remember-me/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Remember Me即记住我,常用于 Web 应用的登录页目的是让用户选择是否记住用户的登录状态。当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中,而无需重新执行登录操作。相信国内很多开发者都使用过或听过一个 云端软件开发协作平台 —— 码云 ,下图是它的登录页:
由上图可知,登录页除了输入用户名和密码之外,还多了一个 记住我 的复选框,用于实现前面提到的 Remember Me 功能,接下来本文将重点介绍如何基于 Spring Security 实现 Remember Me 功能。
二、Remember Me 处理流程
在 Spring Security 中要实现 Remember Me 功能很简单,因为它内置的过滤器 RememberMeAuthenticationFilter 已经提供了该功能。在开始实战前,我们先来看一下 Remember Me 的运行流程。
三、Remember Me 实战
3.1 配置数据源
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/security?useUnicode=yes&characterEncoding=UTF-8&useSSL=false spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.username=root spring.datasource.password=
3.2 添加项目依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
3.3 配置 PersistentTokenRepository 对象
@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private DataSource dataSource; @Bean UserDetailsService myUserDetailService() { return new MyUserDetailsService(); } @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl(); persistentTokenRepository.setDataSource(dataSource); return persistentTokenRepository; } }
PersistentTokenRepository
为一个接口类,这里我们用的是数据库持久化,所以实际使用的 PersistentTokenRepository 实现类是 JdbcTokenRepositoryImpl
,使用它的时候需要指定数据源,所以我们需要将已配置的 dataSource
对象注入到 JdbcTokenRepositoryImpl
的 dataSource
属性中。
3.4 创建 persistent_logins 数据表
create table persistent_logins ( username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null )
3.5 添加 remember me 复选框
打开 resources/templates
路径下的 login.html
登录页,添加 Remember Me 复选框:
<div class="form-field"> Remember Me:<input type="checkbox" name="remember-me" value="true"/> </div>
注意:Remember Me 复选框的 name 属性的值必须为 “remember-me”
3.6 新增 remember me 配置项
protected void configure(HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login") .and() .authorizeRequests() .antMatchers("/authentication/require", "/login").permitAll() .anyRequest().authenticated() .and().csrf().disable() // 新增remember me配置信息 .rememberMe() .tokenRepository(persistentTokenRepository()) // 配置token持久化仓库 .tokenValiditySeconds(3600) // 过期时间,单位为秒 .userDetailsService(myUserDetailService()); // 处理自动登录逻辑 }
四、Remember Me 原理分析
4.1 首次登录过程
当我们首次在登录页执行登录时,登录的请求会由 UsernamePasswordAuthenticationFilter 过滤器进行处理,对于过滤器来说,它核心功能会定义在 doFilter 方法中,但该方法并不是定义在 UsernamePasswordAuthenticationFilter 过滤器中,而是定义在它的父类 AbstractAuthenticationProcessingFilter
中, doFilter
方法的定义如下:
//org/springframework/security/web/authentication/ // AbstractAuthenticationProcessingFilter.java(已省略部分代码) public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; // 若不需要认证,则执行下一个过滤器 if (!requiresAuthentication(request, response)) { chain.doFilter(request, response); return; } Authentication authResult; try { // 基于用户名和密码进行认证操作 authResult = attemptAuthentication(request, response); if (authResult == null) { return; } sessionStrategy.onAuthentication(authResult, request, response); } catch (AuthenticationException failed) { // 处理认证失败的逻辑 unsuccessfulAuthentication(request, response, failed); return; } successfulAuthentication(request, response, chain, authResult); }
在认证成功后,会调用 successfulAuthentication
方法,即执行认证成功回调函数:
// org/springframework/security/web/authentication/ // AbstractAuthenticationProcessingFilter.java protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { // 设置 SecurityContext 对象中的 authentication 属性 SecurityContextHolder.getContext().setAuthentication(authResult); rememberMeServices.loginSuccess(request, response, authResult); successHandler.onAuthenticationSuccess(request, response, authResult); }
在 successfulAuthentication 方法中,除了设置 SecurityContext 对象中的 authentication 属性之外,还会调用 rememberMeServices 对象的 loginSuccess 方法。这里的 rememberMeServices 是 RememberMeServices 接口实现类 PersistentTokenBasedRememberMeServices 所对应的实例,该实现类的定义如下:
// org/springframework/security/web/authentication/rememberme/ // PersistentTokenBasedRememberMeServices.java protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = successfulAuthentication.getName(); PersistentRememberMeToken persistentToken = new PersistentRememberMeToken( username, generateSeriesData(), generateTokenData(), new Date()); try { // 使用数据库持久化保存 persistentToken 并返回 remember-me Cookie tokenRepository.createNewToken(persistentToken); addCookie(persistentToken, request, response); } catch (Exception e) { logger.error("Failed to save persistent token ", e); } }
在 onLoginSuccess 方法内部,会利用认证成功返回的对象创建 persistentToken,然后利用 tokenRepository 对象(在 Remember Me 实战部分中配置的 PersistentTokenRepository Bean 对象)对 token 进行持久化处理。
@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { // 已省略部分代码 @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl(); persistentTokenRepository.setDataSource(dataSource); return persistentTokenRepository; } }
而 JdbcTokenRepositoryImpl 类中 createNewToken 方法的实现逻辑也很简单,就是利用 JdbcTemplate 把生成的 token 插入到 persistent_logins
数据表中:
// org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository { public void createNewToken(PersistentRememberMeToken token) { getJdbcTemplate().update(insertTokenSql, token.getUsername(), token.getSeries(), token.getTokenValue(), token.getDate()); } }
相应的数据库插入语句如下:
insert into persistent_logins (username, series, token, last_used) values(?,?,?,?);
成功执行插入语句后,在数据库 persistent_logins 表中会新增一条记录:
除此之外,在 onLoginSuccess 方法中还会调用 addCookie 添加相应的 Cookie。为了更加直观的感受 addCookie
方法最终达到的效果,我们来看一下实战部分勾选 Remember Me 复选框后登录成功后返回的响应体:
通过上图可知,在勾选 Remember Me 复选框成功登录之后,除了设置常见的 JSESSIONID Cookie 之外,还会进一步设置 remember-me Cookie。
4.2 Remember Me Cookie 校验流程
在成功设置 remember-me Cookie 之后,当前站点下所发起的 HTTP 请求的请求头都会默认带上 Cookie 信息,它包含两部分信息,即 JSESSIONID 和 remember-me Cookie 信息。
这里 remember-me Cookie 的认证处理也会交由 Spring Security 内部的 RememberMeAuthenticationFilter
过滤器来处理。与分析 UsernamePasswordAuthenticationFilter 过滤器一样,我们也先来看一下该过滤器的 doFilter 方法:
// org/springframework/security/web/authentication/rememberme/ // RememberMeAuthenticationFilter.java(已省略部分代码) public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; // 若SecurityContext上下文对象的认证信息为null,则执行自动登录操作 if (SecurityContextHolder.getContext().getAuthentication() == null) { Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null) { try { // 调用authenticationManager对象进行认证,最终调用RememberMeAuthenticationProvider // 对象的authenticate方法进行认证 rememberMeAuth = authenticationManager.authenticate(rememberMeAuth); SecurityContextHolder.getContext().setAuthentication(rememberMeAuth); onSuccessfulAuthentication(request, response, rememberMeAuth); if (successHandler != null) { successHandler.onAuthenticationSuccess(request, response, rememberMeAuth); return; } } catch (AuthenticationException authenticationException) { rememberMeServices.loginFail(request, response); onUnsuccessfulAuthentication(request, response, authenticationException); } } chain.doFilter(request, response); } else { chain.doFilter(request, response); } }
在 doFilter 方法中,若发现 SecurityContext 上下文对象的认证信息为 null,则执行自动登录操作就是通过调用rememberMeServices 对象的 autoLogin
方法来实现:
// org/springframework/security/web/authentication/rememberme/ // AbstractRememberMeServices.java public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) { // 从请求中抽取remember-me Cookie // SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me"; String rememberMeCookie = extractRememberMeCookie(request); if (rememberMeCookie == null) { return null; } // 若remember-me Cookie长度为零,则在响应头中设置它的maxAge属性为0 // 用于禁用持久化登录 if (rememberMeCookie.length() == 0) { logger.debug("Cookie was empty"); cancelCookie(request, response); return null; } UserDetails user = null; try { // 执行解码操作,使用":"分隔符进行切割,转换成token字符串数组 String[] cookieTokens = decodeCookie(rememberMeCookie); user = processAutoLoginCookie(cookieTokens, request, response); userDetailsChecker.check(user); logger.debug("Remember-me cookie accepted"); // 创建RememberMeAuthenticationToken对象 return createSuccessfulAuthentication(request, user); } catch (CookieTheftException cte) { cancelCookie(request, response); throw cte; } // 省略UsernameNotFoundException、InvalidCookieException和AccountStatusException // 异常处理逻辑 catch (RememberMeAuthenticationException e) { logger.debug(e.getMessage()); } cancelCookie(request, response); return null; }
在 autoLogin 方法中,会使用 decodeCookie 方法对 remember-me Cookie 执行解码操作,然后使用 :
分隔符进行切割拆分为 tokens 字符串数组,我本机的解码结果如下:
在完成 cookie 解码之后,会尝试使用该 cookie 进行自动登录,即调用内部的 processAutoLoginCookie 方法,该方法内部的执行流程如下:
-
使用 presentedSeries(series) 作为参数调用 tokenRepository 对象的 getTokenForSeries 方法获取 token (PersistentRememberMeToken) 对象,然后对返回的 token 执行校验,比如判空或有效期验证;
-
验证通过后重新生成新的 newToken (PersistentRememberMeToken)并更新数据库中相应的记录值;
-
使用前面从数据库中获得的 token 对象,并以 token 的用户名作为参数调用 UserDetailsService 对象的 loadUserByUsername 方法加载用户的详细信息。
// org/springframework/security/web/authentication/rememberme/ // PersistentTokenBasedRememberMeServices.java protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) { final String presentedSeries = cookieTokens[0]; final String presentedToken = cookieTokens[1]; PersistentRememberMeToken token = tokenRepository .getTokenForSeries(presentedSeries); // 省略token判空校验、presentedToken与数据库token相等校验和token有效期校验逻辑 PersistentRememberMeToken newToken = new PersistentRememberMeToken( token.getUsername(), token.getSeries(), generateTokenData(), new Date()); try { tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate()); addCookie(newToken, request, response); } catch (Exception e) { logger.error("Failed to update token: ", e); throw new RememberMeAuthenticationException( "Autologin failed due to data access problem"); } return getUserDetailsService().loadUserByUsername(token.getUsername()); }
rememberMeServices 对象的 autoLogin 方法,在登录成功后会返回 RememberMeAuthenticationToken 对象,之后 RememberMeAuthenticationFilter 过滤器会继续调用 authenticationManager 对象执行认证,而最终调用 RememberMeAuthenticationProvider 对象的 authenticate 方法进行认证,认证成功后会前往下一个过滤器进行处理。
本文项目地址: Github - remember-me
全栈修仙之路,及时阅读 Angular、TypeScript、Node.js/Java和Spring技术栈最新文章。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK