들어가며
JWT를 구현하고 있는데, 이번에는 에러 처리가 문제이다!
자꾸 토큰이 유효하지 않은데도 권한 에러인 401
이 아니라 500
NullPointerException
을 던져준다.
원인은 JWT의 플로우를 제대로 이해하지 못하고 있어서였다.
따라서 오늘의 TIL은 이 문제에 대한 트러블슈팅을 하고자 한다.
JWT 401에러 보내기
우선 예외가 발생하는 클래스를 디버깅으로 찾아보았다.
원인은 AuthToken
클래스에서 Claim을 가져오는 메소드가 문제였다.
지금까지는 AuthToken
클래스에서 Claim을 가져올 때 예외가 발생하면 try-catch
문으로 예외처리를 해주고 있었다.
AuthToken.java
public Claims getTokenClaims() {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (SignatureException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (MalformedJwtException e) {
log.info("유효하지 않은 구성의 JWT 토큰입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 형식이나 구성의 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info(e.toString().split(":")[1].trim());
}
return null;
}
public boolean validate() {
return getTokenClaims != null;
}
이 클래스를 타서 catch가 되면 null을 반환해주고 있기 때문에 어떤 에러가 터지든 무조건 NullPointerException
으로 던져지고 있었던 것이다.
내가 원하는 건 TokenAuthenticationFilter
에서 토큰 유효성 검증을 했을 때, 예외 처리를 하고 그에 맞는 에러를 던저주는 것이므로,
FIlter를 타고 권한 인증에 실패하면 RestAuthenticationEntryPoint
클래스를 타도록 해야했다.
그럼 기존의 Filter와 EntryPoint 클래스를 확인해보고 원하는 대로 수정해보자
기존 TokenAuthenticationFilter.java
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private finalAuthTokenProvidertokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequestrequest,
HttpServletResponseresponse,
FilterChainfilterChain) throwsServletException,IOException{
//요청값의 header에서 토큰을 뽑아온다.
StringtokenStr = HeaderUtil.getAccessToken(request);
// String으로 된 token을 AuthToken객체로 변환해준다.
AuthTokentoken = tokenProvider.convertAuthToken(tokenStr);
if (token.validate()) {
//토큰이 유효하다면 인증 객체 생성
Authenticationauthentication = tokenProvider.getAuthentication(token);
// SecurityContextHolder에 인증 객체를 넣는다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request,response);
}
}
기존 RestAuthenticationEntryPoint.java
@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {
authException.printStackTrace();
log.info("Responding with unauthorized error. Message = {}", authException.getMessage());
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED, // 401 에러코드
authException.getLocalizedMessage()
);
}
}
기존 코드를 보면 Filter에서는 validate()
메소드를 통해 에러가 터졌을 때의 값인 null을 검증해준다.
EntryPoint에서는 AuthenticationException
의 경우에만 401 에러를 보내주고 있다.
코드 수정
AuthToken.java
public Claims getTokenClaims() {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validate() {
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return !claimsJws.getBody().isEmpty();
}
먼저 기존의 Claim을 가져오는 getTokenClaims()
메소드와 validate()
메소드를 수정해주었다.
Claims을 가져오는 메소드인 getTokenClaims()
에서 try-catch문을 제거해줌으로써 해당 메소드는 오직 Claim을 가져오는 용도로만 쓰이게끔 수정하였고, 토큰 유효성을 검증하는 메소드인 validate()
에서 Claims의 body가 존재할 때 true를 리턴하도록 변경하였다.
이제 Filter 클래스에서 Token의 유효성을 검사하더라도 NullPointerException
을 던지지 않게 되었다.
TokenAuthenticationFilter.java
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final AuthTokenProvider tokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청값의 header에서 토큰을 뽑아온다.
AuthToken token = tokenProvider.convertAccessToken(request);
try {
if (token != null && token.tokenValidate()) {
Authentication authentication = tokenProvider.getAuthentication(token);
// SecurityContextHolder 에 인증 객체를 넣는다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 에러가 발생했을 때, request에 attribute를 세팅하고 RestAuthenticationEntryPoint로 request를 넘겨준다.
} catch (SignatureException e) {
log.info("잘못된 JWT 서명입니다.");
request.setAttribute("exception", ErrorCode.WRONG_TYPE_SIGNATURE.getCode());
} catch (MalformedJwtException e) {
log.info("유효하지 않은 구성의 JWT 토큰입니다.");
request.setAttribute("exception", ErrorCode.WRONG_TYPE_TOKEN.getCode());
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
request.setAttribute("exception", ErrorCode.EXPIRED_ACCESS_TOKEN.getCode());
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 형식이나 구성의 JWT 토큰입니다.");
request.setAttribute("exception", ErrorCode.WRONG_TYPE_TOKEN.getCode());
} catch (IllegalArgumentException e) {
log.info(e.toString().split(":")[1].trim());
request.setAttribute("exception", ErrorCode.INVALID_ACCESS_TOKEN.getCode());
}
filterChain.doFilter(request, response);
}
}
Filter에서 try-catch를 한 다음 request에 "exception"을 key값으로 Attribute를 담아준다. 그럼 에러가 터졌을 때 EntryPoint에서는 response에서 해당 Attribute를 꺼내올 수 있게 된다.
RestAuthenticationEntryPoint.java
@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
String exception = (String)request.getAttribute("exception");
if(exception == null) {
setResponse(response, ErrorCode.FAILED_MESSAGE);
}
//잘못된 타입의 토큰인 경우
else if(exception.equals(ErrorCode.WRONG_TYPE_TOKEN.getCode())) {
setResponse(response, ErrorCode.WRONG_TYPE_TOKEN);
}
else if(exception.equals(ErrorCode.WRONG_TYPE_SIGNATURE.getCode())) {
setResponse(response, ErrorCode.WRONG_TYPE_SIGNATURE);
}
//토큰 만료된 경우
else if(exception.equals(ErrorCode.EXPIRED_ACCESS_TOKEN.getCode())) {
setResponse(response, ErrorCode.EXPIRED_ACCESS_TOKEN);
}
else {
setResponse(response, ErrorCode.INVALID_ACCESS_TOKEN);
}
}
//한글 출력을 위해 getWriter() 사용
private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
JsonObject responseJson = new JsonObject();
responseJson.addProperty("timestamp", String.valueOf(LocalDateTime.now()));
responseJson.addProperty("status", errorCode.getStatus());
responseJson.addProperty("code", errorCode.getCode());
responseJson.addProperty("error", errorCode.name());
responseJson.addProperty("message", errorCode.getMessage());
response.getWriter().print(responseJson);
}
}
EntryPoint에서는 reqeust에 담겨온 Attribute를 꺼내와서 분기를 나눠준 다음 원하는 형태로 response를 보내주기 위해 setResponse 메소드를 정의해주면 된다.
이제 이 EntryPoint를 Spring security에 적용해주면 원하는 대로 에러 응답이 처리되는 걸 확인할 수 있다.
SecurityConfig.java
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
.authenticationEntryPoint(new RestAuthenticationEntryPoint()) // 요청이 들어올 시, 인증 헤더를 보내지 않는 경우 401 응답 처리
.accessDeniedHandler(tokenAccessDeniedHandler)
...
.antMatchers("/review", "/api/bookmark", "/store/register", "/user").hasAnyAuthority(RoleType.USER.getCode(), RoleType.ADMIN.getCode())
.antMatchers("/admin/**").hasAnyAuthority(RoleType.ADMIN.getCode())
.antMatchers("/**").permitAll() // 그 외 요청은 모두 허용
.anyRequest().authenticated() // 위의 요청 외의 요청은 무조건 권한검사
...
// tokenAuthenticationFilter 가 UsernamePasswordAuthenticationFilter 보다 먼저 실행되도록 하는 메소드
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
마치며
생각보다 구글링을 했더니 바로 원하는 결과가 나와서 쉽게 해결했다.
문제는 아직도 Filter에서 EntryPoint로 어떤 상황일 때 넘어가는 지 플로우를 완전히 이해하지 못했다.
JWT 공부의 길은 멀구나...
출처 및 참고
'Study > TIL' 카테고리의 다른 글
[TIL] 07/25 항해99 78일차 - MySQL 검색기능 : FullText Search (0) | 2022.07.28 |
---|---|
[TIL] 07/19 항해99 72일차 - 트러블 슈팅 : 배포 후, cookie가 삭제되지 않는 문제 (0) | 2022.07.28 |
[TIL] 07/10 항해99 63일차 - postman에서 cookie 사용하기 (1) | 2022.07.25 |
[TIL] 07/07 항해99 60일차 - 트러블 슈팅 : 카카오 로그인에서 이메일이 넘어오지 않는 문제 (0) | 2022.07.25 |
[회고록] 06/25 항해99 48일차 (1) | 2022.06.26 |