이슈
•
로그인이 필요한 API 엔드포인트에, 로그인 없이 요청을 보내면, 401 에러가 아닌 500 에러를 뱉는다.
문제
•
권한이 없다는 Unauthorized (401) 에러를 내야하지만,
Internal Server Error (500) 가 나는 상황
⇒ 500 에러는 어떠한 상황이든 래핑되지 않는 상태로 나가는게 좋지 않다.
재현방법
•
브라우저
◦
cabi 로그인 후 개발자 콘솔 → Application → Cookies → CABI_URL →access token 삭제한 후 요청을 보낸다.
•
POSTMAN , CURL 등 직접요청
◦
유저 권한이 필요한 엔드포인트에 아무런 헤더, 쿠키 없이 요청을 보낸다.
◦
ex ) 새롬관 2층 사물함 조회
http://api-dev.cabi.42seoul.io/v4/cabinets/buildings/새롬관/floors/2
추정되는 원인
•
로그인이 필요한 부분에서 에러가난다.
•
곳곳에 심어놓은 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” 였다.