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로 잡을 수 없음
- AuthenticationEntryPoint와 AccessDeniedHandler는 반드시 JSON 응답을 직접 구성해야 프론트에서 인지 가능