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で一番楽になった部分で、UserDetailsServicePasswordEncoder を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して setUserDetailsServicesetPasswordEncoder を呼び、AuthenticationManager に登録します。

BCryptで登録・照合する

PasswordEncodercreateDelegatingPasswordEncoder() を使ったのがポイントです。これはハッシュの先頭に付いた {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が入っているので、それでテーブルを作ればそのまま動きます。createUserupdateUserdeleteUser といった管理APIも使えるので、ユーザー管理画面を作るときにも便利です。

使い分けの基準はシンプルです。usersテーブルにメールアドレスや表示名など独自カラムを足したい、ロールの持ち方を工夫したい、といった要件があるなら自作ルート。標準スキーマのまま済むなら JdbcUserDetailsManager、という判断でよいでしょう。

アカウント無効化とログイン失敗

UserDetails の4つのフラグは、それぞれ専用の例外に対応しています。isEnabled()false なら DisabledExceptionisAccountNonLocked()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によるステートレス認証 への発展に自然に進めるはずです。