[트러블슈팅] JWT 만료 예외처리

White 와잇·2024년 7월 25일

트러블슈팅 개요

postman에서 사용자 로그인 기능 테스트를 하던 도중, 만료 기한이 지난 액세스 토큰을 보내면 의도치 않은 response를 받았다.

시큐리티 jwt 인가처리하는 필터를 정의할 때, 만료된 토큰에 대하여 리프레시 토큰을 검증하고 확인되면 액세스 토큰을 재발급하는 코드를 작성했는데, 작동하지 않았다.

ExpiredJwtException 예외가 발생하고있다.

JWT 만료 예외처리 수정 과정

JWT 유효 검증

JWT 인가 처리 필터에서 유효하지 않은 액세스 토큰을 검증을 먼저 하고,

유효하다면 만료된 토큰을 검증하는 코드를 썼다.

액세스 토큰 검증 메서드 isTokenValid()는
Jwts.parserBuilder().setSigningKey(JwtConfig.key).build().parseClaimsJws(token); 코드에서 예외 발생으로 확인한다.
여기서 만료 예외인 ExpiredJwtException이 발생하여 잡지 못한채로 필터를 빠져나가는 것을 찾았다.

그래서 위와 같이 ExpiredJwtException을 잡아 계속 진행할 수 있도록 바꿔주었다.

JWT 만료 검증

isTokenExpired() 에서 검증을 위해 Jwts.parserBuilder().setSigningKey(JwtConfig.key).build().parseClaimsJws(token); 을 거쳐 다시 한 번 파싱하는데, 같은 코드를 반복하기에 줄여볼 수 없을까 생각을 해봤다.

예외발생하지 않고 토큰으로부터 만료기한만 꺼낼 수 없을까 찾아봤는데

Jwts.parserBuilder().setSigningKey(JwtConfig.key).build().parseClaimsJws(token);
결국 위의 파싱과정을 거치기 때문에 같은 결과일 것이 뻔하다..

다른 방법?

  1. JWTs 패키지를 사용하지 않고 직접 토큰 스트링을 BASE64로 디코딩하여 파싱하는 방법이 있다..

    1) BASE64 로 디코딩
    2) JSON 데이터를 Object Mapper를 사용하여 파싱
    3) 파싱데이터에 claim 안에 "exp" 를 검색

  2. JwtUtil 클래스에 변수를 두어 저장값을 사용한다.

    이 방법은 static 클래스로 정의된 JwtUtil 으로서는 전역적으로 사용되는 변수를 두는 것은 좋지 않다고 생각한다.
    static 구조가 아닐 때 사용하는 것이 좋을 것 같다.

  3. isTokenValid() 메서드가 만료에 대한 값을 넘겨준다.

    값을 어떤 형태로 넘겨줄지 선택해야 하는 문제가 생긴다.
    enum type, 상수 리터럴..
    파싱 중복코드를 피하는 방법 중에서는 가장 나은 선택으로 보인다.

  4. JwtUtil 클래스에 재발급 메서드를 생성한다.


결국 파싱하는 것은 똑같고, 후자의 방법을 선택해도 내부적으로 같은 과정을 거치기 때문에
1번은 의미가 없다고 생각한다.

2번은 구조를 바꿔야해서 더 많은 작업이 들어갈 것 같다.

3번은 기능상 가장 낫지만 다른 분들에게 충분히 설명되어야 한다. 상수 리터럴로 넘겨주면 간단하지만 명시적이지 않을 수가 있다.

고심 끝에 4번을 실행하기로 했다. 유틸 클래스에 대한 고민을 했는데 유틸의 기능을 해치지 않는다고 판단했고, 서블릿 요청 객체와 응답 객체를 넘겨주면 액세스 토큰과 리프레시 토큰을 검증하고 만료시 새 토큰을 발급해주는 메서드를 작성했다.


  // 액세스토큰 확인 후 만료시 리프레시 토큰을 확인하고 새 토큰 발급
    public static String checkTokens(HttpServletRequest req, HttpServletResponse res) {

        String accessToken = getAccessTokenFromRequest(req);

        if (accessToken != null) {
            // prefix 제거
            accessToken = substringToken(accessToken);

            // JWT 위변조, 만료 검증
            try {
                Jwts.parserBuilder().setSigningKey(JwtConfig.key).build()
                    .parseClaimsJws(accessToken);
                log.debug("토큰 검증 완료: {}", accessToken);

            } catch (SecurityException | MalformedJwtException | IllegalArgumentException |
                     UnsupportedJwtException e) {
                log.error("유효하지 않은 토큰입니다.");
                return null;

            } catch (ExpiredJwtException e) {
                log.error("만료된 액세스 토큰입니다. 액세스 토큰을 재발급합니다.");

                // 리프레시 토큰 검증
                String refreshToken = getRefreshTokenFromRequest(req);
                if (refreshToken != null && isTokenValid(refreshToken)) {

                    String email = getEmailFromToken(accessToken);
                    String role = getAuthorityFromToken(accessToken);

                    // JWT 재발급
                    String newAccessToken = createAccessToken(email, role);
                    addJwtToCookie(newAccessToken, res);

                    accessToken = JwtUtil.substringToken(newAccessToken);
                } else {

                    // 리프레시 토큰이 만료되거나 존재하지 않습니다.
                    // 액세스 토큰 재발급 불가
                    // 재로그인 요청

                    // 액세스 토큰, 리프레시 토큰 쿠키 삭제
                    deleteAccessTokenFromCookie(res);
                    deleteRefreshTokenFromCookie(res);
                    return null;
                }
            }
        }
        return accessToken;
    }

이 방법의 장점은 시큐리티 Jwt 인가 필터가 가벼워졌다는 것이다. 또한 같은 jwt 토큰 파싱을 중복적으로 실행하지 않는다.

이렇게 끝날 줄 알았으나... 토큰이 만료되면 정보를 꺼내올 수 없다는 원초적인 문제로 다른 부분에서 결국 새로운 토큰으로 승계해줄 때 문제가 생겼다.


결론

내가 구현하려 했던 것은 인가 과정에서

액세스 토큰이 만료됨을 감지 -> 리프레시 토큰의 유효성을 검증 -> 만료된 액세스 토큰의 정보를 그대로 새로운 액세스 토큰으로 이관 -> 이어서 사용자의 요청 처리

이었다.

설계 당시 리프레시 토큰에는 권한(authority)정보를 넣지 않고 액세스 토큰에만 넣어 관리하는 것이 기능상 맞다고 생각해서 넣지 않았다.

위 구현은 액세스 토큰이 만료되었을 때, 액세스 토큰의 role을 지정해주려면 액세스 토큰의 authority 정보를 찾아 새로운 액세스 토큰으로 인계해주어야 한다.

하지만 문제는 JWT 토큰을 파싱할 때, 다음과 같은 코드를 사용하며
Jwts.parserBuilder().setSigningKey(JwtConfig.key).build().parseClaimsJws(accessToken);
이미 토큰이 만료된 상태이면 예외처리로 넘어간다.

그래서 이미 만료된 토큰은 저 방법으로 권한(authority)정보를 꺼내올 방법이 없다.

결론적으로 권한 정보를 리프레시 토큰이 알고 있지 않는 이상 힘들 것 같아 보였으므로,
리프레시 토큰도 사용자 권한 정보(authority)를 갖게 변경하였다.

그리고, 액세스 토큰 만료를 감지하면 서버 내부 처리로 바로 토큰을 재생성하는 것이 아니라
사용자에게 만료됨을 알려주고 새 액세스 토큰을 발급할 수 있도록 API를 다시 요청하도록 변경하였다.
기능을 분리함으로서 유지보수와 추가 서비스 연결에 더 적합할 것이라 생각했다.

profile
웹개발 도전! 데브옵스 도전!

0개의 댓글