0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita第4投稿!Spring Security 深掘り編 〜認証処理を自作してみた〜【Spring編(応用)】

Last updated at Posted at 2025-11-09

はじめに

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 にしかならないんですよね。

スクリーンショット 2025-11-09 22.28.29.png

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パラメータで失敗原因を区別できます。

画面収録 2025-11-09 22.43.33.gif

まとめ

UserDetailsService方式では、ユーザーなしでも例外が BadCredentialsException に変換されるため、区別できない

AuthenticationProvider方式では、例外をそのままログイン失敗ハンドラに渡せる

細かいログイン失敗の種類を扱いたい場合は、カスタム認証プロバイダーが必須

今回の修正で、ログイン失敗時に ユーザー存在チェックとパスワードチェックを正しく判定できるようになりました。

次回もぜひ見に来てくださいね〜

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?