Remember Me 即記住我,經常使用於 Web 應用的登陸頁目的是讓用戶選擇是否記住用戶的登陸狀態。當用戶選擇了 Remember Me 選項,則在有效期內若用戶從新訪問同一個 Web 應用,那麼用戶能夠直接登陸到系統中,而無需從新執行登陸操做。相信國內不少開發者都使用過或聽過一個 雲端軟件開發協做平臺 —— 碼雲,下圖是它的登陸頁:html
由上圖可知,登陸頁除了輸入用戶名和密碼以外,還多了一個 記住我 的複選框,用於實現前面提到的 Remember Me 功能,接下來本文將重點介紹如何基於 Spring Security 實現 Remember Me 功能。java
閱讀更多關於 Angular、TypeScript、Node.js/Java 、Spring 等技術文章,歡迎訪問個人我的博客 —— 全棧修仙之路
在 Spring Security 中要實現 Remember Me 功能很簡單,由於它內置的過濾器 RememberMeAuthenticationFilter 已經提供了該功能。在開始實戰前,咱們先來看一下 Remember Me 的運行流程。mysql
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
屬性中。git
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 複選框:github
<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
方法的定義以下:web
//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
方法,即執行認證成功回調函數:spring
// 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 所對應的實例,該實現類的定義以下:sql
// 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
數據表中:segmentfault
// 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 方法,該方法內部的執行流程以下:
// 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