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 となる)
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 が付与されているかどうかを判定しているだけで、エンコード文字列を検証しているわけではない。
使ってみる
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
の定義は以下のような感じ。
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
に変更する場合。
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 みたいに全ユーザのパスワードを強制的に再発行するしかないのかな。
参考文献