密碼儲存
Spring Security 的 PasswordEncoder
介面用於對密碼執行單向轉換,以便安全地儲存密碼。由於 PasswordEncoder
是單向轉換,因此當密碼轉換需要雙向時(例如儲存用於驗證資料庫的憑據),它並不適用。通常,PasswordEncoder
用於儲存需要在認證時與使用者提供的密碼進行比較的密碼。
密碼儲存歷史
多年來,密碼儲存的標準機制一直在演變。最初,密碼以純文字形式儲存。人們認為密碼是安全的,因為儲存密碼的資料儲存需要憑據才能訪問。然而,惡意使用者能夠透過使用 SQL 注入等攻擊找到獲取大量使用者名稱和密碼“資料轉儲”的方法。隨著越來越多的使用者憑據公開,安全專家意識到我們需要做更多工作來保護使用者的密碼。
開發者隨後被鼓勵在使用 SHA-256 等單向雜湊函式處理密碼後儲存密碼。當用戶嘗試認證時,雜湊後的密碼將與他們輸入的密碼的雜湊值進行比較。這意味著系統只需要儲存密碼的單向雜湊值。如果發生洩露,只會暴露密碼的單向雜湊值。由於雜湊是單向的,並且根據雜湊值猜測密碼在計算上很困難,因此找出系統中每個密碼的努力是不值得的。為了攻破這個新系統,惡意使用者決定建立稱為彩虹表(Rainbow Tables)的查詢表。他們不再每次都嘗試猜測每個密碼,而是一次計算出密碼並將其儲存在查詢表中。
為了減輕彩虹表的效力,開發者被鼓勵使用加鹽(salted)密碼。不再僅將密碼作為雜湊函式的輸入,而是為每個使用者的密碼生成隨機位元組(稱為鹽)。鹽和使用者密碼將透過雜湊函式生成唯一的雜湊值。鹽會與使用者密碼一起以明文形式儲存。然後,當用戶嘗試認證時,雜湊後的密碼將與儲存的鹽和他們輸入的密碼的雜湊值進行比較。獨特的鹽意味著彩虹表不再有效,因為每個鹽和密碼組合的雜湊值都不同。
在現代,我們意識到加密雜湊(如 SHA-256)已不再安全。原因是憑藉現代硬體,我們可以每秒執行數十億次雜湊計算。這意味著我們可以輕鬆地單獨破解每個密碼。
現在,開發者被鼓勵利用自適應單向函式來儲存密碼。使用自適應單向函式驗證密碼是故意資源密集型的(它們故意使用大量的 CPU、記憶體或其他資源)。自適應單向函式允許配置一個“工作因子”,該因子可以隨著硬體的改進而增長。我們建議調整“工作因子”,使其在您的系統上驗證一個密碼大約需要一秒鐘。這種權衡是為了使攻擊者難以破解密碼,但又不會過於昂貴以至於給您自己的系統帶來過度負擔或惹惱使用者。Spring Security 試圖為“工作因子”提供一個良好的起點,但我們鼓勵使用者根據自己的系統定製“工作因子”,因為不同系統之間的效能差異很大。應該使用的自適應單向函式的例子包括 bcrypt、PBKDF2、scrypt 和 argon2。
由於自適應單向函式是故意資源密集型的,因此對每個請求驗證使用者名稱和密碼會顯著降低應用程式的效能。Spring Security(或任何其他庫)都無法加快密碼驗證速度,因為安全性是透過使驗證資源密集型來實現的。建議使用者將長期憑據(即使用者名稱和密碼)交換為短期憑據(例如會話、OAuth 令牌等)。短期憑據可以快速驗證,而不會損失安全性。
DelegatingPasswordEncoder
在 Spring Security 5.0 之前,預設的 PasswordEncoder
是 NoOpPasswordEncoder
,它要求純文字密碼。根據密碼歷史部分,您可能期望預設的 PasswordEncoder
現在會是類似於 BCryptPasswordEncoder
的東西。然而,這忽略了三個現實問題
-
許多應用程式使用無法輕易遷移的舊密碼編碼。
-
密碼儲存的最佳實踐將會再次改變。
-
作為框架,Spring Security 不能頻繁進行破壞性更改。
相反,Spring Security 引入了 DelegatingPasswordEncoder
,它透過以下方式解決了所有問題:
-
確保使用當前密碼儲存建議對密碼進行編碼
-
允許驗證現代和舊格式的密碼
-
允許將來升級編碼
您可以使用 PasswordEncoderFactories
輕鬆構建 DelegatingPasswordEncoder
例項:
-
Java
-
Kotlin
PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
val passwordEncoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
或者,您可以建立自己的自定義例項:
-
Java
-
Kotlin
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder =
new DelegatingPasswordEncoder(idForEncode, encoders);
val idForEncode = "bcrypt"
val encoders: MutableMap<String, PasswordEncoder> = mutableMapOf()
encoders[idForEncode] = BCryptPasswordEncoder()
encoders["noop"] = NoOpPasswordEncoder.getInstance()
encoders["pbkdf2"] = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5()
encoders["pbkdf2@SpringSecurity_v5_8"] = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()
encoders["scrypt"] = SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1()
encoders["scrypt@SpringSecurity_v5_8"] = SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()
encoders["argon2"] = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()
encoders["argon2@SpringSecurity_v5_8"] = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
encoders["sha256"] = StandardPasswordEncoder()
val passwordEncoder: PasswordEncoder = DelegatingPasswordEncoder(idForEncode, encoders)
密碼儲存格式
密碼的通用格式是:
{id}encodedPassword
id
是一個識別符號,用於查詢應該使用哪個 PasswordEncoder
,而 encodedPassword
是所選 PasswordEncoder
的原始編碼密碼。id
必須位於密碼的開頭,以 `{` 開頭,以 `}` 結尾。如果找不到 id
,則 id
被設定為 null。例如,以下可能是使用不同 id
值編碼的密碼列表。所有原始密碼都是 password
。
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG (1)
{noop}password (2)
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc (3)
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= (4)
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 (5)
1 | 第一個密碼的 PasswordEncoder id 為 bcrypt ,encodedPassword 值為 $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 。匹配時,它會委託給 BCryptPasswordEncoder |
2 | 第二個密碼的 PasswordEncoder id 為 noop ,encodedPassword 值為 password 。匹配時,它會委託給 NoOpPasswordEncoder |
3 | 第三個密碼的 PasswordEncoder id 為 pbkdf2 ,encodedPassword 值為 5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc 。匹配時,它會委託給 Pbkdf2PasswordEncoder |
4 | 第四個密碼的 PasswordEncoder id 為 scrypt ,encodedPassword 值為 $e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= 。匹配時,它會委託給 SCryptPasswordEncoder |
5 | 最後一個密碼的 PasswordEncoder id 為 sha256 ,encodedPassword 值為 97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 。匹配時,它會委託給 StandardPasswordEncoder |
一些使用者可能擔心儲存格式會提供給潛在的駭客。這不是問題,因為密碼的儲存不依賴於演算法的秘密性。此外,大多數格式即使沒有字首也容易被攻擊者識別。例如,BCrypt 密碼通常以 |
密碼編碼
傳遞給建構函式的 idForEncode
決定了用於編碼密碼的 PasswordEncoder
。在我們之前構建的 DelegatingPasswordEncoder
中,這意味著對 password
進行編碼的結果被委託給 BCryptPasswordEncoder
並以 {bcrypt}
為字首。最終結果如下例所示:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
密碼匹配
匹配是基於 {id}
以及建構函式中提供的 id
到 PasswordEncoder
的對映。我們在密碼儲存格式中的示例提供了一個關於如何完成此操作的可行示例。預設情況下,使用未對映的 id
(包括 null id)呼叫 matches(CharSequence, String)
的結果將引發 IllegalArgumentException
。可以透過使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder)
來自定義此行為。
透過使用 id
,我們可以匹配任何密碼編碼,但使用最現代的密碼編碼來編碼密碼。這很重要,因為與加密不同,密碼雜湊的設計使得無法輕易恢復純文字。由於無法恢復純文字,因此很難遷移密碼。雖然使用者遷移 NoOpPasswordEncoder
很簡單,但我們選擇預設包含它,以便簡化入門體驗。
入門體驗
如果您正在準備一個演示或示例,花費時間雜湊使用者密碼會有點麻煩。有一些便捷機制可以使這更容易,但這仍然不適用於生產環境。
-
Java
-
Kotlin
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("user")
.build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
val user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("user")
.build()
println(user.password)
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
如果您正在建立多個使用者,您也可以重用構建器:
-
Java
-
Kotlin
UserBuilder users = User.withDefaultPasswordEncoder();
UserDetails user = users
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = users
.username("admin")
.password("password")
.roles("USER","ADMIN")
.build();
val users = User.withDefaultPasswordEncoder()
val user = users
.username("user")
.password("password")
.roles("USER")
.build()
val admin = users
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build()
這確實雜湊了儲存的密碼,但密碼仍然暴露在記憶體和編譯後的原始碼中。因此,對於生產環境來說,這仍然不被認為是安全的。對於生產環境,您應該在外部雜湊您的密碼。
使用 Spring Boot CLI 編碼
正確編碼密碼的最簡單方法是使用 Spring Boot CLI。
例如,以下示例對密碼 password
進行編碼,以便與 DelegatingPasswordEncoder 一起使用:
spring encodepassword password
{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6
故障排除
當儲存的密碼中有一個沒有 id
時,就會發生以下錯誤,如密碼儲存格式中所述:
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233) at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)
解決此問題的最簡單方法是弄清楚您當前儲存密碼的方式,並顯式提供正確的 PasswordEncoder
。
如果您正在從 Spring Security 4.2.x 遷移,可以透過公開一個 NoOpPasswordEncoder
bean 來恢復之前的行為。
或者,您可以為所有密碼新增正確 id
字首,並繼續使用 DelegatingPasswordEncoder
。例如,如果您使用的是 BCrypt,您可以將密碼從類似如下的內容遷移:
$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
到:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
有關對映的完整列表,請參閱 PasswordEncoderFactories
的 Javadoc。
BCryptPasswordEncoder
BCryptPasswordEncoder
實現使用廣泛支援的 bcrypt 演算法來雜湊密碼。為了使其更能抵抗密碼破解,bcrypt 是故意設計得很慢的。與其他自適應單向函式一樣,應該對其進行調優,使其在您的系統上驗證一個密碼大約需要 1 秒鐘。BCryptPasswordEncoder
的預設實現使用強度 10,如 BCryptPasswordEncoder
的 Javadoc 中所述。建議您在自己的系統上調優和測試強度引數,以便驗證一個密碼大約需要 1 秒鐘。
-
Java
-
Kotlin
// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
// Create an encoder with strength 16
val encoder = BCryptPasswordEncoder(16)
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))
Argon2PasswordEncoder
Argon2PasswordEncoder
實現使用 Argon2 演算法來雜湊密碼。Argon2 是 密碼雜湊競賽(Password Hashing Competition)的獲勝者。為了對抗自定義硬體上的密碼破解,Argon2 是故意設計得很慢的演算法,需要大量記憶體。與其他自適應單向函式一樣,應該對其進行調優,使其在您的系統上驗證一個密碼大約需要 1 秒鐘。Argon2PasswordEncoder
的當前實現需要 BouncyCastle。
-
Java
-
Kotlin
// Create an encoder with all the defaults
Argon2PasswordEncoder encoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
// Create an encoder with all the defaults
val encoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))
Pbkdf2PasswordEncoder
Pbkdf2PasswordEncoder
實現使用 PBKDF2 演算法來雜湊密碼。為了對抗密碼破解,PBKDF2 是故意設計得很慢的演算法。與其他自適應單向函式一樣,應該對其進行調優,使其在您的系統上驗證一個密碼大約需要 1 秒鐘。當需要 FIPS 認證時,此演算法是一個不錯的選擇。
-
Java
-
Kotlin
// Create an encoder with all the defaults
Pbkdf2PasswordEncoder encoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
// Create an encoder with all the defaults
val encoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))
SCryptPasswordEncoder
SCryptPasswordEncoder
實現使用 scrypt 演算法來雜湊密碼。為了對抗自定義硬體上的密碼破解,scrypt 是故意設計得很慢的演算法,需要大量記憶體。與其他自適應單向函式一樣,應該對其進行調優,使其在您的系統上驗證一個密碼大約需要 1 秒鐘。
-
Java
-
Kotlin
// Create an encoder with all the defaults
SCryptPasswordEncoder encoder = SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
// Create an encoder with all the defaults
val encoder = SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))
其他 PasswordEncoder
存在大量其他 PasswordEncoder
實現,它們完全是為了向後相容而存在。它們都被標記為已棄用,以表明它們不再被認為是安全的。然而,沒有計劃移除它們,因為遷移現有的遺留系統很困難。
密碼儲存配置
Spring Security 預設使用 DelegatingPasswordEncoder。但是,您可以透過將 PasswordEncoder
公開為 Spring bean 來進行自定義。
如果您正在從 Spring Security 4.2.x 遷移,可以透過公開一個 NoOpPasswordEncoder bean 來恢復之前的行為。
恢復使用 |
-
Java
-
XML
-
Kotlin
@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
<b:bean id="passwordEncoder"
class="org.springframework.security.crypto.password.NoOpPasswordEncoder" factory-method="getInstance"/>
@Bean
fun passwordEncoder(): PasswordEncoder {
return NoOpPasswordEncoder.getInstance();
}
XML 配置要求 NoOpPasswordEncoder bean 的名稱為 |
修改密碼配置
大多數允許使用者指定密碼的應用程式也需要一個用於更新該密碼的功能。
“用於修改密碼的知名 URL” 指示了一種機制,密碼管理器可以透過該機制發現給定應用程式的密碼更新端點。
您可以將 Spring Security 配置為提供此發現端點。例如,如果您的應用程式中的修改密碼端點是 /change-password
,則可以像這樣配置 Spring Security:
-
Java
-
XML
-
Kotlin
http
.passwordManagement(Customizer.withDefaults())
<sec:password-management/>
http {
passwordManagement { }
}
然後,當密碼管理器導航到 /.well-known/change-password
時,Spring Security 將重定向到您的端點 /change-password
。
或者,如果您的端點不是 /change-password
,您也可以像這樣指定:
-
Java
-
XML
-
Kotlin
http
.passwordManagement((management) -> management
.changePasswordPage("/update-password")
)
<sec:password-management change-password-page="/update-password"/>
http {
passwordManagement {
changePasswordPage = "/update-password"
}
}
使用上述配置,當密碼管理器導航到 /.well-known/change-password
時,Spring Security 將重定向到 /update-password
。
已洩露密碼檢查
在某些場景下,您需要檢查密碼是否已被洩露,例如,如果您正在建立一個處理敏感資料的應用程式,通常需要對使用者的密碼進行一些檢查以斷言其可靠性。其中一項檢查就是密碼是否已被洩露,通常是因為在資料洩露事件中被發現。
為此,Spring Security 透過 CompromisedPasswordChecker
介面的 HaveIBeenPwnedRestApiPasswordChecker
實現,提供了與 Have I Been Pwned API 的整合。
您可以自行使用 CompromisedPasswordChecker
API,或者,如果您透過 Spring Security 認證機制使用 DaoAuthenticationProvider
,您可以提供一個 CompromisedPasswordChecker
bean,Spring Security 配置將自動載入它。
這樣做後,當您嘗試使用弱密碼(例如 123456
)透過表單登入進行認證時,您將收到 401 錯誤或被重定向到 /login?error
頁面(取決於您的使用者代理)。然而,在這種情況下,僅僅是 401 或重定向用處不大,這會導致一些困惑,因為使用者提供了正確的密碼但仍然無法登入。在這種情況下,您可以透過 AuthenticationFailureHandler
處理 CompromisedPasswordException
來執行您想要的邏輯,例如將使用者代理重定向到 /reset-password
:
-
Java
-
Kotlin
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin((login) -> login
.failureHandler(new CompromisedPasswordAuthenticationFailureHandler())
);
return http.build();
}
@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker();
}
static class CompromisedPasswordAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final SimpleUrlAuthenticationFailureHandler defaultFailureHandler = new SimpleUrlAuthenticationFailureHandler(
"/login?error");
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
if (exception instanceof CompromisedPasswordException) {
this.redirectStrategy.sendRedirect(request, response, "/reset-password");
return;
}
this.defaultFailureHandler.onAuthenticationFailure(request, response, exception);
}
}
@Bean
open fun filterChain(http:HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
formLogin {
failureHandler = CompromisedPasswordAuthenticationFailureHandler()
}
}
return http.build()
}
@Bean
open fun compromisedPasswordChecker(): CompromisedPasswordChecker {
return HaveIBeenPwnedRestApiPasswordChecker()
}
class CompromisedPasswordAuthenticationFailureHandler : AuthenticationFailureHandler {
private val defaultFailureHandler = SimpleUrlAuthenticationFailureHandler("/login?error")
private val redirectStrategy = DefaultRedirectStrategy()
override fun onAuthenticationFailure(
request: HttpServletRequest,
response: HttpServletResponse,
exception: AuthenticationException
) {
if (exception is CompromisedPasswordException) {
redirectStrategy.sendRedirect(request, response, "/reset-password")
return
}
defaultFailureHandler.onAuthenticationFailure(request, response, exception)
}
}