需求
爲了增強用戶體驗,實現“記住我”功能;
爲了提高csrf攻擊門檻,增加圖形驗證碼功能;
記住我
場景類比:
-
從用戶體驗來講:
在用戶登錄系統一次訪問首頁後,在有效期內可以免登錄訪問首頁;中間可以包括不小心電腦關機,關閉瀏覽器等場景…
-
從技術的角度來講:
- 即便session過期,remember me還在有效期內也可以訪問;
- 即便系統重啓,remember me還在有效期內也可以訪問。
驗證碼
對於csrf攻擊,大家可自行百度詳細內容,我在這裏簡單說一下:
要完成一次CSRF攻擊,受害者必須依次完成兩個步驟:
-
登錄受信任網站A,並在本地生成Cookie。
-
在不登出A的情況下,訪問危險網站B(B直達A的鏈接)。
如果在表單中增加一個隨機的數字或字母驗證碼,通過強制用戶和應用進行交互,來有效地遏制CSRF攻擊。
其實還有一種方式放csrf攻擊的方式,通過token校驗或者referer
本文代碼已上傳至GitHub
注:本篇並不打算講解如何通過代碼去搭建完成的springboot整合security工程(一方面因爲網上已經有很多優秀的案例代碼,另一方面我感覺如果用大量的代碼去講解的話,很多人看着看着就繞進去了,或者看到一半迷茫了,從而去糾結爲什麼這麼做,這麼做的目的是什麼),如有需要或疑惑,請到github下載源碼,內部含有詳細註釋。
記住我功能
思考
在引入正文之前,我們可以先思考以下這兩個問題:
- rememberMe的原理是什麼?
- 已經有session了,爲什麼不把會話時間調大一點?爲什麼還要引入rememberMe?
至於第一個問題,其實可以這麼講:
如果我們撇開security來講,其實rememberMe功能的本質就是利用Cookie。
在登錄頁面,如果用戶勾選了“記住我”,那麼服務端在響應中加上Remember-Me的cookie,在這裏我們還可以設置cookie的時長,比如設置3天。在3天內,用戶可以不用登錄,反之則需要重新登錄。
而在security中他是將這個“cookie”給持久化了,從數據庫去拿到這個“cookie”值,從而進行訪問。
那麼第二個問題是問什麼呢?
在security框架中,session是可以設置時長的。你設置3天,意味着一個HttpSession將會由Tomcat保留3天。這樣就加大了服務器的開銷。試想以下如果用戶基數大的情況下,性能急劇下降。
security中的remrember me
爲了防止代碼看着看着就迷路,我們先來看一段流程圖:
上圖是針對與代碼層次的,也就是說用戶在security認證成功後,會問一下remrember me小弟,“兄弟,我這裏保存了用戶信息,你要不要也保存一份”,然後剩下的就靠這個小弟自己權衡了。
另 :強調一下:PersistentTokenBasedRememberMeServices在這裏做了兩件事:
- 向數據庫添加token;
- 另一個是添加cookie
注意:以上圖例是在用戶第一次訪問時候的流程,也就是說剛勾選上“記住我”這個功能,然後訪問的過程。
那麼當服務器重啓或者清除瀏覽器緩存時,他的流程是這樣的:
其中涉及到的類:
- 方法有RememberMeAuthenticationFilter的doFilter方法,判斷session是否存在;
- 不存在則調取rememberMeServices的autoLogin方法,會去到cookie中取是否存在Token,遍歷cookie獲取到RememberMe的Token;
- 最後又通過PersistentTokenBasedRememberMeServices的processAutoLoginCookie方法去數據庫查,然後根據Token獲取用戶信息返回調用的URL信息。
以上就是Spring Security實現RemeberMe功能的原理;
最後再來一張總圖:
在實際使用中,自己曾經遇到過CookieTheftException問題,俗稱cookie欺騙,(主要原因是拿到的cookie令牌沒有跟用戶信息匹配上)大致總結一下該問題的原因:
- 成功登錄後:將使用一些隨機哈希爲用戶創建一個永久令牌。將爲用戶創建一個cookie,上面帶有令牌詳細信息。爲用戶創建一個會話。只要用戶仍具有活動會話,就不會在身份驗證時調用我的功能。
- 用戶會話到期後: “記住我”功能將啓動並使用cookie從數據庫中獲取持久性令牌。如果持久令牌與cookie中的令牌匹配,則每個人都對用戶進行身份驗證感到滿意,將生成一個新的隨機哈希,並使用持久令牌進行更新,併爲後續請求更新用戶的cookie。
- 但是,如果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
Reference
https://blog.csdn.net/qq_37142346/article/details/80114609