Spring Security の DelegatingPasswordEncoder について調べた

# PasswordEncoder とは

Spring Security が提供するインターフェース。
パスワードを安全に保管するために、エンコードを行うメソッドと、エンコードされたパスワードと入力されたパスワードが一致しているかを確認するメソッドがある。
`upgradeEncoding(...)` というメソッドもあるが、これについては後述する。

# DelegatingPasswordEncoder

Spring Security 5.0 から導入された `PasswordEncoder` の実装クラス。
これがデフォルトの `PasswordEncoder` となっている。このクラスは複数の `PasswordEncoder` に処理を委譲する。

なぜこのような実装が必要かというと、以下を見るといいと思う。
https://docs.spring.io/spring-security/site/docs/5.1.5.RELEASE/reference/html/overall-architecture.html#pe-history

端的にまとめると、

- パスワードエンコード処理のベストプラクティスは変わっていっている。
- 古いエンコードを利用しているシステムが多くあり、それをマイグレーションするのは簡単ではない。

ということらしい。

なので、複数の `PasswordEncoder` が共存できるように `DelegatingPasswordEncoder` ができたようだ。

## エンコード文字列のフォーマット

複数の `PasswordEncoder` が共存しているため、どの実装クラスでエンコードされた文字列なのかを判定しないといけない。
（全てのエンコーダで検証すると時間がかかる。最近のエンコーダはわざと処理に時間がかかるようになっている。）

そのため、エンコードされた文字列に対して以下のように prefix を付与し、エンコーダを特定できるようにしている。

```
{bcrypt}$2a$10$7Y049iCPtPAGl6zyBSGoNeiTXbcr3NpPJ0xPEe2NmO7JDzLdGYv9K
```

prefix は `DelegatingPasswordEncoder` で指定することができる。（`Map` の key が prefix となる）

```java
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);

```

## パスワードのエンコード

エンコードに利用する `PasswordEncoder` は `DelegatingPasswordEncoder` のコンストラクタの第一引数で指定する。エンコード処理に利用する `PasswordEncoder` は必ず 1 種類になる。

上記の例であれば、`BcryptPasswordEncoder` がエンコードに利用される。

## パスワードの検証

検証に利用する `PasswordEncoder` は prefix で指定されたものを利用する。prefix に指定された値と一致する `PasswordEncoder` が存在しない場合、デフォルトでは認証失敗となる。
`DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatched(PasswordConder)` メソッドを利用することで、一致しない場合に利用する `PasswordEncoder` を指定することもできる。

## エンコードの更新が必要か判定する

エンコードの利用された `PasswordEncoder` が、現在使用している `PasswordEncoder` と異なるかどうか、`upgradeEncoding(...)` メソッドで判定することができる。
これは現在使用しているエンコーダに該当する prefix が付与されているかどうかを判定しているだけで、エンコード文字列を検証しているわけではない。

## 使ってみる

```java
public class Main {

  public static void main(String[] args) {
    PasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    PasswordEncoder pbkdf2PasswordEncoder = new Pbkdf2PasswordEncoder();

    String idForEncode = "bcrypt";
    Map encoders = new HashMap<>();
    encoders.put(idForEncode, bCryptPasswordEncoder);
    encoders.put("pbkdf2", pbkdf2PasswordEncoder);

    DelegatingPasswordEncoder passwordEncoder =
            new DelegatingPasswordEncoder(idForEncode, encoders);

    // prefix に該当するものがない場合は BcryptPasswordEncoder を利用する
    passwordEncoder.setDefaultPasswordEncoderForMatches(bCryptPasswordEncoder);

    String s1 = passwordEncoder.encode("hogehoge");
    String s2 = bCryptPasswordEncoder.encode("hogehoge");
    String s3 = pbkdf2PasswordEncoder.encode("hogehoge");

    // {bcrypt} の prefix がついている
    System.out.println(s1);

    // prefix はついていない
    System.out.println(s2);
    System.out.println(s3);

    // prefix が {bcrypt} のため、BcryptPasswordEncoder で検証されている
    System.out.println(passwordEncoder.matches("hogehoge", s1));

    // prefix がついていないため、デフォルトである BcryptPasswordEncoder で検証されている
    System.out.println(passwordEncoder.matches("hogehoge", s2));
    System.out.println(passwordEncoder.matches("hogehoge", s3));

    // prefix が {pbkdf2} のため、Pbkdf2PasswordEncoder で検証されている
    System.out.println(passwordEncoder.matches("hogehoge", "{pbkdf2}" + s3));


    // {bcrypt} が付与されているのでアップグレードの必要はない
    System.out.println(passwordEncoder.upgradeEncoding(s1));

    // {bcrypt} が付与されていないのでアップグレードの必要あり
    System.out.println(passwordEncoder.upgradeEncoding(s2));
    System.out.println(passwordEncoder.upgradeEncoding(s3));
  }
}
```

出力は以下のようになる。

```
{bcrypt}$2a$10$iu6uhBTbICW7.Jk4C66a8O5lL7CYjJY3J5NfqRWPqzchLj9Q3KRrO
$2a$10$6URvwDoL1ebU73YcKd9FD.foyJHIvBFJPlGj/IjDX2emx7oIm.4jG
d7dbf38db5387f7e806dc1191ab23cde528ccae02d2459111027b0af6d0721c10476bdd5c106fc8e
true
true
false
true
false
true
true
```

# 移行を考える

Spring Security 4 系を利用していて、`BcryptPasswordEncoder` を利用していたが、
Spring Security 5 系へ移行し `DelegatingPasswordEncoder` を利用するようにしたい場合を考える。

## エンコーダを変更しない場合

`DelegatingPasswordEncoder` の定義は以下のような感じ。

```java
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);
```

ここで問題になるのは、既存のエンコード文字列には prefix が付与されていないこと。
対応としては以下の 2 択か。

- 保管されているエンコード文字列に prefix を付与する
- `DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatched(PasswordConder)` で `BcryptPasswordEncoder` を利用するように設定する

どちらにしてもそこまで難易度は高くないか。
エンコード文字列に prefix を付けると少し長くなるので、DB のカラム定義には気を付けないといけない。

## エンコーダを変更する場合

例えば `Pbkdf2PasswordEncoder` に変更する場合。

```java
String idForEncode = "pbkdf2";
Map encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put(idForEncode, new Pbkdf2PasswordEncoder());
PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);
```

既存のエンコード文字列には prefix が付与されていないので、上記と同様の対処が必要。

で、bcrypt のものを pbkdf2 に変換するにはどうするか。
当然プレーンな文字列は保持していないため、システム側でどうすることもできない。
ユーザ側にプレーンなパスワードを入力してもらう必要がある。

### 1. ユーザが自発的にパスワードを更新するのを待つ
これで新しいエンコーダでエンコードされた文字列が保存される。
パスワードに期限が設定されているのであれば、自ずと変更される。
（パスワードに期限を設けるのは良くないと言われいるが・・・。）

### 2. ログイン時に更新を行う
ログイン時にはプレーンなパスワード文字列を入手することができるので、そのタイミングを利用する。
ログイン成功時に `upgradeEncoding(...)` メソッドで判定し、更新が必要であれば更新を行う。
パスワード変更を待つよりはましな気がする。

どちらにしても完ぺきに変更できるわけではないので、より強いエンコーダに変更するケースならこれでもいいような気がするけど、仮に今使っているエンコーダに脆弱性が見つかった場合はどうするべきなんだろうか？
某 pay みたいに全ユーザのパスワードを強制的に再発行するしかないのかな。

# 参考文献
https://docs.spring.io/spring-security/site/docs/5.1.5.RELEASE/reference/htmlsingle/