Skip to content

Latest commit

 

History

History
474 lines (330 loc) · 25.2 KB

00757.md

File metadata and controls

474 lines (330 loc) · 25.2 KB
title tags categories
Spring Securityでログイン時にパスワードハッシュアルゴリズムを変更する方法
Java
Spring Boot
Spring Security
Programming
Java
org
springframework
security
crypt
password

Spring Securityでログイン時にデータベース上の保存されたエンコードされたパスワードを別のアルゴリズムで再度エンコードして保存する方法を紹介します。

変更の手順を先に説明すると、

  • DelegatingPasswordEncoderidForEncodeを変える
  • 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 にアクセスし、ユーザー名とパスワードを入力するとアカウントが作成され、ログインが行われます。

image

ソースコードは省略しますが、 http://localhost:8080 にアクセスするとログインユーザーのユーザー名とエンコード済みパスワードが表示されます。

image

エンコード済みのパスワードが{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以外であれば何でも良いです。

ソースコードのDiff

新規ユーザー(user2)を登録します。

image

表示されるパスワードは{pbkdf2@SpringSecurity_v5_8}...になり、PBKDF2が使用されていることがわかります。

image

一度ログアウトして、

image

アルゴリズム変更前のユーザー(user1)でログインしてみます。

image

user1は引き続きログイン可能で、BCryptが使用されたままです。

image

この段階ではデータベース上には旧アルゴリズムのBCryptと新アルゴリズムのPBKDF2が両方存在し、どちらでもログインできます。

ここまでのソースコードはこちらです。

既存ユーザーのパスワードハッシュアルゴリズムのマイグレーション

では、既存のユーザーのデータベース上のパスワードを新しいハッシュアルゴリズムへマイグレーションしましょう。

Spring Securityではパスワードエンコードで使用するアルゴリズムが変更された場合に、UserDetailsPasswordServiceを実装したBeanが登録された状態で、ログインを行うと自動でUserDetailsPasswordServiceupdatePasswordメソッドが呼ばれます。 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));
	}

}

ソースコードのDiff

では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

あとは各ユーザーがログインし直してくれればデータベース上の全てのデータが新しいハッシュアルゴリズムを使ったものに置き換わります。

ここまでのソースコードはこちらです。

レガシーなMD5ハッシュからのマイグレーション

今度は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;
	}

始めの状態からのソースコードのDiff

MD5以外のレガシーなエンコーディングを行っている場合は同様にPasswordEncoderを実装すればよいです。

ではこのPasswordEncoderを使って新しいユーザー(user3)を登録します。

image

MD5でハッシュ化されたパスワードが画面に表示されました。DelegatingPasswordEncoderを使用していないの{エンコーダーのキー}ハッシュ値という形式になっていません。

image

ハッシュ化された5f4dcc3b5aa765d61d8327deb882cf99Google検索すると、MD5を使用してもパスワードが守られないことがわかるでしょう。

ここまでのソースコードはこちらです。

ではこのレガシーなパスワードをPBKDF2に移行しましょう。

次のように、DelegatingPasswordEncoderの仕組みとレガシーなパスワードを共存するために、データベース上のエンコード済みパスワードが{エンコーダーのキー}ハッシュ値形式でない場合に使用するPasswordEncodersetDefaultPasswordEncoderForMatchesで指定するところがポイントです。

	@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

この設定を行った状態で、新規ユーザー(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の推奨は次のように説明されています。

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を使ってパスワードマイグレーションを行うと画面が次のような表示に変わります。

image

Spring Securityを使うとパスワードマイグレーションが簡単に行えました。

Spring Securityは設定が難しいという声を聞きますが、セキュリティ機能はフレームワークに任せた方が良いです。Spring Securityを使いましょう。