Engineering
Glossvation Engineering
セキュリティ

Spring Security 認証処理フロー解剖 — PreAuth / UserDetails / PreAuthorize の呼ばれ方

リクエストがどのフィルタを通り、どこでユーザー情報が解決され、どこで @PreAuthorize が効くのか。Spring Security の認証フローを図解しつつ、2024 年版 SecurityFilterChain への移行まで触れる。

Glossvation ·

Spring Security 全体フロー

なぜ Spring Security の 「フロー」 を理解すべきか

@PreAuthorize("hasAuthority('ADMIN')") と一行書くだけで認可が効く魔法のような仕組みですが、 裏で何が起きているか を知らないと以下でハマります:

  • 独自ヘッダー認証を足したい → どの Filter を差し込めば良いのか分からない
  • @PreAuthorize が効かない → @EnableGlobalMethodSecurity を書き忘れていた
  • getPrincipal() が String を返したり User を返したりでテストが辛い
  • CSRF / CORS / セッション固定化 — 何がデフォルトで有効なのか分からない

本稿では PreAuthenticated 方式 (HTTP ヘッダー等で既にユーザー情報が付いてくる前提) を題材に、 Spring Security の処理フローを 5 ステップで解剖します。

5 ステップで追う認証フロー

1. リクエストが FilterChain に突入

DispatcherServlet より前に Spring Security Filter Chain が噛みます。 SecurityFilterChain には認証系の Filter が順序付きで並んでいて、代表的なもの:

  • SecurityContextPersistenceFilter (セッションから既存 Context を取り出す)
  • HeaderWriterFilter (X-Frame-Options 等のセキュリティヘッダ付与)
  • 各種認証 Filter (本稿の AbstractPreAuthenticatedProcessingFilter など)- AuthorizationFilter (@PreAuthorize 判定)
  • ExceptionTranslationFilter (認証例外を 401/403 レスポンスに変換)

2. AbstractPreAuthenticatedProcessingFilter でクレデンシャル取り出し

PreAuth Filter

PreAuthenticated 方式では「認証自体はこのアプリの前で既に済んでいる」前提で、 HTTP ヘッダー等から ユーザー識別子 (principal) と クレデンシャル (credential) を取り出します。

public class HeaderPreAuthFilter extends AbstractPreAuthenticatedProcessingFilter {
    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        return request.getHeader("X-User-Id");
    }
    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return request.getHeader("X-User-Token");
    }
}

この時点では認証は済んでいません。取り出した情報を Authentication オブジェクトに詰めて、 次ステップの AuthenticationManager に投げます。

3. AuthenticationUserDetailsService でユーザー情報の実体化

UserDetails

PreAuthenticatedAuthenticationProvider が AuthenticationManager から呼ばれ、 最終的に AuthenticationUserDetailsService#loadUserDetails(...) でユーザー情報を解決します。

@Component
public class CustomUserDetailsService
        implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token)
            throws UsernameNotFoundException {
        String userId = (String) token.getPrincipal();
        String credential = (String) token.getCredentials();

        var user = userRepository.findActive(userId)
            .orElseThrow(() -> new UsernameNotFoundException("not found: " + userId));

        if (!credential.equals(user.getToken())) {
            throw new BadCredentialsException("token mismatch");
        }

        var authorities = user.getRoles().stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
            .toList();

        return new User(user.getId(), "", authorities);
    }
}

認証に成功すると Authentication#isAuthenticated() が true になり、 SecurityContextHolder に保存されます。

4. SecurityFilterChain 設定で各エンドポイントを保護

SecurityConfig

2017 当時 は WebSecurityConfigurerAdapter を継承していましたが、 Spring Security 5.7+ (Boot 3.x) では廃止され、SecurityFilterChain Bean を返す形式に変わりました:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
                                    HeaderPreAuthFilter preAuthFilter) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())  // API サーバなら無効化が多い
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/health", "/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilter(preAuthFilter)
            .exceptionHandling(e -> e
                .authenticationEntryPoint((req, res, ex) -> res.sendError(401))
                .accessDeniedHandler((req, res, ex) -> res.sendError(403))
            )
            .build();
    }
}

5. メソッドレベル認可: @PreAuthorize / @PostAuthorize

PreAuthorize

Controller / Service のメソッドに付けると、呼び出し時に権限評価が走ります。

@RestController
@RequestMapping("/v1/admin")
public class AdminController {
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/users")
    public List<UserDto> list() { ... }

    @PreAuthorize("#id == authentication.name or hasRole('ADMIN')")
    @DeleteMapping("/users/{id}")
    public void delete(@PathVariable String id) { ... }
}

有効化を忘れると静かに失敗します:

@EnableMethodSecurity   // Spring Security 6+ (旧: @EnableGlobalMethodSecurity)

@PostAuthorize は処理後に評価する (戻り値を使った認可判定):

@PostAuthorize("returnObject.ownerId == authentication.name")
public Order getOrder(String id) { ... }

落とし穴

1. @PreAuthorize の SpEL は実行時評価 → タイポしてもコンパイル通る

@PreAuthorize("hasRole('ADMIM')")  // タイポ! でもビルドは通る

必ず認可の単体テストを書く。@WithMockUser で簡単に:

@Test
@WithMockUser(roles = "ADMIN")
void admin_can_delete() { ... }

2. CSRF が有効なまま API を叩いて 403

ブラウザから叩く REST API は CSRF 有効が原則。 純粋な API サーバ (Bearer Token) なら csrf.disable() を検討。 無効化する場合はそれで何が起きるか理解してから 。

3. SecurityContextHolder の情報が非同期スレッドに引き継がれない

@Async や Reactor スレッド切り替え時に認証情報が失われます。 DelegatingSecurityContextExecutor で包むか、Reactor 版 Spring Security を使う。

4. パスワードを {noop} のまま本番稼働

Spring Security 5+ は PasswordEncoder のプレフィックス指定が必要。 BCryptPasswordEncoder または DelegatingPasswordEncoder を使い、 {noop} プレフィックスは絶対に本番で使わない。

5. フィルタの順序ミスで想定ルートが保護されない

.anyRequest().authenticated() の 「前」 に .requestMatchers(...).permitAll() を置く順序は重要。 Spring Security の設定は 宣言順に評価 されます。

近代化アップデート (2017 → 2024+)

当時現代
WebSecurityConfigurerAdapter を継承SecurityFilterChain Bean
antMatchers("/path")requestMatchers("/path")
authorizeRequests()authorizeHttpRequests()
@EnableGlobalMethodSecurity(prePostEnabled = true)@EnableMethodSecurity
BCryptPasswordEncoder 直接PasswordEncoderFactories.createDelegatingPasswordEncoder()
XML / Java Config の一部混在純 Java Config + Lambda DSL が標準

まとめ: Spring Security は 「Filter Chain + AuthenticationManager + UserDetails + メソッド認可」 の 4 要素で動いています。 一度フローを追えれば、独自認証方式 (JWT / OAuth2 / mTLS / API Key) も同じ型に収まります。 「なんとなく動いている」 を卒業するために、一度は FilterChain のログを DEBUG にして眺めてみるのを強くおすすめします。

関連記事