웹 사이트에서 Http프로토콜은 Stateless하다 보니 사이트가 넘어가거나 새로고침을 하게 되면 동일한 회원인지 알 수가 없습니다. 따라서 인증을 위해 여러 방식을 도입하여 사용하는데 이에 대해 알아보겠습니다.
회원 인증 방식
쿠키
Stateless한 환경을 해결하기 위해 지속적으로 값을 가져갈 수 있도록 쿠키를 사용하게 됩니다.
쿠키는 `Name`과 `Value`로 이루어져 있고, 추가적인 정보를 더 설정할 수 있습니다.
그렇다면 쿠키로는 어떻게 로그인을 유지할까?
쿠키는 지속적으로 값을 가지고 있을 수 있고 Name과 Value가 있으므로 넣어주면 되겠지만, 한 가지 생각해야 하는 것은 '그럼 각각의 사용자를 어떻게 판단할 것인가' 입니다. 이에 대한 답 중 하나는 사용자의 고유 ID를 넣어주면 됩니다.
response.addHeader("Set-Cookie","ID="+userId);
따라서 위와 같이 Header에 `Set-Cookie`에 값을 넣어주면 됩니다.
그럼 위와 같이 Response Header에 담겨오며,
랜더링된 후 Cookie에 값이 들어가는 것을 볼 수 있습니다.
쿠키의 단점
하지만 이렇게 하면 단점이 있는데
- JS로 쿠키에 접근이 가능하다
- 하나의 브라우저에 저장되기 때문에 다른 브라우저에서는 접근이 불가능합니다.
- 쿠키의 값을 그대로 보내기 때문에 조작가능
- 쿠키에는 4KB만큼의 용량이 제한되어 있어 많은 정보를 가져갈 수 없습니다.
JS로 쿠키에 접근이 가능한 부분(1번 문제)은 사실 HttpOnly를 통해 front에서 접근을 못하도록 하면 됩니다. 다만 도중에 패킷을 가로채는 경우에는 쿠키 값이 평문이어서 조작이 가능한 문제가 그대로 남아있습니다.
또한 클라이언트에서 값을 관리한다는 것은 많은 위험을 가지고 있습니다.
따라서 세션을 사용하게 됩니다.
Session
지금 까지 인증값을 클라이언트에서 쿠키로 관리를 하였다면, 관리하는 주체가 변경이 되어 서버에서 관리를 하게 됩니다.
세션이 로그인을 유지하는 방식
- 로그인 요청이 들어오면 : SessionID 값을 Response Header에 넣어서 전달해 준다. 또한 서버단에 세션 저장소를 놔두고 세션값을 저장해 둔다.
- 로그인 유지 시: 세션값과 세션 저장소에 해당 세션이 저장되어 있는지 확인 후 로그인을 유지하게 한다.
여기서 SessionId이 경우 랜덤하게 넣어줄 수 있으며, 상호 인증으로만 하면 된다. 따라서 Cookie로 인증하는 과정과 동일하게 Session도 ID값을 쿠키를 통해 전달하고 그 쿠키 값을 다시 서버에 요청하며 확인하게 된다.
세션 저장소에는 어떤 값을?
처음 생각할 수 있는 것은 `Session 저장소에 어떤 값을 넣어야 할까?`인데 SessionID는 대조를 위해서 무조건 들어가야 한다. 또한 사용자를 확인하기 위해서 `회원 정보를 전부 넣어야 할까?`라고 생각할 수 있지만 위험한 방법이다. 왜냐하면, 세션 또한 `Set-Cookie`를 통해 전달해 주다 보니 패킷을 도중에 본다면 어떤 값이 오는지 알 수 있기 때문에 세션이 탈취당하면 그대로 모든 정보를 알게 되기 때문이다.
따라서, 사용자를 인증할 수 있는 최소한의 값만이 들어가야 하고 유니크한 값으로 그 사람의 정보를 추가적으로 가져오면 된다.
이렇게 되면, 'SessionID, UniqueId, lastAccessTime'과 같은 값들만 저장하면 될 것이다.
쿠키보다 어떤 점이 좋아졌나?
쿠키의 경우 클라이언트가 사용자의 유니크한 ID값을 가지므로 여러 시도할 수 있는 여건이 많아졌다. 하지만 유니크한 값 대신 랜덤한 ID로 클라이언트는 송수신 역할만 하며, 서버에서 인증을 처리하는 역할을 가지게 되었다. 따라서 세션ID 값을 변경하여도 그렇게 크리티컬 한 문제가 발생하지는 않는다.
하지만 문제는 쿠키에서 `세션을 탈취`당했을 때인데, 그렇게 되면 악의적인 사용자가 탈취한 세션을 통해 접속하여 로그인이 가능하게 된다. 이러한 문제점은 사실 세션에서는 약간의 변형으로 해결이 가능하다 탈취되었을 때에는 어쩔 수 없지만 사용자나 운영자가 알아차린다면, 바로 세션저장소에서 해당 세션을 삭제하면 된다.
더 좋은 다른 방법으로는 접속 IP를 세션저장소에 같이 저장하는 것이다. 접속 IP를 통해 해당 로그인 유저가 다른 IP에서 접속을 시도할 경우 그 접속을 막는 방법이다.
세션의 단점
- 사용자의 접속 정보를 서버에서 저장하다 보니 데이터의 양이 많으면 서버에 부하가 많이 간다.
- 다중 서버로 되어있다면 로그인 유지가 안될 수 있다.
2번 같은 경우 WAS에서 값을 저장하는 것이 아니라 따로 DB와 같은 곳에 넣으면 해결이 된다.
하지만 1번의 경우는 좀 더 많은 문제를 가지고 있다.
비단 세션뿐 아니라 일반적인 Token인증에서도 동일한 문제가 발생하는데, 각 요청은 하나의 목적을 가지고 요청이 들어오게 되는데, 그때마다 인증정보를 가지고 있는 DB에 접근하는 부분에서도 많은 리소스가 들어가고 각 DB에 많은 정보를 검색하는 데에서도 시간이 들며, 인증 유효기간이 끝나면 그 부분을 삭제해 줘야 하니 더 많은 시간이 들 수밖에 없다.
결국, 세션 저장소라는 개념도 Scale Out을 고려해야 한다는 점입니다.
이러한 문제를 해결하기 위해 세션 클러스터링이나 간편하게는 스티키 세션으로 구현을 하는데 나중에 트러블 슈팅하게 된다면 다뤄보겠습니다.
JWT - Token 인증
Json Web Token의 약자로 JWT는 Session과 함께 인증/인가에서 많이 사용하는 방식입니다. 세션이나 일반적인 토큰과는 다르게 자체적으로 암호화하여 값을 서버에서 복호화 후 인증하기 때문에 DB에 접근이 따로 필요 없다는 장점이 있습니다.
JWT또한 Session과 동일하게 서버와 클라이언트가 서로 값을 전달해 주기 위해 `Cookie`를 사용하는 것은 동일합니다.
일반적으로 Header의 name값으로 `Authorization`을 전달해 주며 `[인증 타입] [Token]`을 value로 전달해훕니다.
이때 JWT의 경우 인증타입이 Bearer입니다. (추후에는 인증타입에 따라 다르게 구현 가능)
JWT 구조
JWT의 구조는 크게 3파트로 나눌 수 있습니다. `[헤더].[페이로드].[서명]`으로 나눌 수 있습니다. 각 파트는 `.`을 통하여 분류가 되며, Base64인코딩이 되어 있는 형태입니다. 특히 이 JWT같은 경우 URI에서 파라미터로 사용할 수 있도록 URL-Safe한 Base64url인코딩이 되어있습니다.
- 헤더: 토큰의 유형, 알고리즘 방식
- 페이로드: 사용자의 인증/인가 정보가 담겨 있다.
- 서명: 헤더와 페이로드가 비밀키로 서명되어 저장된다.
따라서
헤더를 Base64인코딩 + "." + 페이로드 Base64인코딩 + "." + Hash(Header Encode.Payload Encode, secret)
이렇게만 보면 이해가 힘들 수 있기 때문에 추가적으로 알아보겠습니다.
맨 앞이 빨간색 부분은 JWT의 Header부분을 base64url을 기반으로 인코딩한 값이고, 보라색은 JWT의 Payload를 base64url을 기반으로 인코딩한 값이다. 따라서 각각의 값을 다시 디코딩하면 우리가 작성했던 값이 나오는 만큼 중요한 데이터는 절대로 들어가면 안 된다.
그러면 base64url로 디코딩하면 원래의 값이 나오는데 어떻게 값들이 변경되었는지 알 수 있을까?
Verify Signature부분이 앞에 `Header`와 `Payload`를 검증하는 하나의 수단이라는 것을 알려주는 것이다. 따라서 값이 위변조 되면 저 `Verify Signature`부분으로 알 수 있는 것이다.
Verify Signature부분의 해싱기준은 Header이 alg(알고리즘)을 기준으로 하며, 보통 HS256(HMAC SHA3-256)을 사용하게 된다.
따라서 `Signature의 경우 Header와 Payload의 encode값 + 서버에 저장되어 있는 Secret키`를 기반으로 암호화되며, 추후에 Payload가 변경되더라도 인증 부분에서의 값이 완전히 바뀌다 보니까 해당 값이 위변조 되었는지 알 수 있는 것이다.
결과적으로 보면, JWT는 서버로 접근할 수 있는 티켓 자체의 위변조를 막자에서 온 것이다. 그렇기에 자체가 탈취당한다면, 이거는 사실상 할 수 있는 부분이 적어진다. 이러한 문제를 해결하기 위한 꼼수는 밑에서 계속 다루겠습니다.
따라서 정리해 보자면
JWT
장점
- 인증 정보를 저장하기 위한 별도의 저장소가 필요 없다. signature만 같으면 되기 때문에
- 로그인을 유지할 때, DB접근 자체가 필요가 없어진다.
- OAuth뿐 아니라 OpenID (OIDC)에서도 사용이 가능하다
단점
- 토큰 길이가 길어질수록 네트워크에서 부하가 생긴다.
- 탈취당했을 때 강제로 종료시키기 어렵다 (추가작업이 필요) 또한 토큰 종료시점까지 삭제하지를 못한다.
- secret키가 털리면 안 된다
JWT는 노출되면 안 되지만 식별가능한 값을 클라이언트가 가지고 있다 보니 세션과 달리 저장소가 필요하지 않습니다. 그렇다 보니 서버의 부하를 줄여주고, 각 요청마다 메모리상에서 해결이 이 가능하여 DB접근이 최소화되어 보안적으로 안전합니다.
JWT 구현
User flow 대로 로그인 하고 접근 권한이 필요한 url로 접근하였을 때를 가정하여 작성해 보겠습니다.
먼저 사용자가 아이디와 패스워드를 통해 로그인을 하게 되면, 인증토큰을 발급해줘야 합니다.
// 생성
public String createToken(String email) {
Date issueDate = new Date();
Date exipireDate = new Date(issueDate.getTime() + EXPIRE_TIME);
return Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Registered Claim
.setIssuer(ISS) // 발행자
.setIssuedAt(issueDate) // 발행일
.setExpiration(exipireDate) // 만료일
// Private Claim
.claim("email", email)
// Public Claim
.claim("/member", true)
.claim("/admin", false)
// Signature
.signWith(key, SignatureAlgorithm.HS256)
.compact()
;
}
JWT를 발급해 주기 위해서는 `Header`, `Claim`, `Signature`를 각각을 작성해 줘야 합니다.
Header
- 알고리즘과 토큰 타입에 대해 Signature에서 사용하는 값들을 넣어준다.
Claim
- Claim은 좀 더 세세하게 접근해 보면, `Registered Claim`, `Private Claim`, `Public Claim`로 구분할 수 있다.
- `Registered Claim`: 시스템의 일관성을 위해 사전에 등록되어 있는 정보들
- `Private Claim`: 시스템 위에서만 의미가 있는 정보들을 담았습니다. 개인 정보들을 제공해 줬습니다.
- `Public Claim`: 일반적으로 널리 알려진 정보들이 여기에 해당합니다. 따라서 접근 권한에 대한 정보들을 제공해 줬습니다.
다만 이 부분은 명시적으로 구분할 필요는 없습니다.
Signature
- Key는 대칭키(비대칭키도 구현하면 가능)를 말하는 것이며, 아래와 같이 문자열을 특정 알고리즘에 적합한 대칭키로 만들기 위해서 아래와 같은 메서드를 사용했습니다.
key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
@GetMapping("/login")
public void login(HttpServletResponse response) {
String value = "BEARER "+jwtProvider.createToken("example@gmail.com");
response.addHeader("Set-Cookie", "AUTHORIZATION="+value);
}
이후 response 헤더에 쿠키로 넣어주고 전달해 주면 됩니다. (세부 구현 전부 제외했습니다)
로그인을 하고 인가된 토큰이 필요할 때는 필터에서 미리 제외를 시켜주면 됩니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
/**
* 로그인 전 = 토큰 존재하지 X
*
* 로그인 후 = 토큰 존재
* - 토큰 존재 => 인증 완료
* - 토큰 존재 X => 다시 로그인
*/
try {
String tokenValue = Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(AUTHORIZATION_NAME))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
/**
* 인증하고자 하는 토큰이 JWT 방식인지 확인
*/
if (tokenValue != null && tokenValue.startsWith(BEARER)) {
String token = tokenValue.split(SPACE)[1];
String email = jwtProvider.parseEmail(token);
/**
* 토큰이 유효한지 확인 : 유효시간 & 위변조
* - 유효하지 않다 = 토큰 재발급
* - 위변조 = throw & 예외 발생
* - 유효하다 = 패스
*/
if (!jwtProvider.isTokenValid(token)) {
regenerateTokens(token, response);
}
/**
* 토큰에서 변경된 값 ThreadLocal로 저장
*/
MemberThreadLocal.setEmail(email);
}
} catch (MalformedJwtException |
NullPointerException nullPointerException) {
// ExceptionResolver로 넘겨준다.
} catch (RuntimeException) {
// ExceptionResolver로 넘겨준다.
}
filterChain.doFilter(request, response);
}
- 로그인 전에는 따로 해당 필터를 지나갈 필요가 없습니다. 따라서 쿠키 값이 없다면 잘못된 요청입니다.
- Array.stream에서 null인 경우 에러를 발생시키기 때문에 잡아주면 됩니다.
- 로그인이 된 후 인증을 확인
- JWT 토큰인 것을 확인하기 위해 `Bearer`로 시작하는지 확인합니다.
- JWT토큰이면 해당 토큰이 유효기간과 위변조가 되지 않았는지 확인하기 위해 검증을 해야 합니다.
// 검증
public boolean isTokenValid(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException expiredJwtException) {
throw new RuntimeException();
} catch (Exception exception) {
throw new RuntimeException();
}
return false;
}
위와 같이 JWT를 대칭키를 적용 후 파싱하는데 이때 각 값들을 검증해 준다.
이렇게 검증을 하며 유효기간이 지난 경우 다시 로그인을 하도록 throw를 한다. 토큰을 재발급하고자 한다면 pass를 하면 된다.
마지막으로 인증 절차가 완료되면 해당 회원을 사용해 주기 위해 `ThreadLocal`을 통해 값을 넣은 후 나중에 각 요청이 끝나기 전에 ThreadLocal을 비워주기만 하면 됩니다.
이렇게 보면 JWT는 DB에 대한 접근이 적고, 저장소 또한 필요가 없다 보니 서버 부하가 적습니다. 또한 JWT를 통해 위변조도 가능하니 세션보다 활용도가 높은 거 같은데?라고 생각이 될 수 있지만, 약간은 크리티컬한 문제들이 존재하고 어떤 꼼수를 사용할 수 있을지 JWT의 활용에 대해 추가적으로 확인해 보겠습니다.
보안적으로 보면 세션이 JWT보다 더 활용도가 높습니다. 탈취당하면 중간에 세션을 삭제가 가능하고, 내부적으로 유의미한 값도 없기 때문이죠, 그렇다면 JWT를 좀 더 활용도 높게 하는 방법에는 뭐가 있을까요?
JWT 활용
기본 JWT의 단점으로 탈취에 대한 걱정이 있습니다. 서버에서 클라이언트로 데이터를 전송할 때 도중에 프락시처럼 중간 서버를 통해 어떤 값이 들어오는지 확인하게 된다면 어떤 데이터가 오고 가는지 알 수 있는데, 그럼 그 토큰 정보들을 그대로 탈취가 가능하다는 것입니다. 또한 탈취가 된다면 payload의 경우 `base64 url`방식으로 인코딩만 되어있어 보안이 아예 안되어 있습니다.
어떻게 대처할 수 있을까요?
1. Token 시간의 단축
이미 위에서도 언급했듯, 토큰 자체는 탈취를 당하게 되면 따로 해결할 방법이 없습니다. 따라서 토큰의 유효기간을 짧게 두면서 탈취당하더라도 그 위험성을 낮추는 방식으로 가능합니다.
하지만 시간을 단축하게 되면, 로그인을 그만큼 자주 해 줘야 합니다. 이러한 사용자 측면에서 불편함을 줄이기 위해 Refresh token을 제공하는 방식이 있습니다.
2. Refresh Token
Refresh Token을 추가하게 되면, 기존의 토큰을 `Access Token`이라고 하겠습니다.
짧은 유효기간을 가진 Access Token은 보안을 향상해 준 것은 맞습니다. 다만 그만큼 자주 만료되기 때문에 사용자 측에서는 많은 로그인을 해야 한다는 단점이 있습니다. 그래서 Refresh Token과 같은 Access Token보다 훨씬 더 긴 유효기간을 가진 토큰으로 Access Token이 만료되더라도 Refresh Token이 만료되지 않았다면 자동인가가 가능하게 됩니다.
'그렇다면 모든 문제가 해결된건가?'는 아닙니다. Refresh Token또한 Bearer토큰이므로 토큰이 탈취당할 위험이 존재합니다.
'그럼 Refresh 토큰을 Response 헤더에 넣지 않으면 되는 거 아냐?'라고 생각이 들었습니다.
1) Refresh Token을 전달하지 않는다면?
'Redis에 Refresh Token에 대응하는 Access Token을 저장한다. 그리고 반환을 Access Token만 사용한다면 Refresh Token의 탈취 위험이 없는 거 아냐?'라는 생각을 했습니다.
그런데, 결국 Session과 다른 부분이 없었습니다. JWT의 장점은 로그인 후 인증을 할 때 메모리 상에서 해결이 가능하다는 것이었지만, 지금은 Access Token의 유효시간마다 매번 DB를 접근해야 한다는 것입니다.
이번엔 `Refresh Token을 전달할 때 어떤 조치를 취할 수 있을까`에 대해 알아보겠습니다.
2) Refresh Token Rotation
Refresh Token이 탈취당한다는 문제는 Access Token을 재발급해주면서 같이 Refresh Token을 발급하면 되면 됩니다.
이렇게 되면, `Access Token의 유효기간`이 아니라 `Refresh Token 유효기간`까지 로그인 유지가 가능하며, Access Token을 갱신할 때 Refresh Token까지 갱신하다 보니 그 기간은 계속 늘어나게 됩니다.
또한 Refresh Token가 탈취되더라도 위와 같은 이유로 위험 부담을 줄일 수 있는 것입니다.
추가적으로 현재는 시간을 기반으로 Access Token의 유효기간이 만료되면 재발급하게 했지만, 토큰 검증을 한 횟수를 기반으로 할 수도 있고 특정 이벤트 기반등 여러 방법이 존재합니다.
중요한 것은 Access Token과 Refresh Token을 둘 다 새로 발급한다는 것입니다.
3) Refresh Token Automatic Reuse Detection (BlackList)
토큰을 재사용하는 경우를 자동으로 감지하여 대응을 하는 것입니다. 대응하는 방법이 여러 가지 있을 수 있습니다.
하지만 공통적으로 재사용했다는 것을 알려면 기존의 값이 어떤 것인지 저장하고 있어야 합니다.
Q1 - 만약 기존 값을 저장하지 않는다면?
악의적인 사용자가 새로운 토큰을 발급하더라도 발급한 것을 알 수 없다.
Q2 - 우리의 목표는?
악의적인 사용자가 더 이상 사용하지 못하도록 해야 한다. 하지만 어떤 토큰이 악의적인 사용자인지 모르기 때문에 악의적인 사용자와 일반 사용자가 관련된 모든 토큰은 삭제되어야 한다.
위에서부터 순서대로 요청이 들어온다고 가정하겠습니다. 또한 악의적인 사용자는 도중에 프록시처럼 요청을 인터셉트하여 토큰 값을 확인할 수 있다고 생각해 보겠습니다.
위에서 클라이언트의 요청에 대한 응답을 통해 Access Token과 Refresh Token을 탈취하여, Access Token이 만료되면 Refresh Token까지 재발급한 다는 것을 알고 Refresh Token만 전달하여 Access2와 Refresh2를 받은 상황입니다.
이때 클라이언트는 기존에 가지고 있던 쿠키 값을 통해 로그인을 시도하면 Redis에는 기존 로그인 정보가 없다 보니 로그인이 안된다.
여기서 문제는 악의적인 사용자는 로그인이 가능하지만, 사용자는 로그인이 불가능하다는 것이다.
우리가 여기서 고려해야 할 부분은
- 여러 브라우저에서 로그인을 하게 되면, 각 브라우저마다 로그인 토큰이 존재한다.
- 악의적인 사용자는 토큰을 재발급받게 된다거나, 사용자가 재발급받았을 때 최신의 토큰이 아닌 이전 토큰이 들어오면 악의적인 사용자와 일반 사용자 양쪽 로그인이 불가능하게 해야 한다.
Redis에는 다음과 같이 저장할 수 있을 거 같습니다. (실제로 사용한다면 좀 더 자세하고 구현에 따라 달라질 수 있습니다.)
토큰해시 맵
- 각 토큰과 사용자 이메일이 매핑되어 저장된다.
토큰 폐기 맵
- 삭제 토큰ID를 통해 이전 값들이 존재하는지 확인
어떻게 동작할까
- Refresh Token에는 RemoveID를 가지고 있다.
- RemoveID를 통해 이미 사용하지 않는 토큰들을 저장하는 목록을 매핑.
- ID1: {token}
- 따라서 토큰이 유효하지 않다면, ID1의 토큰 리스트에 들어간다.
- 이후, 새로운 요청이 들어온다면, 이미 폐기된 토큰인지 확인하고 폐기된 토큰이면 모든 토큰 삭제하여 악의적인 사용자와 일반 사용자 둘 다 로그아웃 후 재인증하도록 합니다.
따라서 위와 같은 과정이 됩니다.
이렇게 되면, 반대로 악의적인 사용자가 업데이트하지 않은 토큰 값으로 접근하더라도 동일한 상황이 발생하게 됩니다.
추가적으로 기본적으로 현재 토큰에 대해서도 저장을 했는데, 위와 같이 했던 이유는 여러 브라우저에서 로그인을 하게 된다면 하나의 기기에서 다른 브라우저나 기기에서 로그인되었을 때 그 부분을 삭제할 수 있을 거라 생각했기 때문입니다.
예를 들어, `Token 해쉬 맵`에서 `키-값`을 `email : { loginType: token}` 과 같이 되어 있다고 하면 삭제하고 싶은 부분을 바로 블랙리스트로 넣어주면 되기 때문입니다.
4) IP 로그인
마지막은 IP를 통해 기존 refreshToken을 발급받은 위치에서 벗어나면 ip가 달라지는 점을 이용하는 방식입니다.
redis에 키값으로 `token: {email, ip}`와 같이 저장하고 이후 요청마다 ip를 대조하여 달라지면 재인증을 요청하면 됩니다.
결론
전반적으로 생각해 봤을 때, Access Token을 짧게 두고 해당 토큰만 가져가도 문제가 없다. 하지만 사용자 측에서 생각을 해보면 짧은 시간마다 매번 로그인한다는 건 상당한 스트레스로 올 수도 있다는 생각이 듭니다.
이렇게 봤을 때 Refresh Token을 추가하는 건 좋은 방향이지 않을까 생각합니다. Refresh Token도 결국 토큰이기 때문에 탈취당하면 위험이 있고, 사용자 경험 측면에서는 Refresh Token이 짧으면 안 좋아지기 때문에 적절한 trade off관계를 생각해봐야 하지 않을까 싶습니다.
Refresh Token을 주기를 길게 하면서 위와 같은 문제를 해결하기 위해 적용했던 것이 rotation이었습니다. rotation을 통해 Access token과 refresh token을 Access Token의 유효기간마다 매번 갱신해 주며, 탈취당하더라도 조금은 안전할 수 있었습니다.
여기에 추가적으로 했던 것이 blackList입니다. 만약 rotation없이 blackList를 적용한다면, refresh token의 유효기간마다 갱신이 되는 상태이므로 기간이 상당히 길게 됩니다. 이때 refresh Token을 탈취당했다면, blackList를 따로 등록시켜 주는 것이 아닌 경우 최신 refresh token 생성시기보다 이전의 refresh token이 들어왔을 경우에만 감지할 수 있습니다. 따라서 IP와 같이 추가 작업이 진행되는 것이 좋을 거 같다 생각이 들었습니다.
반대로 rotation을 적용하고 blackList를 적용한다면, Access Token의 유효기간마다 Refresh Token이 갱신될 것이고, Refresh Token의 갱신 주기가 짧아질수록 blackList에 들어가는 주기가 짧아지기 때문에 rotation과 blackList만으로도 충분할 수 있습니다. 하지만 Redis의 블랙리스트에 저장하는 값이 많아질 수 있습니다.
`JWT`, `Session` 어느 하나가 정답인 것은 아니며, 상황에 따라 또한 필요에 따라 그리고 내가 어떤 서비스를 제공하고 싶은지에 따라 많이 갈릴 수 있을 거 같습니다. 또한 `OAuth`나 `OIDC(OpenID Connect)`의 경우 사용자 정의 API에 대해 발급되는 액세스 토큰이 JWT 표준을 따르는 경우도 있습니다.