최근에 토이 프로젝트를 진행하면서 로그인 API를 구현하던 중에 Refresh 토큰에 대해 생각해보게 되었다. 예전에 나는 Refresh 토큰이 "필요하다 vs 필요없다"라는 의견에 대해 토론한 글을 본적이 있었다. 해당 글이 어디있는지는 모르겠다.. 🙂
여튼 당시 나는 Refresh 토큰을 구현하는데 들어가는 공수를 생각해서 그런지 "필요없다"라는 의견에 동의했었다. 그리고 기능 구현에 급급해서 제대로 알아보지도 않고 Refresh 토큰을 구현하지 않았었지만 이번에 다시 공부하면서 정리해보고자 한다.
내가 참고한 유튜브 영상이 전체적인 흐름을 파악하는데 쉽다고 판단해서 가지고 와봤다.
짧게 요약하자면 다음과 같다.
1. 로그인 (Username, Password)
2. Access/Refresh 토큰 발급
3. Access 토큰 만료시 `/refresh` 엔드포인트로 새로운 토큰을 발급
생활 코딩에서는 좀더 자세하게 RFC 문서와 구글 문서를 예제로 설명했다. (구글 문서는 이전과 많이 달라져서 영상에 나온 요청 폼과 결과로 어떤 흐름으로 통신이 되는지만 살펴보면 될 것 같다.)
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
client_id=<your_client_id>&
client_secret=<your_client_secret>&
refresh_token=<refresh_token>&
grant_type=refresh_token
{
"access_token": "1/fFAGRNJru1FTz70BzhT3Zg",
"expires_in": 3920,
"scope": "https://www.googleapis.com/auth/drive.metadata.readonly",
"token_type": "Bearer"
}
토큰과 관련된 내용은 이미 많은 레퍼런스에서 잘 설명하고 있으니 나는 나만의 형태로 Refresh 토큰에 대해서 정리해보자.
JWT는 Stateless한 방식으로 동작하기 때문에 서버는 사용자가 본인이 맞는지에 대한 보장을 할 수 없다. 따라서 해커가 Access 토큰을 탈취할 경우 사용자의 의도와는 다른 행동들을 유발할 수 있다.
그렇다고 Access 토큰 만료 기간을 짧게 구성할 경우 만료될 때마다 로그인을 요구하게 된다면 사용자에게 불편함을 제공하게 된다.
이 문제들을 해결하기 위해 새로운 Access 토큰을 발급해주기 위한 Refresh 토큰을 사용하는 것이다.
Refresh 토큰의 문제점은 크게 3가지를 볼 수 있다.
1. Access, Refresh 토큰을 탈취당할 경우
2. 한 사용자가 여러 디바이스로 서비스를 이용할 경우
3. 복잡한 로직 및 추가적인 DB IO 작업에 의한 성능 감소 (CRUD)
그럼 이 문제들을 보완하기 위해 지금의 내가 할 수 있는 방법은 무엇일까?
내가 참고한 레퍼런스에 따르면 일반적으로 토큰은 쿠키나 로컬 스토리지에 저장하지만, 자바스크립트로 손쉽게 접근할 수 있기에 http-only
속성이 부여된 쿠키에 저장하는게 좋다고 하였다.
대부분 토큰 탈취는 네트워크를 통해 발생한다. 따라서 RTR(Refresh Token Rotation)이라는 기법을 이용하여 One time Use Only - 한번 쓰면 버리고 새로운 Refresh 토큰을 발급하는 방법을 이용한다고 한다.
내가 찾은 방법은 토큰 테이블에 Refresh 토큰 저장하는 방법이다.
1. Access 토큰 만료
2. 사용자는 발급해준 Access, Refresh 토큰을 `/refresh` 엔드포인트로 전달
3. 서버는 만료된 Access 토큰에서 user_id를 얻어내 토큰 테이블에 Refresh 토큰을 비교
4. Refresh 토큰이 유효하다면 새로운 Access 토큰 발급, 유효하지 않다면 새로 로그인
나는 위의 과정을 보완하여 내가 생각했을 때 가장 안전할 것 같은 방식으로 더 자세하게 표현해보았다. (많은 피드백 부탁드립니다.. 🙏🏻)
💁🏻
- 로그인 시도
💻
- 전달받은 로그인 정보로 DB에서 사용자 존재 여부 확인
- Access, Refresh 토큰 발급
- 발급해준 Access, Refresh 토큰을 테이블에 저장 혹은 업데이트
(ex. user_id, access_token, refresh_token, device, ip, created_at, deleted_at)
- 같은 IP인데 디바이스가 다를 경우 가장 최근에 접속한 디바이스 외 모든 토큰 데이터 삭제
- 다른 IP이고 디바이스가 다를 경우 모든 토큰 데이터 삭제
- 이벤트를 생성하여 만료 기간(deleted_at)마다 테이블에서 Access, Refresh 토큰 데이터 삭제
(ex. 크론탭, DB에서 제공하는 기능 (트리거, 이벤트 스케줄러), 별도의 솔루션 등)
💁🏻
- Access, Refresh 토큰 저장
- Access 토큰을 이용하여 API 통신
💻
- API 통신중 Access 토큰이 만료될 경우 만료됨을 응답
💁🏻
- 토큰이 만료되었을 경우 `/refresh` 엔드포인트로 새로운 토큰 발급 요청
- 이때, Access(만료), Refresh 토큰 모두 서버에 전송
💻
- Refresh 토큰 만료 여부 확인
- 전달받은 Refresh 토큰에서 user_id 가져옴
- 테이블에서 user_id, Access(만료), Refresh 토큰 등 모두 동일한지 비교
- 조건들이 모두 만족할 경우 새로운 Access, Refresh 토큰 발급
- 새로 발급해준 Access, Refresh 토큰은 테이블에 업데이트
- 조건들을 모두 만족하지 않을 경우 새로 로그인하도록 응답
(과정 반복)
💡 토큰 만료기간?
토큰 만료기간은 서비스마다 모두 다를 거라 생각한다. Cafe24에서는 Access 토큰은 2시간, Refresh 토큰은 14일이라고 한다. 보안을 생각한다면 기간을 더 짧게 구성하는 것도 좋아보인다.
💡 진행중인 프로젝트에 대한 TMI!
참고로 많은 레퍼런스에 따르면 인증 서버를 별도로 두어서 처리하는 것 같다. 하지만 나는 모놀리틱한 구조로 작업하고 있기 때문에 하나의 서버에서 모든 처리를 할 수 있도록 진행중이다.
위의 과정은 보안적으로 완벽하다고 할 수 없고 사용자의 편의성 차원에서 좋지 않을 수도 있다. 하지만 기존에 내가 사용했던 만료기간을 길게 설정하여 Access 토큰만을 발급해주는 방법보다 더 안전하다고 생각한다. (난 보안을 중요시하는 개발자니까! 😎)
22.11.10 기록
보통 Access 토큰은 Authorization(Bearer) 헤더에 담아서 보낸다. 그럼 Refresh 토큰은 어디에 담아서 보내야 할까?
StackOverflow에 올라온 질문도 나와 동일한 고민을 가지고 있었다. 그리고 질문에 대한 답변은 RFC 공식 문서처럼 바디에 담아 보내면된다 적혀있다.