이번 사이드 프로젝트에서 처음으로 JWT를 이용한 로그인을 구현해봤다. 여기서는 단순한 로그인 구현 과정이 아닌 JWT를 사용하면서 내가 고민했던 부분들을 정리한다.
보통 인증/인가를 구현할때 세션 혹은 JWT를 사용한다. 세션 방식은 서버쪽에서 인증 정보를 관리하기 때문에 JWT 방식에 비해 계정 공유 제한, 디바이스별 로그아웃 기능 등을 구현하기 쉽고 보안이 상대적으로 좋다는 장점이 있다. 하지만 현재 진행중인 프로젝트에서는 해당 기능들을 사용하지 않으며 세션 방식의 가장 큰 문제인 scale-out 시 확장성 문제나 서버 부하 문제가 치명적이라고 생각해서 JWT를 사용하기로 결정했다. 토큰, 세션 관련해서는 아래 잘 정리된 블로그를 참고하자.
주로 대부분의 서비스에서 토큰 그 중 JWT 토큰을 많이 사용한다. 하지만 토큰 방식에는 단점이 있는데 그것은 토큰이 탈취되면 만료될때까지 대처가 불가능하다는 점이다. 즉 다른 사용자가 내 토큰을 탈취하면 나로 위장하여 서비스를 이용할 수 있고 우리는 이를 막을 방법이 없다. 대안으로 토큰의 만료 시간을 짧게 설정하여 토큰 탈취로부터 위험성을 줄일 수 있다. 하지만 이렇게 되면 사용자는 짧아진 유효기간으로 인하여 매번 로그인을 해야하는 문제가 생긴다. 그렇다면 어떻게 이 문제를 해결할 수 있을까?
해결법은 유효 기간이 다른 JWT 토큰 2개(Access token, Refresh token)를 두는 것이다. 평소에 API 통신할 때는 유효 기간이 짧은 Access token을 사용하고, Access token을 갱신할때는 유효 기간이 긴 Refresh token을 사용하면 된다. 나는 Access token의 유효 기간을 30분, refresh token의 유효기간을 2주로 정했다.
비록 Access token이 탈취당하면 이전처럼 탈취자가 나로 위장하여 서비스를 이용할 수 있지만 Refresh token 덕분에 재로그인 없이 Access token의 만료시간을 짧게 설정할 수 있어서 토큰 탈취 위험성을 다소 줄일 수 있었다. 그렇다면 이번에는 Refresh token이 탈취당하면 어떻게 될까?
Refresh token은 유효기간이 길어서 만약 탈취된다면 오랜시간 동안 탈취자가 Access token을 재발급할 수 있는데 이는 치명적이다. 이를 방지하기 위해 등장한 개념이 Refresh token rotation(RTT)이다.
Refresh token rotation(RTR)은 Access token이 만료될 때마다 Refresh token도 함께 교체를 해주는 것이다. 쉽게 말해서 Refresh token을 일회성으로 사용하는것이다. 아래 시나리오를 참고하자.
이번에는 탈취자가 refresh token을 탈취하고 정상 유저보다 먼저 토큰 재발급을 호출하면 어떻게 될까?
결국 refresh token rotation 덕분에 refresh token 탈취로부터 위험성을 어느정도 해소할 수 있었다.
이번에는 로그아웃할때 플로우를 생각해보자. 만약 로그아웃 시, 프론트엔드에서 access token을 제거하고 끝내버리면 access token의 유효기간이 아직 남아 있어서 누군가가 탈취했었으면 토큰의 남은 유효기간 동안 서비스를 마음대로 이용할 수 있다는 문제가 생긴다. 따라서 유저가 로그아웃시 access token의 남은 유효기간만큼 Redis에 유효기간을 설정하여 블랙리스트로 등록해놓았다. 그리고 jwt filter에서 access token에 대한 블랙리스트 검증 로직을 추가했고 유효기간이 끝나면 자연스럽게 블랙리스트(Redis)에서 삭제시켜 메모리에 남아있지 않도록 해주었다.
아래는 지금까지 JWT의 토큰 탈취 시나리오에 대응하기 위해 내가 고민하고 프로젝트에 적용한 내용들이다.
token 탈취 시나리오에 따른 대책 마련
그런데 문득 의문이 들었다.
🤔 분명 JWT 토큰은 Stateless하다고 들었는데 blacklist나 redis는 Stateless 특성에 어긋나는거 아닌가??
아래는 내가 구축한 로그인 플로우다.
-> 로그인/회원가입 api : redis에 refresh token 저장
-> 토큰 재발급 : redis에 refresh token 교체
-> 일반 API 요청 : blacklist(redis)에 access token이 등록되어 있는지 검사
-> 로그아웃 : blacklist(redis)에 access token 등록, redis에 refresh token 삭제
모든 API마다 redis를 경유한다..이러면 session과 별 다를바다 없다고 생각한다. 따라서 어느정도 보안을 희생하더라고 stateless한 JWT의 장점을 살리고 싶었다. 따라서 blacklist 개념은 도입하지 않기로 했다. 이러면 로그인이 아닌 다른 API를 요청할 경우에는 redis에 접근하지 않으므로 stateless 특성을 가진다고 판단했다. 대신 Access token의 유효기간을 30분으로 짧게 설정했다
이상으로 내가 로그인을 구현하면서 고민했던 내용들을 정리해봤다. 초기에는 JWT를 사용하면서 최대한 고도화된 방어 메커니즘을 구상하고자 했으나, 이 과정에서 JWT의 본질인 stateless를 완전히 위반해버렸다. 하지만 이를 해결하는 과정에서 토큰 인증에 대해 깊게 고민해볼 수 있었고 보안과 stateless를 적절히 고려한 나만의 인증 플로우를 구축할 수 있었다.
참고
프론트에서 안전하게 로그인 처리하기 (ft. React)
JWT는 어디에 저장해야할까? - localStorage vs cookie