Remember Me即记住我,常用于 Web 应用的登录页目的是让用户选择是否记住用户的登录状态。当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中,而无需重新执行登录操作。相信国内很多开发者都使用过或听过一个 云端软件开发协作平台 —— 码云 ,下图是它的登录页:
由上图可知,登录页除了输入用户名和密码之外,还多了一个 记住我 的复选框,用于实现前面提到的 Remember Me 功能,接下来本文将重点介绍如何基于 Spring Security 实现 Remember Me 功能。
在 Spring Security 中要实现 Remember Me 功能很简单,因为它内置的过滤器 RememberMeAuthenticationFilter 已经提供了该功能。在开始实战前,我们先来看一下 Remember Me 的运行流程。
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=
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
@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
属性中。
create table persistent_logins ( username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null )
打开 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”
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()); // 处理自动登录逻辑 }
当我们首次在登录页执行登录时,登录的请求会由 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。
在成功设置 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技术栈最新文章。