olrlobt
[인증인가] Access Token, Refresh Token의 저장 위치에 대한 고찰 본문
포트폴리오용 프로젝트를 진행하면서 인증인가 부분은 가장 기피되는 역할 중 하나라 생각한다. 오류가 가장 많이 발생하는 부분이며, 시큐리티를 적용하는 과정 또한 만만치 않은 것이 주된 원인이라 생각한다.
나도 수많은 프로젝트를 진행했지만 인증인가를 맡아본 경험은 없었다. 딱히 기피한 건 아니고.. 더 하고 싶은 부분이 있었을 뿐이지만...
어쨌든 이번에 좋은 기회가 생겨서 인증인가 부분을 맡게 되었는데, 그간 인증인가를 맡은 친구들이 왜 하나같이 화를 냈는지 이해가 갈 수밖에 없었다. 항상 개발에는 정답이 없다고 수도 없이 들어왔지만, 관습처럼 써 오던 부분은 어느샌가 정답이 되어 고민도 안 하고 사용하고 있었는데, 이 인증인가 부분은 정말로 정답이 없었다. 수많은 보안적인 고민을 해야 하며, 어떤 방법으로 보안을 더 강화할지, 왜 이 방법을 선택하는지 끊임없이 고민하고 그 이유를 찾아야만 했다. 수많은 포스팅과 개발자들도 어느 한 방법만을 소개할 뿐, 어떤 것이 정답이라고는 말하고 있지 않았다.
따라서, 이 포스팅에서는 내가 선택한 인증 방식과 그 이유에 대해 고민한 흔적들을 작성해 보려 한다.
다시 한번 말하지만, 정답은 없다!!
사용자 인증
사용자 인증은 시스템이나 애플리케이션에 접근하려는 사용자가 실제로 그 사용자가 맞는지 확인하는 과정이다. 이런 과정이 없다면, 사용자의 정보를 마음대로 악용할 가능성이 생기므로 보안적으로 중요한 요소가 아닐 수 없다. 또한, 사용자 경험과 확장성도 고려해야 한다. 이미 사용자 인증을 마친 회원이 페이지를 이동할 때마다 사용자 인증을 하게 된다면 사용자에게는 굉장히 불편한 서비스가 될 것이며, 서비스가 성장함에 따라 확장에 유연하지 못하다면, 확장 비용을 무시할 수 없게 된다. 따라서, 사용자 인증 방식들을 보안성, 사용자 경험, 확장성을 중심으로 비교해 보고 선택하는 것이 중요하다.
사용자 인증은 크게 세션 인증 방식과 토큰 인증 방식으로 나눌 수 있으며, 각각의 방식에는 장단점이 존재한다.
다른 포스팅에서는 쿠키 인증 방식까지도 다루는데, 사실 쿠키에 세션 ID를 담으면 세션 인증 방식, 쿠키에 토큰을 담으면 토큰 인증 방식이 되어버리므로, 해당 포스팅에서는 다루지 않겠다.
대표적인 두 방법 중, 어떤 방법을 선택할지는 프로젝트의 특성과 요구사항을 잘 고려하여 사용해야 한다. 나만 그런지 모르겠지만, 세션 인증 방식으로 간단하게 인증을 구현해 본 후 토큰 인증 방식으로 넘어가기 때문에, 나도 모르게 토큰 인증 방식을 정답으로 인식하고 있었다.
하지만 개발에는 정답이 없고, 내 프로젝트와 요구사항에 알맞은 기술을 선택하는 것이 가장 중요하기 때문에 두 방식을 자세히 뜯어보고 각각을 사용하는 이유에 대해 알아볼 필요가 있다.
세션 인증 방식의 특징
세션 인증 방식의 가장 큰 특징은 서버에 상태를 저장하는 상태 유지(stateful)이다.
사용자가 로그인을 서버로 요청하면, 요청 정보가 DB와 일치하는지 확인하고 사용자의 세션을 생성한다. 이때 생성한 세션 ID는 서버의 저장소(메모리나 DB)에 저장되는데, 이 값을 서버에 저장하기 때문에 stateful이라 한다. 이 후로는 쿠키에 담아 사용자의 후속 요청마다 세션 ID가 담긴 쿠키를 서버에 전송하게 되고, 해당 세션 ID가 저장소에 존재하는지 확인하는 식으로 사용자를 검증한다.
이러한 방식은 서버에서 인증을 관리하기 때문에, 클라이언트 측에서 정보가 노출될 위험이 적다는 보안적인 장점이 있다.
하지만 빈번히 발생하는 사용자의 요청마다 DB에 접근한다는 것은 응답 속도 저하를 일으키는 원인이 되며, 이러한 요청이 수백, 수천만 건이 된다면 서버에 부하를 일으키는 원인까지 될 수 있다. 또한, 요즘은 금융권도 MSA를 도입할 만큼 분산 환경을 자주 볼 수 있는데, 이런 환경에서 세션의 일관성을 유지하기 위해 중앙 저장소를 사용하거나 추가적인 작업을 해야 한다는 점은 세션 인증 방식의 확장성 면에서 큰 단점으로 꼽힐 수 있다.
토큰 인증 방식을 사용하는 이유
토큰 기반 인증은 세션 인증 방식과 반대로 서버에 상태를 저장하지 않는 상태 비저장(stateless)이 가장 큰 특징이다. 즉, 서버는 사용자의 정보를 검증하기 위해 DB를 거치지 않고 오로지 토큰만을 사용한 방법으로 사용자를 검증한다. 어떻게 토큰만을 사용하여 검증을 진행하는지는 아래에서 살펴보도록 하고, 이 문단에서는 토큰 인증 방식을 사용하는 이유에 대해 집중해 보자.
기존 세션 인증 방식의 가장 큰 단점은 응답 속도 저하로 인한 서버 부하(사용자 경험 저하)와 확장에 어려움이 있다는 점이다. 반면, 토큰 인증 방식은 DB를 거치지 않고 오로지 토큰만을 사용하여 인증하기 때문에 응답속도 저하 문제를 해결할 수 있으며, 확장성면에서도 확실히 우수한 면을 보인다. 또한, 모바일 환경에서는 IP로 세션을 관리하는 경우도 있는데, 이런 경우에는 토큰 방식이 더 선호된다.
즉, 세션 인증 방식에 비해 응답속도, 확장성 면에서 우수하여 토큰 방식을 사용하는 것이다.
토큰 인증 방식의 유일한 단점이라면, 토큰 탈취의 위험이 있다는 것.
세션이 서버에서 관리되는 장점과 상반되는 이유이다. 클라이언트 자체에서 탈취되거나, 악성코드(XSS)등으로 탈취될 가능성도 있고, 클라이언트에서 서버로 전송되는 동안에도 탈취될 가능성이 있다. 만약 해커에게 토큰이 탈취되었다면, 이를 서버 측에서 강제로 해제할 수 있는 방법이 명확하지 않다. 블랙리스트라는 방법으로 토큰을 무효화시킬 수 있지만, 어디까지나 토큰이 탈취된 것을 감지했을 때의 이야기일 뿐이다.
이렇게 대표적인 두 방식의 장단점이 명확하기 때문에, 어떤 방식을 사용할지 프로젝트의 요구사항에 따라 적절히 선택하여야 하며, 사용 방식의 단점들의 보완할 방법들을 함께 적용하여야 한다.
나는 두 방식 중 세션의 단점을 보완하기 위해 등장한 토큰 인증 방식을 선택하기로 하였으며, 토큰 탈취에 대한 방비책을 찾아보고 도입하는 방향으로 진행하려 한다.
토큰 탈취에 대한 방비책을 도입하기 위해, 토큰 인증 방식의 구조를 명확히 이해하고 목적에 맞게 설계를 진행해 보자.
Access Token
토큰 인증방식은 다양한 방법이 있지만, 주로 OAuth 2.0 프로토콜에서 사용되는 Access Token을 이용한다.
Access Token은 사용자의 인증과 권한을 나타내는 단기 유효 토큰(문자열)으로, 클라이언트가 서버에 API 요청을 보낼 때 이 토큰을 헤더에 포함하여 요청을 보내고 액세스 토큰이 유효한지를 확인하여 사용자를 검증한다.
기존 세션 방식은 서버가 인증 정보를 관리했다면, Access Token은 클라이언트가 인증 정보를 직접 관리할 수 있도록 한다. 물론, Access Token의 경우에도 서버에 인증 정보를 관리할 수 있지만, 앞서 세션 방식의 단점을 그대로 안고 가기 때문에 권장되는 방식이 아니다.
그렇다면 Access Token은 어떤 식으로 구성되어 있을까?
Access Token과 JWT
Access Token은 문자열 형식으로 구성되어 있으며, 발급받는 토큰 종류에 따라 구조화된 모양일 수 있고 해석할 수 없는 단순한 문자열일 수 있다.
이때 다양한 토큰 형식 중, JWT(JSON Web Token) 형식이 stateless(상태 저장 없음)하다는 장점 덕분에 주로 사용된다. stateless 하다는 특성은 다시 말해, 서버에서 Access Token 자체를 저장할 필요가 없어진 것으로, DB 통신이 없어질뿐더러 서버 인스턴스가 여러 개 있을 때에도 쉽게 확장할 수 있다는 것이다.
이런 Stateless 한 특징은 JWT의 구조에서 비롯된다.
JWT의 구조는 크게 헤더(Header), 페이로드(Payload), 서명(Signature)으로 구성되어 있으며, 한 줄의 문자열에서. (점)을 이용해 세 부분으로 구분된다.
헤더에는 토큰의 유형과 서명 알고리즘을 포함하며,
페이로드 부분에는 사용자 정보 및 클레임(사용자 ID, 권한, 만료시간 등)이 포함된다.
이 페이로드 부분 덕분에 서버가 별도의 DB 조회 없이도 사용자의 정보를 확인할 수 있어서, 요청 처리 속도가 빨라지고 서버의 부하가 줄어들게 된다. 이러한 특징을 Self-contained(자체 포함)이라 한다.
서명 부분은 헤더와 페이로드를 조합한 후, 헤더의 서명 알고리즘과 비밀 키를 사용하여 생성되는 부분이다. 이 서명 덕분에 클라이언트가 토큰을 변조하지 않았음을 검증할 수 있고, 신뢰할 수 있는 토큰임을 보장하게 된다.
최종적으로, 이 세 부분은 Base64 Url로 인코딩 되어 하나의 문자열로 결합되어 JWT 토큰을 구성하게 된다.
이렇게 구성된 JWT는 필요한 모든 정보와 변조 감지를 위한 서명 부분을 토큰 자체에 포함하고 있으므로, 서버에서 별도의 세션 저장소를 유지할 필요가 없어진다. 단순히 클라이언트가 JWT를 가지고 있으면, 서버는 이 토큰을 검증하여 사용자의 인증 상태를 확인할 수 있는 것이다.
또한, JWT는 IETF의 RFC 7519에 의해 표준화되어 있어, 다양한 라이브러리와 도구가 지원되는데, 이는 개발자들이 JWT를 쉽게 구현하고 사용할 수 있도록 많은 도움을 준다.
이러한 이유들로, JWT는 Access Token에서 널리 사용되는 방식이 되었다.
그렇다면, 앞서 클라이언트가 JWT를 가지고만 있으면 인증이 된다고 하였는데, 이 JWT를 어떻게 저장하고 관리하는 지를 알아보자.
Access Token 저장 위치
클라이언트에서 Access Token을 이용하여 인증 정보(JWT)를 관리하는 방식으로는 로컬 스토리지(Local Storage), 세션 스토리지(Session Storage), 쿠키(Cookies), 메모리(Memory)에 저장하는 방식으로 나눌 수 있다.
1. 로컬 스토리지 (Local Storage) [권장 X]
로컬 스토리지는 클라이언트 측에 데이터를 영구적으로 저장하는 방법이다. 브라우저를 닫아도 데이터가 유지되며, 명시적으로 삭제하지 않는 한 계속 존재한다. 그러나 XSS(교차 사이트 스크립팅, JavaScript 기반 악성 코드를 브라우저에서 실행되도록 하는 공격 방식) 공격에 취약하여, 악성 스크립트가 로컬 스토리지에 저장된 액세스 토큰을 탈취할 수 있는 위험이 있다.
2. 세션 스토리지 (Session Storage) [권장 X]
세션 스토리지는 브라우저가 닫히면 데이터가 삭제되며, 같은 탭에서만 유효하다는 특징이 있다. 로컬 스토리지보다는 상대적으로 보안 측면에서 안전하지만, 여전히 XSS 공격에 취약하다는 단점이 있다.
3. 쿠키 (Cookies)
쿠키는 기본적으로 HTTP의 무상태(Stateless) 특성을 보완하기 위해 등장한 데이터 쪼가리다. 따라서, 모든 클라이언트 요청에 자동적으로 포함된다는 특징이 있다. 이는 Access Token에 사용될 때도 마찬가지로, 클라이언트에서 쿠키를 별도로 포함시키지 않아도 된다는 말이다.
쿠키는 만료 날짜를 지정하는 영구 쿠키와, 브라우저가 닫힐 때 삭제되는 세션 쿠키로 구분된다. 홈페이지 특성에 따라 보안이 중요하다면, 세션 쿠키를 사용하는 것이 권장된다.
특히 쿠키는 앞선 두 방식에서의 XSS 공격 취약점을 어느 정도 예방할 수 있다. HttpOnly 플래그를 사용하여 JavaScript에서 쿠키에 접근할 수 없게 함으로써 XSS 공격에 대한 보안을 높이는 방식이다. 하지만, XSS공격은 다양한 방법으로 이루어 지므로 완벽하게 방어가 된다고 볼 수는 없으며, 추가적인 보안 조치가 필요하다.
또한, Secure 플래그를 사용하면 HTTPS 연결에서만 쿠키가 전송되게 하여 중간자 공격(MITM)으로부터 쿠키를 보호할 수 있으며, SameSite 쿠키 속성을 설정하면 CSRF(교차 사이트 요청 위조) 공격을 방어할 수 있다.
4. 메모리 (Memory)
Access Token을 클라이언트 서버의 메모리에 저장하는 방식이다. 예를 들어, JavaScript의 private 변수에 JWT를 저장하는 것이다. 이렇게 하면, 매 요청 시마다 API 호출 시 Access Token에 접근이 쉬워지지만, 브라우저의 메모리는 세션 단위로 관리되기 때문에 페이지를 이동하면 Access Token이 소멸하는 문제가 발생한다. 따라서 이 방법은 SPA(Single Page Application)에서 주로 사용된다. 예를 들어 React를 사용하여 클라이언트 서버를 구현했다면, 페이지가 이동하는 것처럼 보여도 실제로는 이동하지 않기 때문에 private 변수가 그대로 유지된다. 하지만, 페이지를 새로고침한다면 private 변수가 소멸되기 때문에 다시 로그인을 해주어야 하는 상황이 발생한다.
메모리에 저장한다면 스크립트가 메모리 공간을 직접적으로 제어할 수 없기 때문에 XSS 공격에 의해 쉽게 탈취될 수 없다.
그렇다 해도 XSS 공격에 완벽히 안전한 것은 아니다. 만약 웹 애플리케이션 자체가 XSS 취약점을 가지고 있다면, 악의적인 스크립트로 Access Token을 탈취당할 위험성이 있다.
이 네 가지 방법 중 로컬 스토리지와 세션 스토리지의 경우, XSS 공격에 매우 취약하기 때문에 권장되지 않는 방식이다. 메모리와 쿠키의 경우에도 완벽히 안전하다고는 할 수 없지만, 다른 방식들에 비해서는 비교적 안전한 방식으로 많이 사용된다. 메모리 방식은 Access Token만 사용하는 경우에서 불편하게 보일 수 있지만, 이후에 다루는 Refresh Token까지 고려해 본다면 충분히 안정적으로 사용 가능한 방식이다.
따라서, 쿠키와 메모리 중 적절한 방법을 선택하고, 이에 맞는 보안 대책을 수립하는 것이 필수적이다.
Access Token 사용자 인증 방식
앞서 JWT를 Access Token으로 주로 이용하는 이유를 알아보았다. 본격적으로 이 JWT를 통해 어떻게 사용자 인증이 이루어지는지를 알아보자. JWT와 쿠키를 이용한 Access Token 인증 방식은 간단하게 아래와 같은 플로우로 이루어진다.
1. Access Token 생성
로그인 요청 시 JWT를 생성하여 클라이언트에게 반환하는 과정이다.
로그인 요청: 사용자가 로그인 정보를 입력하고 서버에 로그인 요청을 보낸다.
JWT 발급: 서버는 사용자의 인증 정보를 DB와 일치하는지 확인한 후, JWT를 생성하여 클라이언트에 반환한다.
2. Access Token 사용
서버에서는 JWT가 스스로 정보를 인증하므로 DB에서 사용자 정보와 일치하는지 검증할 필요가 없다.
클라이언트 측에서는 헤더에 JWT를 모든 요청에 포함하여 사용자 인증에 사용한다.
후속 요청: 클라이언트가 서버에 요청을 보낼 때, 헤더에 Access Token을 포함한다. (쿠키의 경우 자동으로 포함)
서버에서 JWT 검증: 서버는 요청받은 Access Token에서 사용자의 인증 상태를 확인한다.
이 인증 방식에서 Access Token의 사용이 무수히 많이 일어났다고 생각해 보자. 요청이 수백 번 일어나더라도, 서버에서 사용자 인증을 위해 DB에 접근하는 부분은 JWT를 발급하는 로그인 부분밖에 없다. 이러한 점이 JWT의 Stateless 한 특징이 가장 도드라지는 부분이다.
Access Token 로그아웃 방식
그렇다면 이 구조에서 로그아웃은 어떻게 하면 될까?
사용자 인증이 이루어지는 부분을 생각해 보면, 단순히 Access Token을 사용하기 때문에 이 부분만 무효화해 주면 된다는 결론에 이를 수 있다.
1. 메모리의 경우 Access Token 제거하기
클라이언트 서버 private 변수에 Access Token을 저장한 경우, Access Token 자체를 제거해 주면 된다. 간단히 null 처리를 통해 로그아웃을 구현할 수 있다.
2. HTTP-Only 쿠키의 경우 Access Token 만료시키기
Http-only 쿠키의 경우에는 JavaScript로 쿠키에 접근하는 방법은 불가능하다. 따라서, 서버에 요청을 보내 로그아웃을 구현한다. JWT는 토큰 자체에 만료 시간을 포함할 수 있지만, 이미 발급된 JWT를 서버에서 직접 만료시키는 것은 불가능하기 때문에 쿠키의 만료시간을 강제하여 쿠키를 삭제하는 방법으로 로그아웃한다.
클라이언트에서 로그아웃 요청이 오면, 서버가 Set-Cookie 헤더에 Expires 또는 Max-Age 속성을 설정하여 쿠키를 제거하도록 지시한다. 이렇게 하면 브라우저는 해당 쿠키를 삭제하므로, 클라이언트는 더 이상 Access Token을 사용할 수 없게 되며, 성공적으로 로그아웃 된다.
3. BlackList에 추가하기
서버에서 토큰을 무효화시키는 방법이다. 서버에서 발급한 JWT를 블랙리스트에 추가하여 해당 토큰의 유효성을 없앤다. 이 방법은 추가적인 저장소를 필요로 한다는 단점이 있으며, 매번 블랙리스트에 있는 Access Token인지 검증해야 할 필요가 생기므로 오버헤드가 발생한다. 또한, JWT를 블랙리스트에 저장하는 과정에서 Stateless 하다는 장점을 잃게 된다.
Access Token 탈취
이렇게만 하면, 간단하게 로그인부터 로그아웃까지 Access Token 하나만으로 구현이 가능하다. 하지만, Access Token 하나만을 사용한 상황에서 해커가 Access Token을 탈취하게 되면 어떻게 될까?
Access Token은 토큰 자체 하나만으로 사용자가 인증되는 강력한 기능을 갖고 있다. 따라서 이 토큰이 탈취된다면, Access Token으로 접근한 요청이 사용자의 요청인지, 해커의 요청인지 구분할 수 없게 되어 피해를 막을 수 없다.
Access Token을 사용 못하게 하는 방법은 무효화하는 것이다. 하지만 서버는 해커가 가진 stateless 한 Access Token에 관한 정보가 하나도 없다. 따라서 만료 시간이 다 될 때까지 아무런 조치도 취할 수 없다. 심지어 Access Token을 하나만 사용한다면, 사용자의 편의를 위해 긴 만료 시간을 갖고 있을 것이다. 이 경우, 서버에서 취할 수 있는 유일한 조치가 로그아웃 부분에서 언급한 BlackList에 추가하는 방법이다.
하지만 BlackList에 추가하는 것 역시 이상 패턴이 발견됐을 경우의 이야기이므로, 이상 패턴이 발견되지 않는다면 피해는 막을 수 없다.
그렇다면 반대로, Access Token의 만료시간을 짧게 하면 해커로부터의 피해를 최소화할 수 있지 않을까?
해커는 Access Token을 탈취하였기 때문에 만료시간까지 공격할 수 있겠지만, 금방 만료되어 버린 토큰은 사용이 불가능해지므로 피해가 최소화되는 효과를 가져온다. 하지만, 이는 사용자의 경우에도 마찬가지이다. 쇼핑을 하는데, 10분마다 다시 로그인을 하라고 한다면, 사용자 경험이 저하되어 사용자는 줄어들게 될 것이다.
따라서, 이 토큰 유효기간의 딜레마를 해결하기 위해 Access Token과 함께 Refresh Token을 사용한다.
Refresh Token
Refresh Token은 Access Token과 함께 발급되는 별도의 토큰으로, 새로운 Access Token을 재발급받기 위해 사용되는 토큰이다. 보안을 위해 짧은 만료기간을 설정한 Access Token이 만료되었다면, 클라이언트는 긴 만료 기간을 가진 Refresh Token의 유효성을 검증하고 인증 서버로부터 새 Access Token을 요청한다. 이를 통해, 연속적인 사용자 인증을 가능하게 하며, 사용자가 다시 로그인할 필요 없이 애플리케이션을 사용할 수 있도록 하는 것이다.
따라서, 만료기간이 긴 Refresh Token이 탈취되었다면, 해커가 Access Token을 재발급받아 사용할 수 있는 위험성 또한 존재한다. 이것이 Access Token 보다 Refresh Token의 보안성이 더 중요하게 여겨지는 이유이다.
또한, Refresh Token은 Access Token의 탈취 위험을 완벽하게 해소할 수 없다. Access Token의 유효 기간을 짧게 설정할 수 있게 함으로써, 해커에게 탈취당했을 때 피해를 최소화하는 것에 목적이 있다.
Refresh Token과 JWT
Refresh Token은 Access Token과 마찬가지로 JWT로 주로 구현한다. 이를 통해 토큰 하나만으로 Refresh Token의 유효성 검증을 진행할 수 있으며, 페이로드 부분에 저장된 최소한의 데이터(user_id 또는 token, id)만을 이용하여 DB에 접근한 후 Access Token을 재발급한다.
Refresh Token에서 JWT의 Self-contained(자체 데이터 포함)한 특징을 이용하여 DB의 접근을 없앨 수 있지만, 이렇게 구성하면 Refresh Token의 목적인 Access Token의 유효성 연장 이외의 정보들을 포함하게 되며, 보안적으로 취약점이 생길 수 있다.
따라서 Self-contained 한 특성은 사용하지 않는 것이 권장된다.
그렇다면, Refresh Token은 어디에서 관리될 수 있을까?
Refresh Token 저장 위치
클라이언트에서 Refresh Token을 관리하는 방법으로는 Access Token과 마찬가지로 로컬 스토리지(Local Storage), 세션 스토리지(Session Storage), 쿠키(Cookies), 메모리(Memory)에 저장하는 방식으로 나눌 수 있다.
1. 로컬 스토리지 (Local Storage) [권장 X]
2. 세션 스토리지 (Session Storage) [권장 X]
3. HTTP-only 쿠키 (Cookies)
4. 메모리 (Memory) [권장 X]
서버에서 Refresh Token을 관리하는 방법은 주로 DB를 이용한 방법을 사용하며, 응답 속도를 빠르게 하기 위하여 Redis를 이용한 관리 방법을 많이 사용한다.
5. 서버 DB에서 관리 (Redis, RDBMS)
로컬 스토리지와 세션 스토리지의 경우는 앞서 XSS 공격의 취약점이 크기 때문에 Access Token에서도 권장하지 않는 방식이라 하였다. 메모리의 경우, 페이지 이동과 새로고침시 소멸하는 문제가 있어 Refresh Token에는 적합하지 않다.
그렇다면 쿠키보다는 서버가 보안적으로 우수한 방식인 것을 알 수 있고, 서버보다는 쿠키가 stateless 하다는 토큰 인증 방식의 특징을 잘 살린 것을 알 수 있다.
그렇다면, Refresh Token의 작동 방식과 탈취되었을 때를 알아보고, 더 적절한 방식을 고민해 보자.
Access Token과 Refresh Token의 작동 방식
앞서 Access Token은 클라이언트에 저장되어 있고,
Refresh Token은 클라이언트 측(쿠키)에서 관리하는 경우와 서버에서 관리하는 경우로 나누었다.
따라서, 요청 시 클라이언트가 Access Token 하나만 보내는 경우와 Access Token과 Refresh Token을 같이 보내는 경우로 나누어 작동 방식을 알아보자.
1. Access Token, Refresh Token 생성
로그인 요청: 사용자가 로그인 정보를 입력하고 서버에 로그인 요청을 보낸다.
토큰 발급: 서버는 사용자의 인증 정보를 DB와 일치하는지 확인한 후, Access Token과 Refresh Token을 함께 발급한다. Access Token은 짧은 만료 시간을 가지며, Refresh Token은 상대적으로 긴 만료 시간을 가진다.
이후, 클라이언트나 서버에 알맞은 위치로 저장한다.
2. Access Token 사용
API 요청: 클라이언트는 API 요청 시 Access Token을 헤더에 포함하여 요청을 보낸다.
서버에서 JWT 검증: 서버는 요청받은 Access Token의 만료 기간을 확인한다. Access Token이 유효한 동안에는 정상적으로 요청이 처리된다.
여기까지는 앞서 살펴본 Access Token만 사용했을 경우와 동일하다.
3. Access Token 만료
API 요청: Access Token이 만료되었다면, 클라이언트는 더 이상 API 요청을 수행할 수 없다. 이후 저장 위치에 따라 아래와 같이 진행한다.
- 클라이언트에 저장한 경우 : 클라이언트는 서버로부터 Access Token 만료 요청을 받아, Refresh Token을 요청에 포함하여 재요청한다. 쿠키의 경우에는 자동으로 Refresh Token이 요청에 포함되어 있으므로 이 과정이 생략된다. 이후, Refresh Token을 이용하여 재발급을 요청한다.
- 서버에 저장한 경우 : 서버는 Access Token 만료를 확인하였으니, 서버 저장소에 저장된 Refresh Token을 이용하여 재발급을 요청한다.
Refresh Token 유효성 검증: Refresh Token의 서명 부분을 이용하여 유효성을 검증한다. 만약, 만료되었다면 재로그인을 요청한다.
새로운 Access Token 발급: Refresh Token이 유효하다면 Refresh Token의 페이로드 부분을 이용해 새로운 Access Token을 발급한다.
Refresh Token을 서버에 저장한 경우와 클라이언트에 저장한 방식의 가장 큰 차이점은 서버가 Refresh Token을 획득하는 방법이다. 서버에 저장되어 있는 경우에는, 서버 DB에서 조회를 한 번 더 해야 하는 과정이 이루어진다. 이를 통해 보안성은 당연히 높겠지만, 토큰 인증 방식의 사용 목적인 응답속도 면에서는 쿠키 방식보다 떨어지지 않을까?
Access Token과 Refresh Token의 로그아웃 방식
사용자가 로그아웃 요청을 했을 때, Refresh Token 역시 로그아웃 조치를 취하여야 한다. 각 토큰들이 저장하는 곳에 따라 다음과 같은 방식으로 로그아웃을 진행한다.
1. 메모리, 토큰 Null 처리하기
2. HTTP-Only 쿠키, Set-Cookie 헤더를 통해 쿠키를 만료시키기
3. 서버, DB에서 토큰 제거하기
4. BlackList에 추가하기
위 방식 모두 이해에 큰 어려움이 없을 것이라 판단하여, 자세한 설명은 생략한다.
그렇다면 가장 중요한 Refresh Token이 탈취당했을 경우를 살펴보자.
Refresh Token 탈취
Refresh Token이 탈취당했다면 어떤 상황이 발생할까?
Refresh Token은 Access Token을 재발급받을 수 있기 때문에, 해커는 Access Token을 재발급받아 다양한 공격에 사용한다. 사용자가 로그아웃 요청을 해서 Refresh Token을 제거하였어도, 해커가 Refresh Token을 이미 탈취한 상태고 서버 측에서 무효화 로직이 없다면 공격을 막을 수 없을 것이다.
그렇다면, Refresh Token을 무효화할 수 있을까?
이 역시 앞서 다룬 내용과 마찬가지로 BlackList에 등록하는 방법을 사용할 수 있다. 이 경우 서버에서 Refresh Token을 관리하고 있는 경우에만 추적하여 사용이 가능하고, Access Token처럼 stateless 할 경우 이상현상을 감지하여 BlackList에 추가하는 방법을 사용한다.
그렇다면, 어떻게 완벽하게 보완할 수 있을까?
서비스에서 완벽한 보안을 보장하는 것은 매우 어려운 과제이다. 보안성을 높이다 보면 편의성이 떨어지고, 편의성을 높이다 보면 보안성이 떨어진다. 따라서, 이 둘의 적절한 균형을 찾고 보안적인 솔루션을 더하여 피해를 최소화하는 방법을 도입하는 것이다. 이 것이 Refresh Token의 도입 목적이기도 하다.
Refresh Token이 탈취당했을 때 피해를 최소화하기 위하여 RTR 같은 기법을 도입한다.
RTR(Refresh Token Rotation)
Refresh Token이 탈취당했을 경우, Refresh Token의 만료 기간을 짧게 가져가면 피해를 최소화할 수 있지 않을까?
이에, Refresh-Refresh-Token을 도입할 수는 없으니, Refresh Token Rotation 기법을 이용하여 피해를 최소화한다.
Refresh Token Rotation은 Access Token이 만료되어 Refresh Token을 요청할 때, 새로운 Refresh Token을 함께 발급받는 방법이다.
이를 통해, 어떻게 피해를 최소화할 수 있는지 다음 경우를 살펴보자.
먼저, 사용자가 Refresh Token을 이용하여 새로운 Refresh Token(RT2)를 발급받는다. 그러면 서버는 기존 Refresh Token(RT1)을 블랙리스트 처리하여 Replay Attack을 방지한다. 이후, 해커가 기존에 탈취한 Refresh Token(RT1)을 이용하여 재발급을 요청한다면 Replay Attack으로 간주하여 사용자와 연결된 모든 Refresh Token을 무효화시킨다.
사용자의 Refresh Token을 모두 무효화시키는 이유는 해커가 먼저 재발급 요청을 했을 때를 방지하기 위함이다. 오른쪽 그림에서, 해커가 먼저 Refresh Token을 재발급하였어도, 사용자의 Refresh Token 재발급 요청에 의해 해커의 Refresh Token을 무효화시킬 수 있다.
즉, RTR(Refresh Token Rotation)은 해커든 사용자든 Refresh Token에 대한 Replay Attack이 감지되면, 토큰을 모두 무효화시키는 방법으로 보안을 강화한다.
Access Token과 Refresh Token의 저장 위치 조합
결론적으로 내가 생각한 Access Token과 Refresh Token의 저장 위치는 다음과 같다.
각 방법은 보안, 성능, 사용자 경험 측면에서 장단점이 있으며, 특정 상황에 따라 적합한 방법이 다를 수 있다. 개인적인 생각에 정답은 없으므로, 다른 의견이 있다면 댓글을 통해 소개해 주면 좋겠다.
1. Access Token (메모리) + Refresh Token(HttpOnly 쿠키) + Refresh Token 유효성(서버 관리)
Access Token은 메모리에 저장하여 접근성을 높이고, Refresh Token은 HttpOnly 쿠키에 저장하여 stateless 하다는 특성을 살린다. 이 경우, 페이지가 새로고침 되면 Access Token이 소멸되기 때문에, Refresh Token 만으로 Access Token을 재발급받는 로직을 구현한다.
또한, Refresh Token을 HttpOnly 쿠키와 서버에 모두 저장한다.
이렇게 하는 이유는, 클라이언트 측에만 Refresh Token이 저장될 경우 비정상적인 활동이 감지되었을 때 토큰을 강제로 만료시킬 방법이 없다. 또한 JWT의 stateless 함을 살리고 싶었다.
이를 통해 Access Token이 만료되었다면, 우선적으로 HttpOnly의 Refresh Token을 JWT 서명부를 통해 인증하고 만료기간이 다 되었다면 로그인 창으로 이동시킨다. 해당 로직을 통해 stateless 함을 잘 활용할 수 있을 것이라 생각했다. Refresh Token이 만료되지 않았다면, DB와의 검증을 통해 Refresh Token이 유효한지 확인한다. Refresh Token이 BlackList에 추가되어 있거나, isActive 한 지 확인하는 과정을 거쳐 최종적으로 RTR 기법을 통해 두 토큰을 다시 저장한다.
2. Access Token (HttpOnly 쿠키) + Refresh Token(HttpOnly 쿠키)
두 토큰 모두 클라이언트에 저장하여 stateless 한 특성을 잘 살리고, HttpOnly 쿠키에 저장하여 보안성 또한 높인 방식이다. 보안적으로 하나의 쿠키에 두 토큰을 모두 저장하는 것이 아닌, 각각의 토큰별로 쿠키를 만들어 사용한다. 쿠키의 특성에 따라 XSS 공격에 대한 저항력이 높고, 모든 요청에 두 토큰이 항상 포함되기 때문에 클라이언트 측에서 토큰을 헤더에 포함하는 구현이 불필요하다.
반대로, 모든 요청에 두 토큰이 항상 포함되기 때문에 요청 시 크기가 커지며, 모든 요청에 Access Token이 포함되어 있기 때문에 CSRF에 취약하다는 단점이 생긴다. 또한, 쿠키에는 크기 제한이 있어서 Access Token의 크기를 고려하여야 한다.
또한, 1번 방법과 마찬가지로 Refresh Token 추적을 위해 서버 측 관리가 필요할 것으로 생각된다.
3. Access Token (메모리 / HttpOnly 쿠키) + Refresh Token(서버)
Refresh Token을 서버에 저장하여 stateless 한 특성을 포기하고, 보안성을 높인 방식이다.
Access Token이 만료되면 Refresh Token을 DB에서 조회해야 하기 때문에 응답 속도 면에서는 떨어지겠지만, Refresh Token의 탈취 걱정을 할 필요가 없다. 하지만 이 역시 많은 사용자가 Access Token 갱신을 요청하는 상황이 온다면 서버 측에 부하가 올 수 있지 않을까? Access Token만 사용하는 방식보다는 토큰 인증 방식의 장점을 잘 살릴 수 있다.
위의 세 가지 방식은 각각 장단점이 있으며, 보안에 완벽히 안전하다고는 할 수 없다.
사용자 인증 시스템을 설계할 때는 편의성과 보안성 간의 균형을 고려하여 서비스 특성에 맞는 방식을 선택하는 것이 중요하다. 또한, 특정 방식의 단점이나 취약점을 보완하기 위해 추가적인 보안 조치(예: CSRF 방어, 이상 행동 감지, 탈취 방지 메커니즘 등)를 충분히 마련해야 한다.
어느 서비스든 완벽한 보안을 장담하기는 어렵고, 위 세 가지 방식 외에도 추가적인 보안 조치를 충분히 고려한다면 다양한 방식의 조합으로 사용이 가능할 것이다. 어떤 방법이 정답이라고 따라 하기보다는, 적절한 보안 조치를 지속적으로 적용하고 개선하며, 현실적인 위협에 대비하는 자세를 가져야 한다.
'Spring > Project' 카테고리의 다른 글
[Spring] Monitoring 도구 Prometheus란? (12) | 2024.10.16 |
---|---|
[Java] CompletableFuture로 비동기 프로그래밍 구현하기 (10) | 2024.10.09 |
[Java] Object pooling(오브젝트 풀링) - Apache Commons Pool2로 구현하기 (2) | 2024.10.05 |
[Java] IntelliJ Profiler로 병목지점 찾아, Java ImageIO 성능 개선하기 (5) | 2024.10.03 |
[디자인 패턴] 전략 패턴(Strategy Pattern), 팩토리 패턴(Factory Pattern), 레지스트리 패턴(Registry Pattern) (0) | 2024.07.30 |