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

記住我功能的基本原理

當用戶登陸發起認證請求時,會經過UsernamePasswordAuthenticationFilter進行用戶認證,認證成功以後,SpringSecurity 調用前期配置好的記住我功能,實際是調用了RememberMeService接口,其接口的實現類會將用戶的信息生成Token並將它寫入 response 的Cookie中,在寫入的同時,內部的TokenRepositoryTokenRepository會將這份Token再存入數據庫一份。html

當用戶再次訪問服務器資源的時候,首先會通過RememberMeAuthenticationFiler過濾器,在這個過濾器裏面會讀取當前請求中攜帶的 Cookie,這裏存着上次服務器保存 的Token,而後去數據庫中查找是否有相應的 Token,若是有,則再經過UserDetailsService獲取用戶的信息。前端

記住我功能的過濾器

從圖中能夠得知記住個人過濾器在過濾鏈的中部,注意是在UsernamePasswordAuthenticationFilter以後。java

前端頁面checkbox設置

在 html 中增長記住我複選框checkbox控件,注意其中複選框的name 必定必須爲remember-megit

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

配置cookie存儲數據庫源

本例中使用了 springboot 管理的數據庫源,因此注意要配置spring-boot-starter-jdbc的依賴:github

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

若是不配置會報編譯異常:spring

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

記住個人安全認證配置:sql

@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()方法進行開啓自動建表操做,可是不建議使用。springboot

當成功登陸以後,RememberMeService會將成功登陸請求的cookie存儲到配置的數據庫中:

源碼分析

首次請求

首先進入到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

博客園:木鯨魚的博客

相關文章
相關標籤/搜索