Search
Duplicate
🔫

JWT Token 파싱에러 feat. Log Intercepter

분야
BE
ISSUE
주제
BE
심각도
중간😵
제보자
담당자
작성자
상태
처리 중
이슈링크(optional)
작성일자
2024/01/15 07:08
공개여부
비공개
글감
Java
Spring
회고
노트

이슈

로그인이 필요한 API 엔드포인트에, 로그인 없이 요청을 보내면, 401 에러가 아닌 500 에러를 뱉는다.

문제

권한이 없다는 Unauthorized (401) 에러를 내야하지만, Internal Server Error (500) 가 나는 상황 ⇒ 500 에러는 어떠한 상황이든 래핑되지 않는 상태로 나가는게 좋지 않다.

재현방법

브라우저
cabi 로그인 후 개발자 콘솔 → Application → Cookies → CABI_URL →access token 삭제한 후 요청을 보낸다.
POSTMAN , CURL 등 직접요청
유저 권한이 필요한 엔드포인트에 아무런 헤더, 쿠키 없이 요청을 보낸다.

추정되는 원인

로그인이 필요한 부분에서 에러가난다.
곳곳에 심어놓은 debug, info 등등의 Log4j2의 로그가 출력되지 않는다. ⇒ Controller에 들어오기전 AOP 로직 관련으로 추측했다.
@AuthGuard, @UserSession 등등 유저 인증/인가와 관련된 AOP 부분을 확인해보자.

과정

AOP

위 getCabinetPerSection 메소드 처럼 @AuthGuard 어노테이션이 달린 메소드들은 메소드에 진입하기 전 AuthAspect 클래스의 로직을 먼저 거치게 된다. 자세한 내용은 ..더보기 .. Spring Security 안쓰고 AOP로 OAuth 구현하기 - 1 Spring Security 안쓰고 AOP로 OAuth 구현하기 - 2
를 통해 알 수 있다.

@AuthGuard - AuthAspect

로직을 한번 확인해보자
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface AuthGuard { AuthLevel level() default AuthLevel.USER_ONLY; /** * 해당 어노테이션의 Level을 이용해서, 필요한 인증의 유무를 명시합니다. USER : 일반 유저 ADMIN : 일반 관리자 MASTER : 최고 관리자 */ } public enum AuthLevel { USER_ONLY, ADMIN_ONLY, USER_OR_ADMIN, MASTER_ONLY, }
Java
복사
AuthGuard 어노테이션에는 enum AuthLevel 을 명시하고 있으며, 어노테이션이 명시되어있는, 메소드에 들어가기전 에 아래 AuthToken 로직을 먼저 타게된다.

AuthGuard → AuthToken

전체 코드

AuthToken 핵심

@Before 어노테이션으로 인해, @AuthGuard 가 붙은 메소드들은, 아래 AuthToken 메서드가 먼저 실행되도록 해준다
@Before("@annotation(authGuard))") public void AuthToken(AuthGuard authGuard) throws JsonProcessingException { ... HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) .getRequest(); String token = extractToken(request); private String extractToken(HttpServletRequest request) { String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (header == null || !header.startsWith(BEARER)) { return null; } return header.substring(BEARER.length()); } switch (authGuard.level()) { case ADMIN_ONLY: ... case USER_ONLY: if (!tokenValidator.isValidTokenWithLevel(token, USER_ONLY)) { cookieManager.deleteCookie(response, mainTokenName); throw ExceptionStatus.UNAUTHORIZED_USER.asServiceException(); } break; case USER_OR_ADMIN: ... case MASTER_ONLY: ... 다 같은 구조여서 생략되었다 ... }
Java
복사
HttpServletRequest → Http 요청에서 request 를 꺼내온다.
extractToken(request): Authorization 헤더 중 Bearer 토큰을 꺼내온다.
하지만 토큰이 없다면 token = null 인 상태이다
⇒ 여기서 문제가 되는 부분은 extractTokentokenValidator 부분이었다.

extractToken

토큰이 없다는 가정하에 진행해 보자.
private String extractToken(HttpServletRequest request) { String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (header == null || !header.startsWith(BEARER)) { return null; } return header.substring(BEARER.length()); }
Java
복사
Header 에서 Bearer 를 가져오는데, 토큰이 없을경우 null 을 반환한다.
case USER_ONLY: if (!tokenValidator.isValidTokenWithLevel(token, USER_ONLY)) { cookieManager.deleteCookie(response, mainTokenName); throw ExceptionStatus.UNAUTHORIZED_USER.asServiceException(); }
Java
복사
밑에부분 로직에서 tokenValidator.isValidTokenWithLevel( null, USER_ONLY ) 가 전달된다.

TokenValidator

public class TokenValidator { ...생략... public JsonNode getPayloadJson(final String token) throws JsonProcessingException { ObjectMapper objectMapper = new ObjectMapper(); final String payloadJWT = token.split("\\.")[1]; Base64.Decoder decoder = Base64.getUrlDecoder(); return objectMapper.readTree(new String(decoder.decode(payloadJWT))); } public boolean isValidTokenWithLevel(String token, AuthLevel authLevel) throws JsonProcessingException { String email = getPayloadJson(token).get("email").asText(); if (email == null) { throw ExceptionStatus.INVALID_ARGUMENT.asServiceException(); } if (!isTokenValid(token, jwtProperties.getSigningKey())) return false; switch (authLevel) { case USER_OR_ADMIN: return isUser(email) || isAdmin(email); case USER_ONLY: return isUser(email); case ADMIN_ONLY: return isAdmin(email); case MASTER_ONLY: return isMaster(email); default: throw ExceptionStatus.INVALID_STATUS.asServiceException(); } }
Java
복사
TokenValidator 는 전달된 토큰이 유효한지 검증하는 클래스이다.
아래 내용을 이해하기 위해서는 JWT 토큰에 대해 모른다면, 구조를 간단하게 보자
JWT 토큰의 구조
문제 지점
token.split("\\.")[1];
여기서 JWT 의 형태는 3개의 점으로 구분되어있고, email 을 찾기위해 . 으로 구분된 jwt 토큰값 중 2번째(payload)를 가져온다.
그러나, token 엔 null 값이 들어가 있어서 NullPointerException 이 터진다.

1 차 해결 시도

public JsonNode getPayloadJson(final String token) throws JsonProcessingException { if (token == null || token.isEmpty()) { throw ExceptionStatus.UNAUTHORIZED.asServiceException(); } ObjectMapper objectMapper = new ObjectMapper(); final String payloadJWT = token.split("\\.")[1]; Base64.Decoder decoder = Base64.getUrlDecoder(); return objectMapper.readTree(new String(decoder.decode(payloadJWT))); }
Java
복사
token 이 null 이거나, 빈값이 올 경우 throw 한다.
해결되지 않았다.

두가지 문제

TokenValidator - extractToken

TokenValidator의 extractToken 에서, header 가 null 값이 아닌, undefined 로 들어간다.
예상했던대로 null 로 진행되는게 아니라 “undefined” 라는 값을 가진 채 로직을 탄다.
따라서 여기서 JWT 토큰이 없을때, 반환되는건 , “Bearer undefined” 의 substring 된 “undefined” 문자열이 반환된다.
그리고 뒤에 다시 split.(”.”) 까지 진행되여 여기서 NullPointerException 이 터진다.

2 차 시도

extractToken 메서드 에서 직접적으로 thorw 해도 되지만 리턴값이 String 이고, validate 하는쪽에서 하는게 가독성이 더 나을것 같아서 TokenValidator 에서 처리 하기로 하고 넘어갔다. ( if 문을 많이 달게 되어 가독성이 안좋아 진다고 생각했다.)
public JsonNode getPayloadJson(final String token) throws JsonProcessingException { if (token == null || token.isEmpty() || token.equals(UNDEFINED)) { throw ExceptionStatus.UNAUTHORIZED.asControllerException(); } final String payloadJWT = token.split("\\.")[1]; Base64.Decoder decoder = Base64.getUrlDecoder(); return objectMapper.readTree(new String(decoder.decode(payloadJWT))); }
Java
복사
따라서 TokenValidator 의 getPayloadJson 메소드를 위와같이 수정했다.
“undefined” 의 경우도 걸러주도록 수정

Log Intercepter

디버그로 따라가보니, getPayloadJson 에서 토큰 없음으로 throw 되는 곳이 한군데 더있었다. ⇒ AllRequestLogIntercepter 라는 클래스에서 위에서 throw 한 UNAUTHORIZED Exception을 먹어버리고 있었다.
뭐하는 로직인고?
모든 요청을 인터셉트하여, 로그를 찍을때 유저가 있다면 유저에 대한 닉네임을, 없다면 UUID 를 찍어 반환하도록 만든 메소드로 확인되었다.
확인할 수 없는 경우
로그인한 유저
따라서 여기서 Throw 를 해버리면, 로그를 찍을 문자열을 만드는 로직이 제대로 동작하지 않는다.
로그를 찍는데서는 throw 가 발생하면 안되는 상태로 두어야 한다.
따라서 Exception 을 무시하도록 처리해 놓은 것으로 보인다.

catch (Exception ) 일단 바꿔

이펙티브 자바를 본 사람으로서 광범위한 Exception 을 그냥 둘 수 없었다.
위 글에서 명확하게 정의하진 않았지만 catch (Exception e) 로 사용하는건, 강타입 자바에서 Object 타입을 선언해서 쓰는것과 다르지 않다는 느낌을 받았다.
private String getUserId(HttpServletRequest request) { JsonNode payloadJson = null; try { payloadJson = tokenValidator.getPayloadJson( cookieManager.getCookieValue(request, jwtProperties.getMainTokenName())); } catch (JsonProcessingException e) { log.error("Failed to parse payloadJson", e); throw ExceptionStatus.JSON_PROCESSING_EXCEPTION.asControllerException(); }
Java
복사
일단 Exception 을 받고, 무시하는게 이상하다는 생각이 들었고, getPayloadJson 을 사용하면, 필요한 JsonProcessingException 만 잡도록 수정하였다.
그랬더니, 로그인이 불가능해 졌다.
예외를 무시해야 하는데, getPayloadJson 에서 throw 한 Unauthorized 가 로그인할때 발생한다.
⇒ 로그인 할때는, 토큰이 없지만 일단 확인할때 getPayloadJson 로직을 탄다.
⇒ 따라서 getPayloadJson 에서 Throw 해버리면, 로그만드는 과정에서 터져버린다.

찐막트

getPayloadJson 수정

public JsonNode getPayloadJson(final String token) throws JsonProcessingException { if (token == null || token.isEmpty() || token.equals(UNDEFINED)) { return null; } final String payloadJWT = token.split("\\.")[1]; Base64.Decoder decoder = Base64.getUrlDecoder(); return objectMapper.readTree(new String(decoder.decode(payloadJWT))); }
Java
복사
thorw → return null 로 수정
before
private String getUserId(HttpServletRequest request) { JsonNode payloadJson = null; try { payloadJson = tokenValidator.getPayloadJson( cookieManager.getCookieValue(request, jwtProperties.getMainTokenName())); } catch (JsonProcessingException e) { log.error("Failed to parse payloadJson", e); throw ExceptionStatus.JSON_PROCESSING_EXCEPTION.asControllerException(); } if (payloadJson == null || payloadJson.get("name").isEmpty()) { String uuid = UUID.randomUUID().toString(); return uuid.substring(uuid.length() - 12); } return payloadJson .get("name") .asText(); }
Java
복사
Exception → JsonProcessingException
payload 가 getPayloadJson 메소드로 부터 리턴받은 값이 null 일 경우, uuid를 만들어서 리턴
정상일경우 name 을 리턴하도록 수정 했다.
⇒ 서순을 좀 바꾸고, null 일 가능성 체크를 조금 더 꼼꼼히 했다.

정리

원인

토큰 파싱하는 로직이 조금 잘못되어 있었다.
로그 인터셉터 로직도, 토큰을 파싱하는 로직에서 가져와서 단순 throw 로는 해결이 안됬다.
로그 인터셉터 로직 수정
토큰 값이 없을땐 null 이 아니라 “undefined” 였다.