JWT 만료 시 403이 아니라 500 에러
현재 서버에서 만료된 토큰으로 토큰을 검증하면 에러가 발생하는 상태.
여기서 발생하는 에러는 토큰이 만료되어 권한이 없는 것이므로 403
이어야 할 것 같지만 실제로 전달되는 status는 500
왜?
현재 JWTFilter
에서는 토큰이 만료되었을 시에 단순히 다음 필터를 진행하는 방식으로 구현되어 있음. 별도의 예외 처리가 되어 있지 않다.
예상
처음에는 만료된 토큰이 그대로 넘어가서 해당 토큰을 까는 과정에서 문제가 생기는 줄 알았다.
500
에러이기 때문에 당연히 서비스 단에서 난 에러라고 생각했다. 대부분의 로직에 토큰에서 사용자의 정보를 가져오는 로직이 존재하기 때문. 그래서 로그를 더 살펴봤다.
그런데 에러가 발생한 부분이 service 단이 아니라 JWT Filter였다. 그런데 여기서 또 의문이 들었다. Security Filter에서 발생한 에러가 403
이 아니라 500
에러를 뿜어낼 줄은 몰랐기 때문.
실제 원인
이 부분에서 만료된 토큰의 Claim을 꺼내오려고 하기 때문에 이 부분에서
ExpiredJwtException
발생! 발생한 에러를 security 측에서는 단순한 서버의 에러로 생각해 500 에러를 뿜는 것이었다. 이러한 에러에 대한 Handling 객체가 없기 때문에 인증 관련 에러로 잡지를 못하는 것.
해결
Security 에서 발생하는 에러에 대한 ExceptionHandling용 객체를 추가해 주어야 함.
1. JWTUtil에서 발생하는 에러에 대한 Try-Catch 처리
현재 JWTFilter
의 JWTUtil
에서 문제가 발생하기 때문에 JWTUtil의 해당 부분에서 모든 에러를 Catch
public static boolean validateToken(String token, String secretKey) {
try {
return Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration()
.before(new Date());
} catch (MalformedJwtException e) {
log.info("MalformedJwtException");
throw new JwtException(ErrorMessage.UNSUPPORTED_TOKEN.getMsg());
} catch (ExpiredJwtException e) {
log.info("ExpiredJwtException");
throw new JwtException(ErrorMessage.EXPIRED_TOKEN.getMsg());
} catch (IllegalArgumentException e) {
log.info("IllegalArgumentException");
throw new JwtException(ErrorMessage.UNKNOWN_ERROR.getMsg());
}
}
이렇게 JWTUtil
에서 발생한 에러를 Filter에서 다시 한번 잡는다.
2. JWTFilter에서의 에러 처리
현재 JWTFilter
의 모든 처리 로직을 Try-Catch로 감싼 후 에러가 발생한 Catch문에서는 해당 에러의 내용을 request에 담아서 넘긴다. → EntryPoint
를 추가하고 Exception을 처리할 것
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try{
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("authorization: {}", authorization);
if (authorization == null) {
log.info("authorization 없음");
request.setAttribute("exception", ErrorMessage.TOKEN_NOT_EXIST.getMsg());
filterChain.doFilter(request,response);
return;
}
// 토큰 없으면 막음
if (!authorization.startsWith("Bearer ")) {
log.info("authorization 잘못됨.");
request.setAttribute("exception", ErrorMessage.UNSUPPORTED_TOKEN.getMsg());
filterChain.doFilter(request,response);
return;
}
// 토큰 꺼내기
String token = authorization.split(" ")[1];
// Token Expired 여부 확인
if (JWTUtil.validateToken(token, secretKey)&& !request.getRequestURI().equals("/api/auth/reissue")) {
filterChain.doFilter(request,response);
return;
}
String memberId = JWTUtil.getId(token, secretKey);
log.info("memberId: {}", memberId);
if(blackListRepository.existsById(token)){
log.error("로그아웃한 사용자");
request.setAttribute("exception", ErrorMessage.ALREADY_LOGOUT.getMsg());
filterChain.doFilter(request,response);
return;
}
Member member = memberRepository.findById(Long.valueOf(memberId)).orElseThrow(MemberNotFoundException::new);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(memberId, null, List.of(new SimpleGrantedAuthority("USER")));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}catch (JwtException e){
request.setAttribute("exception", e.getMessage());
}
filterChain.doFilter(request,response);
}
}
3. request에 담겨오는 에러에 대한 처리
현재에는 request에 에러를 담는다고 해서 처리를 할 수는 없다. 처리를 하는 Handler 객체가 없기 때문.
이를 처리하기 위해 EntryPoint 객체를 만들고 해당 객체를 ExceptionHandling 객체로 등록할 것.
package org.example.back.config;
import java.io.IOException;
import org.example.back.db.enums.ErrorMessage;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.google.gson.JsonObject;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
String exception = (String)request.getAttribute("exception");
if(exception == null) {
setResponse(response, ErrorMessage.ACCESS_DENIED);
}
//잘못된 타입의 토큰인 경우
else if(exception.equals(ErrorMessage.WRONG_TYPE_TOKEN.getMsg())) {
setResponse(response, ErrorMessage.WRONG_TYPE_TOKEN);
}
else if(exception.equals(ErrorMessage.UNSUPPORTED_TOKEN.getMsg())) {
setResponse(response, ErrorMessage.UNSUPPORTED_TOKEN);
}
//토큰 만료된 경우
else if(exception.equals(ErrorMessage.EXPIRED_TOKEN.getMsg())) {
setResponse(response, ErrorMessage.EXPIRED_TOKEN);
}
else if(exception.equals(ErrorMessage.ALREADY_LOGOUT.getMsg())) {
setResponse(response, ErrorMessage.ALREADY_LOGOUT);
}
else {
setResponse(response, ErrorMessage.TOKEN_NOT_EXIST);
}
}
private void setResponse(HttpServletResponse response, ErrorMessage errorMessage) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
JsonObject responseJson = new JsonObject();
responseJson.addProperty("message", errorMessage.getMsg());
responseJson.addProperty("code", errorMessage.getStatus().toString());
response.getWriter().print(responseJson);
}
}
해당 객체를 통해 Exception 발생 시 확인 후 알맞은 에러를 출력할 수 있다. 물론 만든다고 바로 쓰진 못하기 때문에 Security Filter에 등록해 줘야 함.
4. Secutiry Filter에 EntryPoint 등록
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable) //기본 http 보안 설정 사용 안함
.csrf(AbstractHttpConfigurer::disable) //csrf 사용 안함
.cors(AbstractHttpConfigurer::disable) //cors 정책 비활성화
.authorizeHttpRequests(request->{
request.requestMatchers("/api/auth/**", "/ws/**", "/error","/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
.anyRequest().authenticated();//login, join은 전부 허용
// websocket 허용
})
**.exceptionHandling(exceptionHandling -> exceptionHandling.authenticationEntryPoint(restAuthenticationEntryPoint))**
.sessionManagement(
sessionManagement->sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 stateless -> 세션 안 쓴다는 뜻
)
.addFilterBefore(new JwtFilter(blackListRepository, memberRepository, secretKey), UsernamePasswordAuthenticationFilter.class)
.build();
}
강조된 부분이 새로 추가된 exceptionHanding부분!
해결
이 후 모든 토큰 관련 문제가 401에러와 함께 메시지를 제대로 출력하는 것을 볼 수 있다.!
Leave a comment