🦔

Spring Security の挙動をソースコードとともに確認する(認証、BCryptPasswordEncoder 編)

2023/01/20に公開

はじめに

前回の記事にて、Spring Boot にて Spring Security を依存関係に加えた直後に起きていることをソースコードを追いながら確認しました。

https://zenn.dev/kiyotatakeshi/articles/fc593c768ad7e0

今回の記事では、サンプルコード をもとに 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 をインスタンス化している箇所を探すと、
06-zenn-spring-security
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 から呼び出し元を調べる(画像の赤枠の箇所をクリック)と、

07-zenn-spring-security

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 を貼ってログインのリクエストをします。

08-zenn-spring-security

この break point で止まった時点でわかることが2つあります。

1つ目は前回確認したように Basic認証がかかっているので、
遡ると org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java の filter から呼び出されていることです。

2つ目はリクエスト時の平文のパスワード 1qazxsw2InMemoryUserDetailsManager でユーザを初期作成する時に指定した 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);

09-zenn-spring-security

DaoAuthenticationProvider のドキュメントには、以下のように挙動の説明があります。

DaoAuthenticationProvider is an AuthenticationProvider implementation that uses a UserDetailsService and PasswordEncoder to authenticate a username and password.

UserDetailsServicePasswordEncoder を使用してユーザー名とパスワードを認証します。
そして今回は、 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)
    }
}

長くなってきたのでここまでをまとめると、

  1. BasicAuthenticationFilter の fileter 処理
  2. ProviderManagerAuthenticationProvider に委譲
  3. 実装として DaoAuthenticationProviderUserDetailsServicePasswordEncoder を使用してユーザー名とパスワードを認証

です。

ProviderManagerDaoAuthenticationProvider の間で呼ばれている、
org/springframework/security/authentication/dao/AbstractUserDetailsAuthenticationProvider.java のメソッドを見てみると、
authenticate メソッドの始まり(#L123)から DaoAuthenticationProvideradditionalAuthenticationChecks を呼び出す(#L146)間に、
retrieveUser (#L133) というメソッドが呼び出されています。

10-zenn-spring-security

retrieveUser では UserDetailsServiceloadUserByUsername を呼び出しているので、

			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);

11-zenn-spring-security

実装クラスである 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つ目はリクエスト時の平文のパスワード 1qazxsw2InMemoryUserDetailsManager でユーザを初期作成する時に指定した 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つの値が一致すれば、 BCryptPasswordEncodermatchestrue を返すため、認証に成功します。

	public boolean matches(CharSequence rawPassword, String encodedPassword) {

一致しない場合は、DaoAuthenticationProviderBadCredentialsException を 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 に永続化するためのカスタマイズ方法を紹介します。

つづき

https://zenn.dev/kiyotatakeshi/articles/73f722f99b7bf5

Discussion