title | tags | categories | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
Spring Securityでログイン時にパスワードハッシュアルゴリズムを変更する方法 |
|
|
Spring Securityでログイン時にデータベース上の保存されたエンコードされたパスワードを別のアルゴリズムで再度エンコードして保存する方法を紹介します。
変更の手順を先に説明すると、
DelegatingPasswordEncoder
のidForEncode
を変えるUserDetailsPasswordService
を実装する- ログインし直す
です。
以下、少しずつ説明します。
目次
まずはデータベースを使った一般的なSpring Securityのユーザー認証を実装します。次のクラスを使用します。読みやすいように、意図的にシンプルにしてあります。
アカウントの情報を保存するクラスをAccount
とします。
package com.example.account;
public record Account(String username, String password) {
}
このAccount
を保持するSpring SecurityのログインユーザークラスをAccountUserDetails
とします。
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<? extends GrantedAuthority> 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は認証処理時に自動でそのクラスを使用してユーザー情報の取得を試みます。
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
の組み合わせは次のように作成できます。
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
ソースコードを見ると、PasswordEncoderFactories.createDelegatingPasswordEncoder
は次のような実装になっています。
@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
String idForEncode = "bcrypt";
Map<String, PasswordEncoder> 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
で指定するキーがエンコードで使われます。
新規のアプリケーションで、旧バージョンとの互換性を考えなければ、次の定義でも実質同じです。
@Bean
public PasswordEncoder passwordEncoder() {
String idForEncode = "bcrypt";
DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode,
Map.of(idForEncode, new BCryptPasswordEncoder()));
return passwordEncoder;
}
アカウントを作成するサインアップ処理はシンプルに次のように実装します。ここでは確認パスワードのフィールドは用意していません。
@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 にアクセスし、ユーザー名とパスワードを入力するとアカウントが作成され、ログインが行われます。
ソースコードは省略しますが、 http://localhost:8080 にアクセスするとログインユーザーのユーザー名とエンコード済みパスワードが表示されます。
エンコード済みのパスワードが{bcrypt}bcryptでハッシュ化されたパスワード
という形式になっていることがわかります。
ここまでのソースコードは こちら から取得できます。
初めはデフォルトのBCryptを使用していたけれども、FIPS-140準拠のためにPBKDF2でハッシュ化するように変更したいケースを考えます。
新規ユーザーのサインアップでPBKDF2が使われるようにDelegatingPasswordEncoder
を以下のように変更します。
@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
以外であれば何でも良いです。
新規ユーザー(user2
)を登録します。
表示されるパスワードは{pbkdf2@SpringSecurity_v5_8}...
になり、PBKDF2が使用されていることがわかります。
一度ログアウトして、
アルゴリズム変更前のユーザー(user1
)でログインしてみます。
user1
は引き続きログイン可能で、BCryptが使用されたままです。
この段階ではデータベース上には旧アルゴリズムのBCryptと新アルゴリズムのPBKDF2が両方存在し、どちらでもログインできます。
ここまでのソースコードはこちらです。
では、既存のユーザーのデータベース上のパスワードを新しいハッシュアルゴリズムへマイグレーションしましょう。
Spring Securityではパスワードエンコードで使用するアルゴリズムが変更された場合に、UserDetailsPasswordService
を実装したBeanが登録された状態で、ログインを行うと自動でUserDetailsPasswordService
のupdatePassword
メソッドが呼ばれます。
updatePassword
メソッドでアカウントのパスワードを更新する処理を実装すれば、ログイン時にデータベース上のエンコード済みパスワードが更新されます。
UserDetailsPasswordService
を次のように変更します。
@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));
}
}
ではuser1
で再度ログインしてみます。
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セッション上のエンコードされたパスワードをログイン後に使うケースはないと思うので、この挙動でも実質的には問題ないと思われます。
ログアウトして、再度ログインしてみます。
今後は新しい情報が表示されます。
あとは各ユーザーがログインし直してくれればデータベース上の全てのデータが新しいハッシュアルゴリズムを使ったものに置き換わります。
ここまでのソースコードはこちらです。
今度はDelegatingPasswordEncoder
が導入される前からレガシーなアカウントのデータベースが存在するケースを考えます。
今となってはハッシュ化する意味がほぼない、ソルトなしのMD5ハッシュを使用しているケースを考えましょう。
Spring SecurityではソルトなしのMD5ハッシュを用いたPasswordEncoder
は提供されていません。MD5を使いたい場合はnew MessageDigestPasswordEncoder("MD5")
という使い方ができますが、MessageDigestPasswordEncoder
はランダムなソルトを付与します。
PasswordEncoder
を以下のように実装し、DelegatingPasswordEncoder
の代わりにレガシーなMD5ハッシュのPasswordEncoder
を使用します。
@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;
}
MD5以外のレガシーなエンコーディングを行っている場合は同様にPasswordEncoder
を実装すればよいです。
ではこのPasswordEncoder
を使って新しいユーザー(user3
)を登録します。
MD5でハッシュ化されたパスワードが画面に表示されました。DelegatingPasswordEncoder
を使用していないの{エンコーダーのキー}ハッシュ値
という形式になっていません。
ハッシュ化された5f4dcc3b5aa765d61d8327deb882cf99
をGoogle検索すると、MD5を使用してもパスワードが守られないことがわかるでしょう。
ここまでのソースコードはこちらです。
ではこのレガシーなパスワードをPBKDF2に移行しましょう。
次のように、DelegatingPasswordEncoder
の仕組みとレガシーなパスワードを共存するために、データベース上のエンコード済みパスワードが{エンコーダーのキー}ハッシュ値
形式でない場合に使用するPasswordEncoder
をsetDefaultPasswordEncoderForMatches
で指定するところがポイントです。
@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;
}
この設定を行った状態で、新規ユーザー(user4
)を登録します。
PBKDF2でパスワードがハッシュ化されていることがわかります。
ではMD5でハッシュ化されたパスワードがデータベースに保存されているuser3
で再ログインしましょう。
ログインが成功すると次のような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
前述の通り、セッション上のユーザー情報はパスワード変更前のものが使われるので、画面上の表示は変わりませんが、パスワードのマイグレーションは完了しています。
再度ログインすれば、画面上にもPBKDF2でハッシュ化されたパスワードが表示されます。
これでデータベース上のエンコード済みパスワードがレガシーなMD5からPBKDF2に強化されました。 ユーザーはログインさえすればこの処理は自動で行われるので意識する必要がありません。ただし、PBKDF2の場合はハッシュ化に(意図的に)時間がかかるので、ログイン処理の時間は少し遅くなります。 また、生のパスワードは変わっていないので、ハッシュアルゴリズムを強化しても、脆弱なパスワード自体は変わりありません。
Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()
で作成されるPbkdf2PasswordEncoder
は
- アルゴリズム: HMAC-SHA-256
- イテレーション: 310,000回
が設定されています。
本記事作成時点でのFIPS-140準拠時のOWASPの推奨は次のように説明されています。
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回になっています。イテレーションを変更するために次のような設定を行えます。
@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
を使ってパスワードマイグレーションを行うと画面が次のような表示に変わります。
Spring Securityを使うとパスワードマイグレーションが簡単に行えました。
Spring Securityは設定が難しいという声を聞きますが、セキュリティ機能はフレームワークに任せた方が良いです。Spring Securityを使いましょう。