쌩로그

AOP로 AccessToken 검증(빡쳐서 만듬) 본문

TroubleShooting & 고민/BE

AOP로 AccessToken 검증(빡쳐서 만듬)

.쌩수. 2023. 7. 5. 13:56
반응형

목록

  1. 포스팅 개요
  2. 본론
  3. 요약

1. 포스팅 개요

AccessToken을 검증하는데 코드상으론 400대 에러를 던지게 해놨지만, Spring Security 설정 때문인지 내부적으로 500대 에러를 내보낸다.

그래서 살짝 빡쳤다...

계속 살펴보고 살펴보다가 생각해낸 것이 AOP였고, AOP를 이용해서 AccessToken에 대한 검증을 하게 되었다.

지금은 잘 나온다.

토큰 만료시("Token is expired")

토큰 일부러 이상하게 준 뒤 결과("Token is INVALID")

2. 본론

2-1. 발생한 문제

현재 JWT 관리를 어떻게 해야할지 고민 중이다.

Redis를 이용해서 AccessToken이 만료될 때 어떻게 해야할지,
로그아웃 처리는 어떻게 해야할지 고민중이었다.

고민을 해결해나가기 위해 먼저 AccessToken부터 검증이 되는지부터 알아보기로 했다.
일단 AccessToken 만료 시간을 1분으로 설정했다.

그리고 서버를 실행시켰다.
당연히 토큰을 발급받고 난 1분동안은 잘 동작한다.

이제 1분이 지났다.

@PatchMapping  
public ResponseEntity update(Principal principal, @RequestBody MemberDto.Patch patch) {  
    Member findMember = memberService.findMember(principal.getName());   // 이메일 정보로 사용자를 찾아온다.

해당 코드에서 예외가 발생했다.
에러를 추적해봤다.

SpringSecurity 필터는 JwtVerificationFilter를 지나간다.

심지어 해당 Filter의 예외도 처리한다.
48번 라인을 지나간다고 하는데,

48번 라인은 해당 메서드의 끝 부분
filterChain.doFilter(request, response);
이 부분이다.

다음은 JwtVerificationFilter클래스의 코드 중 일부다.

@Override  
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {  
    try {  
        Map<String, Object> claims = verifyJws(request);  
        setAuthenticationToContext(claims);  

        // try-catch 후 일반적으로 예외를 던지지 않음.  
            // 인증정보가 Spring Security에 저장되지 않고,  
            // Filter 내부에서 AuthenticationException이 발생하게 되고,  
            // AuthenticationEntryPoint가 처리하게 됨.  
    } catch (SignatureException se) {       // JWT의 서명이 올바르게 생성되지 않았거나 서명이 JWT 데이터와 일치하지 않는 경우 발생.  
        request.setAttribute("exception", se);  
    } catch (ExpiredJwtException ee) {      // 만료로 인해 발생하는 예외  
        request.setAttribute("exception", ee);  
    } catch (Exception e) {  
        request.setAttribute("exception", e.getMessage());  
    }  

    filterChain.doFilter(request, response);  
}

잠시 TMI지만, setAttribute("exception", se혹은ee혹은e.getMessage()); 이 부분에서
"매개변수를 다 "exception"으로 주는 것이 이유일까?"
라고 생각해서 문자열 부분도 다 바꿔보았다.

이 예외들이 결국 MemberAuthenticationEntryPoint 클래스에서 예외마다 적절히 처리된다고 하는데,
request.setAttribute("exception") 의 매개변수로 문자열을 이렇게 중복으로 준다고해도,
다른 예외 클래스를 지정해주기 때문에 "exception"으로 줘도 상관없다.

MemberAuthenticationEntryPoint 클래스는 다음과 같다.

@Component  
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {  

    // Exception 발생으로 인해 SecurityContext에 Authentication이 저장되지 않을 경우  
    // AuthenticationException이 발생할 때 호출되는 핸들러  

    // AuthenticationException(인증 예외)가 발생할 경우 호출, 처리하고자 하는 로직을 commence() 메서드로 구현하면 됨.  
    @Override  
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {  
        Exception exception = (Exception) request.getAttribute("exception");  

        ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED);  
        logExceptionMessage(authException, exception);  
    }  

    private void logExceptionMessage(AuthenticationException authException, Exception exception) {  
        String message = exception != null ? exception.getMessage() :  authException.getMessage();  
    }  
}

이처럼 여튼간 400번대로 처리되도록 코드가 작성되어있지만,
500번대 코드가 나오는 것이 문제였다.

2-2. 문제 해결 방향

SpringSecurity Filter 구조로 결국 AuthenticationEntryPoint 클래스로 도달한다고 하는데, 도달하지 못 해서 500대 Error를 발생시키는 것 같다.

그래서 생각해낸 것이 Controller 클래스의 메서드 중에 매개변수로 인증 정보가 담겨있는 Principal객체매개변수로 있는 메서드마다 AOP를 적용해서 AccessToken을 검증하기로 생각했다.

2-3. 문제 해결 과정

AOP를 적용할 Token검증클래스 TokenValidationAspect를 만들었다.

@Aspect             // 횡단관심사 적용.  
@Slf4j              // 로그   
@Component  
public class TokenValidationAspect {    // Token 검증 AOP    public final JwtTokenizer jwtTokenizer;  

    public TokenValidationAspect(JwtTokenizer jwtTokenizer) {  
        this.jwtTokenizer = jwtTokenizer;  
    }  

    // 한 개 이상의 매개변수를 가지되, 첫번째 매개변수의 타입이 Principal인 메서드만 선택  
    @Pointcut("execution(* wanderhub.server..*Controller.*(java.security.Principal, ..))")  
    public void controller() {}  


    // Controller  
    @Before("controller()")  
    public void accessTokenValidation() {  
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())  
                .getRequest();  
        String jws = request.getHeader("Authorization").replace("Bearer ", ""); //  request의 header에서 JWT를 얻음. // jws는 서명된 JWT를 의미함.  
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());  
        // 얻어온 토큰을 검증  
        try {  
            jwtTokenizer.verifySignature(jws, base64EncodedSecretKey);  

        } catch (SignatureException se) {       // JWT의 서명이 올바르게 생성되지 않았거나 서명이 JWT 데이터와 일치하지 않는 경우 발생.  
            throw new CustomLogicException(ExceptionCode.TOKEN_INVALID);  
        } catch (ExpiredJwtException ee) {      // 만료로 인해 발생하는 예외  
            throw new CustomLogicException(ExceptionCode.TOKEN_EXPIRED);  
        }  
    }  

}

해당 코드에 대한 로직은 다음과 같다.
// HttpServletRequest 객체를 얻어온다.
// 해당 요청의 Header에서 "Authorization"에 해당하는 Header를 얻어온다. 그리고 "Bearer "부분을 없애준다.
// JwtTokenizer를 통해서 secretKey를 얻어오고, secretKey와 "Bearer "을 없앤 JWT를 검증한다.
// try-catch 문을 이용해서 예외가 발생하면 각 케이스마다 적절히 처리되도록 한다.

그래서 지금은 개요에서 보여준 것처럼 내가 생각한대로 잘 나온다.

토큰 만료시("Token is expired")

토큰 일부러 이상하게 준 뒤 결과("Token is INVALID")

더 이상 400번대 오류를 500번대로 발생시키지 않아도 된다는 안도감이 와서 기분이 좋다.

3. 요약

JWT 관리 고민 중,
AccessToken 검증을 확인 중,
예외 발생시 400번 코드를 보내야함에도 불구하고, 500번 코드로 응답하는 문제를 AOP를 이용해서 해결했다.

AccessToken은 이렇게 검증이 되니 이후 내가 할 일은
refreshToken을 어떻게 관리할지 고민을 해볼 차례다.

-끝-

728x90
Comments