SpringBoot集成SpringSecurity(三)記住我及圖形驗證碼

需求

爲了增強用戶體驗,實現“記住我”功能;

爲了提高csrf攻擊門檻,增加圖形驗證碼功能;

記住我

場景類比:

  • 從用戶體驗來講:

    在用戶登錄系統一次訪問首頁後,在有效期內可以免登錄訪問首頁;中間可以包括不小心電腦關機,關閉瀏覽器等場景…

  • 從技術的角度來講:

    1. 即便session過期,remember me還在有效期內也可以訪問;
    2. 即便系統重啓,remember me還在有效期內也可以訪問。

驗證碼

對於csrf攻擊,大家可自行百度詳細內容,我在這裏簡單說一下:

要完成一次CSRF攻擊,受害者必須依次完成兩個步驟:

  1. 登錄受信任網站A,並在本地生成Cookie。

  2. 在不登出A的情況下,訪問危險網站B(B直達A的鏈接)。

如果在表單中增加一個隨機的數字或字母驗證碼,通過強制用戶和應用進行交互,來有效地遏制CSRF攻擊。

其實還有一種方式放csrf攻擊的方式,通過token校驗或者referer

本文代碼已上傳至GitHub

代碼參考:https://github.com/wanglongsxr/springsecurity.git

注:本篇並不打算講解如何通過代碼去搭建完成的springboot整合security工程(一方面因爲網上已經有很多優秀的案例代碼,另一方面我感覺如果用大量的代碼去講解的話,很多人看着看着就繞進去了,或者看到一半迷茫了,從而去糾結爲什麼這麼做,這麼做的目的是什麼),如有需要或疑惑,請到github下載源碼,內部含有詳細註釋。

記住我功能

思考

在引入正文之前,我們可以先思考以下這兩個問題:

  1. rememberMe的原理是什麼?
  2. 已經有session了,爲什麼不把會話時間調大一點?爲什麼還要引入rememberMe?

至於第一個問題,其實可以這麼講:

如果我們撇開security來講,其實rememberMe功能的本質就是利用Cookie。

在登錄頁面,如果用戶勾選了“記住我”,那麼服務端在響應中加上Remember-Me的cookie,在這裏我們還可以設置cookie的時長,比如設置3天。在3天內,用戶可以不用登錄,反之則需要重新登錄。

而在security中他是將這個“cookie”給持久化了,從數據庫去拿到這個“cookie”值,從而進行訪問。

那麼第二個問題是問什麼呢?

在security框架中,session是可以設置時長的。你設置3天,意味着一個HttpSession將會由Tomcat保留3天。這樣就加大了服務器的開銷。試想以下如果用戶基數大的情況下,性能急劇下降。

security中的remrember me

爲了防止代碼看着看着就迷路,我們先來看一段流程圖:

1590329001(SpringBoot%E9%9B%86%E6%88%90SpringSecurity%EF%BC%88%E4%B8%89%EF%BC%89%E8%AE%B0%E4%BD%8F%E6%88%91%E5%8F%8A%E5%9B%BE%E5%BD%A2%E9%AA%8C%E8%AF%81%E7%A0%81.assets/006M2jFvly1gf3w3pe2h5j30vh0tzq5m.jpg).jpg

上圖是針對與代碼層次的,也就是說用戶在security認證成功後,會問一下remrember me小弟,“兄弟,我這裏保存了用戶信息,你要不要也保存一份”,然後剩下的就靠這個小弟自己權衡了。

另 :強調一下:PersistentTokenBasedRememberMeServices在這裏做了兩件事:

  1. 向數據庫添加token;
  2. 另一個是添加cookie

注意:以上圖例是在用戶第一次訪問時候的流程,也就是說剛勾選上“記住我”這個功能,然後訪問的過程。

那麼當服務器重啓或者清除瀏覽器緩存時,他的流程是這樣的:

1590486374(SpringBoot%E9%9B%86%E6%88%90SpringSecurity%EF%BC%88%E4%B8%89%EF%BC%89%E8%AE%B0%E4%BD%8F%E6%88%91%E5%8F%8A%E5%9B%BE%E5%BD%A2%E9%AA%8C%E8%AF%81%E7%A0%81.assets/006M2jFvly1gf5zvjoeuvj311v0ucdl9.jpg).jpg

其中涉及到的類:

  1. 方法有RememberMeAuthenticationFilter的doFilter方法,判斷session是否存在;
  2. 不存在則調取rememberMeServices的autoLogin方法,會去到cookie中取是否存在Token,遍歷cookie獲取到RememberMe的Token;
  3. 最後又通過PersistentTokenBasedRememberMeServices的processAutoLoginCookie方法去數據庫查,然後根據Token獲取用戶信息返回調用的URL信息。

以上就是Spring Security實現RemeberMe功能的原理;

最後再來一張總圖:

在這裏插入圖片描述

在實際使用中,自己曾經遇到過CookieTheftException問題,俗稱cookie欺騙,(主要原因是拿到的cookie令牌沒有跟用戶信息匹配上)大致總結一下該問題的原因:

  1. 成功登錄後:將使用一些隨機哈希爲用戶創建一個永久令牌。將爲用戶創建一個cookie,上面帶有令牌詳細信息。爲用戶創建一個會話。只要用戶仍具有活動會話,就不會在身份驗證時調用我的功能。
  2. 用戶會話到期後: “記住我”功能將啓動並使用cookie從數據庫中獲取持久性令牌。如果持久令牌與cookie中的令牌匹配,則每個人都對用戶進行身份驗證感到滿意,將生成一個新的隨機哈希,並使用持久令牌進行更新,併爲後續請求更新用戶的cookie。
  3. 但是,如果Cookie中的令牌與持久令牌中的令牌不匹配,則會收到CookieTheftException。令牌不匹配的最常見原因是快速連續觸發了兩個或更多請求,第一個請求將通過,併爲隨後的請求生成新的哈希,但第二個請求仍將舊令牌打開它並因此導致異常。

核心代碼如下:

security 配置類:config

@Bean
    public PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices() {
        PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices("remember-me"
                , myUserDetailService, rememberMeTokenService);
        services.setTokenValiditySeconds(3600);
        services.setParameter("rememberMe");
        return services;
    }

注入remember me實現類:

@Component
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private static final String SECURITY_LOGIN_URL = "/authentication/form";

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailedHandler authenticationFailedHandler;
    @Autowired
    private PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices;

    @PostConstruct
    public void init() {
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(SECURITY_LOGIN_URL, "POST"));
        setAuthenticationSuccessHandler(authenticationSuccessHandler);
        setAuthenticationFailureHandler(authenticationFailedHandler);
        setRememberMeServices(persistentTokenBasedRememberMeServices);
    }

在這裏提一下遇到的坑:

在security源碼中,校驗token是否過期的邏輯是這樣的:

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

注意這裏的getDate(),我dao層使用的是jdbcTemplate,在獲取時間的代碼我是這麼寫的:

public RememberMeToken getRememberMeToken( String seriesId){
        String sql ="select login_name,series,token,last_used from t_remember_token where series=?";
        RememberMeToken rememberMeToken = null;
        try {
             rememberMeToken = jdbcTemplate.queryForObject(sql,new Object[]{seriesId},(rs, rowNum) -> {
                RememberMeToken token = new RememberMeToken();
                token.setSeries(rs.getString("SERIES"));
                token.setToken(rs.getString("TOKEN"));
                token.setLastUsed(rs.getDate("LAST_USED"));
                token.setLoginName(rs.getString("LOGIN_NAME"));
                return token;
            });
        } catch (Exception e) {
        return null;
    }
        return rememberMeToken;
    }

注意獲取"LAST_USED"的時候我用的是rs.getDate()。。。而不是時間戳,這就會導致你的token一直過期。原因如下:

比如你是“2020-05-26 12:12:12”存儲的token,但rs.getDate()只會獲取“2020-05-26”,從而導致security源碼中認爲是“2020-05-26 00:00:00”,如果你token存儲時間短的話,則會一直過期!!!!

圖形驗證碼功能

SpringBoot集成SpringSecurity(二) 登錄認證流程解析一文中,我們清楚的瞭解到了security各個過濾鏈的順序,那我們來做一下遐想:

第一,我們在未進入security認證流程時,就開始驗證圖形驗證碼,也就是說在UsernamePasswordAuthenticationFilter 未將用戶信息轉換爲Authentication類;

第二,我們在用戶已經認證完成後,在進行圖形驗證碼校驗;

毫無疑問,肯定是第一種情況友好;

以下爲核心代碼:

@Component
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private static final String SECURITY_LOGIN_URL = "/authentication/form";

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailedHandler authenticationFailedHandler;
    @Autowired
    private PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices;

    @PostConstruct
    public void init() {
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(SECURITY_LOGIN_URL, "POST"));
        setAuthenticationSuccessHandler(authenticationSuccessHandler);
        setAuthenticationFailureHandler(authenticationFailedHandler);
        setRememberMeServices(persistentTokenBasedRememberMeServices);
    }

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String requestBody = IOUtils.toString(request.getReader());
        LoginRequest loginRequest = JSON.parseObject(requestBody,LoginRequest.class);
        if(loginRequest == null || loginRequest.isInvalid()){
            throw new InsufficientAuthenticationException("身份驗證失敗");
        }
        //校驗驗證碼
        verificationCaptcha(loginRequest,request,response);

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(),loginRequest.getPassword());
        return this.getAuthenticationManager().authenticate(token);
    }

其中,校驗驗證碼的邏輯如下:

private void verificationCaptcha(LoginRequest loginRequest,HttpServletRequest request,HttpServletResponse response) throws IOException {
        HttpSession session = request.getSession();
        String captcha = loginRequest.getCaptcha();
        String verificationCode = (String)session.getAttribute("captcha");
        if(!StringUtils.isEmpty(verificationCode)) {
            //清除驗證碼,不管是或成功
            session.removeAttribute("captcha");
            if (!StringUtils.isEmpty(verificationCode) && !captcha.equals(verificationCode)) {
                throw new AuthenticationServiceException("驗證碼錯誤!");
            }
        }
    }

security配置類,config如下:

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(loginAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
        http.formLogin()
                .loginPage("/login")//登錄頁
                .and().logout().logoutUrl("/logout")//定義退出頁
                .and().authorizeRequests()
                .antMatchers("/r/r1").hasAuthority("p1")//擁有p1權限的人才能訪問r1資源
                .antMatchers("/r/r2").hasAuthority("p2")//擁有p2權限的人才能訪問r2資源
                .antMatchers("/r/**").authenticated()//對r/**的資源需要認證
                .antMatchers(dynamicDcUrl)
                .permitAll()
                .and()
                .csrf().disable();
    }

注:本圖形驗證碼採用的插件爲kaptcha;

本文代碼已上傳至GitHub

代碼參考:https://github.com/wanglongsxr/springsecurity.git

Reference

https://blog.csdn.net/qq_37142346/article/details/80114609

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章