Spring Securityのチュートリアルを一通りやると、たいていはインメモリのユーザーでログインするところまでで終わります。でも実際のアプリでは、ユーザーはDBのusersテーブルに保存されていますよね。ここから先、「自前のテーブルに入れたユーザー名とパスワードでログインさせる」実装に進もうとすると、急に登場人物が増えて迷いがちです。
この記事では、DBに永続化したユーザーで認証する王道パターンを2つのルートに絞って解説します。ひとつは UserDetailsService を自作する方法、もうひとつは標準スキーマを使う JdbcUserDetailsManager です。あわせてBCryptでのパスワード登録と照合、アカウント無効化の扱いまで見ていきましょう。
インメモリ設定やフォームログインの基本は Spring SecurityでBasic認証を実装する で扱っているので、そちらを前提とします。
DB認証の全体像
まず認証がどう流れるのかを押さえておくと、後のコードが読みやすくなります。フォーム認証の場合、ざっくりこうなります。
AuthenticationManager
→ DaoAuthenticationProvider
→ UserDetailsService(DBからユーザーを引く)
→ PasswordEncoder(パスワードを照合する)
DaoAuthenticationProvider が「ユーザー情報の取得」と「パスワードの照合」を分担しています。前者を担うのが UserDetailsService、後者が PasswordEncoder です。
DBのユーザーで認証するルートは2つあります。
- 自作ルート: 自前のスキーマに合わせて
UserDetailsServiceを実装する。テーブル構成やカラムを自由に決められる。 - 標準ルート: Spring Security同梱の標準スキーマを前提に
JdbcUserDetailsManagerを使う。コードをほとんど書かずに済む。
拡張性が欲しいなら自作、手早く済ませたいなら標準、というのが大まかな指針です。順に見ていきます。
usersテーブルとロールを設計する
自作ルートでは、まずスキーマを決めます。ユーザー本体とロールを分けておくのが素直です。
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(72) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role VARCHAR(50) NOT NULL,
PRIMARY KEY (user_id, role),
FOREIGN KEY (user_id) REFERENCES users(id)
);
ポイントはパスワードカラムの長さです。BCryptのハッシュは {bcrypt} プレフィックス込みで70文字前後になるので、VARCHAR(72) くらい確保しておくと安全です。ここに平文を入れることは絶対にありません。
username にはユニーク制約を付けます。ログインのたびに検索するカラムなので、インデックスも自然に効きます。ロールは ROLE_ADMIN のようにプレフィックス込みで格納するか、コード側で付与するかの二択です。今回はコード側で付ける前提で、role には ADMIN のように短く入れておきます。
エンティティとリポジトリを用意する
DBからユーザーを引く永続化層です。JPAでの実装例を挙げますが、リポジトリの詳しい書き方は JPAのクエリメソッド入門 に譲って要点だけにします。
@Entity
@Table(name = "users")
public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private boolean enabled;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
// getter/setter は省略
}
ロールは EAGER でまとめて取得しています。認証時には必ずロールも必要になるので、遅延ロードにしてN+1やセッション外アクセスで悩むより、素直に一緒に引いてしまうほうが楽です。
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByUsername(String username);
}
UserDetailsを実装する
Spring Securityは自作エンティティを直接は理解できません。UserDetails インターフェースに変換してあげる必要があります。エンティティに implements させても動きますが、ドメインとセキュリティの都合を混ぜたくないので、専用のアダプタでラップするのがおすすめです。
public class CustomUserDetails implements UserDetails {
private final UserEntity user;
public CustomUserDetails(UserEntity user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList();
}
@Override public String getUsername() { return user.getUsername(); }
@Override public String getPassword() { return user.getPassword(); }
@Override public boolean isEnabled() { return user.isEnabled(); }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isCredentialsNonExpired(){ return true; }
}
ここで ROLE_ プレフィックスを付けているのが重要です。hasRole("ADMIN") は内部的に ROLE_ADMIN という権限を探すので、DBには ADMIN で保存し、権限へ変換するときに ROLE_ を足す、という役割分担になります。
isEnabled などの4つのフラグは、それぞれ false を返すと対応する例外で認証が弾かれます。まずは enabled だけDBと連動させ、残りは true 固定で始めるのが現実的です。
UserDetailsServiceを自作する
役者が揃ったので、loadUserByUsername でDBから引き当てる本体を書きます。
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) {
UserEntity user = userRepository.findByUsername(username)
.orElseThrow(() ->
new UsernameNotFoundException("ユーザーが見つかりません"));
return new CustomUserDetails(user);
}
}
ユーザーが見つからないときは UsernameNotFoundException を投げます。ここで注意したいのが、「ユーザーが存在しない」と「パスワードが違う」でメッセージを分けないことです。分けてしまうと、攻撃者にユーザー名の存在有無を教えることになります。Spring Securityはどちらの場合も BadCredentials として同じメッセージにまとめてくれるので、こちらから丁寧に区別する必要はありません。
@Transactional(readOnly = true) を付けているのは、ロールを遅延ロードにした場合でも安全に取得できるようにするためです。
DaoAuthenticationProviderに接続する
あとはBeanを登録するだけです。ここがモダンなSpring Securityで一番楽になった部分で、UserDetailsService と PasswordEncoder をBeanとして置いておけば、DaoAuthenticationProvider は自動的に組み立てられます。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login").permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
CustomUserDetailsService は @Service で登録済みなので、明示的に DaoAuthenticationProvider を組む必要はありません。もし複数のProviderを使い分けたいなど特別な事情があるときだけ、DaoAuthenticationProvider を自分でnewして setUserDetailsService と setPasswordEncoder を呼び、AuthenticationManager に登録します。
BCryptで登録・照合する
PasswordEncoder に createDelegatingPasswordEncoder() を使ったのがポイントです。これはハッシュの先頭に付いた {bcrypt} のようなプレフィックスを見て、照合に使うアルゴリズムを切り替えてくれる仕組みです。
ユーザー登録時は、平文を encode してから保存します。
@Service
public class UserRegistrationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// コンストラクタ省略
public void register(String username, String rawPassword) {
UserEntity user = new UserEntity();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(rawPassword));
user.setEnabled(true);
user.getRoles().add("USER");
userRepository.save(user);
}
}
encode の結果は {bcrypt}$2a$10$... という文字列になり、これをそのままDBに保存します。ログイン時は照合側で自動的に matches が呼ばれ、入力された平文を同じソルトでハッシュして比較します。平文を保存してはいけないのは、DBが漏れた瞬間に全ユーザーのパスワードが使われてしまうからです。BCryptならハッシュから元に戻せません。
すでに平文で保存されたデータがある場合は、一括で encode し直すのではなく、{noop} プレフィックスを付けて既存データを平文扱いにしつつ、次回ログイン成功時にBCryptへ再エンコードする、といった移行が安全です。
JdbcUserDetailsManagerで標準スキーマを使う
スキーマの自由度を求めないなら、コードをほとんど書かない選択肢もあります。Spring Securityは標準のusers/authoritiesスキーマ用のDDLを同梱していて、それを前提にした JdbcUserDetailsManager が用意されています。
@Bean
public UserDetailsManager userDetailsManager(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
標準スキーマは users(username, password, enabled) と authorities(username, authority) という固定構成です。クラスパスの org/springframework/security/core/userdetails/jdbc/users.ddl にDDLが入っているので、それでテーブルを作ればそのまま動きます。createUser や updateUser、deleteUser といった管理APIも使えるので、ユーザー管理画面を作るときにも便利です。
使い分けの基準はシンプルです。usersテーブルにメールアドレスや表示名など独自カラムを足したい、ロールの持ち方を工夫したい、といった要件があるなら自作ルート。標準スキーマのまま済むなら JdbcUserDetailsManager、という判断でよいでしょう。
アカウント無効化とログイン失敗
UserDetails の4つのフラグは、それぞれ専用の例外に対応しています。isEnabled() が false なら DisabledException、isAccountNonLocked() が false なら LockedException が投げられ、認証は失敗します。退会や一時停止を enabled カラムで表現しておけば、そのユーザーはログインできなくなります。
ログイン失敗時の挙動はフォームログインの設定で調整できます。
.formLogin(form -> form
.loginPage("/login")
.failureUrl("/login?error")
.permitAll())
/login?error にリダイレクトされるので、テンプレート側で error パラメータを見てメッセージを出します。ここでも「アカウントが存在しません」のような具体的なメッセージは避け、「ユーザー名またはパスワードが違います」に統一しておくのが無難です。
認証が通った後、ロールに応じてアクセスを制御したくなったら、権限が ROLE_ プレフィックス込みで付与されていることが hasRole / hasAuthority の前提になります。メソッド単位の制御は @PreAuthorizeによるメソッドセキュリティ で扱っています。
まとめ
DBに保存したユーザーで認証するルートを2つ見てきました。独自スキーマや拡張が必要なら UserDetailsService を自作し、CustomUserDetails でエンティティをラップして DaoAuthenticationProvider に接続します。標準スキーマで足りるなら JdbcUserDetailsManager でコードを最小限に抑えられます。
どちらのルートでも、パスワードはBCryptでハッシュ化し、enabled などのフラグでアカウント状態を管理する、という基本は共通です。ここまで組めれば、次はメソッドセキュリティでの認可や、JWTによるステートレス認証 への発展に自然に進めるはずです。