액세스 토큰과 리프레시 토큰 어떻게 사용할까?

junto·2024년 6월 9일
0

spring

목록 보기
14/30
post-thumbnail

왜 액세스 토큰과 리프레시 토큰이 등장했을까?

1. 초기 토큰 인증 방식의 등장

  • HTTP는 무 상태 프로토콜이고 서버에서 상태를 저장하지 않으니, 확장에 유리하다. 하지만, 특정 권한이 필요한 서비스(회원 정보 수정, 주문 서비스 등)에서는 서버가 사용자를 기억할 필요가 있다. 초기에는 서버 세션을 사용해 사용자의 요청을 식별하였다.
  • 이는 서버가 상태를 가져야 하므로 확장성에 좋지 못했고, 결국 JWT 토큰 인증 방식이 등장했다. 요청에 JWT 토큰값을 넣어 서버는 개인 키로 해당 토큰이 유효한지 검증할 수 있었기에 무상태를 유지하여 요청에 맞는 서비스를 제공할 수 있게되었다.

2. 단일 토큰 인증 방식의 문제점

1) 토큰 탈취 문제

  • 단일 토큰을 발행하는 경우 토큰이 탈취되면 서버에서는 아무런 조치를 할 수 없다. 악의적인 토큰 탈취자가 토큰의 유효시간 동안 마음대로 서버 리소스에 접근할 수 있다.

2) 사용자 경험 문제

  • 앞선 문제를 예방하기 위해 단일 토큰의 만료 기간을 짧게 잡으면, 토큰이 만료될 때마다 사용자는 로그인 하여 토큰을 갱신해야 한다. 이는 사용자 경험을 악화시킨다.
  • 물론, 단일 토큰만을 사용하면서 토큰의 만료 시간이 되기 전에 어떤 호출이 있다면 액세스 토큰을 새로 발급받으면서 유효시간을 갱신할 수 있다. 보안이 중요한 도메인이라면 위의 방식이 괜찮지만 계속해서 간헐적으로 서비스를 제공받아야 한다면 여전히 빈번하게 로그인해야 한다.

3. 다중 토큰 인증 방식(액세스 토큰과 리프레시 토큰)

  • 위의 요구사항을 만족하기 위해 서버는 대개 2개의 토큰을 두고, 액세스 토큰과 리프레시 토큰이라는 이름을 가진 토큰을 사용한다. 자세한 내용은 rfc6749 내용을 참고하자.
  • 액세스 토큰이란 유효기간을 아주 짧게 두어 특정 리소스를 얻기 위해 사용한다. 리프레시 토큰은 유효기간을 상대적으로 길게 두어 액세스 토큰을 갱신하기 위해 사용한다. 생명주기가 긴 리프레시 토큰을 이용해 액세스 토큰을 연장함으로써 사용자 경험을 향상시킬 수 있다.

리프레시 토큰이 탈취된다면?

  • 악의적인 사용자가 탈취한 리프레시 토큰으로 액세스 토큰을 받아 서버 리소스에 접근하면 리프레시 토큰의 유효기간 동안 서버는 아무 조치도 취할 수 없게 된다.

1) token blacklisting

  • 사용자가 비밀번호 변경, 로그아웃 또는 보안 취약점이 발생한 경우에도 리프레시 토큰은 여전히 사용할 수 있는 상태이다. 위와 같은 상황이 발생할 경우 token blacklist 저장소에 해당 리프레시 토큰을 등록하여 블랙리스트에 등록된 리프레시 토큰은 사용하지 못하도록 할 수 있다.

2) token rotate

  • 사용자가 access token을 갱신할 때 기존에 있는 리프레시 토큰을 저장소에서 삭제하고, 새로 발급한 리프레시 토큰을 저장한다. 이는 리프레시 토큰을 한 번만 사용하여 토큰 탈취의 위험성을 줄인다.
  • 사용자가 비밀번호 변경, 로그아웃 또는 보안 취약점이 발생한 경우 새로운 리프레시 토큰을 받도록하면 된다.
  • token blacklisting이 꼭 필요할까? 프로젝트 규모가 작다면 매번 리프레시 토큰 저장소에서 해당 토큰을 찾아 삭제하고, 새로 발급하면 된다. 물론 규모가 크다면 매번 삭제하는 연산의 비용이 클 수 있고 바로 삭제하기보다는 블랙 리스트에 등록 해놓고 사용을 못 하게 한 다음 나중에 redis ttl이나 scheduler 또는 batch로 한 번에 지우는 것이 효율적일 수 있다.

악의적인 사용자가 사용자 정보를 탈취하여 새로운 IP에서 로그인하는 경우에 어떻게 대처할 수 있을까? 등록된 IP가 아닌 경우 추가 인증을 수행하는 필터를 앞단에 새로 만들어 대응하거나 Access Token에 사용자 IP까지 추가하여 요청할 때 등록된 IP가 아니면 JwtFilter에서 추가 인증을 하도록 요구하는 방식을 적용할 수 있을 것이다.

4. 그런데, 토큰이 상태를 가져도 괜찮은가?

  • 토큰 인증 방식의 등장은 무상태를 기반으로한 확장성이 아닐까? 그런데, 보안 목적을 달성하기 위해 기존의 세션 방식과 마찬가지로 토큰도 상태를 가지게 되었다. 잘못된 거 아닐까?
  • 실질적으로 보안 취약점을 예방하기 위해 일부 확장성을 제한한 trade-off라고 생각한다.

토큰 어떻게 잘 사용할까? RFC 6749 6750 2617

  • RFC 6749 6750 The OAuth 2.0 Authorization Framework: Bearer Token Usage에서 Bearer 인증 방식에 대해 자세하게 설명하고 있다. OAuth2.0 명세지만 특별한 이유가 없으면 일반 로그인 구현 시에도 이를 따르는 게 좋다고 생각한다. 추가로 RFC 2617 HTTP Authentication: Basic and Digest Access Authentication을 참고하여 요청과 응답 방식을 살펴보도록 한다.

중요 내용

  • The client MUST validate the TLS certificate chain when making requests to protected resources. (클라이언트는 반드시 TLS 인증서 체인 유효성을 검사해야 한다.)
  • Clients MUST always use TLS. (클라이언트는 반드시 TLS를 사용해야 한다.)
  • Don't store bearer tokens in cookies: Implementations MUST NOT store bearer tokens within cookies that can be sent in the clear. Implementations that do store bearer tokens in cookies MUST take precautions against cross-site request forgery. (토큰을 쿠키에 저장하지 않아야 한다. 쿠키에 저장했다면 반드시 CSRF 예방 조치를 해야 한다.)
  • Token servers SHOULD issue short-lived (one hour or less) bearer tokens. (토큰 발행 서버는 토큰을 1시간 또는 그 이하로 발행해야 한다.)
  • Don't pass bearer tokens in page URLs: Bearer tokens SHOULD NOT be passed in page URLs. (토큰을 URL로 전달하지 않아야 한다.)

1. 토큰을 응답하는 방식

1) 헤더 및 쿠키 응답하기

HTTP/1.1 200 OK
Set-Cookie: token=mF_9.B5f-4.1JqM; 
Authorization: Bearer mF_9.B5f-4.1JqM
Content-Type: text/plain;charset=UTF-8
Content-Length: 
  • 헤더로 응답한 경우 클라이언트에서 자바스크립트로 자유롭게 조작할 수 있다.
  • 쿠키로 응답한 경우
    • 클라이언트의 요청이 동일한 도메인이면 자동적으로 쿠키에 내용이 포함되어 전송한다.
    • 보안 취약점은 CSRF(교차 출처 리소스 위조) 공격이다. 이를 대응하기 위해선 SameSite=Strict 속성을 사용하여 같은 출처에서만 보낼 수 있도록 한다.
    • HttpOnly를 사용하여 자바스크립트로 조작을 못 하게 하거나, Secure로 HTTPS 상에서만 통신이 되도록 할 수 있다. MaxAge로 쿠키의 제한 시간 및 Domain과 Path를 사용하여 쿠키가 전송될 도메인과 경로를 제한할 수도 있다.

2) 바디로 응답하기

// 구글 OAuth2.0 예시
{
  "access_token": "1/fFAGRNJru1FTz70BzhT3Zg",
  "expires_in": 3920,
  "token_type": "Bearer",
  "scope": "https://www.googleapis.com/auth/drive.metadata.readonly",
  "refresh_token": "1//xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI"
}
  • 클라이언트에서 자바스크립트로 자유롭게 조작할 수 있다. 헤더 방식과 달리 대용량의 데이터도 보낼 수 있다.
  • 토큰을 관리할 책임이 전적으로 클라이언트에게 있다.

2. 요청에 토큰을 포함하는 방식

  • "Authorization" 요청 헤더에 액세스 토큰을 보낼 때 HTTP/1.1 [RFC2617]에 의해 정의된 필드에서 클라이언트는 "Bearer"를 사용한다.
GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM
  • Refresh 토큰의 경우 Access Token처럼 미리 정의되어 있는 헤더가 없다. 따라서 보안상 바디로 보내는 게 바람직하다고 생각한다.
// 구글 Oauth2.0 예시
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded

...
refresh_token=refresh_token&
grant_type=refresh_token

실제로 적용하기

1. 액세스 토큰 검증 시 토큰이 만료된 경우 특정 에러코드 설정

  • 액세스 토큰이 유효기간이 만료된 경우에는 Refresh Token으로 갱신할 수 있도록 특정 에러코드를 설정하여 이를 클라이언트에게 알릴 필요가 있다.
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
  • Jwt 토큰 유효성 검증하는 과정에서 토큰이 유효하지 않다면 다양한 Exception이 발생한다.
  • 어떤 Exception이 발생하는지는 직접 테스트해볼 수 있다. e.printStackTrace();로 에러를 찍어본 결과 유효기간이 만료되면 ExpiredJwtException 에러가 발생한다.
  • https://javadoc.io/doc/io.jsonwebtoken/jjwt-api/latest/io/jsonwebtoken/JwtException.html 해당 문서를 참고하면 어떤 예외가 발생하는지 구체적으로 파악할 수 있다.
  • 유효기간이 지난 예외를 파악하고 싶으므로 아래와 같이 try catch로 특정 에러에 대한 반환 값을 설정한다.
public Integer isValid(String token) {
  try {
 Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
    } catch (Exception e) {
      if (e instanceof ExpiredJwtException) {
        return 1;
      } else {
        e.printStackTrace();
        return 2;
      }
    }
    return -1;
  }

2. 필터에서 커스텀 예외 설정하기

  • JwtFilter에서 토큰의 유효성을 검사한다. jwt 토큰이 유효하지 않을 때 해당 필터를 종료시키면 에러가 분명 발생했음에도 200으로 응답이 된다!!
  • 시큐리티 필터가 진행됨에 따라 에러 코드를 설정하는 중간 필터가 있다는 것이다.
  • unsuccessfulAuthentication에서는 기본적으로 403 에러가 발생하기 때문에, 특정 에러 상황에 따라 내가 원하는 에러를 핸들링하기 위해서는 필터를 종료시킬 때 HttpResponse에 반영해야 한다.
 public void setErrorResponse(HttpServletResponse response, ErrorCode errorCode)
      throws IOException {
    response.setContentType(JwtUtil.CONTENT_TYPE);
    response.setStatus(errorCode.getStatus().value());

    Map<String, Object> data = new HashMap<>();
    data.put(JwtUtil.ERROR_CODE, errorCode.getCode());
    data.put(JwtUtil.ERROR_MSG, errorCode.getMsg());

    OutputStream out = response.getOutputStream();
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(out, data);
    out.flush();
}
  • 현재 ErrorCode라는 enum 클래스에 Http 상태, 에러 코드, 에러메시지를 담고 있다. 직접 httpServeletResponse에 Http Status, 에러 Code 및 Message를 위와 같이 설정하면 필터를 바로 종료시켜도 내가 설정한대로 에러가 발생한다.
  • jwt 필터 토큰 검증부에서 아래와 같이 설정하여 커스텀 예외를 설정한다. (jwtUtil 의존성을 주입받지 않는 곳에서 커스텀 예외 처리를 해야 한다면 예외 로직을 분리할 예정이다)
  private boolean validateAccessToken(HttpServletResponse response, String token)
      throws IOException {
    int errorType = jwtUtil.isValid(token);
    if (errorType == 1) {
      jwtUtil.setErrorResponse(response, ErrorCode.ACCESS_TOKEN_EXPIRED);
      return false;
    }
    if (errorType == 2 || JwtUtil.ACCESS_TOKEN_PREFIX.equals(jwtUtil.getType(token))) {
      jwtUtil.setErrorResponse(response, ErrorCode.ACCESS_TOKEN_INVALID);
      return false;
    }
    return true;
  }

이제 클라이언트는 Access Token이 만료되었다는 특별한 에러코드를 받으면 이를 핸들링하여 Refresh Token으로 Access Token을 갱신하는 로직을 구현할 수 있다.

참고 자료

profile
꾸준하게

0개의 댓글