탈취된 Refresh Token 재사용 문제 해결
문제
- 로그아웃할 때 브라우저 쿠키만 삭제하면 미리 복사된 Refresh Token은 서버에서 계속 유효
- 공격자는 탈취한 Token으로 사용자가 로그아웃한 뒤에도 새 Access Token을 발급받을 수 있음
해결 전략
- Token은 발급 직후
ACTIVE, 재사용이 탐지되면REVOKED상태로 관리 - 갱신할 때마다 이전 Token을
ROTATED로 바꾸고 새 Token 발급 - 같은 로그인에서 이어진 Token을
family_id하나로 묶어 Token Family로 관리 ROTATEDToken이 들어오면 재사용으로 보고 Token Family 전체 폐기
기술 선택 이유
-
만료 시간이 긴 Access Token - 제외
- DB를 조회하지 않아도 되지만 로그아웃하거나 탈취 사실을 확인한 뒤 서버에서 즉시 폐기하기 어려움
-
고정 Refresh Token - 제외
- 서버에 저장하면 로그아웃할 때 폐기할 수 있지만 이전 Token의 재사용을 탈취 신호로 구분하지 못함
-
Refresh Token Rotation - 채택
- 회전 뒤 이전 Token이 다시 나타나면 그 자체로 탈취 신호라 별도 탐지 로직 불필요
- 재사용 요청을 누가 보냈는지 서버가 가릴 수 없어 Token Family 단위 폐기가 필요
구현
재사용 감지와 Token Family 폐기
// 401을 던져도 Token Family 폐기는 커밋되도록 noRollbackFor 적용
@Transactional(noRollbackFor = AuthException.class)
public AuthSession refresh(String rawRefreshToken) {
String tokenHash = refreshTokenCodec.hash(rawRefreshToken);
// Token 원문 대신 해시로 행을 조회하고 행 락으로 동시 요청 직렬화
RefreshTokenSession current = refreshTokenRepository
.findByTokenHashForUpdate(tokenHash)
.orElseThrow(() -> new AuthException(AuthErrorCode.REFRESH_TOKEN_INVALID));
Instant now = clock.instant();
if (current.isRotated()) {
// 재사용으로 보고 Token Family 전체 폐기
revokeFamily(current.getFamilyId(), TokenRevocationReason.REUSE_DETECTED, now);
throw new AuthException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED);
}
// 새 Token을 같은 family_id로 발급
GeneratedRefreshToken nextToken = refreshTokenCodec.generate();
RefreshTokenSession nextSession = RefreshTokenSession.issue(
current.getFamilyId(), current.getMemberId(), nextToken.hash(),
now, now.plus(properties.refreshTokenTtl())
);
// 방금 쓴 Token을 ROTATED로 바꾸고 다음 Token을 가리키게 함
current.rotateTo(nextSession.getRefreshTokenId(), now);
refreshTokenRepository.save(nextSession);
}
검증
k6 동시 요청
const request = refreshRequest(
data.originalRefreshToken,
"auth_refresh_race"
);
http.batch([request, request]);
- k6
http.batch로 같은 Refresh Token을 갱신하는 요청 2건을 동시에 전송
| 확인 항목 | 결과 |
|---|---|
| 동시 갱신 | 성공 1건, 재사용 탐지 1건 |
| 성공한 요청이 받은 신규 Token으로 다시 갱신 | refresh_token_revoked로 거절 |
Token Family에 남은 ACTIVE Token | 0건 |
통합 테스트
| 시나리오 | 확인 결과 |
|---|---|
| 정상 회전 뒤 이전 Token 재사용 | 새로 발급한 Token을 포함해 Token Family 전체 REVOKED |
| 로그아웃 뒤 탈취된 Token 제출 | Access Token 재발급 거절 |
| 동일 Token 동시 갱신 2건 | 1건만 먼저 성공, 나머지 1건에서 재사용 탐지 |
| Token Family 폐기 뒤 신규 Token 제출 | refresh_token_revoked로 거절 |
한계
- 이미 발급된 Access Token은 최대 5분간 유효. 즉시 차단하려면 모든 서비스가 세션 상태를 조회하거나 폐기 목록을 공유해야 하므로 이번에는 짧은 만료 시간을 선택
- 정상적인 중복 갱신도 재사용으로 판단해 로그인이 종료됨. 갱신 요청을 하나로 합치는 single-flight 처리 필요