SpringBoot + Spring Security 學習筆記(四)記住我功能實現

木鯨魚發表於2019-04-14

記住我功能的基本原理

當使用者登入發起認證請求時,會通過UsernamePasswordAuthenticationFilter進行使用者認證,認證成功之後,SpringSecurity 呼叫前期配置好的記住我功能,實際是呼叫了RememberMeService介面,其介面的實現類會將使用者的資訊生成Token並將它寫入 response 的Cookie中,在寫入的同時,內部的TokenRepositoryTokenRepository會將這份Token再存入資料庫一份。

當使用者再次訪問伺服器資源的時候,首先會經過RememberMeAuthenticationFiler過濾器,在這個過濾器裡面會讀取當前請求中攜帶的 Cookie,這裡存著上次伺服器儲存 的Token,然後去資料庫中查詢是否有相應的 Token,如果有,則再通過UserDetailsService獲取使用者的資訊。

SpringBoot + Spring Security 學習筆記(四)記住我功能實現

記住我功能的過濾器

從圖中可以得知記住我的過濾器在過濾鏈的中部,注意是在UsernamePasswordAuthenticationFilter之後。

SpringBoot + Spring Security 學習筆記(四)記住我功能實現

前端頁面checkbox設定

在 html 中增加記住我核取方塊checkbox控制元件,注意其中核取方塊的name 一定必須為remember-me

<input type="checkbox" name="remember-me" value="true"/>
複製程式碼

配置cookie儲存資料庫源

本例中使用了 springboot 管理的資料庫源,所以注意要配置spring-boot-starter-jdbc的依賴:

<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
複製程式碼

如果不配置會報編譯異常:

The type org.springframework.jdbc.core.support.JdbcDaoSupport cannot be resolved. It is indirectly referenced from required .class files
複製程式碼

記住我的安全認證配置:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private DataSource dataSource;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 將自定義的驗證碼過濾器放置在 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) 
            .formLogin()
                .loginPage("/login")	 					// 設定登入頁面
                .loginProcessingUrl("/user/login") 			// 自定義的登入介面
                .successHandler(myAuthenctiationSuccessHandler)
                .failureHandler(myAuthenctiationFailureHandler)
                .defaultSuccessUrl("/home").permitAll()		// 登入成功之後,預設跳轉的頁面
                .and().authorizeRequests()					// 定義哪些URL需要被保護、哪些不需要被保護
                .antMatchers("/", "/index", "/user/login", "/code/image").permitAll() // 設定所有人都可以訪問登入頁面
                .anyRequest().authenticated() 				// 任何請求,登入後可以訪問
                .and().csrf().disable() 					// 關閉csrf防護
            .rememberMe()                                   // 記住我配置
                .tokenRepository(persistentTokenRepository())  // 配置資料庫源
                .tokenValiditySeconds(3600)
                .userDetailsService(userDetailsService);
    }

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
        // 將 DataSource 設定到 PersistentTokenRepository
        persistentTokenRepository.setDataSource(dataSource);
        // 第一次啟動的時候自動建表(可以不用這句話,自己手動建表,原始碼中有語句的)
        // persistentTokenRepository.setCreateTableOnStartup(true);
        return persistentTokenRepository;
    }
} 
複製程式碼

注意:在資料庫源配置之前,建議手動在資料庫中新增一張儲存的cookie表,其資料庫指令碼在JdbcTokenRepositoryImpl的靜態屬性中配置了:

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
		PersistentTokenRepository {
	/** Default SQL for creating the database table to store the tokens */
    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)";
}
複製程式碼

因此可以事先執行以下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);
複製程式碼

當然,JdbcTokenRepositoryImpl自身還有一個setCreateTableOnStartup()方法進行開啟自動建表操作,但是不建議使用。

當成功登入之後,RememberMeService會將成功登入請求的cookie儲存到配置的資料庫中:

SpringBoot + Spring Security 學習筆記(四)記住我功能實現

原始碼分析

首次請求

首先進入到AbstractAuthenticationProcessingFilter過濾器中的doFilter()方法:

public abstract class AbstractAuthenticationProcessingFilter {
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        ……

        try {
            authResult = attemptAuthentication(request, response);
            ……
        }
        catch (InternalAuthenticationServiceException failed) {
            ……
        }

        successfulAuthentication(request, response, chain, authResult);
    }
}
複製程式碼

其中當使用者認證成功之後,會進入successfulAuthentication()方法,在使用者資訊被儲存在了SecurityContextHolder之後,其中就呼叫了rememberMeServices.loginSuccess()

protected void successfulAuthentication(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, Authentication authResult)
			throws IOException, ServletException {

    ……

    SecurityContextHolder.getContext().setAuthentication(authResult);

    // 呼叫記住我服務介面的登入成功方法
    rememberMeServices.loginSuccess(request, response, authResult);

    // Fire event
    if (this.eventPublisher != null) {
        eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
            authResult, this.getClass()));
    }

    successHandler.onAuthenticationSuccess(request, response, authResult);
}
複製程式碼

在這個RememberMeServices有個抽象實現類,在抽象實現類loginSuccess()方法中進行了記住我功能判斷,為什麼前端的核取方塊控制元件的 name 必須為remember-me,原因就在此:

public abstract class AbstractRememberMeServices implements RememberMeServices,
		InitializingBean, LogoutHandler {

    public static final String DEFAULT_PARAMETER = "remember-me";
            
    private String parameter = DEFAULT_PARAMETER;

    @Override
    public final void loginSuccess(HttpServletRequest request,
            HttpServletResponse response, Authentication successfulAuthentication) {

        if (!rememberMeRequested(request, parameter)) {
            logger.debug("Remember-me login not requested.");
            return;
        }

        onLoginSuccess(request, response, successfulAuthentication);
    }
}
複製程式碼

當識別到記住我功能開啟的時候,就會進入onLoginSuccess()方法,其具體的方法實現在PersistentTokenBasedRememberMeServices類中:

public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
	
    protected void onLoginSuccess(HttpServletRequest request,
            HttpServletResponse response, Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();

        logger.debug("Creating new persistent login for user " + username);

        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
                username, generateSeriesData(), generateTokenData(), new Date());
        try {
            // 儲存cookie到資料庫
            tokenRepository.createNewToken(persistentToken);
            // 將cookie回寫一份到響應中
            addCookie(persistentToken, request, response);
        }
        catch (Exception e) {
            logger.error("Failed to save persistent token ", e);
        }
    }
}
複製程式碼

上面的tokenRepository.createNewToken()addCookie()就將 cookie 儲存到資料庫並回顯到響應中。

第二次請求

當第二次請求傳到伺服器的時候,請求會被RememberMeAuthenticationFilter過濾器進行過濾:過濾器首先判定之前的過濾器都沒有認證通過當前使用者,也就是SecurityContextHolder中沒有已經認證的資訊,所以會呼叫rememberMeServices.autoLogin()的自動登入介面拿到已通過認證的rememberMeAuth進行使用者認證登入:

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;

        // SecurityContextHolder 不存在已經認證的 authentication,表示前面的過濾器沒有做過任何身份認證
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            // 呼叫自動登入介面
            Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
                    response);

            if (rememberMeAuth != null) {
                // Attempt authenticaton via AuthenticationManager
                try {
                    rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

                    // Store to SecurityContextHolder
                    SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
                    
                    onSuccessfulAuthentication(request, response, rememberMeAuth);

                    ……

                }
                catch (AuthenticationException authenticationException) {
                    ……
                }
            }

            chain.doFilter(request, response);
        }
        else {
            if (logger.isDebugEnabled()) {
                logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
                        + SecurityContextHolder.getContext().getAuthentication() + "'");
            }

            chain.doFilter(request, response);
        }
    }
}
複製程式碼

這個自動登入的介面,又由其抽象實現類進行實現:

public abstract class AbstractRememberMeServices implements RememberMeServices,
		InitializingBean, LogoutHandler {
    @Override
    public final Authentication autoLogin(HttpServletRequest request,
            HttpServletResponse response) {
        // 從請求中獲取cookie
        String rememberMeCookie = extractRememberMeCookie(request);

        if (rememberMeCookie == null) {
            return null;
        }

        logger.debug("Remember-me cookie detected");

        if (rememberMeCookie.length() == 0) {
            logger.debug("Cookie was empty");
            cancelCookie(request, response);
            return null;
        }

        UserDetails user = null;

        try {
            // 解碼請求中的cookie
            String[] cookieTokens = decodeCookie(rememberMeCookie);
            // 根據 cookie 找到使用者認證
            user = processAutoLoginCookie(cookieTokens, request, response);
            userDetailsChecker.check(user);

            logger.debug("Remember-me cookie accepted");

            return createSuccessfulAuthentication(request, user);
        }
        catch (CookieTheftException cte) {
            ……
        }

        cancelCookie(request, response);
        return null;
    }
}
複製程式碼

processAutoLoginCookie()的具體實現還是由PersistentTokenBasedRememberMeServices來實現,總得來說就是一頓判定當前的cookieTokens是不是在資料庫中存在tokenRepository.getTokenForSeries(presentedSeries),並判斷是不是一樣的,如果一樣,就是把當前請求的新 token 更新儲存到資料庫,最後通過當前請求token中的使用者名稱呼叫UserDetailsService.loadUserByUsername()進行使用者認證。

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) + "'");
        }

        final String presentedSeries = cookieTokens[0];
        final String presentedToken = cookieTokens[1];

        // 從資料庫查詢上次儲存的token
        PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries);

        if (token == null) {
            // 查詢不到拋異常
            throw new RememberMeAuthenticationException(……);
        }

        // token 不匹配丟擲異常
        // We have a match for this user/series combination
        if (!presentedToken.equals(token.getTokenValue())) {
            // Token doesn't match series value. Delete all logins for this user and throw
            // an exception to warn them.
            tokenRepository.removeUserTokens(token.getUsername());

            throw new CookieTheftException(……);
        }

        // 過期判斷
        if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
            throw new RememberMeAuthenticationException("Remember-me login has expired");
        }

        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) {
            ……
        }

        return getUserDetailsService().loadUserByUsername(token.getUsername());
    }
}
複製程式碼

個人部落格:woodwhale's blog

部落格園:木鯨魚的部落格

相關文章