JWT를 이용한 로그인을 구현하며 보안을 높이는 방법에 대해 고민하고 적용했던 과정에 대해 소개합니다. 크게 세 단계로 나뉘었고 각 단계에서 보안 문제를 발견했으며 이를 하나씩 해결하면서 발전시켜왔습니다.
Access Token 발급과 Session storage
->Access Token 발급과 Session storage + Refresh Token
->Access Token 발급. 그리고 Cookie와 Https 이용
(동글-JWT를 이용한 로그인에서 보안을 높이는 방법(HTTPS, Cookie)에서 잘못 적용했던 방식은 수정하여 반영했습니다.)
토큰 기반의 인증 시스템은 인증받은 사용자들에게 토큰을 발급하고, 서버에 요청을 할 때 헤더에 토큰을 함께 보내도록 하여 유효성 검사를 한다. 이러한 시스템에서는 더이상 사용자의 인증 정보를 서버나 세션에 유지하지 않고 클라이언트 측에서 들어오는 요청만으로 작업을 처리한다
이러한 토큰 기반 인증 방식을 통해 아래와 같은 이점을 얻을 수 있다.
무상태성(Stateless) & 확장성(Scalability)
토큰은 클라이언트 측에 저장되기 때문에 서버는 완전히 Stateless하며, 확장하기에 매우 적합하다.
서버의 부담 경감
서버가 인증 데이터를 가지고 있는 대신, 클라이언트가 인증 데이터를 직접 가지고 있다. 따라서 서비스가 커지고 유저의 수가 얼마나 많아지던 서버의 부담이 증가하지 않는다.
JWT는 Json Web Token의 약자로 일반적으로 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용하는 토큰이다. 웹 상에서 정보를 Json형태로 주고 받기 위해 표준규약에 따라 생성한 암호화된 토큰으로 복잡한 string 형태로 저장되어있다.
(Github Oauth를 이용한다고 가정합니다.)
1. 사용자가 github oauth 로그인을 합니다.
2. BE에서는 Github을 통해 사용자를 확인합니다.
3. SecretKey와 algorithm을 적용하여 Access Token을 발급합니다. 이 때, Access Token의 만료기간은 주어지지 않았습니다.
const jwtConfig: Config = {
secretKey: config.jwt_secret,
options: {
algorithm: config.jwt_algorithm as jwt.Algorithm,
},
};
JWT는 Stateless이기 때문에 한 번 만들어지면 제어가 불가능합니다. 임의로 Access Token을 삭제할 수 없기 때문에 만료기간을 설정하지 않으면 탈취될 가능성이 높습니다.
앞서 발생한 보안 이슈를 회피하기 위해 Refresh Token을 사용하기로 했습니다. Refresh Token을 사용함으로서 아래와 같은 보안성을 높였습니다.
const jwtConfig: Config = {
secretKey: config.jwt_secret,
options: {
algorithm: config.jwt_algorithm as jwt.Algorithm,
expiresIn: config.jwt_expire,
},
};
const jwtRefreshConfig: Config = {
secretKey: config.jwt_refresh_secret,
options: {
algorithm: config.jwt_refresh_algorithm as jwt.Algorithm,
expiresIn: config.jwt_refresh_expire,
},
};
발급받은 Access Token과 Refresh Token을 Session Storage에 저장하고 있었습니다. Session Storage는 안전한 저장소일까요? 아닙니다.
Session Storage와 같은 Storage에 대한 접근 및 제어는 자바스크립트를 통해 이루어지기 때문에 XSS 등 스크립트 기반 공격이 가능합니다. JWT는 특히 탈취되면 서버에서 감지하고 선별할 수 없기 때문에 매우 위험합니다.
XSS 공격 : 공격자(해커)가 클라이언트 브라우저에 Javascript를 삽입해 실행하는 공격
Access Token에 만료기간을 주고 Refresh Token도 발급하여 탈취되더라도 공격 범위를 줄였습니다. 그렇다면 안전한 걸까요? 아닙니다.
탈취될 경우 Access Token, Refresh Token 정보는 그대로 노출됩니다. Token에 대한 정보뿐만 아니라 만약 통신 내용 중 중요한 개인정보가 들어있다면 그 정보도 모두 노출되어 위험합니다.
자바스크립트 기반 공격에 취약한 Session Storage 대신 Cookie를 사용합니다.
Session Storage 역할을 대체 가능
Cookie는 (만료시간이 정해져 있지 않은 경우) 새로고침을 해도 FE에서 BE로 보내는 API 요청에 그대로 담겨 있습니다. 그래서 새로고침을 해도 로그인 여부를 알기 위해 도입한 Session Storage의 역할을 대체할 수 있습니다.
Session Storage 취약점(XSS 공격) 예방 가능
그리고 Cookie는 Session Storage의 취약점인 자바스크립트 기반 공격을 HTTP Only option을 통해 방어할 수 있습니다.
HTTP Only option : 자바스크립트로 쿠키를 조회하는 것을 막는 옵션
여러 장점들
또한 Cookie를 적용함으로서 아래와 같은 장점이 있습니다.
앞서 탈취될 경우 Access Token, Refresh Token뿐만 아니라 개인 정보와 같은 중요한 정보도 그대로 노출될 위험이 있다고 언급했습니다.
이를 예방하기 위해 HTTPS를 적용합니다. HTTPS를 사용하면 서버와 클라이언트 사이의 모든 통신 내용이 암호화됩니다. 따라서 탈취되더라도 암호화되어 있어 정보가 노출되는 위험이 적어집니다.
또한, Cookie 역시 HTTPS인 경우에만 전송되도록 Secure Cookie option을 추가합니다.
Secure Cookie option : 웹브라우저와 웹서버가 HTTPS로 통신하는 경우에만 웹브라우저가 쿠키를 서버로 전송하는 옵션
앞서 HTTP Only option을 이용해 자바스크립트 기반 공격을 예방했고 Secure Cookie option을 이용해 HTTPS 내에서만 Cookie가 사용되도록 하여 탈취 시 정보 노출에 대한 위험을 예방했습니다.
보안을 높이는 Cookie option을 한가지만 더 소개해보고자 합니다. 바로 CSRF 공격을 예방하는 SameSite option 입니다. 크로스 사이트 요청에 쿠키가 항상 전송되는 None
이 아닌 크로스 사이트 요청에 대한 쿠키 전송에 제약을 주는Strict
또는 Lax
를 프로젝트 상황에 맞게 설정합니다.
CSRF 공격 : CSRF(Cross-Site Request Forgery)는 사이트 간 요청 위조를 의미합니다. 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격입니다.
SameSite option : 크로스 사이트(Cross-site)로 전송하는 요청의 경우 쿠키의 전송에 제한을 두도록 합니다. 쿠키의 정책으로
None
,Lax
,Strict
세 가지 종류를 선택할 수 있고, 각각 동작하는 방식이 다릅니다. (참고 : 브라우저 쿠키와 SameSite 속성)
한 가지 빠진 것을 혹시 찾으셨나요? 바로 Refresh Token이 이 과정에서는 존재하지 않습니다. 실수일까요? 의도적으로 삭제했습니다.
거슬러 올라가서 Refresh Token이 사용되었던 환경 및 특징에 대해 되짚어 봅시다. Access Token과 Refresh Token은 Client의 Session Storage에 저장되어 있었습니다. 그리고 Refresh Token은 Access Token이 만료한 경우만 FE에서 서버로 보내기 때문에 탈취 위험이 적었습니다.
Cookie를 사용하는 경우를 생각해 보면 Cookie에는 Access Token, Refresh Token이 함께 담겨있을 것입니다. Client에서 Session Storage를 사용해 Token을 저장하는 것은 보안이 취약했고 서버에서 Token을 저장하는 것은 토큰 기반의 인증 시스템의 장점이 줄어들고 서버 기반의 인증 시스템과 가까워지기 때문입니다.
따라서 Cookie를 사용할 경우 Refresh Token의 탈취 위험이 적다는 장점이 사라졌다고 판단했기 때문에 Refresh Token을 의도적으로 삭제했습니다.
그리고 Cookie와 Access Token에 만료시간을 적정하게 주는 것으로 마무리 지었습니다.
이 마무리가 정답일까요? 아닙니다.
RTR, Sliding Session 등 추후 알게 된 내용들이 있었고 이에 대해서는 다음 글을 통해 소개하도록 하겠습니다.
결론적으로 Access Token 발급. 그리고 Cookie와 Https 이용
해결법을 적용했고 아래의 공격들에 대해 대비했습니다.
👌 Refresh Token
Access Token의 공격 범위를 줄여주고 Access Token에 비해 적게 노출되어 탈취될 위험이 적습니다. 하지만 Cookie를 적용하면서 필요하지 않아 최종적으로 적용하지 않았습니다.
👌🏻 HTTPS와 Cookie option - Secure
HTTPS를 사용하여 패킷을 암호화함으로서 Sniffing에서 정보를 지킬 수 있습니다.
👌🏽 Cookie option - Http Only
Client에서 Javascript로 접근할 수 없어 XSS 공격을 방지할 수 있습니다.
👌🏿 Cookie option - SameSite option
CSRF 공격을 방지하기 위해 sameSite를 lax로 설정했습니다.
HTTPS가 만능은 아닙니다. SSL Strip, MITM 공격(중간자 공격)과 같은 공격들로 HTTPS가 뚫릴 수 있기 때문에 token에 만료시간을 주는 것이 필요합니다.
단순히 생각해 보면 Refresh Token을 위한 다른 Token을 만들어 보는 건 어떨까요? 만약 그럴 경우, Refresh Token을 위한 다른 A Token을 만들고, A Token을 위한 B Token을 만들고... 계속해서 Token들이 생기는 무한굴레에 빠지게 됩니다. 따라서 Token을 새롭게 발급하는 방식 말고 다른 예방 방식을 생각해야 합니다.
대표적으로 RTR 방식이 있습니다.