Modu Square

탈취된 Refresh Token 재사용 문제 해결

문제

  • 로그아웃할 때 브라우저 쿠키만 삭제하면 미리 복사된 Refresh Token은 서버에서 계속 유효
  • 공격자는 탈취한 Token으로 사용자가 로그아웃한 뒤에도 새 Access Token을 발급받을 수 있음
사용자 브라우저Refresh Token R0공격자Auth ServiceDomain ServiceAccess Token 검증로그아웃 전 R0 탈취탈취한 R0로 갱신 요청새 Access Token 발급Access Token으로 정상 호출

해결 전략

  • Token은 발급 직후 ACTIVE, 재사용이 탐지되면 REVOKED 상태로 관리
  • 갱신할 때마다 이전 Token을 ROTATED로 바꾸고 새 Token 발급
  • 같은 로그인에서 이어진 Token을 family_id 하나로 묶어 Token Family로 관리
  • ROTATED Token이 들어오면 재사용으로 보고 Token Family 전체 폐기

기술 선택 이유

  • 만료 시간이 긴 Access Token - 제외

    • DB를 조회하지 않아도 되지만 로그아웃하거나 탈취 사실을 확인한 뒤 서버에서 즉시 폐기하기 어려움
  • 고정 Refresh Token - 제외

    • 서버에 저장하면 로그아웃할 때 폐기할 수 있지만 이전 Token의 재사용을 탈취 신호로 구분하지 못함
  • Refresh Token Rotation - 채택

    • 회전 뒤 이전 Token이 다시 나타나면 그 자체로 탈취 신호라 별도 탐지 로직 불필요
    • 재사용 요청을 누가 보냈는지 서버가 가릴 수 없어 Token Family 단위 폐기가 필요
정상 갱신 - Token RotationToken R0ACTIVEAuth ServiceToken 갱신갱신Token R0ROTATEDToken R1ACTIVE이전 Token 재사용 - Family RevokeToken R0ROTATEDAuth Service재사용 감지다시 제출Token R0REVOKEDToken R1REVOKED

구현

재사용 감지와 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 Token0건

통합 테스트

시나리오확인 결과
정상 회전 뒤 이전 Token 재사용새로 발급한 Token을 포함해 Token Family 전체 REVOKED
로그아웃 뒤 탈취된 Token 제출Access Token 재발급 거절
동일 Token 동시 갱신 2건1건만 먼저 성공, 나머지 1건에서 재사용 탐지
Token Family 폐기 뒤 신규 Token 제출refresh_token_revoked로 거절

한계

  • 이미 발급된 Access Token은 최대 5분간 유효. 즉시 차단하려면 모든 서비스가 세션 상태를 조회하거나 폐기 목록을 공유해야 하므로 이번에는 짧은 만료 시간을 선택
  • 정상적인 중복 갱신도 재사용으로 판단해 로그인이 종료됨. 갱신 요청을 하나로 합치는 single-flight 처리 필요