密碼儲存
Spring Security 的 PasswordEncoder
介面用於對密碼執行單向轉換,以安全地儲存密碼。 由於 PasswordEncoder
是單向轉換,因此當密碼轉換需要雙向時(例如儲存用於驗證資料庫的憑證),它就沒有用處。 通常,PasswordEncoder
用於儲存需要在身份驗證時與使用者提供的密碼進行比較的密碼。
密碼儲存歷史
多年以來,儲存密碼的標準機制不斷演進。 最初,密碼以明文形式儲存。 密碼被認為是安全的,因為儲存密碼的資料儲存庫需要憑證才能存取。 然而,惡意使用者能夠透過使用 SQL 注入等攻擊來取得大量的使用者名稱和密碼「資料傾印」。 隨著越來越多的使用者憑證公開,安全專家意識到我們需要採取更多措施來保護使用者的密碼。
隨後,開發人員被鼓勵在儲存密碼之前,先使用單向雜湊演算法(例如 SHA-256)對密碼進行處理。 當使用者嘗試身份驗證時,系統會將雜湊後的密碼與使用者輸入的密碼雜湊值進行比較。 這表示系統只需要儲存密碼的單向雜湊值。 如果發生洩漏,則只會洩漏密碼的單向雜湊值。 由於雜湊是單向的,並且從雜湊值推測密碼在計算上非常困難,因此找出系統中每個密碼是不值得的。 為了破解這個新系統,惡意使用者決定建立稱為「彩虹表」的查找表。 他們不是每次都猜測每個密碼,而是計算一次密碼並將其儲存在查找表中。
為了降低彩虹表的效力,開發人員被鼓勵使用加鹽密碼。 系統不再僅使用密碼作為雜湊函數的輸入,而是為每個使用者的密碼生成隨機位元組(稱為鹽)。 鹽和使用者的密碼將通過雜湊函數運行,以產生唯一的雜湊值。 鹽將與使用者的密碼一起以明文形式儲存。 然後,當使用者嘗試身份驗證時,系統會將雜湊後的密碼與儲存的鹽和使用者輸入的密碼的雜湊值進行比較。 唯一的鹽意味著彩虹表不再有效,因為每個鹽和密碼組合的雜湊值都不同。
在現代,我們意識到密碼雜湊(如 SHA-256)已不再安全。 原因是在現代硬體上,我們每秒可以執行數十億次雜湊計算。 這意味著我們可以輕鬆地單獨破解每個密碼。
現在鼓勵開發人員利用適應性單向函數來儲存密碼。 使用適應性單向函數驗證密碼是有意耗費資源的(它們有意使用大量的 CPU、記憶體或其他資源)。 適應性單向函數允許配置「工作因子」,該因子可以隨著硬體效能的提升而增加。 我們建議調整「工作因子」,使其在您的系統上驗證密碼大約需要一秒鐘。 這種權衡是為了讓攻擊者難以破解密碼,但又不會過於昂貴,以至於給您自己的系統帶來過度的負擔或惹惱使用者。 Spring Security 已嘗試為「工作因子」提供一個良好的起點,但我們鼓勵使用者為自己的系統自訂「工作因子」,因為效能在不同系統之間差異很大。 應使用的適應性單向函數的範例包括 bcrypt、PBKDF2、scrypt 和 argon2。
由於適應性單向函數是有意耗費資源的,因此為每個請求驗證使用者名稱和密碼可能會顯著降低應用程式的效能。 Spring Security(或任何其他程式庫)都無法加快密碼驗證速度,因為安全性是透過使驗證耗費資源來獲得的。 建議使用者將長期憑證(即使用者名稱和密碼)換成短期憑證(例如 session、OAuth Token 等)。 短期憑證可以快速驗證,而不會損失任何安全性。
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`。 比對時,它將委派給 |
4 | 第四個密碼的 PasswordEncoder id 為 `scrypt`,encodedPassword 值為 `$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=`。 比對時,它將委派給 SCryptPasswordEncoder |
5 | 最後一個密碼的 PasswordEncoder id 為 `sha256`,encodedPassword 值為 `97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0`。 比對時,它將委派給 StandardPasswordEncoder |
有些使用者可能會擔心儲存格式是為潛在的駭客提供的。 這不是問題,因為密碼的儲存並不依賴於演算法是秘密的。 此外,大多數格式對於攻擊者來說很容易在沒有前綴的情況下弄清楚。 例如,BCrypt 密碼通常以 `$2a$` 開頭。 |
密碼編碼
傳遞到建構子的 `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 是 密碼雜湊競賽的獲勝者。 為了擊敗自訂硬體上的密碼破解,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 設定要求 |
變更密碼設定
大多數允許使用者指定密碼的應用程式也需要更新密碼的功能。
用於變更密碼的 Well-Known 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 透過 HaveIBeenPwnedRestApiPasswordChecker
實作 CompromisedPasswordChecker
介面,提供了與 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)
}
}