Spring Securityでの認証における認証用パスワードのハッシュアルゴリズムを更新する

# はじめに
長い期間運用しているサイトだと、認証用のパスワードのハッシュアルゴリズムが陳腐化してしまうことが少なくありません。
その問題に対応するための仕組みがSpring Securityには備えられています。
そして、その仕組みは現在使用中のシステムにおいても適用できるようになっています。
ここでは、Spring Securityにて、パスワードのハッシュアルゴリズムを更新する方法を紹介します。

# パスワードエンコーダーの定義
DelegatingPasswordEncoderを使用することで、複数のパスワードエンコーダーを登録することが出来ます。
それにより、新しいハッシュアルゴリズムが出来た時に、新たなエンコーダーを登録することで、同一システム内に複数のハッシュアルゴリズムの共存が可能になります。

```java:ApplicationConfig
    @Bean("passwordEncoder")
    public PasswordEncoder passwordEncoder() {
        Map<String, PasswordEncoder> encoderMap = new HashMap<>();
        encoderMap.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoderMap.put("bcrypt", bCryptPasswordEncoder());
        encoderMap.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
        return new DelegatingPasswordEncoder("pbkdf2", encoderMap);
    }
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
```
上記の例では、ハッシュアルゴリズム"pdkdf2"をデフォルトとして設定しています。

DelegatingPasswordEncoderでエンコードされたパスワードは、以下の例のように、上記例のMapのキー値に設定した値が先頭に付与された値となります。
```
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
```
それにより、１つのシステムで複数のハッシュアルゴリズムの共存が可能となります。

新たなハッシュアルゴリズムを追加したい場合は、Mapに新たなエンコーダーを登録し、そして、DelegatingPasswordEncoderの第一引数に、新たに追加したエンコーダーを示す文字列にすることで、ハッシュアルゴリズムを容易に更新することが出来ます。


### 既存システムへの適用方法
既に運用しているサイトの場合、パスワードは、上記のような形式では保存されていませんので、先頭にハッシュアルゴリズムを示す文字列は付与されていません。
そこで、旧システムに対応したパスワードエンコーダーを作成し、それを上記DelegatingPasswordEncoderに登録します。
手順は以下の通りです。

#### 古いハッシュアルゴリズムに対応したパスワードエンコーダーを、PasswordEncoderインタフェースを実装して作成
```java:OldPasswordEncoder.java
public class OldPasswordEncoder implements PasswordEncoder {
    // ここではこのメソッドは未使用です
    @Override
    public boolean upgradeEncoding(String encodedPassword) {
        return false;
    }

    @Override
    public String encode(CharSequence rawPassword) {
        // ここで、rawPasswordを古いハッシュ方式でハッシュ化する処理を行います
        // ...
        // 古いハッシュ方式でハッシュ化されたパスワードを返します
        return oldHashedPassword;
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        // rawPasswordをハッシュ化して、引数のencodedPasswordと比較して結果を返します
        return encodedPassword.equals(encode(rawPassword));
    }
}

```
#### 作成したパスワードエンコーダーをApplicationConfigで登録

```java:ApplicationConfig
@Bean("passwordEncoder")
public PasswordEncoder passwordEncoder() {
    Map<String, PasswordEncoder> encoderMap = new HashMap<>();
    encoderMap.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    encoderMap.put("bcrypt", bCryptPasswordEncoder());
    encoderMap.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
    DelegatingPasswordEncoder bean = new DelegatingPasswordEncoder("pbkdf2", encoderMap);
    // ここで既存システムでのハッシュ方式を用いたパスワードエンコーダーを登録する
    bean.setDefaultPasswordEncoderForMatches(oldPasswordEncoder());
    return bean;
}

//既存システムでのハッシュ方式のパスワードエンコーダーをBean定義
@Bean
public OldPasswordEncoder oldPasswordEncoder() {
    return new OldPasswordEncoder();
}

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder();
}
```
これで、既存システムへ新たなハッシュアルゴリズムを適用することが出来るようになります。

# 保存されているパスワードを新しいハッシュアルゴリズムに更新
上記により、古いハッシュアルゴリズムと新しいハッシュアルゴリズムの共存が可能になりましたが、今のままだと、システムに保存されているパスワードは相変わらず古いハッシュアルゴリズムのままです。
それを解決する仕組みもあります。

具体的には、UserDetailsPasswordServiceインタフェースを実装したクラスを作成し、updatePasswordメソッドを実装することです。

```java:UserDetailsPasswordServiceインタフェース実装クラス
@Service // Serviceアノテーションを付与する
public class SampleUserDetailsPasswordService impliments UserDetailsPasswordService {
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        String username = user.getUsername();
        // ここに、usernameのユーザのパスワードをnewPasswordに更新する処理を記述する。
        // newPasswordは新しいハッシュ方式でハッシュ化されたパスワードである
        // ...

        return user;
     }
}
```
:::note warn
ここでは、UserDetailsServiceインタフェースを実装したクラスを既に作成しており、Spring Securityの認証処理に対応していることを前提として話を進めます。
Spring Securityの認証の仕組みやUserDetailsServiceに関しては、ここでは触れません。
:::

そして、上記で作成したクラスをSecurityConfigに設定します。

```java:SecurityConfig
// 引数にQualifierアノテーションを付与して、ぞれぞれのBeanを引数により取得する
@Bean("authenticationProvider")
public AuthenticationProvider authenticationProvider(@Qualifier("passwordEncoder") PasswordEncoder passwordEncoder,
        @Qualifier("sampleUserDetailsService") UserDetailsService sampleUserDetailsService,
        @Qualifier("sampleUserDetailsPasswordService") UserDetailsPasswordService sampleUserDetailsPasswordService) {
    // パスワードエンコーダーを引数にDaoAuthenticationProviderのインスタンスを生成
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(passwordEncoder);
    // 認証に使用するUserDtailesServiceのBeanを設定
    authProvider.setUserDetailsService(sampleUserDetailsService);
    // ここに上記で作成したUserDetailsPasswordServiceのBeanを設定する
    authProvider.setUserDetailsPasswordService(sampleUserDetailsPasswordService);
    return authProvider;
}
```

上記を行うことにより、ログイン認証が通った後、そのパスワードのハッシュ方式が古い場合、UserDetailsPasswordService実装クラスのupdatePasswordが呼び出されます。それにより、保存されているパスワードが新しいハッシュ方式のものに更新されます。

# 最後に
UserDetailsPasswordServiceの使い方を具体的に紹介しているページが日本語ではあまり見つからなかったのですが、とても有用な機能だと感じたので、ここで紹介させていただきました。
参考になれば幸いです。