Access Token과 Refresh Token에 대한 사전 지식이 필요합니다.
XSS, CSRF 공격이 무엇인지 알면 좋습니다.
Access Token과 Refresh Token은 인증에 관련한 중요한 정보이기 때문에, 유출되지 않도록 신경을 써줘야 합니다. 하지만 이러한 부분을 인지하지 못한 채 LocalStorage에 토큰을 저장하는 경우가 종종 보이는데요. 이는 보안상 위험요소로 작용합니다.
이 글은 다음과 같은 순서로 진행될 예정입니다.
클라이언트에서 변수를 어디에 저장할 수 있는지 말씀드리고, 각 저장소의 특징에 대해 먼저 말씀드리겠습니다.
이후 고려해야 하는 보안 이슈인 XSS와 CSRF를 어떻게 방어할 수 있는지 말씀드리겠습니다.
1. 쿠키
2. 로컬스토리지
3. 세션스토리지
4. 메모리
(캐시 스토리지, Indexed DB도 있다고 하는데 이 부분은 생략하겠습니다)
브라우저에서 변수는 위에서 말씀드린 네 가지 공간에 저장이 가능합니다.
딱 필요한 만큼만 이들에 대해서 설명드리겠습니다.
서버로 요청을 할 때마다 브라우저는 해당 서버에 해당되는 쿠키를 전부 보내줍니다. api.naver.com 에 요청을 보내면 브라우저는 api.naver.com에 저장된 모든 쿠키들을 헤더에 넣어 전달합니다. 쿠키는 자바스크립트로 접근이 가능합니다.
위 두 저장공간은 브라우저에 위치한 저장 공간입니다. 이들은 자바스크립트로 접근이 가능합니다.
메모리에 값을 저장하려면 웹앱의 로컬변수를 사용해서 값을 저장해야 합니다. 이 방식을 사용하면 자바스크립트 코드로 접근이 불가능합니다. 또 하나 기억해야 할 특징은 웹 앱이 새롭게 실행된다면(Ex. 새로고침) 내부 변수가 초기화됩니다.
XSS(Cross Site Scripting)
악의적인 사용자(웹사이트 관리자가 아닌 누군가)가 웹 페이지에 악성 스크립트를 삽입할 수 있는 취약점
이미지 출처 : https://kevinthegrey.tistory.com/36
이처럼 XSS 취약점이 있는 서버는 특정 페이지를 방문하는 사용자의 브라우저에서 자바스크립트 명령어를 실행합니다.
핵심은 XSS 공격에 성공한다면 해커는 자바스크립트를 실행할 수 있습니다. 이 말은 자바스크립트 코드로 접근이 가능한 쿠키, 로컬스토리지, 세션스토리지는 XSS 공격에 취약하다는 의미가 됩니다. 다만 쿠키의 경우 HttpOnly 속성을 추가하면, 자바스크립트로 접근할 수 없는 쿠키를 만들 수 있습니다.
XSS 공격을 방어하기 위해 토큰은 HttpOnly 속성이 걸려있는 쿠키, 메모리에 저장된 로컬 변수 중 한 곳에 저장되어야 합니다.
결론부터 말씀드리면 Refresh Token은 HttpOnly 쿠키로 저장합니다.
Access Token은 메모리에 저장하는 게 보안상 최선입니다만! Access Token에 저장할 경우에는 새로고침할 때마다 토큰을 받아와야 한다는 성능상 문제가 발생합니다.
잘 고민해보시고 선택하면 좋을 거 같습니다. 제 개인적인 첨언으로는 특별한 이유가 없다면 메모리에 저장하는 게 좋지 않을까 생각합니다.
사실 외부에서 접근하기 가장 어려운 위치는 메모리입니다. 메모리에 저장하려면 웹앱의 로컬 변수에 저장해야 하는데, 이 경우 웹 앱이 실행되면 데이터가 전부 초기화된다고 말씀드렸습니다. 만약 Refresh Token을 로컬 변수에 저장한다면 우리는 새로고침을 하거나 새롭게 서비스에 들어오는 모든 순간에 다시 로그인 작업을 수행해야 합니다. 너무 귀찮겠죠?? 그렇기 때문에 Refresh Token은 쿠키로 저장합니다.
CSRF(Cross Site Request Forgery, 사이트 간 요청 위조)
사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격
사용자를 해커가 만든 사이트에 접속하게 하거나, 특정 링크를 클릭하도록 유도하여 Cross Site 요청을 보내는 공격 기법입니다.
쿠키는 CSRF 공격에 취약합니다. CSRF 공격은 사용자의 브라우저를 통해 요청을 보내는 공격입니다. 사용자의 브라우저에서 요청을 보내기 때문에 쿠키는 함께 전달될 수밖에 없습니다.
서버 입장에서는 이게 사용자가 보낸 요청인지, 해커가 보낸 요청인지 알 길이 없습니다.
외부 서비스에서 우리 서비스의 데이터를 변경하는 요청을 보내지 못하게 만든다
이게 CSRF 공격을 막는 방법입니다. 이를 위해 우리는 쿠키의 Same Site라는 속성을 사용할 겁니다.
Cross Site는 Cross Origin이랑 다른 개념입니다. 잘 모르시는 분들은 이 글 을 먼저 읽고 와주세요.
쿠키의 SameSite 속성은 Strict, Lax, None 이라는 값을 가질 수 있습니다.
Strict
Cross Site에서 요청을 보내면 쿠키를 보내지 않습니다.
Lax
Cross Site에서 요청을 보내면, 특정한 조건이 만족할 때만 쿠키를 전달합니다.
특정한 조건이란 HTML의 최상단에서 페이지 이동이 일어나는 경우를 의미합니다. <a>
태그나 location.href
속성을 사용하는 상황이 있겠습니다.
None
Cross Site에서 요청을 보내면 쿠키를 전달합니다.
쿠키에 SameSite 속성에 대해 Strict 또는 Lax 값을 주면 CSRF 공격을 방어할 수 있습니다.
Strict 값을 갖는 쿠키는 모든 Cross Site 요청을 막아버리기 때문에 CSRF 공격에 있어 100% 안전합니다. 또한, 쿠키가 Lax 값을 가져도 CSRF 공격에 안전합니다. 페이지가 이동하는 상황은 GET 요청을 보내는 상황이라고 이야기할 수 있는데, 서버는 데이터를 변경하는 요청에 대해 GET 요청을 사용하지 않기 때문입니다.
GET 요청 역시 아무나 사용하면 위험할 거 같은데요,,
위험하지 않습니다.
아마 GET 요청의 응답으로 위험한 값을 전달하는 과정에서 데이터를 털리지는 않을까 생각하셔서 걱정을 하고 계실 텐데요.
CSRF 공격은 사용자의 브라우저에서 서버로 요청을 보내는 공격입니다. 요청을 보낸 주체가 사용자의 브라우저이기 때문에, 응답은 사용자의 브라우저에게 돌아갑니다. 이 과정에서 해커가 응답을 탈취할 수는 없습니다.
서버는 인증 처리가 완료된 사용자에게 다음과 같은 응답을 전달합니다.
클라이언트는 Access Token을 로컬 변수에 저장하여 사용합니다. 만약 Access Token이 날아가거나 기간이 만료되면 Refresh Token을 통해 Access Token을 재발급 받습니다.
현재 개발중인 서비스에서는 데이터베이스에 Refresh Token을 저장하고 있습니다. 이를 통해 각 계정은 가장 최근에 발급한 Refresh Token만을 사용 가능하도록 만들어주었습니다. 혹시나 만약에 토큰이 털렸을 경우, 해당 토큰을 무효화하는 등 서버에서 최소한의 관리를 할 수 있게 만들고 싶었습니다.
다만, 지금은 OAuthInfo라는 테이블의 refreshToken이라는 컬럼에 토큰을 저장하고 있습니다. 하나의 계정에 하나의 인증 방식만을 지원하기로 했기 때문입니다. 만약 하나의 계정에 여러 인증 방식을 사용할 수 있게 된다면 저장하는 위치를 변경해야 합니다.
아마 이후에 리팩토링을 한다면 Redis 혹은 Auth Service에 별도의 테이블을 하나 만들어서 사용할 거 같습니다.
몇 개의 블로그에서 Access Token과 Refresh Token를 LocalStorage에 저장하라고 작성되어 있습니다. 해당 방식은 보안을 고려하지 못한 잘못된 방식이고, 저 역시 잘못된 글을 보고 혼란을 겪었습니다.
제 글을 읽으신 분들은 더이상 혼란을 겪지 않았으면 하는 마음에 글을 작성했습니다. 혹시 틀린 내용이 있다면 꼭 말씀해주세요, 읽어주셔서 감사합니다.
(24.05.24) 해당 글과 플롯이 굉장히 유사하네요. 제가 자료 조사 과정에서 해당 글을 읽었었나 봅니다. 해당 글도 함께 보시면 좋을 거 같아 링크 첨부합니다.
감사합니다!