개요
지난 글
지난번 인증 방법에 대해 고민하면서 안전한 저장소에 대해서도 함께 고민했다.
그리고 일반적인 브라우저에서는 쿠키가 그나마 안전하다고 생각했다.
HttpOnly
옵션을 사용하면, 스크립트의 접근을 막아 XSS 공격으로부터 안전하기 때문이다.
여기에 CSRF도 방어한다면 일반적으로는 로컬 스토리지보다 훨씬 안전하다.
(물론 클라이언트-사이드 스토리지로서의 한계는 있다)
달리는 기차의 바퀴를 갈아끼워보자
우리는 이제 막 액세스 토큰 갱신을 위한 리프레쉬 토큰을 도입을 마쳤다.
그리고 토큰 저장소를 로컬 스토리지에서, 쿠키로 이관할 것이다.
어떻게 서비스에 영향을 미치지 않고 인증 정보 저장소를 바꿀 수 있을까?
둘 다 지원하는 상태
달리는 기차의 바퀴를 갈아끼우려면, 보조 바퀴를 덧대야 한다.
즉, 하위 호환성을 고려해 신버전과 구버전 모두를 지원하는 상태로 만들어야 한다.
인증을 열쇠와 열쇠 구멍으로 비유해보자.
‘두 열쇠구멍 중 하나라도 풀리면 문이 열려야 한다’ 라는 이상한 조건을 만족해야 한다.
기존 열쇠 구멍이 하나였다면, 우리는 이제 새로운 열쇠 구멍을 추가해야 한다.
또한, 문의 걸쇠는 하나로 유지해야 한다.
하위 호환성을 지키지 않으면?
스낵게임 클라이언트에서는 현재 헤더에 액세스 토큰을 싣어 보낸다. (이하 ‘헤더 토큰’ 방식)
이것을 쿠키에 싣도록 개선하려고 한다. (이하 ‘쿠키 토큰’ 방식)
헤더 토큰을 버리고, 쿠키 토큰만 읽도록 변경하면 어떻게 될까?
헤더 토큰 방식을 사용하는 구버전 클라이언트 사용자들은 갑작스럽게 장애를 겪게 된다.
열쇠 구멍이 사라졌기 때문이다!
그래서 우리는 두 가지 방식 모두를 지원하는 상태로 만들어야 한다.
토큰 읽기 마이그레이션
if (헤더 토큰 || 쿠키 토큰) { … }
구현
public Object resolveArgument(...) {
String authorization = webRequest.getHeader(HttpHeaders.AUTHORIZATION);
String token = bearerTokenExtractor.extract(authorization);
accessTokenProvider.validate(token);
Long memberId = Long.parseLong(accessTokenProvider.getSubjectFrom(token));
return memberResolver.resolve(memberId)
.orElseThrow(TokenAuthenticationException::new);
}
ArgumentResolver
를 사용했다.기존 코드를 수정해 쿠키 토큰 방식을 지원하게 구현해보자.
헤더 토큰 방식을 fallback으로 사용하도록 구현하면 된다.
public Object resolveArgument(...) {
String accessToken = getAccessTokenCookieFrom(webRequest)
.map(Cookie::getValue)
.orElseGet(() -> {
String authorization = webRequest.getHeader(AUTHORIZATION);
return bearerTokenExtractor.extract(authorization);
});
return resolveMemberFrom(accessToken);
}
테스트
정말 열쇠 구멍이 두 개인지 테스트해보자.
참고) Fallback이 뭔가요?
- 장애 대응책 - failover (이건 해결 삽가능이지!)
- 장애 복구책 - failback (아 안되네요… 빠꾸…)
- 장애 대비책 - fallback (이가 없으면 잇몸으로 한다!)
→ ex) 프로덕션 장애시 예비 어플리케이션으로 요청을 라다이렉션한다.
→ ex) 배포 중 실패 시 기존 버전 어플리케이션을 실행한다.
→ ex) CQRS를 적용했을 때, 읽기 DB 접근이 안될 수 있으니 master DB로 다 처리하도록 대비해둔다.
토큰 발급 마이그레이션
토큰 발급은 어떨까? 하위 호환성을 고려해야 할까?
우리는 위에서 둘 중 하나만 맞으면 풀리는 열쇠 구멍을 만들었다.
새로운 열쇠 구멍이 있으니까 옛날 열쇠 구멍에 맞는 키를 발급할 필요가 없는 것일까?
그렇지 않다. 서버 배포와 클라이언트 배포에는 시간적 간격이 존재한다.
따라서 서버는 두 열쇠 모두 발급해야 한다.
클라이언트는 아직 새 열쇠가 무슨 열쇠인지 모르는 상태이기 때문이다.
클라이언트가 준비되지 않은 상태로 기존 방식을 없애버리면, 처참하게도 로그인할 수 없는 문제가 발생할 것이다.
클라이언트가 대응할 때까지의 과도기를 어떻게 해결할 것인가?
- ⛔️ 서비스 점검시간을 두어 양측의 배포가 끝나고 한번에 서비스를 재개한다.
- 이 정도로 시급한 일은 아니라는 생각이 들었다.
- ⛔️ 롤링 배포 전략을 사용한다.
- 한 서버에 새로운 열쇠 구멍을 위한 키만 깎는 새 버전을 배포하고, 또 다른 서버에는 기존 버전을 유지한다. 이후 클라이언트 배포가 모두 완료되면 기존 버전을 종료한다.
- 하지만 현재 우리는 단일 서버로 운영하고 있기에 적합하지 않은 방법이다.
- ✅ 새로운 열쇠 구멍을 위한 키와 옛날 열쇠 구멍을 위한 키를 클라이언트가 대응할때까지 준다.
다시 하나로
옛날 열쇠 구멍은 언제 없애면 좋을까?
새 토큰 저장소에 대응한 클라이언트가 일시에 배포된다고 가정해보자.
배포 이후 로그인하는 경우 토큰이 쿠키에 저장되므로 고려대상이 아니다.
문제는 기존에 로그인 한 사용자들이다.
리프레시 토큰이 쿠키에 있음에도 불구하고, 액세스 토큰이 재발급되지 않는다.
재발급 해요…
현재 스낵게임의 액세스 토큰 유효기간은 일주일이다.
따라서 클라이언트 배포 완료 후 일주일 후에는 두 가지 사용자들이 존재하게 된다.
1. 쿠키 토큰 방식을 사용한다.
2. 헤더 토큰이지만 만료가 되었다.
만료 이후에는 새 토큰을 발급받으면서 자연스럽게 저장소가 옮겨질 것이다.
하지만 만료까지 기다려야 할까?
임의로 재발급하게 한다
JWT는 이미 그 자체로 ‘유효기간’ 이라는 상태를 가지므로 우리가 임의로 상태를 조작할 수 없다.
하지만 클라이언트가 ‘헤더 토큰 방식을 사용중이면 즉시 재발급하는 코드’를 작성한다면?
서버는 즉시, 클라이언트는 30일 후에 사용자에게 영향없이 헤더 토큰 방식을 제거할 수 있다.
왜 ‘30일’동안 지원을 유지해야할까?
로그인 유지 기간이 30일이기 때문이다.
클라이언트도 즉시 지원을 제거하는 경우를 예시로 알아보자.
논리적으로 로그인이 연장되어야 하는 마지노선이다.
이상하게도 리프레시 토큰이 있지만 서버는 ‘너 열쇠가 없는데?’라고 말한다.
클라이언트가 헤더 토큰 지원을 종료했으므로 어떤 토큰도 실리지 않았기 때문이다.
이것을 방지하려면 29일 23시간 59초 동안 기존 토큰에 대한 지원을 유지해야 한다.
액세스 토큰과 리프레시 토큰의 쿠키 경로(Path)는 다르다.
때문에 재발급 API 경로와 다른 경우 리프레시 토큰이 실리지 않아 서버가 알 수 없다.
논리적으로 더 이상 로그인이 유지될 필요가 없다.
따라서 우리는 1️⃣번 경우까지만 지원하면 된다.
마침내 결론. 일주일을 기다린 후 옛날 열쇠 구멍을 없애면 우아하게 이관이 완료될 것이다.
부록) 리프레시 토큰도 Local Storage에 저장되었다면?
리프레시 토큰도 이관이 필요했다면 어땠을까?
토큰 읽기 마이그레이션과 마찬가지로, 리프레시 토큰을 읽는 창구도 2개로 열어둔다.
기존 버전 지원을 유지해야하는 기간은 액세스 토큰과 마찬가지로 ‘로그인 유지 기간’인 30일이다.