--- title: Spring Securityでログイン時にパスワードハッシュアルゴリズムを変更する方法 tags: ["Java", "Spring Boot", "Spring Security"] categories: ["Programming", "Java", "org", "springframework", "security", "crypt", "password"] date: 2023-08-17T10:43:10Z updated: 2023-08-17T12:32:42Z --- Spring Securityでログイン時にデータベース上の保存されたエンコードされたパスワードを別のアルゴリズムで再度エンコードして保存する方法を紹介します。 変更の手順を先に説明すると、 * `DelegatingPasswordEncoder`の`idForEncode`を変える * `UserDetailsPasswordService`を実装する * ログインし直す です。 以下、少しずつ説明します。 **目次** ### 一般的なユーザー認証 まずはデータベースを使った一般的なSpring Securityのユーザー認証を実装します。次のクラスを使用します。読みやすいように、意図的にシンプルにしてあります。 アカウントの情報を保存するクラスを`Account`とします。 ```java package com.example.account; public record Account(String username, String password) { } ``` この`Account`を保持するSpring Securityのログインユーザークラスを`AccountUserDetails`とします。 ```java package com.example.account; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; public class AccountUserDetails implements UserDetails { private final Account account; public AccountUserDetails(Account account) { this.account = account; } public Account getAccount() { return account; } @Override public Collection getAuthorities() { return AuthorityUtils.createAuthorityList("ROLE_USER"); } @Override public String getPassword() { return this.account.password(); } @Override public String getUsername() { return this.account.username(); } // 以下、略 } ``` この`AccountUserDetails`を取得するクラスを`AccountUserDetailsService`とします。`UserDetailsService`インターフェースの実装クラスがBeanが登録されるとSpring Securityは認証処理時に自動でそのクラスを使用してユーザー情報の取得を試みます。 ```java package com.example.account; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.DataClassRowMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class AccountUserDetailsService implements UserDetailsService { private final JdbcTemplate jdbcTemplate; public AccountUserDetailsService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { try { Account account = this.jdbcTemplate.queryForObject( "SELECT username, password FROM account WHERE username = ?", new DataClassRowMapper<>(Account.class), username); return new AccountUserDetails(account); } catch (EmptyResultDataAccessException e) { throw new UsernameNotFoundException("user not found", e); } } } ``` パスワードのハッシュ化を行う`PasswordEncoder`を登録します。Spring Securityはversion 5から、`DelegatingPasswordEncoder`を使うことが推奨されています。 `DelegatingPasswordEncoder`は名前の通り、実際のエンコード処理を別のクラスに委譲します。`DelegatingPasswordEncoder`には複数の`PasswordEncoder`をMapで保存できます。 `DelegatingPasswordEncoder`でエンコードされるパスワードは`{エンコーダーのキー}ハッシュ値`という形式になります。 デフォルトの`DelegatingPasswordEncoder`の組み合わせは次のように作成できます。 ```java @Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } ``` [ソースコード](https://github.com/spring-projects/spring-security/blob/main/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java)を見ると、`PasswordEncoderFactories.createDelegatingPasswordEncoder`は次のような実装になっています。 ```java @SuppressWarnings("deprecation") public static PasswordEncoder createDelegatingPasswordEncoder() { String idForEncode = "bcrypt"; Map encoders = new HashMap<>(); encoders.put(idForEncode, 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")); encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5()); encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1()); encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); return new DelegatingPasswordEncoder(idForEncode, encoders); } ``` 複数のエンコーダーが登録されていますが、実際にエンコードで使用されるのはBCrypt(`BCryptPasswordEncoder`)です。 `idForEncode`で指定するキーがエンコードで使われます。 新規のアプリケーションで、旧バージョンとの互換性を考えなければ、次の定義でも実質同じです。 ```java @Bean public PasswordEncoder passwordEncoder() { String idForEncode = "bcrypt"; DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, Map.of(idForEncode, new BCryptPasswordEncoder())); return passwordEncoder; } ``` アカウントを作成するサインアップ処理はシンプルに次のように実装します。ここでは確認パスワードのフィールドは用意していません。 ```java @Controller public class SignupController { private final JdbcTemplate jdbcTemplate; private final PasswordEncoder passwordEncoder; public SignupController(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { this.jdbcTemplate = jdbcTemplate; this.passwordEncoder = passwordEncoder; } @GetMapping(path = "/signup") public String signup() { return "signup"; } @PostMapping(path = "/signup") public String signup(SignupForm form, HttpServletRequest request, HttpServletResponse response) { String encoded = this.passwordEncoder.encode(form.password()); this.jdbcTemplate.update("INSERT INTO account(username, password) VALUES (?, ?)", form.username(), encoded); // サインアップ後の自動ログイン処理省略 (GitHub上のソースコードを見てください) return "redirect:/"; } record SignupForm(String username, String password) { } } ``` これで http://localhost:8080/signup にアクセスし、ユーザー名とパスワードを入力するとアカウントが作成され、ログインが行われます。 image ソースコードは省略しますが、 http://localhost:8080 にアクセスするとログインユーザーのユーザー名とエンコード済みパスワードが表示されます。 image エンコード済みのパスワードが`{bcrypt}bcryptでハッシュ化されたパスワード`という形式になっていることがわかります。 ここまでのソースコードは [こちら](https://github.com/making/demo-password-encoder-migration) から取得できます。 ### パスワードハッシュアルゴリズムの変更 初めはデフォルトのBCryptを使用していたけれども、FIPS-140準拠のためにPBKDF2でハッシュ化するように変更したいケースを考えます。 新規ユーザーのサインアップでPBKDF2が使われるように`DelegatingPasswordEncoder`を以下のように変更します。 ```java @Bean public PasswordEncoder passwordEncoder() { String idForEncode = "pbkdf2@SpringSecurity_v5_8"; DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, Map.of(idForEncode, Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(), "bcrypt", new BCryptPasswordEncoder())); return passwordEncoder; } ``` `pbkdf2@SpringSecurity_v5_8`というキー名はSpring Security 5.8時点での`Pbkdf2PasswordEncoder`のデフォルト値を使用しているという意味で、`PasswordEncoderFactories.createDelegatingPasswordEncoder`に合わせました。 `bcrypt`以外であれば何でも良いです。 [ソースコードのDiff](https://github.com/making/demo-password-encoder-migration/commit/28b85ad813b323b1d4555f5707667d64b1b0db89) 新規ユーザー(`user2`)を登録します。 image 表示されるパスワードは`{pbkdf2@SpringSecurity_v5_8}...`になり、PBKDF2が使用されていることがわかります。 image 一度ログアウトして、 image アルゴリズム変更前のユーザー(`user1`)でログインしてみます。 image `user1`は引き続きログイン可能で、BCryptが使用されたままです。 image この段階ではデータベース上には旧アルゴリズムのBCryptと新アルゴリズムのPBKDF2が両方存在し、どちらでもログインできます。 ここまでのソースコードは[こちら](https://github.com/making/demo-password-encoder-migration/tree/use-pbkdf2-for-new-users)です。 ### 既存ユーザーのパスワードハッシュアルゴリズムのマイグレーション では、既存のユーザーのデータベース上のパスワードを新しいハッシュアルゴリズムへマイグレーションしましょう。 Spring Securityではパスワードエンコードで使用するアルゴリズムが変更された場合に、`UserDetailsPasswordService`を実装したBeanが登録された状態で、ログインを行うと自動で`UserDetailsPasswordService`の`updatePassword`メソッドが呼ばれます。 `updatePassword`メソッドでアカウントのパスワードを更新する処理を実装すれば、ログイン時にデータベース上のエンコード済みパスワードが更新されます。 `UserDetailsPasswordService`を次のように変更します。 ```java @Service public class AccountUserDetailsService implements UserDetailsService, UserDetailsPasswordService { private final JdbcTemplate jdbcTemplate; public AccountUserDetailsService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // ... } @Override public UserDetails updatePassword(UserDetails user, String newPassword) { this.jdbcTemplate.update("UPDATE account SET password = ? WHERE username = ?", newPassword, user.getUsername()); return new AccountUserDetails(new Account(user.getUsername(), newPassword)); } } ``` [ソースコードのDiff](https://github.com/making/demo-password-encoder-migration/commit/7d8286e027e04c556382c94b28b8707dbd29bbeb) では`user1`で再度ログインしてみます。 image `logging.level.sql=trace`を設定していれば、次のようなログが出力されます。確かにデータベースの`user1`のパスワードがPDKDF2でハッシュ化したものにUPDATEされていることがわかります。 ``` 2023-08-17T15:18:50.348+09:00 DEBUG 12885 --- [nio-8080-exec-4] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL query 2023-08-17T15:18:50.348+09:00 DEBUG 12885 --- [nio-8080-exec-4] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [SELECT username, password FROM account WHERE username = ?] 2023-08-17T15:18:50.355+09:00 TRACE 12885 --- [nio-8080-exec-4] o.s.jdbc.core.StatementCreatorUtils : Setting SQL statement parameter value: column index 1, parameter value [user1], value class [java.lang.String], SQL type unknown 2023-08-17T15:18:50.988+09:00 DEBUG 12885 --- [nio-8080-exec-4] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL update 2023-08-17T15:18:50.988+09:00 DEBUG 12885 --- [nio-8080-exec-4] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [UPDATE account SET password = ? WHERE username = ?] 2023-08-17T15:18:50.989+09:00 TRACE 12885 --- [nio-8080-exec-4] o.s.jdbc.core.StatementCreatorUtils : Setting SQL statement parameter value: column index 1, parameter value [{pbkdf2@SpringSecurity_v5_8}932c1b5beaeddfa19f3f72272e5c69d04fde8d7afc3fe096ecbb1e8b0839ea0e44ad4596d1ff05dd1e40087324292fc5], value class [java.lang.String], SQL type unknown 2023-08-17T15:18:50.989+09:00 TRACE 12885 --- [nio-8080-exec-4] o.s.jdbc.core.StatementCreatorUtils : Setting SQL statement parameter value: column index 2, parameter value [user1], value class [java.lang.String], SQL type unknown 2023-08-17T15:18:50.991+09:00 TRACE 12885 --- [nio-8080-exec-4] o.s.jdbc.core.JdbcTemplate : SQL update affected 1 rows ``` HTTPセッション上に保存されているログインユーザー情報はパスワード更新前のものが保存されているため、画面上は前のパスワードが表示されますが、この時点でデータベース上のパスワードのマイグレーションは完了しています。 HTTPセッション上のエンコードされたパスワードをログイン後に使うケースはないと思うので、この挙動でも実質的には問題ないと思われます。 image ログアウトして、再度ログインしてみます。 image 今後は新しい情報が表示されます。 image あとは各ユーザーがログインし直してくれればデータベース上の全てのデータが新しいハッシュアルゴリズムを使ったものに置き換わります。 ここまでのソースコードは[こちら](https://github.com/making/demo-password-encoder-migration/tree/migrate-password)です。 ### レガシーなMD5ハッシュからのマイグレーション 今度は`DelegatingPasswordEncoder`が導入される前からレガシーなアカウントのデータベースが存在するケースを考えます。 今となってはハッシュ化する意味がほぼない、ソルトなしのMD5ハッシュを使用しているケースを考えましょう。 Spring SecurityではソルトなしのMD5ハッシュを用いた`PasswordEncoder`は提供されていません。MD5を使いたい場合は`new MessageDigestPasswordEncoder("MD5")`という使い方ができますが、`MessageDigestPasswordEncoder`はランダムなソルトを付与します。 `PasswordEncoder`を以下のように実装し、`DelegatingPasswordEncoder`の代わりにレガシーなMD5ハッシュの`PasswordEncoder`を使用します。 ```java @Bean public PasswordEncoder passwordEncoder() { PasswordEncoder legacyMd5Encoder = new PasswordEncoder() { @Override public String encode(CharSequence rawPassword) { try { MessageDigest messageDigest = MessageDigest.getInstance("MD5"); return new String(Hex.encode(messageDigest.digest(Utf8.encode(rawPassword)))); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return Objects.equals(this.encode(rawPassword), encodedPassword); } }; return legacyMd5Encoder; } ``` [始めの状態からのソースコードのDiff](https://github.com/making/demo-password-encoder-migration/commit/ca01884508def12bdd1ee52d67dc597c39d2b93e) MD5以外のレガシーなエンコーディングを行っている場合は同様に`PasswordEncoder`を実装すればよいです。 ではこの`PasswordEncoder`を使って新しいユーザー(`user3`)を登録します。 image MD5でハッシュ化されたパスワードが画面に表示されました。`DelegatingPasswordEncoder`を使用していないの`{エンコーダーのキー}ハッシュ値`という形式になっていません。 image ハッシュ化された`5f4dcc3b5aa765d61d8327deb882cf99`を[Google検索](https://www.google.com/search?q=5f4dcc3b5aa765d61d8327deb882cf99)すると、MD5を使用してもパスワードが守られないことがわかるでしょう。 ここまでのソースコードは[こちら](https://github.com/making/demo-password-encoder-migration/tree/use-legacy-md5-encoder)です。 ではこのレガシーなパスワードをPBKDF2に移行しましょう。 次のように、`DelegatingPasswordEncoder`の仕組みとレガシーなパスワードを共存するために、データベース上のエンコード済みパスワードが`{エンコーダーのキー}ハッシュ値`形式でない場合に使用する`PasswordEncoder`を`setDefaultPasswordEncoderForMatches`で指定するところがポイントです。 ```java @Bean public PasswordEncoder passwordEncoder() { PasswordEncoder legacyMd5Encoder = /* ... */; String idForEncode = "pbkdf2@SpringSecurity_v5_8"; DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, // Map.of(idForEncode, Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8())); passwordEncoder.setDefaultPasswordEncoderForMatches(legacyMd5Encoder); return passwordEncoder; } ``` [ソースコードのDiff](https://github.com/making/demo-password-encoder-migration/commit/9fb9336f07e03804c2b8d95fc9819fa6236a28c3) この設定を行った状態で、新規ユーザー(`user4`)を登録します。 image PBKDF2でパスワードがハッシュ化されていることがわかります。 image ではMD5でハッシュ化されたパスワードがデータベースに保存されている`user3`で再ログインしましょう。 image ログインが成功すると次のようなUPDATEのログが出力されます。 ``` 2023-08-17T15:49:10.573+09:00 DEBUG 18780 --- [nio-8080-exec-6] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL query 2023-08-17T15:49:10.574+09:00 DEBUG 18780 --- [nio-8080-exec-6] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [SELECT username, password FROM account WHERE username = ?] 2023-08-17T15:49:10.575+09:00 TRACE 18780 --- [nio-8080-exec-6] o.s.jdbc.core.StatementCreatorUtils : Setting SQL statement parameter value: column index 1, parameter value [user3], value class [java.lang.String], SQL type unknown 2023-08-17T15:49:11.157+09:00 DEBUG 18780 --- [nio-8080-exec-6] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL update 2023-08-17T15:49:11.157+09:00 DEBUG 18780 --- [nio-8080-exec-6] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [UPDATE account SET password = ? WHERE username = ?] 2023-08-17T15:49:11.157+09:00 TRACE 18780 --- [nio-8080-exec-6] o.s.jdbc.core.StatementCreatorUtils : Setting SQL statement parameter value: column index 1, parameter value [{pbkdf2@SpringSecurity_v5_8}dcff3d567b32aab6303faa38e4f0da1eda18f3fa1f46fc9d6de218372f7441d1ad51409090a4de646249d4e3e34c7ae6], value class [java.lang.String], SQL type unknown 2023-08-17T15:49:11.157+09:00 TRACE 18780 --- [nio-8080-exec-6] o.s.jdbc.core.StatementCreatorUtils : Setting SQL statement parameter value: column index 2, parameter value [user3], value class [java.lang.String], SQL type unknown 2023-08-17T15:49:11.157+09:00 TRACE 18780 --- [nio-8080-exec-6] o.s.jdbc.core.JdbcTemplate : SQL update affected 1 rows ``` 前述の通り、セッション上のユーザー情報はパスワード変更前のものが使われるので、画面上の表示は変わりませんが、パスワードのマイグレーションは完了しています。 image 再度ログインすれば、画面上にもPBKDF2でハッシュ化されたパスワードが表示されます。 image これでデータベース上のエンコード済みパスワードがレガシーなMD5からPBKDF2に強化されました。 ユーザーはログインさえすればこの処理は自動で行われるので意識する必要がありません。ただし、PBKDF2の場合はハッシュ化に(意図的に)時間がかかるので、ログイン処理の時間は少し遅くなります。 また、生のパスワードは変わっていないので、ハッシュアルゴリズムを強化しても、脆弱なパスワード自体は変わりありません。 ### (おまけ) OWASP推奨のPBKDF2ハッシュを使用する `Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()`で作成される`Pbkdf2PasswordEncoder`は * アルゴリズム: HMAC-SHA-256 * イテレーション: 310,000回 が設定されています。 本記事作成時点でのFIPS-140準拠時の[OWASPの推奨](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)は次のように説明されています。 > If FIPS-140 compliance is required, use PBKDF2 with a work factor of 600,000 or more and set with an internal hash function of HMAC-SHA-256. イテレーションが600,000回になっています。イテレーションを変更するために次のような設定を行えます。 ```java @Bean public PasswordEncoder passwordEncoder() { String idForEncode = "pbkdf2@FIPS-140_OWASP"; DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, Map.of(idForEncode, new Pbkdf2PasswordEncoder("", 16, 600_000, Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256), "pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(), // "bcrypt", new BCryptPasswordEncoder())); return passwordEncoder; } ``` この`DelegatingPasswordEncoder`を使ってパスワードマイグレーションを行うと画面が次のような表示に変わります。 image --- Spring Securityを使うとパスワードマイグレーションが簡単に行えました。 Spring Securityは設定が難しいという声を聞きますが、セキュリティ機能はフレームワークに任せた方が良いです。Spring Securityを使いましょう。