👨‍💻

ミニSNSアプリの開発備忘録その②【MVP仕様】

に公開

前回の続き...

https://zenn.dev/kaichang/articles/0d932075458a84

3.10) Security Config

Security Configはどのようにアプリに反映されるか

SecurityConfigが作るBean(特に SecurityFilterChain)をSpring Bootが自動的に全リクエストへ差し込むので、アプリ全体に効く。

1) コンポーネントスキャンで読み込まれる

  • @SpringBootApplication(通常は com.example.backend直下)から配下のパッケージをスキャン。
  • そこにある @Configuration付与のSecurityConfigが検出→Bean 定義が登録される。

2) SecurityFilterChain が“アプリ全体のフィルタ”になる

  • SecurityConfig#filterChain(HttpSecurity …)が返す SecurityFilterChain Bean を Spring Security が取得。
  • Servlet フィルタ springSecurityFilterChain(DelegatingFilterProxy)が 全ての HTTP リクエストをこのチェーンに通す。
  • その中に設定(JWT 認証、CORS、例外ハンドラ、認可ルール、CSRF無効、セッション無効など)が入る=全コントローラに適用。

もし SecurityFilterChainを自前で定義しない場合は、Bootのデフォルト設定が使われる。自前で定義した時点でその設定が優先される。

backend/config/SecurityConfig.java

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final RequestIdFilter requestIdFilter;
    private final AccessLogFilter accessLogFilter;
    private final ErrorResponseWriter errorWriter;

    public SecurityConfig(RequestIdFilter requestIdFilter, AccessLogFilter accessLogFilter, ErrorResponseWriter errorWriter) {
        this.requestIdFilter = requestIdFilter;
        this.accessLogFilter = accessLogFilter;
        this.errorWriter = errorWriter;
    }

    /** パスワードハッシュ用(signupで使用) */
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /** ローカル開発用CORS(Next.js 3000番から叩けるように) */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        var config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000", "http://127.0.0.1:3000"));
        config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);
        config.setExposedHeaders(List.of("X-Request-ID"));    // ★レスポンスで見えるヘッダ
        config.setMaxAge(java.time.Duration.ofHours(1));      // ★Preflightキャッシュ
        var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    /** HS256用 JwtDecoder */
    @Bean
    JwtDecoder jwtDecoder(@Value("${auth.jwt.secret}") String secret) {
        var key = new javax.crypto.spec.SecretKeySpec(
                secret.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256");
        return org.springframework.security.oauth2.jwt.NimbusJwtDecoder
                .withSecretKey(key)
                .macAlgorithm(org.springframework.security.oauth2.jose.jws.MacAlgorithm.HS256)
                .build();
    }

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
        var json401 = new JsonAuthenticationEntryPoint(errorWriter, "X-Request-ID");
        var json403 = new JsonAccessDeniedHandler(errorWriter, "X-Request-ID");

        http
                .csrf(csrf -> csrf.disable())
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() // ★追加(任意)
                        .requestMatchers("/health/**", "/auth/**").permitAll()
                        .anyRequest().authenticated()
                )
                .httpBasic(b -> b.disable())
                .formLogin(f -> f.disable())
                .exceptionHandling(ex -> ex.accessDeniedHandler(json403))
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(jwt -> jwt.decoder(jwtDecoder).jwtAuthenticationConverter(jwtAuthUserConverter()))
                        .authenticationEntryPoint(json401)
                );

        http.addFilterBefore(requestIdFilter, BearerTokenAuthenticationFilter.class);
        http.addFilterAfter(accessLogFilter, org.springframework.security.web.context.SecurityContextHolderFilter.class);

        return http.build();
    }

    /** JWTのクレーム → principal(AuthUser) + GrantedAuthority 変換 */
    @Bean
    Converter<Jwt, AbstractAuthenticationToken> jwtAuthUserConverter() {
        var rolesConv = new JwtGrantedAuthoritiesConverter();
        rolesConv.setAuthoritiesClaimName("roles"); // 発行側のclaim名に合わせる
        rolesConv.setAuthorityPrefix("ROLE_");

        return jwt -> {
            var authorities = rolesConv.convert(jwt);

            Long id = Optional.ofNullable((Number) jwt.getClaim("user_id"))
                    .map(Number::longValue)
                    .orElseThrow(() -> new IllegalArgumentException("user_id missing"));

            String username = Optional.ofNullable(jwt.getClaimAsString("username"))
                    .orElse(jwt.getSubject());

            Set<String> roles = Optional.ofNullable(jwt.getClaimAsStringList("roles"))
                    .map(Set::copyOf)
                    .orElseGet(Set::of);

            var principal = new AuthUser(id, username, roles);
            return new UsernamePasswordAuthenticationToken(
                    principal, "N/A", authorities
            );
        };
    }
}
解説

1. クラス全体について

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
  • @Configuration
    • Springの設定クラスであることを示します(@Beanを定義できる)。
  • @EnableMethodSecurity
    • メソッドレベルでの権限制御(例: @PreAuthorize("hasRole('ADMIN')"))を有効にします。

2. PasswordEncoder

/** パスワードハッシュ用(signupで使用) */
@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

UserServiceにおいてorg.springframework.security.crypto.passwordからインポートしているPasswordEncoderは、Spring Boot / Spring SecurityによってデフォルトでBeanを自動登録されない。

そもそもDIについて

DIとは、Springがアプリ起動時に@Configurationを読み込み、その@Beanの戻り値(ここでは PasswordEncoder)をコンテナに登録し、型で自動解決して注入すること。

流れ

  • @SpringBootApplication(=@Configuration + @EnableAutoConfiguration + @ComponentScan)が基底パッケージ配下をスキャンする。
  • スキャンで見つかった SecurityConfig(@Configuration)が読み込まれ、@Bean passwordEncoder() の戻り値オブジェクトが Bean(シングルトン) として登録される。
  • UserService はコンストラクタで PasswordEncoder という型を要求 → Spring が同じ型の Bean を解決して注入する。

つまり:

  • UserService は SecurityConfig クラスを知る必要なし
  • 参照するのは クラスではなく「型(PasswordEncoder)」の Bean
  • Spring が裏でリフレクション+コンテナでつないでくれる
IntelliJの「no usages」表示
  • IntelliJ は 静的解析で「どこから参照されているか」を調べている。
  • でも @Bean は Spring コンテナがリフレクション経由で管理しているので、コード上に直接呼び出し元が現れない。
  • そのため IntelliJ は「このメソッドを呼んでいるコードがない → 未使用」とみなしてしまう。

3. CORS 設定

@Bean
CorsConfigurationSource corsConfigurationSource() {
    var config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:3000", "http://127.0.0.1:3000"));
    config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    config.setExposedHeaders(List.of("X-Request-ID"));
    config.setMaxAge(java.time.Duration.ofHours(1));
    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}
  • Next.js フロントエンド(ポート 3000)からのアクセスを許可するための設定。
  • OPTIONS を明示的に許可 → Preflight リクエスト(ブラウザが送る確認リクエスト)を通す。
  • ExposedHeaders で X-Request-ID をクライアントに見せるようにしている。

4. JWTデコーダ

@Bean
JwtDecoder jwtDecoder(@Value("${auth.jwt.secret}") String secret) {
    var key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    return NimbusJwtDecoder
            .withSecretKey(key)
            .macAlgorithm(MacAlgorithm.HS256)
            .build();
}
- HS256 署名方式の JWT を検証するためのデコーダ。
- 秘密鍵はアプリの設定ファイル(application.yml などの auth.jwt.secret)から読み込む。

### 5. SecurityFilterChain(セキュリティ全体のルール)
```java
@Bean
SecurityFilterChain filterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
    var json401 = new JsonAuthenticationEntryPoint(errorWriter, "X-Request-ID");
    var json403 = new JsonAccessDeniedHandler(errorWriter, "X-Request-ID");

    http
        .csrf(csrf -> csrf.disable())   // CSRF 無効(API サーバなのでセッションを持たない)
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // セッション使わない
        .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // Preflight 全許可
                .requestMatchers("/health/**", "/auth/**").permitAll()   // 認証不要
                .anyRequest().authenticated()                            // 他は認証必須
        )
        .httpBasic(b -> b.disable())
        .formLogin(f -> f.disable())
        .exceptionHandling(ex -> ex.accessDeniedHandler(json403))
        .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.decoder(jwtDecoder).jwtAuthenticationConverter(jwtAuthUserConverter()))
                .authenticationEntryPoint(json401)
        );

    http.addFilterBefore(requestIdFilter, BearerTokenAuthenticationFilter.class);
    http.addFilterAfter(accessLogFilter, SecurityContextHolderFilter.class);

    return http.build();
}
  • Stateless API サーバー向け設定:CSRF無効、セッションなし、フォームログイン無効。
  • 認可ルール:
    • /health/** と /auth/** → 誰でもアクセス可。
    • その他 → JWT が必要。
  • 例外ハンドリング:
    • 401 (未認証) → JsonAuthenticationEntryPoint で JSON 返却。
    • 403 (権限不足) → JsonAccessDeniedHandler で JSON 返却。
  • 独自フィルタの差し込み:
    • requestIdFilter はリクエスト開始時に実行。
    • accessLogFilter は SecurityContext 確立後に実行。

6. JWT → Spring Security の Principal に変換

@Bean
Converter<Jwt, AbstractAuthenticationToken> jwtAuthUserConverter() {
    var rolesConv = new JwtGrantedAuthoritiesConverter();
    rolesConv.setAuthoritiesClaimName("roles");
    rolesConv.setAuthorityPrefix("ROLE_");

    return jwt -> {
        var authorities = rolesConv.convert(jwt);

        Long id = Optional.ofNullable((Number) jwt.getClaim("user_id"))
                .map(Number::longValue)
                .orElseThrow(() -> new IllegalArgumentException("user_id missing"));

        String username = Optional.ofNullable(jwt.getClaimAsString("username"))
                .orElse(jwt.getSubject());

        Set<String> roles = Optional.ofNullable(jwt.getClaimAsStringList("roles"))
                .map(Set::copyOf)
                .orElseGet(Set::of);

        var principal = new AuthUser(id, username, roles);
        return new UsernamePasswordAuthenticationToken(principal, "N/A", authorities);
    };
}

概要

  • JWT のクレームを読み取って、AuthUser という独自の principal に変換。
  • 具体的には:
    • user_id → DB のユーザー ID。必須。
    • username → ユーザー名。無ければ sub クレームを使用。
    • roles → 権限(Spring Security の ROLE_ プレフィックス付きに変換)。
  • こうすることで、Controller 内で @AuthenticationPrincipal AuthUser me が使える。

全体像

  • Converter<Jwt, AbstractAuthenticationToken> …
    • JWT トークンを受け取って、Spring Security が使う AbstractAuthenticationToken に変換する仕組み。
  • Spring Security の Resource Server (OAuth2/JWT 認証) を使う場合、このようなコンバータを登録しておくと、アプリ内で @AuthenticationPrincipal などを通じて 独自のユーザ情報(AuthUser) を利用できるようになる。
  • ここでは、SecurityConfig は OAuth2 Resource Server(JWT) を有効にしており、その中で jwtAuthUserConverter() を JWT→Authentication 変換器として差し込んでいる。

コード解説

①メソッドの役割
@Bean
Converter<Jwt, AbstractAuthenticationToken> jwtAuthUserConverter()
  • Jwt → Authentication に変換するコンポーネント。
    • Resource Server は受け取った JWT を検証した後、ここで Spring Security が理解できる Authentication に変える。
②権限の取り出し(rolesConv)
var rolesConv = new JwtGrantedAuthoritiesConverter();
rolesConv.setAuthoritiesClaimName("roles"); // claim名を"roles"に
rolesConv.setAuthorityPrefix("ROLE_");      // "ROLE_" プレフィックスを付与
  • JwtGrantedAuthoritiesConverter は Spring が用意している標準の JWT → 権限 (GrantedAuthority) コンバータ。
  • デフォルトだと claim 名は scope や scp を見るが、ここでは roles を使うように上書き。
  • Spring Security は内部的に ROLE_ プレフィックスを要求するので、自動で付ける。

👉 結果:JWT の "roles": ["ADMIN", "USER"] が ROLE_ADMIN, ROLE_USER として Spring Security に渡る。

③ユーザーIDを抽出
Long id = Optional.ofNullable((Number) jwt.getClaim("user_id"))
        .map(Number::longValue)
        .orElseThrow(() -> new IllegalArgumentException("user_id missing"));
  • JWT の claim "user_id" を取り出して Long 型に変換。
  • 無ければ例外を投げて認証失敗にする。

👉 このアプリのユーザーID(DBの主キー)を Authentication に埋め込む。

④ユーザー名を抽出
String username = Optional.ofNullable(jwt.getClaimAsString("username"))
        .orElse(jwt.getSubject());
  • JWT に "username" があればそれを使う。
  • 無ければ JWT の sub (Subject) をフォールバックとして利用。
⑤役割(roles)の取り出し
Set<String> roles = Optional.ofNullable(jwt.getClaimAsStringList("roles"))
        .map(Set::copyOf)
        .orElseGet(Set::of);
  • JWT の claim "roles" がリスト形式なら、それを Set<String> に変換。
  • 無ければ空集合。

👉 Spring Security の GrantedAuthority としては rolesConv が使われるけど、ここでは独自の AuthUser レコードに格納するために roles も取り出している。

⑥独自のユーザープリンシパルを作成
var principal = new AuthUser(id, username, roles);
public record AuthUser(Long id, String username, Set<String> roles) implements Serializable { ... }
  • 自作の AuthUser レコードにまとめる。

👉 これを使うことで、コントローラ内で @AuthenticationPrincipal AuthUser me と書けばJWT の中身を毎回パースせずに使える。

この仕組みについて詳しく

「コントローラ内で @AuthenticationPrincipal AuthUser me と書けば JWT の中身を毎回パースせずに使える仕組み」
ポイントは「SecurityContext に入った Authentication の principal を、MVC が引っ張ってきて引数に入れてくれる」という流れ。

全体の流れ(1リクエスト中)

  1. Bearer トークンの抽出
    • BearerTokenAuthenticationFilter が Authorization: Bearer <JWT> を取り出す。
  2. JWTの検証 & 認証
    • JwtAuthenticationProvider(中で NimbusJwtDecoder など)が署名・exp・iss 等を検証。
    • 成功すると Jwt → Authentication 変換が走る(自作したConverter<Jwt, AbstractAuthenticationToken> がここで使われる)。
  3. Authentication の構築
    • 自作コンバータがprincipal に new AuthUser(id, username, roles) を設定
    • authorities も付与した UsernamePasswordAuthenticationToken(または JwtAuthenticationToken) を返す。
  4. SecurityContext に保存
    • SecurityContextHolder.getContext().setAuthentication(authentication)
      → 現在スレッド(リクエスト処理中)の ThreadLocal に格納される。
  5. Controller 呼び出し時の引数解決
    • Spring MVC の HandlerMethodArgumentResolver の一つ、AuthenticationPrincipalArgumentResolver が動く。
    • @AuthenticationPrincipal が付いた引数を見ると、SecurityContextHolder.getContext().getAuthentication().getPrincipal() を取り出し、型が合えばそのまま引数にセットする(型変換や SpEL も可)。

だから、principal に AuthUser を入れておけば、

@GetMapping("/mypage")
public Map<String, Object> mypage(@AuthenticationPrincipal AuthUser me) { ... }

の me にそのまま入る、という仕組み。

重要ポイント

  • 誰が principal を決める?
    → Converter<Jwt, AbstractAuthenticationToken>。
    ここで principal を AuthUser にしたから、コントローラでその型を直接受け取れる。
  • なぜ毎回 JWT をパースしなくていいの?
    → すでにフィルタ段階でパース済み。SecurityContext に Authentication が入っているから。
  • @AuthenticationPrincipal が null になるケース
    • 未認証(トークンなし / 失効など)
    • 匿名許可のエンドポイント(permitAll)で AnonymousAuthenticationToken の場合
      → こういう時は引数が null になることがあるので、required=false 的な扱い(直での指定はないが Optional<...> にする等)を検討。
  • Principal / Authentication との違い
    • Principal(java.security.Principal)なら getName() だけ使える汎用的な型。
    • Authentication なら getPrincipal()・getAuthorities() まで触れる。
    • @AuthenticationPrincipal で ちょうど欲しい型(AuthUser) を直接受け取るのが一番快適。
  • スレッド境界
    • SecurityContext は基本 ThreadLocal。@Async や WebFlux などスレッドをまたぐ場合は セキュリティコンテキストの伝搬を別途考慮(DelegatingSecurityContext... 系)する。
⑦Authentication を返す
return new UsernamePasswordAuthenticationToken(
        principal, "N/A", authorities
);
  • principal: 先ほど作った AuthUser
  • credentials: 今回は不要なので "N/A" にしている
  • authorities: Spring Security 標準の ROLE_XXX 権限セット(rolesConvで作成)

👉 これで最終的に Spring Security が理解できる Authentication が完成する。

まとめ

この jwtAuthUserConverter は:

  1. JWT の claim から user_id / username / roles を取り出す
  2. それをアプリ独自の AuthUser レコードにまとめる
  3. Spring Security 標準の GrantedAuthority も作成する
  4. それらを詰めて UsernamePasswordAuthenticationToken を返す

3.11) ロギング

backend/web/log/AccessLogFilter.java
AccessLogFilter は、Spring Bootアプリケーションで全リクエストのアクセスログを出力するフィルタ。
もしこのAccessLogFilterを自作せずに、Spring Boot標準の機能を使うなら、CommonsRequestLoggingFilterという組み込みフィルタもあるが、今作ったようにレスポンスのステータスや処理時間まで取れるのは自作のほうが柔軟。

@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@Component
public class AccessLogFilter implements Filter{
    private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        long start = System.currentTimeMillis();
        try {
            chain.doFilter(request, response);
        } finally {
            HttpServletResponse res = (HttpServletResponse) response;
            long ms = System.currentTimeMillis() - start;
            // Authorization等の機密ヘッダは出さない
            log.info("{} {} {} {}ms", req.getMethod(), req.getRequestURI(), res.getStatus(), ms);
        }
    }
}
解説

クラス宣言部分

@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@Component
public class AccessLogFilter implements Filter
  • @Component
    → Spring のコンポーネントスキャン対象になり、自動的に Bean 登録される。
    → 特別な設定をせずにアプリ全体のフィルタとして有効になる。
  • @Order(Ordered.HIGHEST_PRECEDENCE + 1)
    → 複数のフィルタがある場合の実行順序を指定。
    → HIGHEST_PRECEDENCE は最優先。+1 なので「ほぼ最優先、でも一番最初ではない」という位置づけ。(例:SecurityFilterChain より少し後に動かすなどの意図)
  • implements Filter
    → Servlet API (jakarta.servlet.Filter) を実装することで、リクエスト・レスポンス処理の間に割り込める。

doFilterメソッド

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
        throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) request;
    long start = System.currentTimeMillis();
    try {
        chain.doFilter(request, response);
    } finally {
        HttpServletResponse res = (HttpServletResponse) response;
        long ms = System.currentTimeMillis() - start;
        log.info("{} {} {} {}ms", req.getMethod(), req.getRequestURI(), res.getStatus(), ms);
    }
}
  • @Override
    • jakarta.servlet.Filterを継承したクラスはこのdoFilterを必ずオーバーライドしなければならない。
    package jakarta.servlet;
    
    public interface Filter {
        void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException;
    }
    
  • HttpServletRequest req = (HttpServletRequest) request;
    • Servletの抽象的な ServletRequestをHTTP用のHttpServletRequestにキャスト。(リクエストメソッドや URI を取るために必要)
Servlet/HttpServlet、キャストについて

ServletRequest と HttpServletRequest

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  • doFilterの引数にはServletRequest / ServletResponseがある(Servlet API の基本インターフェース)。
    • つまり「どんなプロトコルのリクエストでも扱える汎用的な型」。
  • でも実際に Web アプリでは HTTP しか使わないので、実際に渡ってくるオブジェクトは HttpServletRequest / HttpServletResponse
    • これらは ServletRequest / ServletResponse を 継承していて、HTTP 特有の情報を取れるようになっている。
      • req.getMethod() → GET, POSTなど
      • req.getRequestURI() → /mypageなど

キャスト

  • キャストとは「変数の型をより具体的な型に変換して扱うこと」
  • Javaではこういう関係がある
    • ServletRequest(親インターフェース)
      • HttpServletRequest(子インターフェース)
  • 実際に渡ってくるインスタンスは HttpServletRequest
  • でも、メソッドの引数の型は「親」である ServletRequest
  • だから、子のメソッドを呼びたいときに「キャスト」が必要になる。
ServletRequest request = ... // 実体は HttpServletRequest

// request.getMethod(); // コンパイルエラー!
// (親型なので getMethod は存在しない)

HttpServletRequest req = (HttpServletRequest) request;
req.getMethod(); // OK

(HttpServletRequest) が「キャスト」。
「この request は実際は HttpServletRequest 型なんだよ」とコンパイラに教えてあげるイメージ。

  • long start = System.currentTimeMillis();
    • 処理開始時刻を記録しておく。
  • chain.doFilter(request, response);
    • 次のフィルタや実際のコントローラ処理に渡す。
    • これを呼ばないとリクエストが処理されず止まる。
  • finally
    • リクエスト処理が例外で失敗しても必ずログ出力するようにする。
  • HttpServletResponse res = (HttpServletResponse) response;
    • ステータスコードを取得するためにキャスト。
  • long ms = System.currentTimeMillis() - start;
    • 処理時間(応答時間)を計算。
  • log.info(...);
    • INFO レベルでログ出力。
      GET /mypage 200 15ms
      POST /login 401 32ms
      

backend/web/log/RequestIdFilter.java
RequestIdFilterは 全リクエストに「リクエストID」を付与してログとひもづけるためのフィルタ。

@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
public class RequestIdFilter extends OncePerRequestFilter {

    // 他の層(Advice等)からも使えるように public に
    public static final String HDR = "X-Request-ID";
    public static final String MDC_KEY = "requestId";

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {

        String rid = req.getHeader(HDR);
        if (rid == null || rid.isBlank()) rid = UUID.randomUUID().toString();

        // レスポンスに必ず付与(本文の requestId と一致させやすい)
        res.setHeader(HDR, rid);

        // try-with-resources でリーク防止(スレッドプールでも安全)
        try (var ignored = MDC.putCloseable(MDC_KEY, rid)) {
            chain.doFilter(req, res);
        }
    }
}
解説

クラス宣言部分

@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
public class RequestIdFilter extends OncePerRequestFilter {
  • @Order(Ordered.HIGHEST_PRECEDENCE)
    • フィルタの実行順を最優先に設定。(リクエストIDを「最初に」付けたいから)
  • extends OncePerRequestFilter
    • Spring 提供の便利抽象クラス。
      • 通常の Filter と違って「1リクエストにつき必ず1回だけ実行」される保証がある。
      • 実装するのは doFilterInternal(...) だけでOK。
Filter と OncePerRequestFilter の違い

jakarta.servlet.Filter

  • Servlet API が定義する標準インターフェース。
  • すべての Java EE / Jakarta EE アプリケーションで使える。
  • 特徴
    • 低レベルな API。
    • ServletRequest / ServletResponse 型しかないので、HTTP 専用の機能を使うにはキャストが必要 (HttpServletRequest など)。
    • doFilter は リクエストごとに複数回呼ばれる可能性がある(同じリクエストでも forward や include で再実行される)。
    • 実装者が毎回「1リクエストに1回だけ動かしたいかどうか」を気をつけないといけない。

OncePerRequestFilter

  • Spring Framework が提供する抽象クラス。
  • 内部で Filter を実装している。
  • 特徴
    • HttpServletRequest / HttpServletResponse を前提にしているので、キャスト不要。
    • 「1リクエストにつき必ず1回だけ」呼ばれるように制御されている(forward や include でも二重実行されない)。
    • 自分が実装するのは doFilterInternal(...) だけ。

結論

  • Filterは標準的な仕組みで「なんでもできるけど自分で全部管理が必要」。
  • OncePerRequestFilterは Spring が提供するFilterのラッパーで「HTTP 専用・1回だけ実行保証・キャスト不要」。
    → Spring Boot の場合は基本的に OncePerRequestFilterを使うのがベストプラクティス。

定数定義

public static final String HDR = "X-Request-ID";
public static final String MDC_KEY = "requestId";
  • HDR → HTTP ヘッダ名として使うキー (X-Request-ID)。
  • MDC_KEY → ログ出力用のキー名。MDC (Mapped Diagnostic Context) で参照される。

こうして定数にしておくと「Controller や Advice 層」でも同じキーを参照できる。

doFilterInternal

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
        throws ServletException, IOException {
  • 引数は HTTP 用の型。
    • キャスト不要なのは OncePerRequestFilter が最初からHttpServletRequest/Response を使ってくれるから

リクエストIDの生成・設定

String rid = req.getHeader(HDR);
if (rid == null || rid.isBlank()) rid = UUID.randomUUID().toString();
  • クライアントが X-Request-ID を送ってきたらそれを使う。
  • 無ければサーバー側で UUID を発行。

レスポンスヘッダに付与

res.setHeader(HDR, rid);
  • レスポンスにも同じリクエストIDを載せる。
  • これで「サーバーログ」と「クライアントから見えるレスポンス」を一致させられる。
    → デバッグや問い合わせ対応に便利

ログ MDC に紐付け

try (var ignored = MDC.putCloseable(MDC_KEY, rid)) {
    chain.doFilter(req, res);
}
そもそもMDCとは?
  • Mapped Diagnostic Context の略。
  • ログ出力のときに「スレッドごとのコンテキスト情報(ユーザーID, リクエストID など)」を保持する仕組み。
  • 例えば Logback で %X{requestId} をログパターンに入れると、自動でそのリクエストの ID がログに出力される。

例(logback-spring.xml):

<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg requestId=%X{requestId}%n</pattern>

ログ出力:

12:01:23.456 [http-nio-8080-exec-1] INFO  ... - GET /mypage 200 requestId=550e8400-e29b-41d4-a716-446655440000
  • MDC.putCloseable(...)
    • MDC.putCloseable(key, value) は 値を MDC にセットしつつ、自動でクリーンアップできるハンドルを返す。
    • 返り値は java.lang.AutoCloseable なので、try-with-resources 構文で使える。
    try (var ignored = MDC.putCloseable("requestId", rid)) {
        // ここにいる間は MDC["requestId"] = rid が有効
        chain.doFilter(req, res);
    }
    // スコープを抜けると自動で MDC から削除される
    
try-with-resources の意味

通常ならこう書く:

MDC.put("requestId", rid);
try {
    chain.doFilter(req, res);
} finally {
    MDC.remove("requestId");
}

これを簡潔にしたのが:

try (var ignored = MDC.putCloseable("requestId", rid)) {
    chain.doFilter(req, res);
}
  • finally で remove するのを忘れる事故を防げる。
  • 特にスレッドプール(Tomcat, Undertow など)では、MDC に残ったままだと「次のリクエストに前の requestId が誤って表示される」バグになる。
  • var ignored の意味
var ignored = MDC.putCloseable(...);
- 戻り値を実際には使わないけど、try-with-resources でスコープを管理するために変数を受け取っている。
- 「ignored」という名前にすることで「この変数は使いませんよ」という意図を明示している。

動作の流れ

  1. MDC.putCloseable("requestId", rid) → requestId をセット
  2. chain.doFilter(req, res) → コントローラなどの処理が実行される
    • この間に出すログには必ず requestId が付く
  3. try スコープを抜ける → MDC の値が自動的に削除される

HTTPリクエスト/レスポンスヘッダに対するイメージ

FilterOncePerRequestFilterは「HTTP リクエスト/レスポンスを横取りして処理する仕組み」なので、基本的にはブラウザやAPIクライアントから送られてくるリクエストヘッダ、そしてサーバーが返すレスポンスヘッダを扱っている。

HTTP リクエストの想定例

例えばクライアントがログインページにアクセスする場合:

GET /mypage HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh)
Accept: application/json
Accept-Language: ja,en;q=0.9
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
X-Request-ID: abc123   ← RequestIdFilter で拾える

ポイント:

  • リクエストライン: GET /mypage HTTP/1.1
  • 標準ヘッダ: Host, User-Agent, Accept, Authorization など
  • アプリ独自ヘッダ: X-Request-ID (RequestIdFilter が参照する)

フィルタがやること

  • AccessLogFilter:
    → req.getMethod(), req.getRequestURI() を使ってログに出す。
INFO  GET /mypage 200 12ms
  • RequestIdFilter:
    → req.getHeader("X-Request-ID") でリクエストヘッダをチェック。
    → 無ければ UUID を生成して使う。

HTTP レスポンスの想定例

サーバーが返すレスポンス:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 42
X-Request-ID: abc123   ← フィルタで必ず付与される

ポイント:

  • AccessLogFilter が出すログと X-Request-ID の値が一致する。
  • クライアント側も「このレスポンスは requestId=abc123 に対応する」とわかる。

3.11) テスト

TBA

4. フロントエンド実装

4.0) 仕様確認

MVP 仕様書に沿ったフロントエンドを Next.js(App Router)で効率良く実装していく。

前提整理

  • バックエンドの公開エンドポイントは /auth/signup/auth/login などの認証不要 API と、/mypage/posts/follows/{targetId}/timeline など認証必須 API。
  • 投稿は content 最大 280 文字で、レスポンスには、iduserIdusernamecreatedAt などが含まれる。
  • タイムラインは自分とフォロー先の投稿を返し、カーソルは opaque な Base64URL 文字列で次ページを取得する。
  • JWT を用いた認証方式のため、トークンをHTTP-only Cookie で管理する。

アーキテクチャ

クライアントからのリクエストは以下の順番にバトンが渡っていく。

1. 認可ミドルウェア

認可ミドルウェアとは?

概要

  • Next.jsの認可ミドルウェア(middleware.ts)は「リクエストがページやAPIに届く前」にエッジで走り、条件に応じて通す/リダイレクト/書き換えを決める仕組み。
  • 公式の要点は「リクエスト前に実行され、NextRequestを見てNextResponseで返答やリダイレクトを制御」。
  • これによりログイン必須ページの保護ができる。
  • 何ができる?
    • アクセス制御:ログインしてなければ/loginへリダイレクト。
    • クッキー参照/設定:ミドルウェア内ではreq.cookies.get(...)で読み、NextResponse経由でres.cookies.set(...)して返す。
    • パスごとの適用export const config = { matcher: [...] }で、どのURLにミドルウェアを走らせるか細かく指定可能。

最小実装

  • 方針
    • 公開パス:/login, /signup, /api/session など
    • それ以外:クッキーにJWTが無ければ/login
  • NextRequest/NextResponseというWeb標準ベースのAPIでリダイレクトを実行。
  • matcherURLパスに対して働く(ファイルパスではない)点に注意。
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";

const PUBLIC_PATHS = ["/login", "/signup", "/api/session", "/favicon", "/_next"];

export function middleware(req: NextRequest) {
  // 公開パスなら素通り
  if (PUBLIC_PATHS.some((p) => req.nextUrl.pathname.startsWith(p))) {
    return NextResponse.next();
  }

  // 認証チェック(例:httpOnly CookieにJWTを保存している前提)
  const token = req.cookies.get(process.env.SESSION_COOKIE_NAME!)?.value;
  if (!token) {
    const url = new URL("/login", req.url);
    url.searchParams.set("next", req.nextUrl.pathname); // 任意: 復帰先
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

// どのパスで実行するか(URLベースで指定)
export const config = {
  matcher: [
    // API全スキップなら '/((?!api|_next|favicon).*)' などに調整
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

よくある設計パターン

1) 存在チェック型(最速)

ミドルウェアはトークンの存在だけ見る(有無判定)。検証(署名/期限)はバックエンドがやる。UI遷移のガード目的なら十分で、レイテンシーも最小。

2) 軽い検証型(役割ベースなど)

ロールで /admin を守りたい等でペイロードだけ参照(完全検証は重い)。ミドルウェアはEdge環境なのでNodeのcryptoではなくWeb Cryptoや外部検証エンドポイント呼び出しになるが、ここで厳密検証を頑張りすぎないのが実務的に◎。公式ガイドも認証は構成を分けて考えることを推奨。
 #### 3) サブアプリ/言語リダイレクト
言語判定やサブアプリ切り替えもミドルウェア向き(rewrite/redirect活用)。

2. App Router

App Routerとは?

App Routerapp/ フォルダの構成がそのまま URL にマッピングされる仕組み。たとえば次のような構成になっている:

  • app/(public)/login/page.tsx – ログイン画面。フォーム送信時にバックエンドへ認証情報を送信し、成功すればトークンを BFF 経由で保存してトップに遷移する。
  • app/(protected)/page.tsx – タイムライン画面。useSWRInfinite でタイムラインを無限スクロールし、投稿フォームから createPost を呼び出す。
  • app/(protected)/mypage/page.tsx – マイページ。サーバコンポーネントとしてクッキーからトークンを読み出し、me(token) で自分のユーザー情報を取得する。

App Router では (public)(protected) という括弧付きフォルダを使うことで URL には影響せずにコードを整理する。

3. 薄いラッパ(app/lib/api.ts)

4. BFF(Backend For Frontend)

Next.js上にapp/api/**/route.tsとして実装されるRoute Handler。

Route Handler/BFFとは?

概要

  • App Router配下のファイルベースAPIapp/api/**/route.ts に HTTPメソッド関数(GET/POST/PUT/…)をexportすると、そのURLでAPIが動く。
  • Web標準APIで書く:Request/Responsereq.json()/req.formData()Headers 等。
  • 実行環境nodejs or edge を選べる(export const runtime = 'nodejs'|'edge')。今回のMVPは nodejs でOK。

設計指針

  1. /api/session
    • ログイン後に受け取る{token}httpOnly Cookieに保存/取得/削除
    • Client Component からは fetch('/api/session') だけで済む
  2. BFFエンドポイント
    • 例:/api/timeline/api/posts/api/follows/[id]
    • CookieからJWTを読み出してBearer化→Springの /timeline など本番APIへサーバ側でプロキシ
    • CORS問題の回避ベースURLの秘匿ヘッダー統一エラーフォーマット整形ができる
    • クライアントからは /api/timeline?size=20&cursor=... を叩くだけ。Route HandlerがCookie→Authorization化してSpringへ。
    • 利点:フロントのfetchは常に /api/* 固定、Bearer付与やURLはBFFが吸収。CORSや秘密情報露出を回避。

5. Spring App

4.1) 環境構築

  1. npx create-next-app mini-sns-web --ts で新規プロジェクトを作成し、必要に応じて Tailwind などの UI ライブラリを追加。
  2. .env.localNEXT_PUBLIC_API_BASE_URL=http://localhost:8080 などバックエンドのベースURLを設定。

4.2) ディレクトリ構成

mvp-mini-sns-app/
├── frontend/  # Next.js ベースのフロントエンド
│   ├── middleware.ts
│   ├── .env.local
│   ├── app/
│   │   ├── lib/api.ts
│   │   ├── features/follow/components/FollowButton.tsx
│   │   ├── api/
│   │   │   ├── posts/route.ts・posts/[id]/route.ts
│   │   │   ├── users/[id]/route.ts・users/[id]/posts/route.ts
│   │   │   ├── auth/login/route.ts・auth/signup/route.ts
│   │   │   ├── timeline/route.ts
│   │   │   ├── session/route.ts
│   │   │   └── follows/[id]/route.ts
│   │   ├── (protected)/  # 認証が必要なページ
│   │   │   ├── page.tsx
│   │   │   ├── mypage/page.tsx
│   │   │   ├── posts/[id]/page.tsx
│   │   │   └── users/[id]/page.tsx
│   │   ├── (public)/   # 公開ページ
│   │   │   ├── login/page.tsx
│   │   │   └── signup/page.tsx

4.3) ページ/ルーティング

  • サインアップ
  • ログイン
  • マイページ
  • 投稿ページ

4.4) サインアップページ実装

4.x) テスト

TBA

5. 技術トピック

Spring Advice

以下の記事に自分なりにまとめました。
https://zenn.dev/kaichang/articles/5f68b3e77e6fb7

Spring Security

https://zenn.dev/kaichang/articles/a0d31b74902683

CIについて

https://zenn.dev/kaichang/articles/11ec8d5bd7a1ea

6. 感想・反省

Discussion