Search
moon
sun
🔫

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 인 상태이다
⇒ 여기서 문제가 되는 부분은 extractToken 과 tokenValidator 부분이었다.

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” 였다.