Jwt : 보안과 stateless 사이의 tradeoff

이상훈·2024년 3월 4일
0

Project

목록 보기
7/10
post-thumbnail

서론

 이번 사이드 프로젝트에서 처음으로 JWT를 이용한 로그인을 구현해봤다. 여기서는 단순한 로그인 구현 과정이 아닌 JWT를 사용하면서 내가 고민했던 부분들을 정리한다.


JWT를 사용한 이유

 보통 인증/인가를 구현할때 세션 혹은 JWT를 사용한다. 세션 방식은 서버쪽에서 인증 정보를 관리하기 때문에 JWT 방식에 비해 계정 공유 제한, 디바이스별 로그아웃 기능 등을 구현하기 쉽고 보안이 상대적으로 좋다는 장점이 있다. 하지만 현재 진행중인 프로젝트에서는 해당 기능들을 사용하지 않으며 세션 방식의 가장 큰 문제인 scale-out 시 확장성 문제나 서버 부하 문제가 치명적이라고 생각해서 JWT를 사용하기로 결정했다. 토큰, 세션 관련해서는 아래 잘 정리된 블로그를 참고하자.

JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)

 주로 대부분의 서비스에서 토큰 그 중 JWT 토큰을 많이 사용한다. 하지만 토큰 방식에는 단점이 있는데 그것은 토큰이 탈취되면 만료될때까지 대처가 불가능하다는 점이다. 즉 다른 사용자가 내 토큰을 탈취하면 나로 위장하여 서비스를 이용할 수 있고 우리는 이를 막을 방법이 없다. 대안으로 토큰의 만료 시간을 짧게 설정하여 토큰 탈취로부터 위험성을 줄일 수 있다. 하지만 이렇게 되면 사용자는 짧아진 유효기간으로 인하여 매번 로그인을 해야하는 문제가 생긴다. 그렇다면 어떻게 이 문제를 해결할 수 있을까?


1. Refresh token 도입

 해결법은 유효 기간이 다른 JWT 토큰 2개(Access token, Refresh token)를 두는 것이다. 평소에 API 통신할 때는 유효 기간이 짧은 Access token을 사용하고, Access token을 갱신할때는 유효 기간이 긴 Refresh token을 사용하면 된다. 나는 Access token의 유효 기간을 30분, refresh token의 유효기간을 2주로 정했다.

  1. 일반 로그인(ID, PW) 혹은 소셜 로그인을 진행한다.
  2. DB에 해당 사용자가 있는지 확인한다.
  3. Access token과 Refresh token을 발급한다. Refresh token을 서버쪽에서 보관해야 하는데 나는 Redis를 사용했다.
  4. 클라이언트는 서버로부터 Access token, Refresh token을 받고 로컬에 저장해둔다.
  5. 클라이언트는 Access token과 함께 인증이 필요한 API 통신을 요청한다.
  6. 서버에서 Access token을 검증한다.
  7. 검증이 성공하면 요청 데이터를 사용자에게 반환해준다.
  8. 클라이언트는 만료된 Access token과 함께 인증이 필요한 API 통신을 요청한다.
  9. 서버에서 Access token을 검증하고 해당 Access token이 만료되었음을 확인한다.
  10. 클라이언트에게 미리 합의한 Access token 만료 에러 코드를 반환한다.(나의 경우는 1020)
  11. 클라이언트는 Access token, Refresh token과 함께 토큰 재발급 API를 호출한다.
  12. 서버에서는 Refresh token의 만료 여부와 Redis에 저장되어 있는 Refresh token과의 일치 여부를 대조하여 문제가 없으면 Access token을 재발급한다.
  13. 서버는 새로운 Access token을 클라이언트에게 반환한다.

 비록 Access token이 탈취당하면 이전처럼 탈취자가 나로 위장하여 서비스를 이용할 수 있지만 Refresh token 덕분에 재로그인 없이 Access token의 만료시간을 짧게 설정할 수 있어서 토큰 탈취 위험성을 다소 줄일 수 있었다. 그렇다면 이번에는 Refresh token이 탈취당하면 어떻게 될까?


2. Refresh token rotation(RTT) 도입

 Refresh token은 유효기간이 길어서 만약 탈취된다면 오랜시간 동안 탈취자가 Access token을 재발급할 수 있는데 이는 치명적이다. 이를 방지하기 위해 등장한 개념이 Refresh token rotation(RTT)이다.
 Refresh token rotation(RTR)은 Access token이 만료될 때마다 Refresh token도 함께 교체를 해주는 것이다. 쉽게 말해서 Refresh token을 일회성으로 사용하는것이다. 아래 시나리오를 참고하자.

  1. 탈취자가 refresh token(r1) 탈취
  2. 정상 유저가 access token 재발급 요청
  3. 서버는 새로운 access token, refresh token(r2) 생성
  4. db에서 기존 refresh token(r1) 삭제, 새로운 refresh token(r2) 저장
  5. 서버는 정상유저에게 새로운 access token, refresh token(r2) 발급
  6. 탈취자가 기존 refresh token(r1)으로 acccess token 재발급 요청
  7. refresh token(r1)은 만료되었으므로 access denied

 이번에는 탈취자가 refresh token을 탈취하고 정상 유저보다 먼저 토큰 재발급을 호출하면 어떻게 될까?

  1. 탈취자가 refresh token(r1)을 이용하여 새로운 access token, refresh token(r2) 재발급
  2. 정상 유저가 refresh token(r1)을 이용하여 재발급 요청
  3. 해당 refresh token(r1)은 만료되었으므로 access denied
  4. 사용자는 재로그인 요청

 결국 refresh token rotation 덕분에 refresh token 탈취로부터 위험성을 어느정도 해소할 수 있었다.


3. Blacklist 도입

 이번에는 로그아웃할때 플로우를 생각해보자. 만약 로그아웃 시, 프론트엔드에서 access token을 제거하고 끝내버리면 access token의 유효기간이 아직 남아 있어서 누군가가 탈취했었으면 토큰의 남은 유효기간 동안 서비스를 마음대로 이용할 수 있다는 문제가 생긴다. 따라서 유저가 로그아웃시 access token의 남은 유효기간만큼 Redis에 유효기간을 설정하여 블랙리스트로 등록해놓았다. 그리고 jwt filter에서 access token에 대한 블랙리스트 검증 로직을 추가했고 유효기간이 끝나면 자연스럽게 블랙리스트(Redis)에서 삭제시켜 메모리에 남아있지 않도록 해주었다.


4. Stateless에 대한 고민

 아래는 지금까지 JWT의 토큰 탈취 시나리오에 대응하기 위해 내가 고민하고 프로젝트에 적용한 내용들이다.

  • token 탈취 시나리오에 따른 대책 마련

    • Refresh token 탈취
      → Refresh token Rotation을 도입해서 Access token, Refresh token 모두 재발급
        
    • Access token 탈취
      -> 로그아웃 시 플로우 : redis에서 Refresh token 삭제 및 Access token을 blacklist에 등록


그런데 문득 의문이 들었다.

🤔 분명 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

profile
Problem Solving과 기술적 의사결정을 중요시합니다.

0개의 댓글

관련 채용 정보