JwtAuthenticationFilter.java
package com.hwhub.backend.security;
import com.hwhub.backend.domain.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* JWT(Bearer Token)を検証し、認証済みユーザーを Spring Security の SecurityContext に設定するフィルタ。
*
* <p>処理概要:
*
* <ol>
* <li>Authorization ヘッダから "Bearer {token}" を取得
* <li>JwtProvider で署名・期限の検証(validateToken)
* <li>トークンの subject(sub)から userId を取得
* <li>必要に応じて「発行時刻(iat)」と「passwordChangedAt」を比較し、失効判定
* <li>有効なら Authentication を生成し、SecurityContext に格納
* </ol>
*
* <p>principal の設計:
*
* <ul>
* <li>principal に userId.toString() を設定する(現在は UserDetails を使わない簡易方式)
* <li>権限(roles)は現時点で未使用のため空リストを設定する
* </ul>
*
* <p>トークン失効(passwordChangedAt):
*
* <ul>
* <li>パスワード変更時に passwordChangedAt を更新し、以降に発行された token のみを有効とする
* <li>token.iat < passwordChangedAt の場合は無効として SecurityContext を設定しない
* </ul>
*
* <p>除外パス:
*
* <ul>
* <li>ログイン/認証系の一部 API や OAuth コールバック等は JWT 検証対象外(shouldNotFilter)
* </ul>
*
* <p>注意:
*
* <ul>
* <li>本フィルタは「認証情報をセットするだけ」で、401 を返す責務は持たない
* <li>認可(アクセス制御)は Spring Security の設定(SecurityFilterChain 等)側で行う
* </ul>
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final UserRepository userRepository;
/**
* リクエストごとに JWT を検証して SecurityContext を更新する。
*
* @param request HTTPリクエスト
* @param response HTTPレスポンス
* @param filterChain 次のフィルタ
* @throws ServletException フィルタ処理の例外
* @throws IOException I/O例外
*/
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtProvider.validateToken(token)) {
Long userId = jwtProvider.getUserIdFromToken(token);
Date issuedAt = jwtProvider.getIssuedAtFromToken(token);
if (issuedAt == null) {
filterChain.doFilter(request, response);
return;
}
Instant tokenIatInstant = issuedAt.toInstant();
boolean isValid = true;
var opt = userRepository.findPasswordChangedAt(userId);
if (opt.isPresent()) {
Instant pwChangedInstant =
opt.get().atZone(ZoneId.of("Asia/Tokyo")).toInstant().truncatedTo(ChronoUnit.SECONDS);
// トークン発行時刻がパスワード変更時刻より古い場合、無効とする
if (tokenIatInstant.plusSeconds(1).isBefore(pwChangedInstant)) {
isValid = false;
}
}
if (isValid) {
// userId を principal として Authentication を作成
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userId.toString(), null, List.of() // いま役割(role)は使わないので空リスト
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
filterChain.doFilter(request, response);
}
/**
* JWT 検証を行わないパスを定義する。
*
* <p>例:
*
* <ul>
* <li>ログイン / パスワードリセットなどの public API
* <li>Google OAuth の start / callback
* </ul>
*
* @param request HTTPリクエスト
* @return true の場合はフィルタ処理をスキップする
*/
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String path = request.getServletPath();
// JWT 検証不要
if (path.startsWith("/api/auth/login")) return true;
if (path.startsWith("/api/auth/email-verification/verify")) return true;
if (path.startsWith("/api/auth/email-verification/resend")) return true;
if (path.startsWith("/api/auth/password-reset/request")) return true;
if (path.startsWith("/api/auth/password-reset/confirm")) return true;
if (path.startsWith("/oauth/google")) return true;
// それ以外のパスはフィルタ実行
return false;
}
}