Spring Security の挙動をソースコードとともに確認する(認証、BCryptPasswordEncoder 編)
はじめに
前回の記事にて、Spring Boot にて Spring Security を依存関係に加えた直後に起きていることをソースコードを追いながら確認しました。
今回の記事では、サンプルコード をもとに Spring Security のログイン時の認証の流れと BCryptPasswordEncoder
の挙動を追っていきます。
今回の作業ブランチは explore-BCryptPasswordEncoder です。
Spring Security が設定してくれている内容と、フレームワーク内部の実装を掘り下げることで、
セキュリティを意識する上で必要な実装についての理解を深められればと思います。
ライブラリ | バージョン | Maven central URL |
---|---|---|
spring-boot-starter-web |
3.0.1 |
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web/3.0.1 |
spring-boot-starter-security |
3.0.1 |
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security/3.0.1 |
spring-core |
6.0.3 |
https://mvnrepository.com/artifact/org.springframework/spring-core/6.0.3 |
spring-security-web |
6.0.1 |
https://mvnrepository.com/artifact/org.springframework.security/spring-security-web/6.0.1 |
InMemoryUserDetailsManager
を使用したユーザの追加
前回の記事で確認したように、Spring Security 導入直後は、 1ユーザのみがインメモリで保持されています。
まずは、複数のユーザを初期設定で作成できるようにします。
InMemoryUserDetailsManager
に関するこちらのドキュメントの設定を追加(=Bean定義)します。
SecurityConfig
というクラス名は任意のもので、
今後はこのクラスに Spring Security の挙動をカスタマイズするためBean定義を記述していきます。
Java-based Container Configuration によりDIコンテナにインスタンス(=Bean)を登録しています。
Spring Boot CLI を使用して、
パスワードを BCrypt で encoding したものを指定します。
@Configuration
class SecurityConfig {
@Bean
fun userDetailsManager(): UserDetailsManager {
val admin: UserDetails = User.builder()
.username("admin")
// encode with Spring Boot CLI
// https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-boot-cli
// $ spring encodepassword 1qazxsw2
.password("{bcrypt}\$2a\$10\$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy")
.authorities("USER", "ADMIN")
.build()
val user: UserDetails = User.builder()
.username("user")
// $ spring encodepassword 2wsxzaq1
.password("{bcrypt}\$2a\$10\$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6")
.authorities("USER")
.build()
return InMemoryUserDetailsManager(admin, user)
}
}
この状態でアプリケーションを起動すると、
複数のユーザ情報をメモリ上で管理できていることが確認できます。
$ admin_encoded_credential=$(echo -n "admin:1qazxsw2" | base64)
$ curl --location --request GET 'localhost:9080/public' --header "Authorization: Basic $admin_encoded_credential"
hello public world
$ user_encoded_credential=$(echo -n "user:2wsxzaq1" | base64)
$ curl --location --request GET 'localhost:9080/public' --header "Authorization: Basic $user_encoded_credential"
hello public world
ただし、これもあくまでも開発時点でサンプルのユーザを作成する方法に過ぎないでしょう。
またの機会に DB にユーザを永続化する方法も紹介します。
BCryptPasswordEncoder
が選ばれれている仕組み
ユーザのパスワードは、 Spring Boot により BCrypt で保護されているようなので、その設定を行っている箇所を追ってみます。
BCryptPasswordEncoder
をインスタンス化している箇所を探すと、
org/springframework/security/crypto/factory/PasswordEncoderFactories.java
にて bcrypt
を引数に DelegatingPasswordEncoder
をインスタンス化していました。
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
// 略
return new DelegatingPasswordEncoder(encodingId, encoders);
}
この static method が呼ばれている箇所をデバック実行して、
stacktrace から呼び出し元を調べる(画像の赤枠の箇所をクリック)と、
org/springframework/security/authentication/dao/DaoAuthenticationProvider.java
で encoder がセットされており、
public DaoAuthenticationProvider() {
setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
その呼び出し元は org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java
で、
PasswordEncoder 型の Bean がDIコンテナ(の実装である ApplicationContext)から取得できた場合(=カスタマイズされていた場合)に encorder をセットして更新しています。
PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
if (passwordEncoder != null) {
provider.setPasswordEncoder(passwordEncoder);
}
つまり、 PasswordEncoder のBean定義を追加しなかった場合は、
デフォルトで BCryptPasswordEncoder
が使われているわけです。
BCryptPasswordEncoder
がパスワードを判定している箇所
上記の例だと、 BCryptPasswordEncoder
を使用している状況で、
ユーザの入力値 1qazxsw2
と {bcrypt}\$2a\$10\$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy
あるいは、2wsxzaq1
と {bcrypt}\$2a\$10\$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6
がマッチしてログインができているようでした。
この時、ライブラリ内部ではどのような処理が起きていたかを見てみます。
そもそも PasswordEncoder
は一方向の変換を行います。
Spring Security’s PasswordEncoder interface is used to perform a one-way transformation of a password to let the password be stored securely.
つまり、BCryptPasswordEncoder
で encoding したパスワードを平文に戻すことはできないです。
ドキュメントの Password Storage History
のセクションにパスワードを保存するためのメカニズムの進化の歴史が記載されていて大変勉強になります。
平文で保存されていたものが、一方向のハッシュを保存するようになり、
レインボーテーブル攻撃を防ぐために、パスワードにソルト(ランダムな情報)を加えてハッシュを作成するようになりました。
さらに、ハッシュ化を実行する関数は繰り返し実行されないように、意図的に低速に動作するように調整するのが良い、
ただしそうするとアプリケーションのパフォーマンスが下がってしまうため、
ユーザーが長期のクレデンシャル(ユーザー名とパスワード)ではなく、
短期のクレデンシャル(セッション、JWT など)を使用できるようにするのが良いとのことです。
では、どのように一方向のハッシュ化とログイン時の検証が行われているかを追ってみます。
BCryptPasswordEncoder
に平文と encoding 済みのパスワードを引数にとって boolean を返す如何にもなメソッド(#L121)があったので、こちらに break point を貼ってログインのリクエストをします。
この break point で止まった時点でわかることが2つあります。
1つ目は前回確認したように Basic認証がかかっているので、
遡ると org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java
の filter から呼び出されていることです。
2つ目はリクエスト時の平文のパスワード 1qazxsw2
と InMemoryUserDetailsManager
でユーザを初期作成する時に指定した encoding ずみのパスワード $2a$10$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy
が比較されていることです。
これは、 平文のパスワードを encoding した結果が encoding 済みで保存されているパスワードと一致していれば有効なパスワードと判定しているということ です。
決してパスワードを平文に戻して、平文のリクエストと比較しているわけではない のです。
(というより、一方向のハッシュ生成の関数を実行したため戻せないです)
Spring Security が認証を行う流れ
ログインのリクエストから BCryptPasswordEncoder
がパスワードを判定する箇所までの stacktrace を追うことで Spring Security がどのように認証を行っているかを掘り下げます。
stacktrace をクリックして呼び出し元を見てみます。
org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java
の以下の箇所で、
authenticationManager
の実装として使用されているのは ProviderManager
です。
Authentication authResult = this.authenticationManager.authenticate(authRequest);
ここで Spring Security のServlet Authentication Architecture のドキュメント を確認すると、
ProviderManager
の記載もあります。
ProviderManager delegates to a List of AuthenticationProvider instances. Each AuthenticationProvider has an opportunity to indicate that authentication should be successful, fail, or indicate it cannot make a decision and allow a downstream AuthenticationProvider to decide.
複数の AuthenticationProvider
に認証を委譲しすべてが成功したら認証を成功とみなすようです。
ProviderManager
の呼び出し箇所の stacktrace をクリックし確認します。
以下の provider という変数は上記のドキュメントの記載が示すように、
AuthenticationProvider
interface を実装したクラスであるので、
画像のように確認すると DaoAuthenticationProvider
であることが分かります。
result = provider.authenticate(authentication);
DaoAuthenticationProvider
のドキュメントには、以下のように挙動の説明があります。
DaoAuthenticationProvider is an AuthenticationProvider implementation that uses a UserDetailsService and PasswordEncoder to authenticate a username and password.
UserDetailsService
と PasswordEncoder
を使用してユーザー名とパスワードを認証します。
そして今回は、 UserDetailsService
の実装として SecurityConfig.kt
で設定したように InMemoryUserDetailsManager
が使用されています。
(再度、以下に記載します)
@Configuration
class SecurityConfig {
@Bean
fun userDetailsManager(): UserDetailsManager {
val admin: UserDetails = User.builder()
.username("admin")
// encode with Spring Boot CLI
// https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-boot-cli
// $ spring encodepassword 1qazxsw2
.password("{bcrypt}\$2a\$10\$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy")
.authorities("USER", "ADMIN")
.build()
val user: UserDetails = User.builder()
.username("user")
// $ spring encodepassword 2wsxzaq1
.password("{bcrypt}\$2a\$10\$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6")
.authorities("USER")
.build()
return InMemoryUserDetailsManager(admin, user)
}
}
長くなってきたのでここまでをまとめると、
-
BasicAuthenticationFilter
の fileter 処理 -
ProviderManager
がAuthenticationProvider
に委譲 - 実装として
DaoAuthenticationProvider
がUserDetailsService
とPasswordEncoder
を使用してユーザー名とパスワードを認証
です。
ProviderManager
と DaoAuthenticationProvider
の間で呼ばれている、
org/springframework/security/authentication/dao/AbstractUserDetailsAuthenticationProvider.java
のメソッドを見てみると、
authenticate
メソッドの始まり(#L123)から DaoAuthenticationProvider
の additionalAuthenticationChecks
を呼び出す(#L146)間に、
retrieveUser
(#L133) というメソッドが呼び出されています。
retrieveUser
では UserDetailsService
の loadUserByUsername
を呼び出しているので、
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
実装クラスである InMemoryUserDetailsManager
を使用し、ユーザを取得しています。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
org/springframework/security/core/userdetails/UserDetailsService.java
は以下の interface となっており、
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
こちらを実装することで、ユーザの取得に関するロジックを任意のものにカスタマイズできます。
なお、UserDetailsService
のドキュメントには in-memory(InMemoryUserDetailsManager
) と JDBCのサポート があるようです。
Spring Security provides in-memory and JDBC implementations of UserDetailsService
そして DaoAuthenticationProvider
が、
ログイン時のリクエストのパスワードと取得したユーザのパスワードを PasswordEncoder
を使用して比較します(#L77)。
BCryptPasswordEncoder
がパスワードを判定する箇所まで戻ってきました。
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
BCryptPasswordEncoder はどのようにパスワードを判定しているか
では、以下の箇所の具体的な挙動をみていきます。
2つ目はリクエスト時の平文のパスワード
1qazxsw2
とInMemoryUserDetailsManager
でユーザを初期作成する時に指定した encoding ずみのパスワード$2a$10$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy
が比較されていることです。
これは、 平文のパスワードを encoding した結果が encoding 済みで保存されているパスワードと一致していれば有効なパスワードと判定しているということ です。
決してパスワードを平文に戻して、平文のリクエストと比較しているわけではない のです。
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java#L133
から内部に入っていきます。
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
org/springframework/security/crypto/bcrypt/BCrypt.java
のメソッド hashpw
に、
引数 passwordb
はリクエストの値(平文)=1qazxsw2
引数 salt
は取得したユーザの 1qazxsw2
を encoding した値=$2a$10$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy
が
に渡されてきます。
return hashpw(passwordb, salt, true);
その先では、encoding した値の文字数があっているか、 $2
で始まっているかをチェックし、
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
}
if (salt.charAt(0) != '$' || salt.charAt(1) != '2') {
throw new IllegalArgumentException("Invalid salt version");
}
round(アルゴリズムの強度)の情報($10
の 10)を取り出し
rounds = Integer.parseInt(salt.substring(off, off + 2));
salt 部分(1gHHMqYmv7spE.896lYtKu
)を取り出します。
real_salt = salt.substring(off + 3, off + 25);
そして、リクエストの値(平文)に salt,round の情報を渡して BCrypt によるハッシュ化を実行します。
hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0, for_check);
/**
* Perform the central password hashing step in the bcrypt scheme
* @param password the password to hash
* @param salt the binary salt to hash with the password
* @param log_rounds the binary logarithm of the number of rounds of hashing to apply
* @param sign_ext_bug true to implement the 2x bug
* @param safety bit 16 is set when the safety measure is requested
* @return an array containing the binary hashed password
*/
private byte[] crypt_raw(byte password[], byte salt[], int log_rounds, boolean sign_ext_bug, int safety,
2つの値が一致すれば、 BCryptPasswordEncoder
の matches
は true
を返すため、認証に成功します。
public boolean matches(CharSequence rawPassword, String encodedPassword) {
一致しない場合は、DaoAuthenticationProvider
が BadCredentialsException
を throw することになります。
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
BCryptPasswordEncoder が生成するハッシュ値は毎回異なる
BCryptPasswordEncoder
によるパスワードの判定の処理が具体的にわかったので1つ確かめたいことがあります。
それは、「同じパスワードから別のハッシュが生成されるか」です。
ランダム値である salt が混ざっていることで、毎回異なるハッシュ値が生成されるを実際に確認してみます。
シンプルに BCryptPasswordEncoder
を実行したいので UT を書いて確かめてみます。
class PasswordEncodingTest {
private val password = "1qazxsw2"
@Test
@DisplayName("BCryptPasswordEncoder は実行するたびに別のHash値を生成する")
fun `"generated different hash using different salt"`() {
val bcrypt: PasswordEncoder = BCryptPasswordEncoder()
val bcryptEncodedPass1 = bcrypt.encode(password)
val bcryptEncodedPass2 = bcrypt.encode(password)
println("bcrypt encoded password1: $bcryptEncodedPass1")
println("bcrypt encoded password2: $bcryptEncodedPass2")
assertNotEquals(bcryptEncodedPass1, bcryptEncodedPass2)
}
}
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java
で encoding する際に、ランダムの salt が使われるため
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
生成されるハッシュ値は一致しないです。
bcrypt encoded password1: $2a$10$Ypv7SbTvcJNM2FSQKcm2be0a05Xyhsrp11p41IC0umkgQwGWvCUia
bcrypt encoded password2: $2a$10$awLeOqD1HeHLQiRs/8y1xea/UKVgFCCji9gFaf1gZJOSFvRAjRKEi
salt の値を固定にした(なので定義的に salt ではないですが)、
CustomEncoder.kt
を使用すると、
class CustomEncoder: PasswordEncoder {
private val bcryptPattern = Pattern.compile("\\A\\$2(a|y|b)?\\$(\\d\\d)\\$[./0-9A-Za-z]{53}")
override fun encode(rawPassword: CharSequence?): String {
requireNotNull(rawPassword) { "rawPassword cannot be null" }
val notRandomSalt: String = "\$2a\$10\$AzYnC1K8/95SCW8Wktu1e."
return BCrypt.hashpw(rawPassword.toString(), notRandomSalt)
}
// BCryptPasswordEncoder と同じ実装
override fun matches(rawPassword: CharSequence?, encodedPassword: String?): Boolean {
requireNotNull(rawPassword) { "rawPassword cannot be null" }
if (encodedPassword == null || encodedPassword.isEmpty()) {
return false
}
if (!this.bcryptPattern.matcher(encodedPassword).matches()) {
return false
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword)
}
}
当然、同じハッシュ値が生成されました。
@Test
@DisplayName("同じ salt を使用して encode したら同じHash値になる")
fun `"generated same hash using same salt"`() {
val customEncoder: PasswordEncoder = CustomEncoder()
val customEncodedPass1 = customEncoder.encode(password)
val customEncodedPass2 = customEncoder.encode(password)
println("custom encoded password1: $customEncodedPass1")
println("custom encoded password1: $customEncodedPass2")
assertEquals(customEncodedPass1, customEncodedPass2)
}
custom encoded password1: $2a$10$AzYnC1K8/95SCW8Wktu1e..rwZtMXzOnMR4e5WiDdbNcNjb0SmnV2
custom encoded password2: $2a$10$AzYnC1K8/95SCW8Wktu1e..rwZtMXzOnMR4e5WiDdbNcNjb0SmnV2
おわりに
Spring Security の認証時の処理の流れはなかなかに複雑でした。
ただ今回のように、実際のログインのリクエスト時にデバック実行して stacktrace から処理をたどることで、ただドキュメントを読むだけより格段に理解が深まりました。
また、Spring は大変ドキュメントが丁寧で充実しているので、
ソースコードリーディングの答え合わせができる点がおすすめだと感じました。
次回は、ユーザの情報を DB に永続化するためのカスタマイズ方法を紹介します。
つづき
Discussion