Spring Security 實現 Remember Me

1、什麼是 Remember Me

Remember Me 即記住我,經常使用於 Web 應用的登陸頁目的是讓用戶選擇是否記住用戶的登陸狀態。當用戶選擇了 Remember Me 選項,則在有效期內若用戶從新訪問同一個 Web 應用,那麼用戶能夠直接登陸到系統中,而無需從新執行登陸操做。相信國內不少開發者都使用過或聽過一個 雲端軟件開發協做平臺 —— 碼雲,下圖是它的登陸頁:html

gitee-login-form.jpg

由上圖可知,登陸頁除了輸入用戶名和密碼以外,還多了一個 記住我 的複選框,用於實現前面提到的 Remember Me 功能,接下來本文將重點介紹如何基於 Spring Security 實現 Remember Me 功能。java

閱讀更多關於 Angular、TypeScript、Node.js/Java 、Spring 等技術文章,歡迎訪問個人我的博客 —— 全棧修仙之路

2、Remember Me 處理流程

在 Spring Security 中要實現 Remember Me 功能很簡單,由於它內置的過濾器 RememberMeAuthenticationFilter 已經提供了該功能。在開始實戰前,咱們先來看一下 Remember Me 的運行流程。mysql

remember-me-flow.png

3、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 對象注入到 JdbcTokenRepositoryImpldataSource 屬性中。git

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 複選框:github

<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()); // 處理自動登陸邏輯
}

4、Remember Me 原理分析

4.1 首次登陸過程

當咱們首次在登陸頁執行登陸時,登陸的請求會由 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 表中會新增一條記錄:

persistent-logins-record.jpg

除此以外,在 onLoginSuccess 方法中還會調用 addCookie 添加相應的 Cookie。爲了更加直觀的感覺 addCookie 方法最終達到的效果,咱們來看一下實戰部分勾選 Remember Me 複選框後登陸成功後返回的響應體:

response-remember-me-cookie.jpg

經過上圖可知,在勾選 Remember Me 複選框成功登陸以後,除了設置常見的 JSESSIONID Cookie 以外,還會進一步設置 remember-me Cookie。

4.2 Remember Me Cookie 校驗流程

在成功設置 remember-me Cookie 以後,當前站點下所發起的 HTTP 請求的請求頭都會默認帶上 Cookie 信息,它包含兩部分信息,即 JSESSIONID 和 remember-me Cookie 信息。

request-remember-me-cookie.jpg

這裏 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 字符串數組,我本機的解碼結果以下:

split-remember-me-cookies.jpg

在完成 cookie 解碼以後,會嘗試使用該 cookie 進行自動登陸,即調用內部的 processAutoLoginCookie 方法,該方法內部的執行流程以下:

  1. 使用 presentedSeries(series) 做爲參數調用 tokenRepository 對象的 getTokenForSeries 方法獲取 token (PersistentRememberMeToken) 對象,而後對返回的 token 執行校驗,好比判空或有效期驗證;
  2. 驗證經過後從新生成新的 newToken (PersistentRememberMeToken)並更新數據庫中相應的記錄值;
  3. 使用前面從數據庫中得到的 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

5、參考資源

full-stack-logo

相關文章
相關標籤/搜索