JWT 폐기

이호영·2023년 9월 4일
0

JWT

목록 보기
5/5

들어가기 전...

앞선 포스트에서 우리는 JWT 생성, 검증을 확인하였습니다.
하지만, 사용자가 토큰을 탈취 당한다면 공격자는 사용자의 토큰을 사용하여 정상적인 사용자인척 서비스에 접근 할 수 있습니다. 이를 막기위한 방법을 알아봅시다.

토큰의 유효시간을 짧게

토큰의 시간을 짧게 만들어 주기적으로 서버는 사용자에게 토큰 갱신(ex: 로그인)등을 요구 할 수 있습니다. 하지만 토큰의 유효시간이 짧다면 사용자가 피곤할 수 있습니다.

토큰의 유효시간을 짧게 하려면?

토큰을 생성할 때 우리는 시간을 함께 넣었습니다.
토큰 생성시 JWTProperties 인터페이스를 사용하여 이전 포스트에서 10일간 유효하도록 토큰을 발급하였습니다. JWTProperties의 EXPIRATION_TIME을 짧게 가져가 토큰의 만료시간을 조절 할 수 있습니다.

Refresh Token 사용

토큰의 유효시간을 짧게 가져간다면 서버의 안전성은 증가하지만 사용자 경험이 감소합니다.
이를 해결 하기 위해 Refresh토큰을 사용합니다.

Refresh Token?

클라이언트가 서버측 리소스에 접근할 때 클라이언트 본인을 인증할 수 있는 토큰으로 동작한다. 그런데 이 JWT는 Stateless한 방식이기 때문에 서버측에서는 이 토큰을 갖고 있는 클라이언트가 정말 클라이언트 본인이 맞는지 확인할 수 없다는 문제점이 있다.

그래서 이에 대한 보안 대책으로 리프레쉬 토큰이라는 추가적인 토큰을 활용할 수 있다. 이 리프레쉬 토큰은 사용자 인증이 아닌 새로운 토큰을 생성하는 용도로만 사용된다. 그러면 왜 굳이 별도의 토큰을 두고 새로운 액세스 토큰(JWT)을 발급받도록 할까?
왜냐하면 토큰이 탈취 될 가능성이 있기 때문이다.

  1. 로그인시 cess Token과 Refresh Token을 둘 다 생성한다.
  2. 토큰의 유효 기간을 짧게 설정한다.
  3. Refresh Token의 유효 기간은 길게 설정한다.
  4. 사용자는 Access Token과 Refresh Token을 둘 다 서버에 전송하여 Access Token 인증하고 만료됐을 시 Refresh Token으로 새로운 Access Token을 발급받는다.
  5. 공격자는 Access Token을 탈취하더라도 짧은 유효 기간이 지나면 사용할 수 없다.
  6. 정상적인 클라이언트는 유효 기간이 지나더라도 Refresh Token을 사용하여 새로운 Access Token을 생성, 사용할 수 있음.

즉, OTP 인증처럼 짧은 시간 동안에만 사용할 수 있도록 하고 주기적으로 재발급받도록 하여 토큰이 유출되더라도 그 피해를 최소화한다는 방식이다.

토큰의 시간을 길게 설정하고 공격자가 토큰을 탈취 하였을 경우 토큰의 시간동안 공격자는 서비스에 접근할 수 있다 하지만, 토큰의 시간을 짧게 설정하고 Refresh Token을 사용하여 주기적으로 갱신한다면 Refresh Token없는 공격자는 더 이상 서비스에 접근 할 수 없다.

공격자의 Refresh token 탈취?

공격자가 Refresh token을 탈취한다면 어떻게 할까? 우리는 이러한 공격을 막기위해 redis를 적용하자

  1. 사용자의 로그인시 우리는 발급된 JWT와 Refresh token을 Key-Value 형태로 Redis에 저장한다. (Redis는 빠른 I/O가 장점이므로 이와 같은 인증 과정에서 사용하기 좋다.)
  2. 사용자는 기존의 Access Token으로 접근하며 서버측에서는 데이터베이스에 저장된 Access Token과 비교하여 검증한다.
  3. 공격자는 탈취한 Refresh Token으로 새로 Access Token을 생성한다. 그리고 서버측에 전송하면 서버는 데이터베이스에 저장된 Access Token과 공격자에게 받은 Access Token이 다른 것을 확인한다.
  4. 만약 데이터베이스에 저장된 토큰이 아직 만료되지 않은 경우, 즉 굳이 Access Token을 새로 생성할 이유가 없는 경우 서버는 Refresh Token이 탈취당했다고 가정하고 두 토큰을 모두 만료시킨다.
  5. 이 경우 정상적인 사용자는 자신의 토큰도 만료됐으니 다시 로그인해야 한다. 하지만 공격자의 토큰 역시 만료됐기 때문에 공격자는 정상적인 사용자의 리소스에 접근할 수 없다.

쿠키를 만료시킨다?

서버가 생성한 토큰은 모든 요청에 필요하다.
하지만, 사용자가 로그아웃 또는 공격자에게 토큰이 탈취 되었을 경우 우리는 토큰을 만료시켜야한다. 하지만, 토큰은 문자열로 저장되었기 때문에 서버에서 간섭할 수 없다.
이를 해결해봅시다.

토큰의 시간을 변화시키자

클라이언트는 요청을 보낼 때 항상 토큰을 함께 보낸다.
그럼 토큰을 서버에서 임의로 만료시킬 수 있을까?

  1. HttpServletRequest의 getCookie를 통해 쿠키를 가져온다.
Cookie[] cookies = request.getCookies();
  1. 가져온 쿠키에서 서버에서 발급한 토큰이름과 같은 것을 고른다.
  2. 쿠키의 maxAge를 0으로 설정한다.

위의 과정을 코드로 보자

public void clearCookie(String cookieName, HttpServletRequest request, HttpServletResponse response) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(cookieName)) {
                    cookie.setMaxAge(0); // 쿠키의 만료시간을 0으로 설정하여 삭제
                    cookie.setHttpOnly(true); // HttpOnly 설정
                    cookie.setPath("/");
                    response.addCookie(cookie);
                    break;
                }
            }
        }
    }

이렇게 하여 쿠키를 삭제 시킨다.

로그아웃 구현

로그아웃도 마찬가지로 사용자의 토큰이 탈취 되어 사용하지 않도록 해야합니다.
클라이언트에게 토큰의 시간을 0으로 세팅하여 보내 클라이언트의 쿠키에서 토큰을 지웁니다.
하지만 토큰은 아직 유효 하기 때문에 Redis에 저장합니다.
Redis는 TTL을 설정할 수 있고 TTL이 지난 key는 삭제됩니다. 코드로 봅시다.

logout(LoginCotroller) -> 로그아웃 요청 처리

로그아웃 요청이 들어왔을 때, 사용자의 인증 정보를 제거하고 해당 토큰을 무효화하는 작업이 필요합니다

@GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null) {
            new SecurityContextLogoutHandler().logout(request, response, authentication);
            tokenService.logoutToken(request, response);
        }
        return "redirect:/main/index";
    }

위 코드에서는 먼저 현재 보안 컨텍스트에서 인증 정보(Authentication)를 가져옵니다. 만약 인증 정보가 존재한다면 SecurityContextLogoutHandler를 이용하여 해당 정보를 제거하고 토큰 서비스의 logoutToken 메서드를 호출하여 토큰 무효화 작업을 진행합니다.

logoutToken -> 토큰 무효화

로그아웃 시 JWT 토큰은 더 이상 유효하지 않게 되므로 이를 Redis 등의 저장소에 저장하여 추후 검증 과정에서 해당 토큰이 유효하지 않음을 판단할 수 있게 합니다.

public void logoutToken(HttpServletRequest request, HttpServletResponse response) {

        String token = tokenProvider.getTokenFromRequest(request);

        Base64.Decoder decoder = Base64.getDecoder();
        final String[] splitJwt = Objects.requireNonNull(token).split("\\.");
        final String payloadStr = new String(decoder.decode(splitJwt[1].replace('-', '+' )
                .replace('_', '/' ).getBytes()));
        //base 64의 경우 "-"와 "_"가 없기 때문에 illegal base64 character 5f 에러 발생
        Long userId = getUserIdFromToken(payloadStr);
        Date expirationDate = getDateExpFromToken(payloadStr);
        redisService.saveToken(token, userId, expirationDate);
        clearCookie(JwtProperties.HEADER_STRING, request, response);
    }
    
    public Long getUserIdFromToken(String payloadStr) {
        JsonObject jsonObject = new Gson().fromJson(payloadStr, JsonObject.class);
        return jsonObject.get("id").getAsLong();
    }

    public Date getDateExpFromToken(String payloadStr) {
        JsonObject jsonObject = new Gson().fromJson(payloadStr, JsonObject.class);
        long exp = jsonObject.get("exp").getAsLong();
        return new Date(exp * 1000);
    }

위 코드에서는 우선 토큰을 요청으로부터 추출하고, Base64 디코딩을 통해 payload를 추출합니다. 그 다음, payload로부터 사용자 ID와 만료 시간을 가져옵니다. 이 정보를 바탕으로 Redis에 토큰 정보를 저장하고, 클라이언트의 쿠키에서 해당 토큰을 삭제합니다.

saveToken -> Redis에 토큰 저장

토큰과 사용자 ID, 그리고 만료 시간을 Redis에 저장하는 작업은 아래와 같습니다.

@Service
@RequiredArgsConstructor
public class RedisService {

    @Resource(name = "jwtRedisTemplate")
    private final RedisTemplate<String, Object> jwtRedisTemplate;

    public void saveToken(String token, Long userId, Date expirationDate) {
        jwtRedisTemplate.opsForValue().set(token, userId);
        jwtRedisTemplate.expireAt(token, expirationDate);
    }

위 코드에서는 opsForValue().set() 메서드를 이용하여 토큰과 사용자 ID를 key-value 형태로 Redis에 저장하고 expireAt() 메서드로 해당 데이터의 만료 시간을 설정합니다.

clearCookie -> 쿠키 삭제

public void clearCookie(String cookieName, HttpServletRequest request, HttpServletResponse response) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(cookieName)) {
                    cookie.setMaxAge(0); // 쿠키의 만료시간을 0으로 설정하여 삭제
                    cookie.setHttpOnly(true); // HttpOnly 설정
                    cookie.setPath("/");
                    response.addCookie(cookie);
                    break;
                }
            }
        }
    }

위 코드에서는 요청으로부터 받은 모든 쿠기를 확인하며 주어진 이름과 일치하는 쿠기가 있다면 그 만료시간을 0으로 설정하여 브라우저가 해당 쿠기를 제거하도록 합니다.

정리

오늘은 Spring Security와 JWT를 폐기하고 로그아웃 하는 방법에 대해 알아보았습니다. 우리는 사용자의 로그아웃 요청을 받으면, 사용자의 인증 정보를 제거하고 해당 토큰을 무효화하는 작업을 진행하였습니다. 이를 위해 Redis에 토큰 정보를 저장하여 추후 검증 과정에서 해당 토큰이 유효하지 않음을 판단할 수 있도록 하였습니다.

또한, 클라이언트 측에서 JWT가 담긴 쿠키를 삭제하는 작업도 진행하였습니다. 이렇게 함으로써 클라이언트는 더 이상 유효하지 않은 토큰을 가지고 있지 않게 되어 보안성이 강화됩니다.

로그아웃 처리는 사용자의 세션 관리 및 보안성 강화에 중요한 역할을 합니다. 따라서 실제 서비스에서는 오늘 다룬 내용과 같은 로그아웃 처리 방법을 반드시 구현해야 합니다.

0개의 댓글