はじめに
Spring Security はデフォルトでも十分強力ですが、
「自分で認証ロジックを制御したい」と思いますよね
そんな時に役立つのが、AuthenticationProvider の自作です。
これを使うと、Spring内部の認証処理を自分のルールで書き換えることが可能になります。
基本構成:UserDetailsService を使う場合
まずは一般的な構成から。
@Bean
public UserDetailsService userDetailsService() {
return username -> {
var user = userService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("ユーザーが存在しません: " + username);
}
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
};
}
この方法では内部的に DaoAuthenticationProvider が動作し、
UserDetailsService を通じてユーザーを検索・認証します。
ただし、カスタム認証(例:外部API連携や多要素認証など)をやろうとすると、
この仕組みでは少し制限が多いです。
上のコードで、現在しているプロジェクトでを実装してみたんだけど、問題点を見つけたので、記録のために、保存します!
最初のコードの問題
まず、目標は画面でのログインする際、エラーメッセージを分けたくて、
1)存在しないユーザーの場合
2)ユーザーは存在するが、パースワードが違う場合
に合わせて、プロントコードを仕組みした。
今回は、簡単なテストのため、thymeleafを使用した。
<!-- エラーメッセージ表示 -->
<div th:if="${param.error}">
<p th:if="${param.error[0] eq 'bad_password'}" class="error-message">パスワードが間違っています。</p>
<p th:if="${param.error[0] eq 'no_user'}" class="error-message">存在しないユーザーです。会員登録してください。</p>
<p th:if="${param.error[0] eq 'unknown'}" class="error-message">ログインエラーが発生しました。</p>
</div>
JAVAコード
package com.example.demo.global.config;
import com.example.demo.user.service.UserService;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import java.io.IOException;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
// ユーザー情報を読み込むサービス
@Bean
public UserDetailsService userDetailsService() {
return username -> {
var user = userService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("ユーザーが存在しません: " + username);
}
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
};
}
// パスワードを暗号化するエンコーダー
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 認証マネージャーをBean登録
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
// セキュリティ設定本体
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/join", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // ログインページ
.defaultSuccessUrl("/home", true) // 成功時のリダイレクト先
.failureHandler(authenticationFailureHandler()) // 失敗時の処理
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout") // ログアウトURL
.logoutSuccessUrl("/login?logout") // ログアウト後の遷移
)
.csrf(csrf -> csrf.disable()); // 開発時のみ無効化
return http.build();
}
// ログイン失敗時のハンドラ
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
org.springframework.security.core.AuthenticationException exception)
throws IOException, ServletException {
String errorParam = "unknown";
// ユーザーが存在しない
if (exception instanceof UsernameNotFoundException) {
errorParam = "no_user";
}
// パスワードが間違っている
else if (exception instanceof BadCredentialsException) {
errorParam = "bad_password";
}
// エラー種別ごとにリダイレクト
response.sendRedirect("/login?error=" + errorParam);
}
};
}
}
このコードだと、UsernameNotFoundException を投げても、Spring Securityの内部では 全て BadCredentialsException として扱われてしまうことを見つけました。
つまり、どんなエラーでもログイン失敗ハンドラには BadCredentialsException が渡されるので、結果的に常に bad_password にしかならないんですよね。
URLを参考すると、bad_passwordに接続されてることが確認できる。
解決した方法:カスタム認証プロバイダーを使う
Spring Securityでログイン機能を作っていたんですけど、
「ユーザーが存在しない場合」と「パスワードが間違っている場合」を分けて処理したくても、最初の実装では 常に bad_password しか返らず困っていました。
今回は自分の解決方法とその理由を共有します。
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
// パスワードの暗号化
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// カスタム認証プロバイダー
@Bean
public AuthenticationProvider authenticationProvider() {
return new AuthenticationProvider() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
User user = userService.findByUsername(username);
// ユーザーが存在しない場合
if (user == null) {
throw new UsernameNotFoundException("ユーザーが存在しません");
}
// パスワードが一致しない場合
if (!passwordEncoder().matches(password, user.getPassword())) {
throw new BadCredentialsException("パスワードが間違っています");
}
// 認証成功
return new UsernamePasswordAuthenticationToken(username, password,
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_" + user.getRole()));
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
};
}
// 認証マネージャー
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
ProviderManager manager = new ProviderManager(authenticationProvider());
return manager;
}
// セキュリティ設定
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/join", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home", true)
.failureHandler(customAuthFailureHandler())
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
)
.csrf(csrf -> csrf.disable());
return http.build();
}
// ログイン失敗時のハンドラ
@Bean
public AuthenticationFailureHandler customAuthFailureHandler() {
return new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
String errorParam = "unknown";
if (exception instanceof UsernameNotFoundException) {
errorParam = "no_user";
} else if (exception instanceof BadCredentialsException) {
errorParam = "bad_password";
}
response.sendRedirect("/login?error=" + errorParam);
}
};
}
}
ポイント
・UserDetailsService ではなく、AuthenticationProvider で直接認証処理を実装
・ユーザー存在チェックとパスワードチェックを自前で行う
・例外をそのままログイン失敗ハンドラに渡せる
・ProviderManager にカスタム認証プロバイダーを渡すことで、Spring Securityが認証時にこのプロバイダーを使うようになります。
・AuthenticationFailureHandlerを通じて、ハンドラを作る。
→UsernameNotFoundException → no_user
→BadCredentialsException → bad_password
→URLパラメータで失敗原因を区別できます。
まとめ
UserDetailsService方式では、ユーザーなしでも例外が BadCredentialsException に変換されるため、区別できない
AuthenticationProvider方式では、例外をそのままログイン失敗ハンドラに渡せる
細かいログイン失敗の種類を扱いたい場合は、カスタム認証プロバイダーが必須
今回の修正で、ログイン失敗時に ユーザー存在チェックとパスワードチェックを正しく判定できるようになりました。
次回もぜひ見に来てくださいね〜

