本文在前文 Spring Security 入门(二):图形验证码和手机短信验证码 的基础上介绍 Remember-Me 功能和注销登录。
Remember-Me 功能
在实际开发中,为了用户登录方便常常会启用记住我(Remember-Me)功能。如果用户登录时勾选了“记住我”选项,那么在一段有效时间内,会默认自动登录,免去再次输入用户名、密码等登录操作。该功能的实现机理是根据用户登录信息生成 Token 并保存在用户浏览器的 Cookie 中,当用户需要再次登录时,自动实现校验并建立登录态的一种机制。
Spring Security
提供了两种 Remember-Me 的实现方式:
简单加密 Token:用散列算法加密用户必要的登录系信息并生成 Token 令牌。
持久化 Token:数据库等持久性数据存储机制用的持久化 Token 令牌。
基本原理
Remember-Me 功能的开启需要在configure(HttpSecurity http)
方法中通过http.rememberMe()
配置,该配置主要会在过滤器链中添加 RememberMeAuthenticationFilter 过滤器,通过该过滤器实现自动登录。该过滤器的位置在其它认证过滤器之后,其它认证过滤器没有进行认证处理时,该过滤器尝试工作:
注意: Remember-Me 功能是用于再次登录(认证)的,而不是再次请求。工作流程如下:
当用户成功登录认证后,浏览器中存在两个 Cookie,一个是 remember-me,另一个是 JSESSIONID。用户再次请求访问时,请求首先被 SecurityContextPersistenceFilter 过滤器拦截,该过滤器会根据 JSESSIONID 获取对应 Session 中存储的 SecurityContext 对象。如果获取到的 SecurityContext 对象中存储了认证用户信息对象 Authentiacaion,也就是说线程可以直接获得认证用户信息,那么后续的认证过滤器不需要对该请求进行拦截,remember-me 不起作用。
当 JSESSIONID 过期后,浏览器中只存在 remember-me 的 Cookie。用户再次请求访问时,由于请求没有携带 JSESSIONID,SecurityContextPersistenceFilter 过滤器无法获取 Session 中的 SecurityContext 对象,也就没法获得认证用户信息,后续需要进行登录认证。如果没有 remember-me 的 Cookie,浏览器会重定向到登录页面进行表单登录认证;但是 remember-me 的 Cookie 存在,RememberMeAuthenticationFilter 过滤器会将请求进行拦截,根据 remember-me 存储的 Token 值实现自动登录,并将成功登录后的认证用户信息对象 Authentiacaion 存储到 SecurityContext 中。当响应返回时,SecurityContextPersistenceFilter 过滤器会将 SecurityContext 存储在 Session 中,下次请求又通过 JSEESIONID 获取认证用户信息。
总结: remember-me 只有在 JSESSIONID 失效和前面的过滤器认证失败或者未进行认证时才发挥作用。此时,只要 remember-me 的 Cookie 不过期,我们就不需要填写登录表单,就能实现再次登录,并且 remember-me 自动登录成功之后,会生成新的 Token 替换旧的 Token,相应 Cookie 的 Max-Age 也会重置。
此处对http.rememberMe()
返回值的主要方法进行说明,这些方法涉及 Remember-Me 配置,具体如下:
rememberMeParameter(String rememberMeParameter)
:指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
。
key(String key)
:“记住我”的 Token 中的标识字段,默认是一个随机的 UUID 值。
tokenValiditySeconds(int tokenValiditySeconds)
:“记住我” 的 Token 令牌有效期,单位为秒,即对应的 cookie 的 Max-Age 值,默认时间为 2 周。
userDetailsService(UserDetailsService userDetailsService)
:指定 Remember-Me 功能自动登录过程使用的 UserDetailsService 对象,默认使用 Spring 容器中的 UserDetailsService 对象.
tokenRepository(PersistentTokenRepository tokenRepository)
:指定 TokenRepository 对象,用来配置持久化 Token。
alwaysRemember(boolean alwaysRemember)
:是否应该始终创建记住我的 Token,默认为 false。
useSecureCookie(boolean useSecureCookie)
:是否设置 Cookie 为安全,如果设置为 true,则必须通过 https 进行连接请求。
简单加密 Token(基本使用)
在用户选择“记住我”登录并成功认证后,Spring Security
将默认会生成一个名为 remember-me 的 Cookie 存储 Token 并发送给浏览器;用户注销登录后,该 Cookie 的 Max-Age 会被设置为 0,即删除该 Cookie。Token 值由下列方式组合而成:
1 2 base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))
其中,username 代表用户名;password 代表用户密码;expirationTime 表示记住我的 Token 的失效日期,以毫秒为单位;key 表示防止修改 Token 的标识,默认是一个随机的 UUID 值。具体使用如下:
☕️ 修改 login.html 和 login-mobile.html,在登录表单中添加“记住我”选项
1 <div > <input name ="remember-me" type ="checkbox" > 记住我</div >
以 login.html 为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <!DOCTYPE html > <html lang ="en" xmlns:th ="http://www.thymeleaf.org" > <head > <meta charset ="UTF-8" > <title > 登录</title > </head > <body > <h3 > 表单登录</h3 > <form method ="post" th:action ="@{/login/form}" > <input type ="text" name ="name" placeholder ="用户名" > <br > <input type ="password" name ="pwd" placeholder ="密码" > <br > <input name ="imageCode" type ="text" placeholder ="验证码" > <br > <img th:onclick ="this.src='/code/image?'+Math.random()" th:src ="@{/code/image}" alt ="验证码" /> <br > <div th:if ="${param.error}" > <span th:text ="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style ="color:red" > 用户名或密码错误</span > </div > <div > <input name ="remember-me" type ="checkbox" > 记住我</div > <button type ="submit" > 登录</button > </form > </body > </html >
☕️ 修改安全配置类 SpringSecurityConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService userDetailsService; @Override protected void configure (HttpSecurity http) throws Exception { http.rememberMe() .rememberMeParameter("remember-me" ) .tokenValiditySeconds(200 ) .userDetailsService(userDetailsService); http.logout() .logoutUrl("/logout" ) .logoutSuccessUrl("/login/page?logout" ); } }
☕️ 测试
访问localhost:8080/login/page
,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:
成功登录认证后,在返回的响应头中可以找到 key 为 JSESSIONID 的 Cookie,生命周期为浏览器关闭时就删除;key 为 remember-me 的 Cookie,Max-age 为 200 秒:
访问localhost:8080/logout
,注销登录,在返回的响应头中可以找到 remember-me 的 Cookie,Max-Age 被设置为 0,即删除该 Cookie:
简单加密 Token(源码分析)
首次登录
⭐️ AbstractAuthenticationProcessingFilter#successfulAuthentication
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware , MessageSourceAware { protected void successfulAuthentication (HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { if (this .logger.isDebugEnabled()) { this .logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult); } SecurityContextHolder.getContext().setAuthentication(authResult); this .rememberMeServices.loginSuccess(request, response, authResult); if (this .eventPublisher != null ) { this .eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this .getClass())); } this .successHandler.onAuthenticationSuccess(request, response, authResult); } }
用户登录后,在成功认证处理时,上述(2)过程会调用 AbstractRememberMeServices 的 loginSuccess() 方法进行 Remember-Me 处理。
⭐️ AbstractRememberMeServices#loginSuccess
1 2 3 4 5 6 7 8 9 10 11 12 13 public abstract class AbstractRememberMeServices implements RememberMeServices , InitializingBean , LogoutHandler { public final void loginSuccess (HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { if (!this .rememberMeRequested(request, this .parameter)) { this .logger.debug("Remember-me login not requested." ); } else { this .onLoginSuccess(request, response, successfulAuthentication); } } }
⭐ TokenBasedRememberMeServices#onLoginSuccess
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class TokenBasedRememberMeServices extends AbstractRememberMeServices { public void onLoginSuccess (HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = this .retrieveUserName(successfulAuthentication); String password = this .retrievePassword(successfulAuthentication); if (!StringUtils.hasLength(username)) { this .logger.debug("Unable to retrieve username" ); } else { if (!StringUtils.hasLength(password)) { UserDetails user = this .getUserDetailsService().loadUserByUsername(username); password = user.getPassword(); if (!StringUtils.hasLength(password)) { this .logger.debug("Unable to obtain password for user: " + username); return ; } } int tokenLifetime = this .calculateLoginLifetime(request, successfulAuthentication); long expiryTime = System.currentTimeMillis(); expiryTime += 1000L * (long )(tokenLifetime < 0 ? 1209600 : tokenLifetime); String signatureValue = this .makeTokenSignature(expiryTime, username, password); this .setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}, tokenLifetime, request, response); if (this .logger.isDebugEnabled()) { this .logger.debug("Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'" ); } } } }
二次登陆
✏️ RememberMeAuthenticationFilter#doFilter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware { public void doFilter (ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; if (SecurityContextHolder.getContext().getAuthentication() == null ) { Authentication rememberMeAuth = this .rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null ) { try { rememberMeAuth = this .authenticationManager.authenticate(rememberMeAuth); SecurityContextHolder.getContext().setAuthentication(rememberMeAuth); this .onSuccessfulAuthentication(request, response, rememberMeAuth); if (this .logger.isDebugEnabled()) { this .logger.debug("SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'" ); } if (this .eventPublisher != null ) { this .eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this .getClass())); } if (this .successHandler != null ) { this .successHandler.onAuthenticationSuccess(request, response, rememberMeAuth); return ; } } catch (AuthenticationException var8) { if (this .logger.isDebugEnabled()) { this .logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '" + rememberMeAuth + "'; invalidating remember-me token" , var8); } this .rememberMeServices.loginFail(request, response); this .onUnsuccessfulAuthentication(request, response, var8); } } chain.doFilter(request, response); } else { if (this .logger.isDebugEnabled()) { this .logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'" ); } chain.doFilter(request, response); } } }
上述的(2)过程调用 AbstractRememberMeServices 的 autoLogin() 方法实现自动登录,获取用户信息。
✏️ AbstractRememberMeServices#autoLogin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public abstract class AbstractRememberMeServices implements RememberMeServices , InitializingBean , LogoutHandler { public final Authentication autoLogin (HttpServletRequest request, HttpServletResponse response) { String rememberMeCookie = this .extractRememberMeCookie(request); if (rememberMeCookie == null ) { return null ; } else { this .logger.debug("Remember-me cookie detected" ); if (rememberMeCookie.length() == 0 ) { this .logger.debug("Cookie was empty" ); this .cancelCookie(request, response); return null ; } else { UserDetails user = null ; try { String[] cookieTokens = this .decodeCookie(rememberMeCookie); user = this .procssAutoLoginCookie(cookieTokens, request, response); this .userDetailsChecker.check(user); this .logger.debug("Remember-me cookie accepted" ); return this .createSuccessfulAuthentication(request, user); } catch (CookieTheftException var6) { } this .cancelCookie(request, response); return null ; } } } }
上述的(3)过程调用 TokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法获取用户信息。
✏️ TokenBasedRememberMeServices#processAutoLoginCookie
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class TokenBasedRememberMeServices extends AbstractRememberMeServices { protected UserDetails processAutoLoginCookie (String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) { if (cookieTokens.length != 3 ) { throw new InvalidCookieException("Cookie token did not contain 3 tokens, but contained '" + Arrays.asList(cookieTokens) + "'" ); } else { long tokenExpiryTime; try { tokenExpiryTime = new Long(cookieTokens[1 ]); } catch (NumberFormatException var8) { throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1 ] + "')" ); } if (this .isTokenExpired(tokenExpiryTime)) { throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')" ); } else { UserDetails userDetails = this .getUserDetailsService().loadUserByUsername(cookieTokens[0 ]); Assert.notNull(userDetails, () -> { return "UserDetailsService " + this .getUserDetailsService() + " returned null for username " + cookieTokens[0 ] + ". This is an interface contract violation" ; }); String expectedTokenSignature = this .makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword()); if (!equals(expectedTokenSignature, cookieTokens[2 ])) { throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2 ] + "' but expected '" + expectedTokenSignature + "'" ); } else { return userDetails; } } } } }
持久化 Token(原理分析)
在用户选择“记住我”成功登录认证后,默认会生成一个名为 remember-me 的 Cookie 储存 Token,并发送给浏览器,具体实现流程如下:
用户选择“记住我”功能成功登录认证后,Spring Security
会把用户名 username、序列号 series、令牌值 token 和最后一次使用自动登录的时间 last_used 作为一条 Token 记录存入数据库表中,同时生成一个名为 remember-me 的 Cookie 存储series:token
的 base64 编码,该编码为发送给浏览器的 Token。
当用户需要再次登录时,RememberMeAuthenticationFilter 过滤器首先会检查请求是否有 remember-me 的 Cookie。如果存在,则检查其 Token 值中的 series 和 token 字段是否与数据库中的相关记录一致,一致则通过验证,并且系统重新生成一个新 token 值替换数据库中对应记录的旧 token,该记录的序列号 series 保持不变,认证时间 last_used 更新,同时重新生成新的 Token(旧 series : 新 token)通过 Cookie 发送给浏览器,remember-me 的 Cookie 的 Max-Age 也因此重置。
上述验证通过后,获取数据库中对应 Token 记录的 username 字段,调用 UserDetailsService 获取用户信息。之后进行登录认证,认证成功后将认证用户信息 Authentication 对象存入 SecurityContext。
如果对应的 Cookie 值包含的 token 字段与数据库中对应 Token 记录的 token 字段不匹配,则有可能是用户的 Cookie 被盗用,这时将会删除数据库中与当前用户相关的所有 Token 记录,用户需要重新进行表单登录。
如果对应的 Cookie 不存在,或者其值包含的 series 和 token 字段与数据库中的记录不匹配,则用户需要重新进行表单登录。如果用户退出登录,则删除数据库中对应的 Token 记录,并将相应的 Cookie 的 Max-Age 设置为 0。
在实现上,Spring Security
使用 PersistentRememberMeToken 来表明一个验证实体:
1 2 3 4 5 6 7 8 public class PersistentRememberMeToken { private final String username; private final String series; private final String tokenValue; private final Date date; }
对应的,在数据库需要有一张 persistent_logins 表(存储自动登录信息的表),表结构如下:
1 2 3 4 5 6 CREATE TABLE `persistent_logins` ( `username` varchar (64 ) NOT NULL , `series` varchar (64 ) PRIMARY KEY, `token` varchar (64 ) NOT NULL , `last_used` timestamp NOT NULL );
由于需要使用持久化 Token 方案,所以需要定制 tokenRepository,用于与数据库表的交互。为此,我们需要创建一个 PersistentTokenRepository 实例,该实例中定义了持久化令牌的一些必要方法:
1 2 3 4 5 6 7 8 9 public interface PersistentTokenRepository { void createNewToken (PersistentRememberMeToken var1) ; void updateToken (String var1, String var2, Date var3) ; PersistentRememberMeToken getTokenForSeries (String var1) ; void removeUserTokens (String var1) ; }
我们可以自定义实现 PersistentTokenRepository 接口,也可以使用Spring Security
提供的 JDBC 方案实现:
1 2 3 4 5 6 7 8 9 10 11 12 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 = ?" ; }
持久化 Token(基本使用)
☕ 创建数据库表 persistent_logins,用于存储自动登录信息
1 2 3 4 5 6 CREATE TABLE `persistent_logins` ( `username` varchar (64 ) NOT NULL , `series` varchar (64 ) PRIMARY KEY, `token` varchar (64 ) NOT NULL , `last_used` timestamp NOT NULL );
☕ 修改安全配置类 SpringSecurityConfig,使用持久化 Token 方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private DataSource dataSource; @Bean public JdbcTokenRepositoryImpl tokenRepository () { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); return jdbcTokenRepository; } @Override protected void configure (HttpSecurity http) throws Exception { http.rememberMe() .rememberMeParameter("remember-me" ) .tokenValiditySeconds(200 ) .tokenRepository(tokenRepository()) .userDetailsService(userDetailsService); http.logout() .logoutUrl("/logout" ) .logoutSuccessUrl("/login/page?logout" ); } }
完整的安全配置类 SpringSecurityConfig 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 package com.example.config;import com.example.config.security.CustomAuthenticationFailureHandler;import com.example.config.security.CustomAuthenticationSuccessHandler;import com.example.config.security.ImageCodeValidateFilter;import com.example.config.security.mobile.MobileAuthenticationConfig;import com.example.service.CustomUserDetailsService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;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.builders.WebSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;import javax.sql.DataSource;@EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private CustomAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private CustomAuthenticationFailureHandler authenticationFailureHandler; @Autowired private ImageCodeValidateFilter imageCodeValidateFilter; @Autowired private MobileAuthenticationConfig mobileAuthenticationConfig; @Autowired private DataSource dataSource; @Bean public JdbcTokenRepositoryImpl tokenRepository () { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); return jdbcTokenRepository; } @Bean public BCryptPasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder(); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login/page" ) .loginProcessingUrl("/login/form" ) .usernameParameter("name" ) .passwordParameter("pwd" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler); http.authorizeRequests() .antMatchers("/login/page" , "/code/image" ,"/mobile/page" , "/code/mobile" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAuthority("ROLE_USER" ) .anyRequest().authenticated(); http.csrf().disable(); http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); http.apply(mobileAuthenticationConfig); http.rememberMe() .rememberMeParameter("remember-me" ) .tokenValiditySeconds(200 ) .tokenRepository(tokenRepository()) .userDetailsService(userDetailsService); http.logout() .logoutUrl("/logout" ) .logoutSuccessUrl("/login/page?logout" ); } @Override public void configure (WebSecurity web) throws Exception { web.ignoring().antMatchers("/**/*.css" , "/**/*.js" , "/**/*.png" , "/**/*.jpg" , "/**/*.jpeg" ); } }
☕ 测试
访问localhost:8080/login/page
,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:
首次登录
成功登录认证后,可以在对应的数据表中找到相关 Token 记录:
在浏览器返回的响应头中可以找到 key 为 JSESSIONID 的 Cookie,生命周期为浏览器关闭时就删除;key 为 remember-me 的 Cookie,Max-age 为 200 秒:
上图中,如果对 remember-me 的 Cookie 值进行 base64 解码,可以发现解码后的字符串就是series:token
。
二次登录
将浏览器保存的 JSESSIONID 删除,只保留 remember-me 的 Cookie。访问localhost:8080
,查看请求头和响应头:
从上图可以看出,请求头中只携带 remember-me 的 Cookie,响应头返回新的 JSESSIONID 和 remember-me 的 Cookie。对比上面两张图,明显可以发现 remember-me 的 Cookie 值改变,并且该 Cookie 的 Max-Age 重置。查看数据库表,可以发现 token 字段改变,last_used 字段更新:
注销登录
访问localhost:8080/logout
,注销登录,数据库表中对应的 Token 记录会被删除:
在返回的响应头中可以找到 remember-me 的 Cookie,Max-Age 被设置为 0,即删除该 Cookie:
持久化 Token(源码分析)
首次登录
⭐️ AbstractAuthenticationProcessingFilter#successfulAuthentication
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware , MessageSourceAware { protected void successfulAuthentication (HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { if (this .logger.isDebugEnabled()) { this .logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult); } SecurityContextHolder.getContext().setAuthentication(authResult); this .rememberMeServices.loginSuccess(request, response, authResult); if (this .eventPublisher != null ) { this .eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this .getClass())); } this .successHandler.onAuthenticationSuccess(request, response, authResult); } }
用户登录后,在成功认证处理时,上述(2)过程会调用 AbstractRememberMeServices 的 loginSuccess() 方法进行 Remember-Me 处理。
⭐️ AbstractRememberMeServices#loginSuccess
1 2 3 4 5 6 7 8 9 10 11 12 13 public abstract class AbstractRememberMeServices implements RememberMeServices , InitializingBean , LogoutHandler { public final void loginSuccess (HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { if (!this .rememberMeRequested(request, this .parameter)) { this .logger.debug("Remember-me login not requested." ); } else { this .onLoginSuccess(request, response, successfulAuthentication); } } }
⭐ PersistentTokenBasedRememberMeServices#onLoginSuccess
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { protected void onLoginSuccess (HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = successfulAuthentication.getName(); this .logger.debug("Creating new persistent login for user " + 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); } } }
二次登陆
✏️ RememberMeAuthenticationFilter#doFilter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware { public void doFilter (ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; if (SecurityContextHolder.getContext().getAuthentication() == null ) { Authentication rememberMeAuth = this .rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null ) { try { rememberMeAuth = this .authenticationManager.authenticate(rememberMeAuth); SecurityContextHolder.getContext().setAuthentication(rememberMeAuth); this .onSuccessfulAuthentication(request, response, rememberMeAuth); if (this .logger.isDebugEnabled()) { this .logger.debug("SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'" ); } if (this .eventPublisher != null ) { this .eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this .getClass())); } if (this .successHandler != null ) { this .successHandler.onAuthenticationSuccess(request, response, rememberMeAuth); return ; } } catch (AuthenticationException var8) { if (this .logger.isDebugEnabled()) { this .logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '" + rememberMeAuth + "'; invalidating remember-me token" , var8); } this .rememberMeServices.loginFail(request, response); this .onUnsuccessfulAuthentication(request, response, var8); } } chain.doFilter(request, response); } else { if (this .logger.isDebugEnabled()) { this .logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'" ); } chain.doFilter(request, response); } } }
上述的(2)过程调用 AbstractRememberMeServices 的 autoLogin() 方法实现自动登录,获取用户信息。
✏️ AbstractRememberMeServices#autoLogin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public abstract class AbstractRememberMeServices implements RememberMeServices , InitializingBean , LogoutHandler { public final Authentication autoLogin (HttpServletRequest request, HttpServletResponse response) { String rememberMeCookie = this .extractRememberMeCookie(request); if (rememberMeCookie == null ) { return null ; } else { this .logger.debug("Remember-me cookie detected" ); if (rememberMeCookie.length() == 0 ) { this .logger.debug("Cookie was empty" ); this .cancelCookie(request, response); return null ; } else { UserDetails user = null ; try { String[] cookieTokens = this .decodeCookie(rememberMeCookie); user = this .procssAutoLoginCookie(cookieTokens, request, response); this .userDetailsChecker.check(user); this .logger.debug("Remember-me cookie accepted" ); return this .createSuccessfulAuthentication(request, user); } catch (CookieTheftException var6) { } this .cancelCookie(request, response); return null ; } } } }
上述的(3)过程调用 PersistentTokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法获取用户信息。
✏️ PersistentTokenBasedRememberMeServices#processAutoLoginCookie
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { 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 { if (this .logger.isDebugEnabled()) { this .logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + 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()); } } } }
注销登录
注销登录需要在安全配置类的configure(HttpSecurity http)
里使用http.logout()
配置,该配置主要会在过滤器链中加入 LogoutFilter 过滤器,Spring Security
通过该过滤器实现注销登录功能。
此处对http.logout()
返回值的主要方法进行介绍,这些方法设计注销登录的配置,具体如下:
logoutUrl(String outUrl)
:指定用户注销登录时请求访问的地址,默认为 POST 方式的/logout
。
logoutSuccessUrl(String logoutSuccessUrl)
:指定用户成功注销登录后的重定向地址,默认为/登录页面url?logout
。
logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler)
:指定用户成功注销登录后使用的处理器。
deleteCookies(String ...cookieNamesToClear)
:指定用户注销登录后删除的 Cookie。
invalidateHttpSession(boolean invalidateHttpSession)
:指定用户注销登录后是否立即清除用户的 Session,默认为 true。
clearAuthentication(boolean clearAuthentication)
:指定用户退出登录后是否立即清除用户认证信息对象 Authentication,默认为 true。
addLogoutHandler(LogoutHandler logoutHandler)
:指定用户注销登录时使用的处理器。
需要注意,Spring Security
默认以 POST 方式请求访问/logout
注销登录,以 POST 方式请求的原因是为了防止 csrf(跨站请求伪造),如果想使用 GET 方式的请求,则需要关闭 csrf 防护。前面我们能以 GET 方式的请求注销登录,是因为我们在configure(HttpSecurity http)
方法中关闭了 csrf 防护:
1 2 3 4 5 6 @Override protected void configure (HttpSecurity http) throws Exception { http.csrf().disable(); }
默认配置下,成功注销登录后会进行如下三个操作:
删除用户浏览器中的指定 Cookie。
将用户浏览器中 remember-me 的 Cookie 删除,并清除用户在数据库中 remember-me 的 Token 记录;
当前用户的 Session 删除,并清除当前 SecurityContext 中的用户认证信息对象 Authentication。
通知用户浏览器重定向到/登录页面url?logout
。
基本使用
☕ 自定义成功注销登录处理器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package com.example.config.security;import com.example.entity.ResultData;import com.fasterxml.jackson.databind.ObjectMapper;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.Authentication;import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;import org.springframework.stereotype.Component;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@Component public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { @Autowired private ObjectMapper objectMapper; @Override public void onLogoutSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { String xRequestedWith = request.getHeader("x-requested-with" ); if ("XMLHttpRequest" .equals(xRequestedWith)) { response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(0 , "注销登录成功!" ))); }else { super .setDefaultTargetUrl("/login/page?logout" ); super .onLogoutSuccess(request, response, authentication); } } }
☕ 修改安全配置类 SpringSecurityConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomLogoutSuccessHandler logoutSuccessHandler; @Override protected void configure (HttpSecurity http) throws Exception { http.logout() .logoutUrl("/logout" ) .logoutSuccessHandler(logoutSuccessHandler) .deleteCookies("JSESSIONID" ) .invalidateHttpSession(true ) .clearAuthentication(true ); } }
完整的安全配置类 SpringSecurityConfig 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 package com.example.config;import com.example.config.security.CustomAuthenticationFailureHandler;import com.example.config.security.CustomAuthenticationSuccessHandler;import com.example.config.security.CustomLogoutSuccessHandler;import com.example.config.security.ImageCodeValidateFilter;import com.example.config.security.mobile.MobileAuthenticationConfig;import com.example.service.CustomUserDetailsService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;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.builders.WebSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;import javax.sql.DataSource;@EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private CustomAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private CustomAuthenticationFailureHandler authenticationFailureHandler; @Autowired private ImageCodeValidateFilter imageCodeValidateFilter; @Autowired private MobileAuthenticationConfig mobileAuthenticationConfig; @Autowired private CustomLogoutSuccessHandler logoutSuccessHandler; @Autowired private DataSource dataSource; @Bean public JdbcTokenRepositoryImpl tokenRepository () { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); return jdbcTokenRepository; } @Bean public BCryptPasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder(); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login/page" ) .loginProcessingUrl("/login/form" ) .usernameParameter("name" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler); http.authorizeRequests() .antMatchers("/login/page" , "/code/image" ,"/mobile/page" , "/code/mobile" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAuthority("ROLE_USER" ) .anyRequest().authenticated(); http.csrf().disable(); http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); http.apply(mobileAuthenticationConfig); http.rememberMe() .rememberMeParameter("remember-me" ) .tokenRepository(tokenRepository()) .userDetailsService(userDetailsService); http.logout() .logoutUrl("/logout" ) .logoutSuccessHandler(logoutSuccessHandler) .deleteCookies("JSESSIONID" ) .invalidateHttpSession(true ) .clearAuthentication(true ); } @Override public void configure (WebSecurity web) throws Exception { web.ignoring().antMatchers("/**/*.css" , "/**/*.js" , "/**/*.png" , "/**/*.jpg" , "/**/*.jpeg" ); } }
☕ 测试
访问localhost:8080/login/page
,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:
访问localhost:8080/logout
,注销登录,查看请求头和响应头:
由上图可以看出,注销登录后,用户浏览器的 JSESSIONID 和 remember-me 的 Cookie 被删除。
源码分析
✌ LogoutFilter#doFilter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class LogoutFilter extends GenericFilterBean { public void doFilter (ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; if (this .requiresLogout(request, response)) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (this .logger.isDebugEnabled()) { this .logger.debug("Logging out user '" + auth + "' and transferring to logout destination" ); } this .handler.logout(request, response, auth); this .logoutSuccessHandler.onLogoutSuccess(request, response, auth); } else { chain.doFilter(request, response); } } }
上述(3)过程调用 CompositeLogoutHandler 处理器的 logout 方法进行注销登录的处理。
✌ CompositeLogoutHandler#logout
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public final class CompositeLogoutHandler implements LogoutHandler { private final List<LogoutHandler> logoutHandlers; public void logout (HttpServletRequest request, HttpServletResponse response, Authentication authentication) { Iterator var4 = this .logoutHandlers.iterator(); while (var4.hasNext()) { LogoutHandler handler = (LogoutHandler)var4.next(); handler.logout(request, response, authentication); } } }
下面会对这三个处理器的 logout()
✍ CookieClearingLogoutHandler#logout
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public final class CookieClearingLogoutHandler implements LogoutHandler { private final List<Function<HttpServletRequest, Cookie>> cookiesToClear; public CookieClearingLogoutHandler (String... cookiesToClear) { Assert.notNull(cookiesToClear, "List of cookies cannot be null" ); List<Function<HttpServletRequest, Cookie>> cookieList = new ArrayList(); String[] var3 = cookiesToClear; int var4 = cookiesToClear.length; for (int var5 = 0 ; var5 < var4; ++var5) { String cookieName = var3[var5]; Function<HttpServletRequest, Cookie> f = (request) -> { Cookie cookie = new Cookie(cookieName, (String)null ); String cookiePath = request.getContextPath() + "/" ; cookie.setPath(cookiePath); cookie.setMaxAge(0 ); cookie.setSecure(request.isSecure()); return cookie; }; cookieList.add(f); } this .cookiesToClear = cookieList; } public void logout (HttpServletRequest request, HttpServletResponse response, Authentication authentication) { this .cookiesToClear.forEach((f) -> { response.addCookie((Cookie)f.apply(request)); }); } }
✍ PersistentTokenBasedRememberMeServices#logout
1 2 3 4 5 6 7 8 9 10 11 12 public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { public void logout (HttpServletRequest request, HttpServletResponse response, Authentication authentication) { super .logout(request, response, authentication); if (authentication != null ) { this .tokenRepository.removeUserTokens(authentication.getName()); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { public void logout (HttpServletRequest request, HttpServletResponse response, Authentication authentication) { if (this .logger.isDebugEnabled()) { this .logger.debug("Logout of user " + (authentication == null ? "Unknown" : authentication.getName())); } this .cancelCookie(request, response); } protected void cancelCookie (HttpServletRequest request, HttpServletResponse response) { this .logger.debug("Cancelling cookie" ); Cookie cookie = new Cookie(this .cookieName, (String)null ); cookie.setMaxAge(0 ); cookie.setPath(this .getCookiePath(request)); if (this .cookieDomain != null ) { cookie.setDomain(this .cookieDomain); } if (this .useSecureCookie == null ) { cookie.setSecure(request.isSecure()); } else { cookie.setSecure(this .useSecureCookie); } response.addCookie(cookie); } private String getCookiePath (HttpServletRequest request) { String contextPath = request.getContextPath(); return contextPath.length() > 0 ? contextPath : "/" ; } }
✍ SecurityContextLogoutHandler#logout
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class SecurityContextLogoutHandler implements LogoutHandler { private boolean invalidateHttpSession = true ; private boolean clearAuthentication = true ; public void logout (HttpServletRequest request, HttpServletResponse response, Authentication authentication) { Assert.notNull(request, "HttpServletRequest required" ); if (this .invalidateHttpSession) { HttpSession session = request.getSession(false ); if (session != null ) { this .logger.debug("Invalidating session: " + session.getId()); session.invalidate(); } } if (this .clearAuthentication) { SecurityContext context = SecurityContextHolder.getContext(); context.setAuthentication((Authentication)null ); } SecurityContextHolder.clearContext(); } }
本文转载自:[呵呵233 ]《Spring Security 入门(三):Remember-Me 和注销登录 》