안전한 로그인 토큰 관리, 프론트엔드의 필수 전략
November 22, 2024
서문
현대 기술 환경에서 사용자 인증 방식은 날로 변화하고 있으며, 그에 맞춰 보안 방식도 끊임없이 발전하고 있습니다. 높은 확장성과 유연성을 지닌 토큰 기반 인증 방식은 빠르게 표준화되고 있으며, 특히 보안성과 효율성을 이유로 전통적인 세션 기반 인증 방식을 대체하는 사례가 증가하고 있습니다. 쿼리파이 또한 보안성을 높이기 위해 제품에 토큰 기반 인증 방식을 도입했습니다.
프론트엔드 진영에서의 새로운 도전
웹 프론트엔드 진영에서 인증을 구현한 역사는 그렇게 길지 않습니다. 전통적인 세션기반의 인증방식은 서버가 사용자 세션을 관리합니다. 프론트엔드 진영에서 인증이란 Form 을 잘 만들어서, 서버가 지정한 엔드포인트에 id와 password를 잘 전송하는 것으로 끝나는 역할이었습니다. 그러나 최근 몇년간 프론트엔드 진영은 여러가지 기술적인 변화를 맞이하게 됩니다.
- AJAX 의 등장과 Single Page Application 구조 덕분에 프론트엔드 개발자들은 백엔드와 JSON 기반의 API통신으로 데이터를 주고 받게 됩니다.
- Web Storage API 가 등장하며 프론트엔드에서 영속성 (persistence)를 쉽게 처리하게 되었습니다.
- NextJS와 같은 SSR 서버의 등장으로 이전에 서버에서 하던 역할들이 프론트엔드로 넘어오게 됩니다.
위의 변화는 토큰 기반의 인증 방식과 맞물려 프론트엔드 진영에 안전한 토큰 송수신 및 영속성 관리라는 새로운 과제를 던져주게 되었습니다.
안전한 토큰, 그렇지 못한 사용자 환경
토큰 기반 인증은 개발 편의성 뿐 아니라 토큰의 위변조가 불가능에 가깝기에 보안적인 측면에서도 우수합니다. 하지만 토큰 자체가 탈취된다면, 이야기가 달라집니다.
위협의 종류
토큰을 탈취할 수 있는 주요 경로는 사용자 브라우저입니다. 사용자가 오래된 브라우저를 사용한다면, 브라우저 자체의 취약점을 공략당할 수 있습니다. 근래에는 크롬과 같이 언제나 사용자가 최신버전을 사용하게 하는 에버그린 브라우저의 점유율이 높기 때문에, 브라우저의 취약점은 빠르게 수정이 됩니다.
오히려 취약한 부분은 브라우저가 제공하는 여러가지 보안 수준을 만족하지 않는 자바스크립트 코드나, 서버 설정일 가능성이 더 큽니다. 이러한 상황을 악용하여 공격자는 Cross Site Scripting (XSS), Cross Site Request Forgery (CSRF), Session Hijacking 공격으로 토큰 탈취, 또는 그에 준하는 공격을 할 수 있습니다.
Cross Site Scripting (XSS)
https://owasp.org/www-community/attacks/xss/
Cross-Site Scripting (XSS) attacks are a type of injection, in which malicious scripts are injected into otherwise benign and trusted websites. XSS attacks occur when an attacker uses a web application to send malicious code, generally in the form of a browser side script, to a different end user. Flaws that allow these attacks to succeed are quite widespread and occur anywhere a web application uses input from a user within the output it generates without validating or encoding it.
XSS 공격이란 사용자 브라우저에 악의적인 스크립트를 구동시키는 방식이며, 전통적인 code injection 공격입니다. 여러가지 공격방식이 있지만 가장 흔한 방식의 예는 아래와 같습니다.
- 공격자는
vulnerable-site.com/search?query=${query}
의 스킴으로 접속했을 때, query search parameter가 그대로 결과 페이지에 출력되는 취약점을 확인 합니다. - 공격자는
vulnerable-site.com/search?query=<script>...악성코드...</script>
를 넣었을 때 결과 페이지에서 악성코드가 전송되는 링크를 만듭니다. - 이후 공격자는 메신저나 커뮤니티등을 이용하여 악성 링크로 다른사용자들이 들어가도록 링크를 만듭니다.
- 만약 해당 사이트 이용자의 access token이 js를 통해 획득할 수 있는 상태라면 탈취 위협에 노출됩니다.
Cross Site Request Forgery (CSRF)
https://owasp.org/www-community/attacks/csrf
Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing. If the victim is a normal user, a successful CSRF attack can force the user to perform state changing requests like transferring funds, changing their email address, and so forth. If the victim is an administrative account, CSRF can compromise the entire web application.
CSRF 공격은 그자체로 토큰을 탈취하는 위협은 아닙니다. 그러나 XSS 공격과 마찬가지로 공격자는 웹 사이트의 설계 헛점과 사용자 인증상태를 이용하여, 사용자에게 피해를 줄 수 있는 행위를 시킬 수 있습니다. 이는 토큰 탈취 만큼 중대한 취약점입니다.
- 공격자는 사용자에게 피싱 메일을 보내서
vulnerable-shop.com
사이트인척scam-shop.com
링크를 누르도록 속입니다. vulnerable-shop.com
에 로그인되어있던 사용자는 해당 링크를 누릅니다.scam-shop.com
페이지에는 아래와 같은 스크립트가 페이지 로드시 자동으로 실행됩니다.
<form id="form" action="https://vulerable-shop.com/api/purchase" method="POST">
<input type="hidden" name="item_id" value="$expensive_item">
<input type="hidden" name="address" value="$attackes_house">
<input type="hidden" name="amount" value="10000">
<button type="submit">Purchase</button>
</form>
<script>
document.getElementById('form').submit();
</script>
사용자가 이미 로그인 되어있고, 적절한 보안 설정이 되어 있지 않다면, 사용자는 본인도 모르는 사이에 공격자에게 비싼 물건을 10000개나 결제해서 보내게 됩니다.
CSRF는 cookie의 성격을 이용한 공격인데, cookie는 아래와 같이 동작합니다.
- Cookie는 사용자 브라우저에 저장됩니다.
- Cookie는 해당 cookie에 설정된 도메인으로 요청시 언제나 포함됩니다.
프론트엔드 인증 보안: 쿼리파이의 RED 팀과 함께하는 모범 사례 및 위협 진단
인증을 잘 구현하기는 매우 어렵습니다. 특히 일반적인 기능 개발뿐만 아니라 보안도 잘 챙겨야합니다. 쿼리파이라는 보안 솔루션을 만들면서, 프론트엔드 팀도 보안 솔루션에 인증을 제대로 붙이기 위하여 많은 고민을 하고 리서치를 했습니다. 인증을 구현하는 과정에서 쿼리파이의 RED 팀도 중요한 역할을 했습니다. RED 팀은 자사 프로덕트를 대상으로 화이트 해킹을 통해 잠재적인 취약점을 발견하고, 이를 개선하는 데 큰 기여를 했습니다. 덕분에 더욱 안전한 인증 체계를 구축할 수 있었습니다.
문서 초기에 언급한대로, 프론트엔드에서 인증처리란 아직은 그리 성숙한 분야가 아닙니다. 인터넷에 있는 인증을 구현하는 방법에 대한 여러 가이드는 실제로 보안적으로 취약한 것들이 많기 때문에, 정확한 정보를 골라내기 쉽지 않습니다.
쿼리파이에서 실제로 프론트엔드 개발자가 작성하는 코드에 어떤 위협이 있는지 예시를 통하여 프론트엔드 코드에서의 위협을 진단하고, 모범 사례를 제안드리겠습니다. 이 글을 보고 나면 더이상 프론트엔드에서의 인증 처리는 고민이 필요 없을 겁니다.
예시 - 초보 프론트엔드 개발자의 SPA 인증 구현
아래는 Single Page Application에서 흔히 보이는 로그인과 인증이 필요한 api 요청 코드입니다.
async function login(id, pw) {
const res = await fetch('<cross_origin_api_url>/api/auth', {
method: 'POST',
body: encrypt({id, pw}),
mode: 'cors'
});
const token = await res.json();
localStorage.setItem('accessToken', token.accessToken);
}
async function getProtectedResource(id) {
const accessToken = localStorage.getItem('accessToken');
const res = fetch(`<cross_origin_api_url>/api/protected-resource/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`
},
mode: 'cors'
});
}
해당 코드는 아래와 같은 히스토리가 있습니다.
- Backend 와의 API 규약
Authorization
헤더에 token을 포함한다.Access-Control-Allow-Origin
헤더는 개발 편의를 위해*
로 설정한다.
- 토큰 영속성 구현
localStorage
API를 이용하여 token을 저장한다.
위의 코드와 히스토리를 보았을때, 노출되는 위협은 아래와 같습니다.
Authorization Header → XSS Attack
먼저 Authorization
헤더를 통한 Access Token 전달 방식은 XSS 공격에 취약할 수 있습니다. Authorization
헤더를 통해서 토큰을 전달하려면 어떤 방식으로든 토큰이 js 메모리나 스토리지 영역에 저장해두어야 합니다. XSS 공격 성공후 공격자는 페이지 내 자바스크립트 코드에 접근할 수 있는 권한이 생기므로, 만약 토큰이나 토큰을 가져올 수 있는 함수를 글로벌 영역에 노출시켰다면, 토큰을 탈취할 수 있게 됩니다.
LocalStorage → XSS Attack
위의 코드에서는 localStorage api를 이용하여 영속성을 구현하고 있습니다. localStorage 는 브라우저 전역에서 접근 가능하기때문에 key만 알고 있다면 얼마든지 탈취가 가능합니다. 따라서 XSS어택에 성공시 아주 쉽게 탈취가 가능합니다.
Access-Control-Allow-Origin: * → Session Hijacking, CSRF
브라우저는 HTTP 요청자와 서버의 Origin이 다르다면 이를 Cross Origin이라고 하여, 민감한 정보를 함부로 보내지 못하는 Cross Origin Resource Sharing 정책을 구현하고 있습니다. 해당 제약을 풀어주려면, Access-Control-Allow-Origin 헤더를 서버가 응답으로 내려야 합니다. *
은 모든 요청에 서버가 열려있다는 의미로, 전혀 관련없는 사이트에서 보내는 요청도 서버가 받게 됩니다.
이와 같은 CORS 설정이 직접적으로 CSRF 취약점과 연결되는 것은 아니지만, CSRF 취약점이 발생했을때의 위험도가 더욱 증가하게 됩니다.
인증 방식 개선하기
프론트엔드에서 토큰을 안전하게 관리 하기 위해서는 역설적으로, 코드레벨에서 토큰을 관리하려 하기보다, 보안 설정이 완료된 쿠키를 통해 수동적 관리가 이루어지도록 하는 것이 더 효과적입니다. 이를 위해, 다음과 같은 조치를 권장합니다.
HTTPS 기반 통신
요즘에는 너무 당연한 이야기 일 수 있습니다만, HTTPS 기반으로 통신하지 않는다면, 아무리 코드레벨에서의 보안수준을 높여도, 공격자의 Man in the Middle 공격에 취약해집니다. HTTPS는 기본중의 기본입니다.
영속성은 Web Storage → Cookie로 변경
XSS 취약점이 있는 Web Storage API 대신, 토큰같이 민감한 데이터는 Cookie를 이용해 브라우저에 저장합니다. Cookie 자체에도 CSRF, XSS 취약점이 존재하지만, 아래의 추가적인 설정으로 두개의 위협을 최소화 할 수 있습니다.
- TTP Only - javascript에서 cookie를 접근하지 못하도록 합니다. 이는 XSS 공격을 이용한 직접적인 cookie 탈취를 막아줍니다. 이 때 쿠키는 기본적으로 서버가 생성하게 됩니다.
- SameSite=Strict - 동일한 사이트에서의 요청이 아니라면 cookie를 헤더에 보내지 않습니다. 이는 CSRF 공격을 상당부분 완화합니다. 만약 특별한 사유가 있어서 same site로의 api 요청이 어렵다면 최소한 SameSite=Lax 값을 설정합니다.
- Secure - 브라우저와 서버가 모두 https인 환경에서만 쿠키를 전송합니다. 이는 MitM 공격 방어에 도움이 됩니다.
- 세분화된 domain 및 path 설정 - 토큰마다 접근할 수 있는 도메인 범위를 축소시키는 것도 CSRF 공격이나, misconfiguration에 따른 과도한 권한 부여를 방어하는 데 도움이 됩니다.
적절한 CORS 정책 설정
만약 서버와 origin이 다른 웹앱이라면 Access-Control-Allow-Origin
헤더를 허용된 일부 도메인에만 동적으로 할당하도록 설정하는 것이 보안에 도움이 됩니다.
예를들어 people.abc.com
에서 defg.com/api
로 CORS 요청을 보낸다면, defg.com/api
개발자는 people.abc.com
에서 온 요청에 대해서만 값을 남기도록 설정합니다.
가능하면 CORS 정책을 열어주기보다, 웹앱과 API endpoint가 동일한 도메인을 사용하고, SameSite 환경으로 만들어 주는 것이 보다 안전합니다.
Access-Control-Allow-Origin: people.abc.com
또한 cookie 기반으로 CORS 요청을 통한 인증을 하게 된다면 서버는 추가적인 헤더 세팅을 해 주어야만 cookie에 저장된 access token을 서버로 보낼 수 있습니다.
Access-Control-Allow-Credentials: true
개선 결과
위의 솔루션대로 원래의 코드를 개선한다면 어떤 모양이 될까요?
async function login(id, pw) {
await fetch('https://<cross_origin_api_url>/api/auth', {
method: 'POST',
body: encrypt({id, pw}),
mode: 'cors'
});
}
async function getProtectedResource(id) {
const res = fetch(`https://<cross_origin_api_url>/api/protected-resource/${id}`, {
mode: 'cors',
credentials: 'include'
});
}
프론트엔드 코드는 놀랍도록 단순해졌습니다. 프론트엔드에서 로직은 없어지고 설정만 남게 됩니다. 하지만 오히려 뒤에서는 더 많은 일들이 일어나고 있습니다. 이를 분해해서 한번 알아보면 어떤식으로 인증이 일어나는지 알 수 있습니다.
그림은 예제 코드에서 login
함수를 호출했을 때 브라우저와 서버간의 통신을 보여줍니다.
- 브라우저는 요청을 보내기 전에 교차 사이트로의 요청인지 검증합니다.
- 교차사이트라면, preflight request를 서버에 보냅니다 (
OPTIONS
method가 사용됩니다.)- 서버에서는
OPTIONS
method 요청에 대해서 허용된 도메인 여부를 판단해 교차사이트 요청 허용 헤더 (Access-Control-Allow-Origin
andAccess-Control-Allow-Credentials
)를 보냅니다.
- 서버에서는
- 브라우저는 preflight response를 확인하고, 적절한 헤더를 서버가 보내주었으면, 원래의 요청을 다시 보냅니다.
- 서버는 요청의 인증을 확인하고 cookie에
HttpOnly
,Secure
,SameSite=Lax
를 설정합니다. - 브라우저는 해당 cookie를 브라우저에 저장합니다.
- 브라우저 - 해당 리소스 서버로 CORS Preflight 요청을 보냅니다. 이는 서버가 CORS 헤더를 어떻게 설정했는지 검증하는 절차입니다.
- 서버 - 해당 리소스로의 CORS Preflight 요청이 오면
Access-Control-Allow-Origin
헤더를 잘 줄지 결정합니다. - 브라우저 - 서버가 CORS 헤더를 제대로 설정했다면, 실제 요청을 진행합니다. credentials 필드가 include 로 되어 있다면,
Access-Control-Allow-Credentials: true
인지 확인하고 cookie를 요청 헤더에 담아서 보냅니다. - 서버 - 요청에 포함된 cookie에서 token을 꺼내 내부에서 인증합니다. 로그인이 되지 않았거나, 프론트에서 보안 관련 설정을 잘못했다면 cookie는 없을것이므로 리소스로의 접근을 차단할 수 있습니다.
결과적으로 XSS, CSRF를 상당부분 방어할 수 있는 형태로 보안수준 또한 올라갔습니다.
더 개선해보기
우리 프론트엔드 팀 또한 기존의 Web Storage 기반 토큰 관리 방식을 쿠키 방식으로 전환하며 보안 수준도 크게 개선하고, 인증 로직도 단순화 할 수 있었습니다. 다만 보안이라는 분야에서 항상 안전한 방법이란 없으므로, 결과적으로 토큰이 탈취되더라도 피해를 줄일 수 있는 방법또한 세팅해야 합니다.
Token Rotation
Token의 유효기간을 줄이고 자주 토큰을 갱신시키는 것입니다. 이를 Token Rotation이라고 부릅니다.
유효기간이 긴 access token은 한번 탈취되고 나면 공격자가 시스템에 끼칠 수 있는 공격의 규모가 커지게 됩니다. 따라서 토큰 탈취를 성공하더라도 금방 탈취한 토큰의 권한이 소멸하도록 access token에 짧은 생명 주기를 부여해야합니다.
Refresh Tokens
토큰의 유효시간이 짧으면 일반적인 사용자는 자주 로그인 해야하는 불편함이 있을 수 있습니다. 이를 방지하려면 실제 인증, 인가를 위해 사용하는 access token의 시간은 짧게 가져가더라도, access token 재발급을 위한 refresh token 을 두어 사용자는, refresh token 이 유효한 동안은 다시 로그인 하지 않아도 token rotation을 구현할 수 있습니다.
안전한 웹 서비스를 위한 프론트엔드 토큰 인증 구현 전략
궁극적으로 웹 보안의 기본 원칙을 준수하면서 안전한 프론트엔드 토큰 인증 방식을 구현하는 것은 매우 중요합니다. 복잡성을 줄이고 중요한 보안 처리를 서버 측에서 수행하는 것은 해커의 공격 표면을 줄이는 데 효과적입니다. 프론트엔드에서의 토큰 관리는 쿠키 기반의 수동적 보안 접근을 통해 강화할 수 있으며, 브라우저의 보안 정책을 활용하여 인증 과정을 안전하게 처리할 수 있습니다.
또한, 토큰 탈취 상황을 대비하여 토큰의 수명을 짧게 설정하고 주기적으로 갱신하는 토큰 로테이션 전략을 도입함으로써 보안성을 높이는 동시에 사용자 경험을 개선할 수 있습니다. 이러한 접근 방식을 통해 쿼리파이에서 제안하는 안전한 프론트엔드 토큰 인증을 구현하여 보다 안전한 웹 서비스를 제공하시길 바랍니다.