一個(gè)奇怪的登錄需求,你知道嗎?
一個(gè)奇怪的登錄需求。
這是小伙伴們?cè)谖⑿湃豪锏囊粋€(gè)提問(wèn),我覺(jué)得很有意思:
雖然這并非一個(gè)典型需求,但是把這個(gè)問(wèn)題解決了,有助于加深大家對(duì)于 Spring Security 的理解。
因此,松哥打算擼一篇文章和大家稍微聊聊這個(gè)話(huà)題。
1. 問(wèn)題再現(xiàn)
可能有小伙伴還不明白這個(gè)問(wèn)題,因此我先稍微解釋一下。
當(dāng)我們登錄失敗的時(shí)候,可能用戶(hù)名寫(xiě)錯(cuò),也可能密碼寫(xiě)錯(cuò),但是出于安全考慮,服務(wù)端一般不會(huì)明確提示是用戶(hù)名寫(xiě)錯(cuò)了還是密碼寫(xiě)錯(cuò)了,而只會(huì)給出一個(gè)模糊的用戶(hù)名或者密碼寫(xiě)錯(cuò)了。
然而對(duì)于很多新手程序員而言,可能并不了解這樣一些“潛規(guī)則”,可能會(huì)給用戶(hù)一個(gè)明確的提示,明確提示是用戶(hù)名寫(xiě)錯(cuò)了還是密碼寫(xiě)錯(cuò)了。
為了避免這一情況,Spring Security 通過(guò)封裝,隱藏了用戶(hù)名不存在的異常,導(dǎo)致開(kāi)發(fā)者在開(kāi)發(fā)的時(shí)候,只能獲取到 BadCredentialsException,這個(gè)異常既表示用戶(hù)名不存在,也表示用戶(hù)密碼輸入錯(cuò)誤。Spring Security 這樣做是為了確保我們的系統(tǒng)足夠安全。
然而由于種種原因,有時(shí)候我們又希望能夠分別獲取到用戶(hù)不存在的異常和密碼輸入錯(cuò)誤的異常,這個(gè)時(shí)候就需要我們對(duì) Spring Security 進(jìn)行一些簡(jiǎn)單的定制了。
2. 源碼分析
首先我們要先找到問(wèn)題發(fā)生的原因,發(fā)生的地方。
在 Spring Security 中,負(fù)責(zé)用戶(hù)校驗(yàn)的工作的類(lèi)有很多,我這里就不一一列舉了(感興趣的小伙伴可以查看《深入淺出Spring Security》一書(shū)),我這里直接說(shuō)我們涉及到的關(guān)鍵類(lèi) AbstractUserDetailsAuthenticationProvider。
這個(gè)類(lèi)將負(fù)責(zé)用戶(hù)名密碼的校驗(yàn)工作,具體在 authenticate 方法里邊,這個(gè)方法本來(lái)特別長(zhǎng),我這里只把和本文相關(guān)的代碼列出來(lái):
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
retrieveUser 方法就是根據(jù)用戶(hù)登錄輸入的用戶(hù)名去查找用戶(hù),如果沒(méi)找到,就會(huì)拋出一個(gè) UsernameNotFoundException,這個(gè)異常被 catch 之后,會(huì)首先判斷是否要隱藏這個(gè)異常,如果不隱藏,則原異常原封不動(dòng)拋出來(lái),如果需要隱藏,則拋出一個(gè)新的 BadCredentialsException 異常,BadCredentialsException 異常從字面理解就是密碼輸入錯(cuò)誤的異常。
所以問(wèn)題的核心就變成了 hideUserNotFoundExceptions 變量了。
這是一個(gè) Boolean 類(lèi)型的屬性,默認(rèn)是 true,AbstractUserDetailsAuthenticationProvider 也為該屬性提供了 set 方法:
public void setHideUserNotFoundExceptions(boolean hideUserNotFoundExceptions) {
this.hideUserNotFoundExceptions = hideUserNotFoundExceptions;
}
看起來(lái)修改 hideUserNotFoundExceptions 屬性并不難!只要找到 AbstractUserDetailsAuthenticationProvider 的實(shí)例,然后調(diào)用相應(yīng)的 set 方法就能修改了。
現(xiàn)在問(wèn)題的核心變成了從哪里獲取 AbstractUserDetailsAuthenticationProvider 的實(shí)例?
看名字就知道,AbstractUserDetailsAuthenticationProvider 是一個(gè)抽象類(lèi),所以它的實(shí)例其實(shí)就是它子類(lèi)的實(shí)例,子類(lèi)是誰(shuí)?當(dāng)然是負(fù)責(zé)用戶(hù)密碼校驗(yàn)工作的 DaoAuthenticationProvider。
這個(gè)知識(shí)點(diǎn)先記住,我們一會(huì)會(huì)用到。
3. 登錄流程
為了弄明白這個(gè)問(wèn)題,我們還需要搞懂 Spring Security 一個(gè)大致的認(rèn)證流程,這個(gè)也非常重要。
首先大家知道,Spring Security 的認(rèn)證工作主要是由 AuthenticationManager 來(lái)完成的,而 AuthenticationManager 則是一個(gè)接口,它的實(shí)現(xiàn)類(lèi)是 ProviderManager。簡(jiǎn)而言之,Spring Security 中具體負(fù)責(zé)校驗(yàn)工作的是 ProviderManager#authenticate 方法。
但是校驗(yàn)工作并不是由 ProviderManager 直接完成的,ProviderManager 中管理了若干個(gè) AuthenticationProvider,ProviderManager 會(huì)調(diào)用它所管理的 AuthenticationProvider 去完成校驗(yàn)工作,如下圖:
另一方面,ProviderManager 又分為全局的和局部的。
當(dāng)我們登錄的時(shí)候,首先由局部的 ProviderManager 出場(chǎng)進(jìn)行用戶(hù)名密碼的校驗(yàn)工作,如果校驗(yàn)成功,那么用戶(hù)就登錄成功了,如果校驗(yàn)失敗,則會(huì)調(diào)用局部 ProviderManager 的 parent,也就是全局 ProviderManager 去完成校驗(yàn)工作,如果全局 ProviderManager 校驗(yàn)成功,就表示用戶(hù)登錄成功,如果全局 ProviderManager 校驗(yàn)失敗,就表示用戶(hù)登錄失敗,如下圖:
OK,有了上面的知識(shí)儲(chǔ)備,我們?cè)賮?lái)分析一下我們想要拋出 UsernameNotFoundException 該怎么做。
4. 思路分析
首先我們的用戶(hù)校驗(yàn)工作在局部的 ProviderManager 中進(jìn)行,局部的 ProviderManager 中管理了若干個(gè) AuthenticationProvider,這若干個(gè) AuthenticationProvider 中就有可能包含了我們所需要的 DaoAuthenticationProvider。那我們是否需要在這里調(diào)用 DaoAuthenticationProvider 的 setHideUserNotFoundExceptions 方法完成屬性的修改呢?
松哥的建議是沒(méi)必要!
為什么?
因?yàn)楫?dāng)用戶(hù)登錄的時(shí)候,首先去局部的 ProviderManager 中去校驗(yàn),如果校驗(yàn)成功當(dāng)然最好;如果校驗(yàn)失敗,并不會(huì)立馬拋出異常,而是去全局的 ProviderManager 中繼續(xù)校驗(yàn),這樣即使我們?cè)诰植?ProviderManager 中拋出了 UsernameNotFoundException 也沒(méi)用,因?yàn)樽罱K這個(gè)異常能不能拋出來(lái)決定權(quán)在全局 ProviderManager 中(如果全局的 ProviderManager 所管理的 DaoAuthenticationProvider 沒(méi)做任何特殊處理,那么局部 ProviderManager 中拋出來(lái)的 UsernameNotFoundException 異常最終還是會(huì)被隱藏)。
所以,我們要做的就是獲取全局的 ProviderManager,進(jìn)而獲取到全局 ProviderManager 所管理的 DaoAuthenticationProvider,然后調(diào)用其 setHideUserNotFoundExceptions 方法修改相應(yīng)屬性值即可。
弄明白了原理,代碼就簡(jiǎn)單了。
5. 具體實(shí)踐
全局 ProviderManager 的修改在 WebSecurityConfigurerAdapter#configure(AuthenticationManagerBuilder) 類(lèi)中,這里配置的 AuthenticationManagerBuilder 最終用來(lái)生成全局的 ProviderManager,所以我們的配置如下:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
userDetailsService.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
auth.authenticationProvider(daoAuthenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.failureHandler((request, response, exception) -> System.out.println(exception))
.permitAll();
}
}
這里的代碼就簡(jiǎn)單了:
- 創(chuàng)建一個(gè) DaoAuthenticationProvider 對(duì)象。
- 調(diào)用 DaoAuthenticationProvider 對(duì)象的 setHideUserNotFoundExceptions 方法,修改相應(yīng)的屬性值。
- 為 DaoAuthenticationProvider 配置用戶(hù)數(shù)據(jù)源。
- 將 DaoAuthenticationProvider 設(shè)置給 auth 對(duì)象,auth 將用來(lái)生成全局的 ProviderManager。
- 在另一個(gè) configure 方法中,我們就配置一下登錄回調(diào)即可,登錄失敗的時(shí)候,打印異常信息看看。
行啦。
接下來(lái)啟動(dòng)項(xiàng)目進(jìn)行測(cè)試。輸入一個(gè)錯(cuò)誤的用戶(hù)名,可以看到 IDEA 控制臺(tái)會(huì)打印出如下信息:
可以看到,UsernameNotFoundException 異常已經(jīng)拋出來(lái)了。
6. 小結(jié)
好啦,今天就和小伙伴們分享了一下在 Spring Security 中如何拋出 UsernameNotFoundException 異常,雖然這只是一個(gè)小眾需求,但是可以加深大家對(duì) Spring Security 的理解,感興趣的小伙伴可以仔細(xì)琢磨下。