TIL

[250401 TIL] Spring Security + JWT 예외 처리 정리

도원좀비 2025. 4. 1. 20:08

📝 작업 내용 요약

  • Spring Security + JWT 인증 예외 처리 흐름 정리
  • JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 구현 및 적용
  • 토큰 관련 예외 코드 정의 (AUTH_005 ~ AUTH_008)
  • JwtAuthenticationFilter에서 커스텀 예외 응답 처리
  • AuthUtil 유틸로 인증된 작성자 ID 추출 방식 정리

예시 코드

더보기
더보기

(JwtTokenProvider.java)

/**
 * JWT 토큰 발급 및 검증 유틸리티
 */
@Component
public class JwtTokenProvider {

    private final Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private final long expireTimeMs = 1000 * 60 * 30;

    /**
     * JWT 생성
     *
     * @param authorId 작성자 ID
     * @return 생성된 JWT 토큰 문자열
     */
    public String generateToken(String authorId) {
        return Jwts.builder()
                .setSubject(authorId)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(secretKey)
                .compact();
    }

    /**
     * @param token JWT 토큰
     * @return 추출된 작성자 ID
     */
    public String getAuthorId(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    /**
     * JWT 토큰의 유효성 검증
     *
     * @param token JWT 토큰
     * @return 유효한면 true 아니면 @exception TOKEN_INVALID
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            throw new CustomException(ExceptionCode.TOKEN_EXPIRED);
        } catch (UnsupportedJwtException e) {
            throw new CustomException(ExceptionCode.TOKEN_MALFORMED);
        } catch (SecurityException | MalformedJwtException e) {
            throw new CustomException(ExceptionCode.TOKEN_SIGNATURE_INVALID);
        } catch (JwtException | IllegalArgumentException e) {
            throw new CustomException(ExceptionCode.TOKEN_INVALID);
        }
    }

}

 

(SecurityConfig.java)

/**
 * Spring Security 설정 클래스
 */
@Configuration
@EnableWebSecurity // 스프링시큐리티 기능 활성화
@AllArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    /**
     * securityFilterChain 빈 정의
     *
     * @param http HttpSecurity 객체
     * @return SecurityFilterChain 객체
     * @throws Exception 설정 중 오류 발생 시 예외
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                // 모든 요청에 대해 보안 정책을 적용함 (securityMatcher 선택적)
                .securityMatcher((request -> true))

                // CSRF 보호 비활성화 (JWT 세션을 사용하지 않기 때문에 필요 없음)
                .csrf(AbstractHttpConfigurer::disable)

                // 요청별 권한 설정
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/login",
                                "/authors/signup",
                                "/schedules",                      // 전체 조회
                                "/schedules/paged",                // 페이징 조회
                                "/schedules/search",               // 검색
                                "/schedules/{scheduleId}",         // 단건 조회
                                "/comments/schedules/**"           // 댓글 전체 조회
                        ).permitAll()
                        .anyRequest().authenticated()
                ).exceptionHandling(ex -> ex
                        .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                        .accessDeniedHandler(new JwtAccessDeniedHandler())
                )


                // 커스텀 JWT 인증 필터 등록
                // UsernamePasswordAuthenticationFilter 전에 실행되어 JWT 검증 처리
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)

                // 최종 SecurityFilterChain 반환
                .build();
    }
}

 

(JwtAuthenticationFilter.java)


public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider provider) {
        this.jwtTokenProvider = provider;
    }


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

        String token = resolveToken(request);

        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            if (jwtTokenProvider.validateToken(token)) {
                String authorId = jwtTokenProvider.getAuthorId(token);
                UsernamePasswordAuthenticationToken auth =
                        new UsernamePasswordAuthenticationToken(authorId, null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } else {
                handleException(response, ExceptionCode.TOKEN_INVALID);
                return;
            }
        } catch (io.jsonwebtoken.ExpiredJwtException e) {
            handleException(response, ExceptionCode.TOKEN_EXPIRED);
            return;
        } catch (io.jsonwebtoken.security.SecurityException e) {
            handleException(response, ExceptionCode.TOKEN_SIGNATURE_INVALID);
            return;
        } catch (io.jsonwebtoken.MalformedJwtException e) {
            handleException(response, ExceptionCode.TOKEN_MALFORMED);
            return;
        } catch (Exception e) {
            handleException(response, ExceptionCode.TOKEN_INVALID);
            return;
        }

        filterChain.doFilter(request, response);
    }

    private void handleException(HttpServletResponse response, ExceptionCode error) throws IOException {
        response.setStatus(error.getStatus());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        ExceptionResponse body = new ExceptionResponse(
                error.getStatus(),
                error.getCode(),
                error.getMessage(),
                LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
        );

        new ObjectMapper().writeValue(response.getWriter(), body);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }


}

🤔 배운 점

  • Spring Security의 예외는 필터 체인에서 처리되기 때문에 @ControllerAdvice로 잡을 수 없음
  • AuthenticationEntryPointAccessDeniedHandler는 반드시 JSON 응답을 직접 구성해야 프론트에서 인지 가능