Tech Blog

Spring Profile で dev だけ認証を緩める設計 — prod の構造を壊さない最小限の Filter

Spring Security Spring Boot Java Profile JWT PoC 設計

この記事でわかること

  • 本物の JWT 検証を入れる前に「認証通った前提」で API を動かす最小限の方法
  • Spring の @Profile("dev") + ObjectProvider で prod 側のコードを汚さない Filter 差し込みパターン
  • prod の Resource Server に切り替える時に何を消すだけで済むようにしておくか

対象読者

  • Spring Boot で PoC を作りながら、本番化のときに認証構造を壊さない設計を考えたい方
  • SecurityFilterChain に dev 専用 Filter を差し込む書き方を知りたい方
  • 「dev だけ if 分岐」を Service ではなく Configuration に閉じ込めたい方

動作環境

項目バージョン
Java21
Spring Boot3.4.5
Spring Security6.x

シリーズ記事(開発進行中) — この記事は 街中の電動キックボードシェアリングを自作して理解する — 設計・実装・運用の記録シリーズ の一部です。プロジェクトは現在も継続中で、新しい記事や設計判断の追記が随時行われます。プロジェクト全体の動機・採用技術・画面IDなどの用語表・他記事への索引はリンク先にまとめています。

はじめに

電動キックボードシェアリングアプリで E2E を通そうとした時、ログイン以降の API が 全部 401 で弾かれていました。

S05 ログイン画面 — ここまでは公開エンドポイントなので 401 にならない

実装を確認すると:

  • AuthService.verifyOtp は OTP 123456 で成功し、dummy-jwt-token-for-userid-{UUID} という文字列を返す
  • でも SecurityConfig 側に その文字列を解釈する Filter が無い
  • 結果として /users/me /payments/** /ekyc/** /rentals/** などは .anyRequest().authenticated() で全部 401

本番化のときは Spring Security OAuth2 Resource Server を入れて本物の JWT を署名検証する設計でしたが、PoC のためにそこまで踏み込むと時間が溶けます。

そこで採った戦略が、dev profile 限定の Filter を1枚だけ足す こと。


何を割り切ったか

本物の JWT 検証は以下を満たす必要があります。

  • 署名検証(HS256 / RS256 など)
  • 有効期限チェック(exp クレーム)
  • 発行者チェック(iss クレーム)
  • スコープ・ロール抽出

PoC でこれらは要りません。User ID が取り出せて Authentication として SecurityContext に入れば、業務ロジックが回り始める からです。

割り切るのはこの一点に絞りました。


最小実装

@Profile("dev") をつけた OncePerRequestFilter を1枚追加します。

@Component
@Profile("dev")
@Slf4j
public class DevDummyJwtAuthFilter extends OncePerRequestFilter {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final Pattern DUMMY_TOKEN_PATTERN =
            Pattern.compile("^dummy-jwt-token-for-userid-([0-9a-fA-F\\-]{36})$");

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            String token = authHeader.substring(BEARER_PREFIX.length()).trim();
            Matcher matcher = DUMMY_TOKEN_PATTERN.matcher(token);
            if (matcher.matches()) {
                String userId = matcher.group(1);
                Authentication auth = new UsernamePasswordAuthenticationToken(
                        userId, null, Collections.emptyList());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(request, response);
    }
}

要点:

  • 正規表現でトークン形式を厳格に縛る(任意の文字列を渡して認証を通すことはない)
  • principalUUID 文字列 にすることで、CurrentUserProviderauthentication.getName() から UUID として読み取れる
  • 権限リストは空 (Collections.emptyList())。dev では権限差別化が要らない

SecurityConfig 側の差し込み

ここが今回いちばん工夫したところです。

普通に @Autowired private DevDummyJwtAuthFilter devFilter; と書くと、prod 起動時に「Bean が存在しないため起動失敗」になります。@Autowired(required = false) でもいいですが、より明示的に 存在しないかもしれない Bean を扱う ObjectProvider を使いました。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final ObjectProvider<DevDummyJwtAuthFilter> devDummyJwtAuthFilterProvider;

    public SecurityConfig(ObjectProvider<DevDummyJwtAuthFilter> devDummyJwtAuthFilterProvider) {
        this.devDummyJwtAuthFilterProvider = devDummyJwtAuthFilterProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/ports/**").permitAll()
                        .requestMatchers("/api/v1/scooters/*/status").permitAll()
                        .requestMatchers("/api/v1/auth/send-otp", "/api/v1/auth/verify-otp").permitAll()
                        .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                        .anyRequest().authenticated()
                );

        // dev profile が有効な場合のみ、ダミーJWT認証フィルタを差し込む。
        // prod ではこの Bean が存在しないため何も追加されない。
        DevDummyJwtAuthFilter devFilter = devDummyJwtAuthFilterProvider.getIfAvailable();
        if (devFilter != null) {
            http.addFilterBefore(devFilter, UsernamePasswordAuthenticationFilter.class);
        }

        return http.build();
    }
}

ポイント:

  • dev/prod の判定が「Bean が存在するかどうか」に集約されている。environment.acceptsProfiles(...) のような明示判定が要らない
  • prod では DevDummyJwtAuthFilter 自体が @Profile("dev") で生成されないので、getIfAvailable()null を返す → addFilterBefore が呼ばれない
  • chain の他の構成(permitAll の対象、anyRequest().authenticated())は dev/prod 共通

CurrentUserProvider との連携

業務ロジックは Authentication から UUID を取り出すために CurrentUserProvider を経由します。

@Component
public class CurrentUserProvider {

    public UUID requireUserId(Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required");
        }
        String principalName = authentication.getName();
        if (principalName == null || principalName.isBlank() || "anonymousUser".equals(principalName)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated principal");
        }
        try {
            return UUID.fromString(principalName);
        } catch (IllegalArgumentException ex) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated principal is not a valid UUID", ex);
        }
    }
}

dev でも prod でも、この requireUserId の入力は Authentication

  • dev: DevDummyJwtAuthFilter がダミー JWT から UUID を抽出してセット
  • prod: OAuth2 Resource Server などが署名検証済み JWT から UUID を抽出してセット

CurrentUserProvider は両方とも同じシグネチャで動くので、業務ロジックは dev/prod の違いを知らずに済みます。

S06 決済登録 — フィルタ追加後は認証必須エンドポイントも 200 で通る

スクショの customer_id: cus_mock_...client_secret: seti_mock_... は API 側の Service が dev で発行している値。Authorization ヘッダのダミー JWT が Filter で解釈されて UserId 取得 → Service が呼ばれた という証跡になっています。


落とし穴:JSR-305 @NonNull

doFilterInternal を override する時、Spring 6 の親クラスがパラメータに @NonNull を要求しています。

Missing non-null annotation: inherited method from OncePerRequestFilter
specifies this parameter as @NonNull

警告だけでビルドは通りますが、気になるなら org.springframework.lang.NonNull を import してパラメータに付ければ消えます。

@Override
protected void doFilterInternal(
        @NonNull HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain chain) throws ServletException, IOException {
    ...
}

ちなみに IDE のキャッシュが古いと、新しい @NonNull import が一時的に「使われていない」と誤検知することがあります。コンパイルが通っていれば気にしなくて OK。


prod 切替時に何を消すだけで済むようにしたか

PoC が動いた後、本番化するときの作業は以下に絞られます。

  1. DevDummyJwtAuthFilter.java を削除(または @Profile("dev") のまま残して prod では Bean が作られない状態にしておく)
  2. SecurityConfigoauth2ResourceServer(...).jwt(...) を追加し、本物の JWT 検証を入れる
  3. AuthServiceImpl.verifyOtp で発行する JWT を 本物の jjwt 等で署名する ように差し替える

CurrentUserProviderControllerService も触らずに済む。これが、dev profile の分岐を Filter / Configuration の層に閉じ込めた ご利益です。


学び

  • dev/prod の if 分岐は Service よりも上のレイヤー(Filter / Configuration)に置く と、業務ロジックが両プロファイル共通で済む
  • @Profile("dev") + ObjectProvider の組み合わせは、prod 起動時に余計な Bean を作らないし、Configuration 側で null チェック1行で扱える
  • 「PoC で割り切ったコード」が「本番化のときに何を消せば良いか」を最初から決めておくと、移行が機械的になる

PoC の認証は、本物の信頼境界の前で 「ここから先は同じインタフェースで叩ける」状態を作る ためのワンクッションだと割り切るのが現実的でした。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。