본 캠프_69일차

졸용·2025년 6월 2일

TIL

목록 보기
70/144

✅ JwtFilter

@Component
@RequiredArgsConstructor
// OncePerRequestFilter 를 상속받아 HTTP 요청당 한 번만 실행
public class JwtFilter extends OncePerRequestFilter {

	private final JwtUtil jwtUtil;
	// TODO - 직렬화/역직렬화 커스텀 설정 여부 확인 필요
	private final RedisTemplate<String, Object> redisTemplate;

	// TODO - URL 추가 필요
	// WHITELIST
	private static final List<String> WHITELIST = List.of(
		"/auth/login",
		"/users/signup",
		"/auth/refresh"
	);

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

		// HttpServletRequest 에서 요청된 URI 경로를 문자열로 추출
		String url = request.getRequestURI();

		// 요청된 URL 이 인증 우회 대상인지 판단 후 JWT 인증 필터 건너뛰기/ 후 다음 필터로 넘김
		if (WHITELIST.contains(url)) {
			filterChain.doFilter(request, response);
			return;
		}

		// 클라이언트가 HTTP 요청에 Authorization 헤더 포함시켰는지 확인
		String bearerJwt = request.getHeader("Authorization");

		if (bearerJwt == null) {
			writeErrorResponse(response, 401, "Authorization 헤더가 존재하지 않습니다.");
			return;
		}

		/*
		 * 토큰 파싱 및 유효성 검사
		 * subStringToken() : 유효성 검사 + Bearer 제거
		 * subStringToken() 안에서 발생한 예외 잡아서 HTTP 응답에 401 에러와 함께 메시지 JSON 으로 응답
		 */
		String jwt;

		try {
			jwt = jwtUtil.subStringToken(bearerJwt);
		} catch (ResponseStatusException e) {
			writeErrorResponse(response, e.getStatusCode().value(), e.getReason());
			return;
		}

		/*
		 * tokenKey : Redis 에 저장된 블랙리스트 토큰의 Key 를 구성하는 부분
		 * jwt 는 현재 요청에서 추출한 Access Token 문자열이기 때문에
		   → Redis 에 저장할 때 "BLACKLIST_" 접두어 붙여서 구분
		 */
		String tokenKey = "BLACKLIST_" + jwt;

		/*
		 * Redis 에서 tokenKey 존재하는지 확인
		 * hasKey() : 해당 키가 Redis 에 존재하는지 여부에 따라 true/false 반환
		 */
		Boolean isBlackListed = redisTemplate.hasKey(tokenKey);

		/*
		 * isBlackListed 가 true 인지 확인 → null-safe 체크
		 * Redis 에서 해당 토큰이 블랙리스트에 등록되어 있으면 writeErrorResponse 로직으로 진입
		 */
		if (Boolean.TRUE.equals(isBlackListed)) {
			writeErrorResponse(response, HttpStatus.UNAUTHORIZED, "로그아웃된 토큰입니다. 다시 로그인 해주세요.");
			return;
		}

		try {
			// 내부에서 Bearer 제거 + 토큰의 유효성 검증 → Claims 객체로 반환
			Claims claims = jwtUtil.getClaims(bearerJwt);
			// Claims 는 JWT 내부 payload 정보들을 갖고 있어 getSubject() 로 값 추출 가능
			String userId = claims.getSubject();

			// TODO - 권한 부여 로직 추가 후 권한 목록 수정 → List.of()
			// Spring Security 에서 사용하는 인증 객체 생성
			UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null,
				List.of());

			/*
			 * 보안 컨텍스트에 방금 만든 authentication 객체를 저장
			   → 이후 컨트롤러 단에서 @AuthenticationPrincipal 같은 방식으로 사용자 정보를 꺼낼 수 있다
			   → 인증을 수동으로 완료 처리함
			 */
			SecurityContextHolder.getContext().setAuthentication(authentication);

			filterChain.doFilter(request, response);
			// jwtUtil.getClaims() 에서 토큰 만료됐거나 위조된 경우 예외 발생
		} catch (ResponseStatusException e) {
			writeErrorResponse(response, e.getStatusCode(), e.getReason());
		}
	}

	// JWT 없거나 잘못된 경우 사용할 공통 메서드
	private void writeErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws
		IOException {

		response.setStatus(status.value());
		response.setContentType("application/json;charset=UTF-8");
		response.getWriter().write(String.format("{\"status\":%d,\"message\":\"%s\"}", status.value(), message));
	}
}

Spring Security 인증 처리와 Redis 기반 로그아웃 처리까지 포함된 상태이다.


☑️ JwtFilter 전체 동작 요약 흐름

  1. 요청 URL 확인

    • 화이트리스트(WHITELIST)에 포함된 URL이면 JWT 검사 없이 바로 다음 필터로 진행.
  2. Authorization 헤더 확인

    • Authorization 헤더가 없으면 401 에러 응답 반환.
  3. JWT 파싱 및 유효성 검사

    • Bearer 접두어 제거 (jwtUtil.subStringToken())
    • 형식이 틀리거나 유효하지 않으면 ResponseStatusException 처리 → 401 응답
  4. Redis 블랙리스트 확인

    • "BLACKLIST_" + jwt 형식으로 Redis에 저장된 토큰 키 존재 여부 확인
    • 존재하면 로그아웃된 토큰 → 401 응답
  5. JWT Claims에서 사용자 식별자 추출

    • jwtUtil.getClaims() → 내부적으로 서명 검증, 만료 검증, 구조 검증
    • getSubject()로 사용자 ID 추출
  6. Spring Security 인증 객체 등록

    • UsernamePasswordAuthenticationToken을 SecurityContext에 수동 등록
    • 인증 완료 처리
  7. 다음 필터로 요청 전달

    • filterChain.doFilter() 호출

✔️ 추후 리팩토링 고려사항 및 개선사항

  • List.of() 권한 정보는 현재 비어 있으니, 추후에 실제 권한 정보를 JWT에 담거나 사용자 DB 조회로 반영할 수 있음.
  • 로그아웃 처리 시 Redis에 "BLACKLIST_" + jwt 저장할 때 만료 시간도 토큰과 동일하게 맞춰주는 것이 좋음 (예: redisTemplate.expire()).
  • 예외 메시지 출력은 JSON 형식을 유지하고 있지만, 추후 글로벌 에러 핸들러로 통합하는 것도 고려 가능.
profile
꾸준한 공부만이 답이다

0개의 댓글