Home Refresh 토큰을 이용한 Access 토큰 갱신에서의 문제
Post
Cancel

Refresh 토큰을 이용한 Access 토큰 갱신에서의 문제

Refresh 로직을 구현하고 프론트에서까지 구현한 후 테스트를 제대로 하지 않고 다른 기능을 구현하고 있었다.

그런데 Access 토큰이 만료되고, 401 에러가 나타나면 인터셉터를 거쳐 구현해놓은 Refresh 로직을 통해 권한을 유지해야 하는데 이 부분에서 문제를 맞닥뜨렸다.

그 문제와 문제 해결 과정에 대해 서술해보려 한다.

우선 결론부터 말하면 내가 Vue와 JWT 토큰 방식에 대해 깊이 이해하지 못하고 있어서 발생한 문제였다.

문제

우선 다른 기능들도 있지만 로그아웃으로 예시를 든다.

로그아웃은 당연히 로그인이 된 상태에서 이루어져야 한다. 따라서 권한이 없다면 로그아웃에 대한 요청은 에러가 발생한다.

  • 로그인이 된 상태에서 Access 토큰의 만료 시간이 지나고 로그아웃 버튼을 누르면 다음과 같은 에러가 발생한다.
    • JWT expired 26967 milliseconds ago ~

즉 JWT 토큰이 만료되어 어느 부분에 접근하지 못해 발생하는 문제이다.

문제 해결 과정

과정 1.

  • 응답 로그를 보면 403 Forbidden 에러가 발생하는 것을 볼 수 있었다.
    • 인터셉터에서는 401을 처리해야 하는데?
    • 401이 아니기 때문에 refresh 자체가 호출이 안되는데?

이러한 생각을 바탕으로 403을 던지는 부분에 집중했다.

아래와 같은 생각의 흐름으로 해결하려 했다.

  • JwtFilter에서 토큰이 만료되었을 때 401을 던지도록 되어 있었다.
  • 그런데 403 에러가 발생하는 것을 보니 JwtFilter의 응답을 가로채고 어떤 부분에서 최종적으로 403 에러를 던진다고 가정했다.
    • 따라서 인터셉터를 우선 403 에러를 처리하도록 수정해보았다.

결과

인터셉터를 403 에러를 처리하도록 수정했을 때, 인터셉터가 정상적으로 작동했다.

하지만 /api/auth/refresh 엔드포인트에서 또 다른 403 error가 발생했다.

과정 2.

JwtFilter에서 Jwt토큰의 만료를 확인한 후 doFilter를 통해 요청 처리를 지속하도록 했었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
String authorization = request.getHeader("Authorization");  
  
if (authorization == null || !authorization.startsWith("Bearer ")) {  
    filterChain.doFilter(request, response);  
    return;  
}  
  
String token = authorization.split("  ")[1];  
  
if (jwtUtil.isExpired(token)) {  
    filterChain.doFilter(request, response); 
    return;  
}

그렇다면 다음 필터인 LoginFilter로 넘어가 LoginFilter의 unsuccessfulAuthentication() 메서드가 호출되어 401 에러를 반환시켜야 하는 것 아닌가? 라는 생각을 했었다.

  • unsuccessfulAuthentication()
    • 하지만 이 메서드는 인증 시도가 실패했을 때 호출되는 메서드이기 때문에 토큰 만료 감지에 의해 호출되지 않는다.

따라서 만료를 감지했을 때, 401 에러를 날리도록 설정해보도록 했다.

1
2
3
4
if (jwtUtil.isExpired(token)) {  
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  
    return;  
}

결과

여전히 토큰이 만료되면 403 에러를 발생시켰다.

과정 3.

우선 토큰이 만료되었을 경우 401 에러를 처리해 토큰을 갱신하는 것이 더 올바르다고 판단되어 403 대신 401을 날릴 수 있도록 설정해주었다.

1
2
3
4
5
6
7
8
9
@Component  
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {  
  
    @Override  
    public void commence(HttpServletRequest request, HttpServletResponse response,  
                         AuthenticationException authException) throws IOException, ServletException {  
	    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized - Authentication token이 유효하지 않습니다.");  
    }  
}

구현한 CustomAuthenticationEntryPoint를 SecurityConfig에 설정한다.

1
2
3
4
//인증 실패 시 403 대신 401 응답 반환하도록  
http  
        .exceptionHandling(exceptionHandling ->  
                exceptionHandling.authenticationEntryPoint(customAuthenticationEntryPoint));

이 과정을 통해 인터셉터는 401 에러를 처리하도록 하였고, 의도한대로 로그아웃 등의 요청에서 권한이 없을 때 403 에러 대신 401 에러를 반환하게 되었다.

하지만 여전히 refresh 과정에서 에러는 발생했다.

과정 4.

결국 기존에 만료를 감지했을 때 에러를 던지는 것은 Filter의 잘못된 구현에서 발생된 일이 아니라는 것을 알았다. 필터는 정상적으로 요청을 우회, 처리했다.

디버깅을 해봐도 필터 부분에서 에러를 발생시키지는 않았다.

이후 SpringSecurity 로깅을 활성화하고 다시 테스트했다.

인터셉터가 동작하고 refresh 요청이 컨트롤러까지 도달하는 것을 볼 수 있었다.

즉 컨트롤러 내부에서 refresh 로직이 실행되는 도중에 예외가 발생하는 것이었다.

최종적으로 아래의 부분에서 에러가 발생하는 것을 볼 수 있었다.

1
jwtUtil.isExpired(cachedAccessToken);

캐싱된 Access 토큰이 만료되지 않았다면 캐싱된 토큰을 사용하도록 한 캐싱 로직에 사용된 부분이 문제였다.

1
2
3
4
5
public Boolean isExpired(String token) {  
    return Jwts.parser().verifyWith(secretKey).build()  
	    .parseSignedClaims(token).getPayload()  
	    .getExpiration().before(new Date());  
}

알고 보니 단순히 만료에 따른 true, false만 반환하는 것이 아니었다.

  • Payload를 가져오는 과정에서 토큰의 만료가 확인되면 ExpiredJwtException을 발생시킨다.
  • 이렇게 토큰 구문을 분석하는 과정에서 아예 예외가 발생되어 자꾸 에러가 발생했던 것이다.

따라서 로직을 아래와 같이 수정했다.

1
2
3
4
5
6
7
8
9
10
11
boolean cachedTokenExpired = false;  
  
try {  
    cachedTokenExpired = jwtUtil.isExpired(cachedAccessToken);  
} catch (ExpiredJwtException e) {  
    cachedTokenExpired = true;  
}  
  
if (cachedAccessToken == null || cachedTokenExpired) {
	//...
}

에러에 관해 예외처리를 해 캐싱로직은 그대로 두고, refresh 로직이 정상적으로 수행될 수 있도록 하였다.

결과

refresh 로직이 정상적으로 수행되는 것을 확인했다.

응답 헤더에 정상적으로 새 Access 토큰이 발급되는 것을 볼 수 있었다.

또 다른 문제

응답 헤더에 새 토큰이 발급된 것을 보았는데, 인터셉터의 로직에 의하면 refresh 이후 자동으로 실패했던 이전 요청을 다시 수행해야 한다.

하지만 실패했던 이전 요청이 실패하는 것을 볼 수 있었다.

요청 헤더를 보니 헤더에 갱신된 Access 토큰이 제대로 들어가지 않아서 발생한 문제였다.

1
2
const response = await axios.post('/api/auth/refresh', { withCredentials: true });  
const {accessToken} = response.data;

이렇게 accessToken을 담도록 설정했는데, accessToken에는 Bearer undefined 값이 들어가있었다.

  • 알고보니 response.data는 응답의 body를 가져오는 것이었다.
  • 통상적으로 response.data를 통해 토큰을 가져오기 때문에 갱신된 Access 토큰을 바디에 담아주기로 하였다.

결과

결과는 정상적으로 동작했다.

이 부분은 내가 프론트엔드 서버를 다루는데 지식이 부족하고 익숙하지 않아서 발생한 사소한 문제였다.

정리

문제는 어떻게 보면 사소했지만, 이번 문제를 해결하는 과정을 통해 어떻게 문제에 접근하고, 해결하는 지에 대한 좋은 경험이 되었다.

그리고 어떤 부분에서 에러가 발생했는지 바로 파악하지 못하는 과정에서 Filter의 동작 순서, Jwt 토큰에 대한 부분을 더 깊게 찾아볼 수 있는 기회가 되었다.

This post is licensed under CC BY 4.0 by the author.

JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민(2) - 프론트 서버

mbti 지연로딩 문제, 변경감지 with setter