개요
이전 포스팅에서는 쿠키와 세션에 대해 알아보는 시간을 가졌습니다.
이번 포스팅에서는 저번에 알아본 쿠키와 세션, JWT등을 바탕으로 어떻게 사용자 인증을 처리할지에 대해 고민해보는 시간을 가지려고 합니다. 또한, 로그인은 보안과도 직결되는 부분입니다. 그렇기 때문에, 여러 로그인 방식별 보안 위협이나 취약점은 무엇이 있는지 알아보고, 어떤 방식이 가장 최선일지 결론을 도출해보겠습니다.
로그인 / 인증 절차
먼저, 로그인 또는 사용자를 인증하는 방식 중 제가 사용해보았던 두가지 방식을 알아보려고 합니다.
1. 세션 ID를 이용한 인증
이 과정은 저번 포스팅에서도 소개하였어서, 간단하게 언급만 하고 넘어가겠습니다. 한마디로, 고유한 임의의 랜덤 값으로 세션 ID를 생성하여, 세션 저장소에 있는 정보와 매칭하여 사용자를 식별하는 방식입니다.
2. JWT를 이용한 인증
이 방식은 널리 사용되고 알려진 방식입니다. JWT가 무엇인지에 대해 궁금하시다면,아래 링크를 참고하시길 바랍니다.
아래는 제가 현재 프로젝트에서 사용하는 JWT기반 인증 방식입니다.
- 클라이언트 측에서 서버에 로그인 요청
- 서버 액세스 토큰과 리프레시 토큰 두 개를 발급, 리프레쉬 토큰은 DB에 저장
- 서버측에서 토큰 두 개를 클라이언트 측에 전송
- 클라이언트는 전송받은 토큰을 저장
- 클라이언트 측에서 인증이 필요한 API 요청을 서버측에 보낼 때마다 액세스 토큰을 같이 전송 (요청 헤더에 토큰 첨부)
- 서버 측에서는 전달받은 액세스 토큰에 대한 유효성 검사 후 유효하다면 클라이언트 요청을 처리. 만약 액세스 토큰이 만료되었다면 토큰이 만료되었다는 에러를 클라이언트에게 전달
- 에러를 전달받은 클라이언트는 리프레시 토큰과 함께 새 액세스 토큰을 서버 측에 요청
- 서버에서는 리프레시 토큰 유효성 검사를 위해 데이터베이스에서 리프레시 토큰을 조회 후 비교
- 유효하다면 새 액세스 토큰을 발급하여 클라이언트에게 전달. 클라이언트는 새 액세스 토큰을 가지고 원래 요청하려던 API를 다시 요청
토큰에는 유저 정보가 포함되어 암호화 되어있습니다. 따라서 이 토큰을 서버 내부에서 복호화하여 사용자가 일치한지, 토큰이 유효한지 검증하는 과정을 거치게 됩니다.
로그인 과정을 알아보았으니, 다음으로는 보편적으로 알려진 보안 위협에는 어떤것이 있는지에 대해 알아보겠습니다.
XSS, CSRF
XSS(Cross-site Scripting) : 크로스 사이트 스크립트
공격자(해커)가 클라이언트의 브라우저에 JavaScript를 넣어 실행하여 공격하는 기법입니다. 주로 다른 웹사이트와 정보를 교환하는 식으로 작동하기 때문에 사이트 간 스크립팅이라고 불립니다.
이 취약점은 웹 애플리케이션이 사용자로 부터 입력 받은 값을 제대로 검사하지 않고 사용할 때 나타나며, 공격에 성공하면 사이트에 접속된 사용자는 삽입된 코드를 실행하게 됩니다. 이를 통해 공격자는 글로벌 변수, 토큰, 세션 등 민감정보를 가져올 수 있으며, 의도치 않은 악의적인 동작을 수행시킬 수 있습니다.
이 방법은 공격 방법이 단순하고 가장 기초적임에도 많은 사이트들이 방비를 해두지 않아 공격 당하는 경우가 많습니다. 많은 사용자들이 접근하는 게시판, 댓글에 스크립트 코드를 삽입하거나, 심지어 닉네임과 이메일에 삽입해두는 경우가 있습니다.
방지 대책
XSS를 방지하기 위해서는 다음과 같은 방법을 적용할 수 있습니다.
- 중요한 정보를 서버에 저장
- 정보를 암호화
- httpOnly 속성 On : JS의 document.cookie를 이용해 쿠키에 직접 접근하여 탈취하는 것을 방지
- Secure Coding : url endoding이나 문자열을 치환
CSRF(Cross-Site-Request-Forgery) : 크로스 사이트 요청 변조
사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 의미합니다. XSS는 사용자가 특성 웹사이트를 신뢰하는 점을 노린 공격이라면, CSRF는 특정 사이트가 사용자를 신뢰하는 점을 노린 공격 입니다.
사용자가 웹사이트에 로그인한 상태에서 사이트간 요청 위조 공격 코드가 삽입된 페이지를 열면, 공격 대상이 되는 사이트는 위조된 명령을 신뢰할 수 있는 사람이 발송한 것으로 판단하여 공격에 노출됩니다. 예를 들어, 사용자가 은행 사이트에 로그인 된 상태에서 피싱 사이트를 열게 되면, 은행 서버로 자금 전송 요청이 가게되고, 은행 서버는 정상 사용자의 요청으로 판단하여 돈을 송금하게 됩니다.
방지 대책
CSRF를 방지하기 위해서 다음과 같은 방법을 적용할 수 있습니다.
- Referrer 검증 : 서버에서 request의 referrer을 확인하여 domain이 일치하는지 확인. 일치하지 않을 시 차단
- Security Token 사용 : Referrer 검증이 어려운 환경이라면, 시큐리티 토큰을 사용. 사용자 세션에 임의의 난수 값을 저장하고, 사용자의 요청마다 해당 난수값을 포함하여 요청을 전송하도록 함. 이후 서버에서는 세션에 저장된 난수값과 요청에 포함된 난수값이 동일한지 검증
이 방법들 또한 같은 도메인 내 XSS 취약성이 존재한다면, CSRF 공격에도 취약할 수 있습니다.
브라우저 저장소
앞에서 설명했던 로그인의 프로세스를 보면, 페이지를 새로고침 하거나 창을 닫고 켰을때 다시 연결하기 위해서 세션 ID나 accessToken 같은 정보를 클라이언트(브라우저)에서 저장하고 있어야 합니다. 이 정보는 어디에 저장될 수 있고 어떤 보안 위협이 존재하는지에 대해 알아보겠습니다.
1. LocalStorage 저장 방식
브라우저 저장소에 저장하는 방식입니다. 자바스크립트 내 글로벌 변수로 read/write가 가능합니다. localStorage에 저장된 토큰이나 세션 ID와 같은 값은 XSS 취약점을 통해 탈취당할 수 있으며, 공격자는 이것들을 이용해 API 콜을 위조하여 악의적인 요청을 요청할 수 있습니다.
2. Cookie 저장 방식
브라우저에 쿠키로 저장되는데, 클라이언트가 HTTP 요청을 보낼 때마다 자동으로 쿠키가 서버에 전송됩니다. 이것 또한 자바스크립트 내 글로벌 변수로 read/write가 가능합니다.
세션 ID나 액세스 토큰을 쿠키에 넣어둔다면, 로컬 스토리지 방식과 같이 XSS 취약점을 통해 정보를 탈취당해 악의적인 요청에 이용될 수 있습니다. 또한 쿠키에 세션 id나 accessToken을 저장해 인증에 이용하는 구조에 CSRF 취약점이 있다면 인증 정보가 쿠키에 담겨 서버로 보내집니다. 공격자는 유저 권한으로 악의적인 요청을 수행할 수 있게됩니다.
3. secure, httpOnly 쿠키 저장 방식
브라우저에 쿠키로 저장되는 건 같지만, 자바스크립트 내에서 접근이 불가능합니다. secure 옵션을 적용하면, https 접속에서만 토큰이 전송됩니다.
httpOnly 방식은 XSS 취약점 공격을 통해 담긴 값을 탈취할 수 없으며, Jwt 리프레쉬 토큰만을 넣어두고 accessToken을 발급받는 방식으로 진행한다면 CSRF 공격도 방어할 수 있습니다.
하지만 쿠키 저장방식과 같은 이유로 세션 ID나 직접 접근이 가능한 액세스 토큰은 넣으면 안되며, httpOnly 쿠키에 담긴 값에 직접 접근할 수는 없지만 XSS 취약점을 이용해 API를 요청하게 되면, httpOnly 쿠키에 담긴 값들도 함께 보내져 유저로 위장해 악의적인 행동이 가능해집니다.
JWT + Cookie를 이용한 로그인
제가 보안 전문가가 아니고 깊이있게 공부하지 않아서 제가 생각한 방법이 정답이라고 생각하지 않습니다. 지금 제 지식으로서는, 기존에 제가 구현한 JWT 인증 로직을 활용하여, 쿠키에 값을 담아서 인증을 진행하는 방식이 가장 베스트 옵션이라고 생각 됩니다.
중요한건, accessToken과 refreshToken 모두 쿠키에 담는 것이 아닌, accessToken을 재발급 받기 위한 용도로 사용하는 refreshToken만을 httpOnly secure 쿠키에 넣어서 요청을 보내도록 하려고 합니다. 이를 통해 CSRF 취약점을 개선할 수 있습니다. 공격자가 쿠키를 이용하여 사용자인척 서버에 요청을 보내더라도, refreshToken만으로는 서버에 접근할 수 없기 때문입니다.
또한, httpOnly 쿠키를 이용하여 JS에서 쿠키에 접근하지 못하도록 하여 XSS 취약점을 개선하고, secure 옵션을 통해 https에서만 동작하게 하여 데이터 탈취를 방지하겠습니다.
정리하자면,
- 유저는 ID + PW의 조합으로 서버에 로그인 요청 -> /login
- 서버는 ID와 PW가 일치하면 accessToken과 RefreshToken이 담긴 쿠키를 담아 응답
- 유저는 accessToken으로 서버에 접근.
- 페이지 리로딩이나 accessToken 만료 1분 전에 로그인 연장 요청 -> /login-extension
- 쿠키에 담긴 refreshToken 정보가 DB에 있는 정보와 일치할 시 accessToken 재발급 하여 전송
위 단계를 통해 인증을 진행합니다. 지금까지 XSS 취약점을 통해 저장된 유저의 민감 정보를 읽지 못하게 하였고, CSRF 공격을 방어해보도록 하였습니다. 하지만 여전히 XSS 취약점을 통해 API콜을 보내는 유형의 공격 무방비하기 때문에, XSS 자체를 막기 위해서는 클라이언트와 서버 모두 노력해야 합니다.
서버 입장에서 저는, 이 문제에 어느정도 대응하기 위해 accessToken의 유효시간을 refreshToken의 유효시간보다 짧게 가져가도록 하는 방법을 사용합니다. 하지만 극단적으로 짧게 가져가는 경우, 로그인 연장 API 호출이 많아져 서버에 부하가 걸릴 수 있기 때문에, 적절한 선을 계속 찾아가보려고 합니다.
근데, RefreshToken이 탈취되면 긴 기간동안 AccessToken을 재발급 받을 수 있지 않나요?
문득 이런 생각이 들었습니다. accessToken의 유효시간을 짧게 만들긴 하였지만, 만약 refreshToken이 담긴 토큰의 정보가 모종의 이유로 탈취당한다면 공격자가 긴 기간동안 악의적인 행동을 지속시킬 수 있는거 아닌가? 그러면 accessToken의 유효시간이 짧은게 의미가 있는건가?
이 문제를 해결해보고자, RTR(Refresh Token Rotation) 기법을 적용하기로 하였습니다.
RTR(Refresh Token Rotation) 기법
위에서 살펴본 기존 로직에서는, RefreshToken으로 오직 AccessToken만 재발급 하였습니다. RTR 기법은, AccessToken만 재발급 하는 것이 아닌, RefreshToken또한 새로 발급합니다. 새로 발급된 RefreshToken은 Redis 또는 RDB에 저장됨으로, RefreshToken이 탈취되었을때, 서버가 인지하거나 대응할 수 있습니다.
RefreshToken이 공격자에 의해 탈취되는 경우, 다음과 같은 두 가지 상황이 발생할 수 있습니다.
1. 공격자가 탈취한 RefreshToken이 이미 사용자가 재발급받아 업데이트 된 경우
공격자가 사용자의 RefreshToken을 탈취하였다고 가정하겠습니다. 공격자가 탈취한 RefreshToken으로 어떠한 요청을 하기 이전에, 사용자가 먼저 RefreshToken으로 로그인 연장 요청을 한 경우, 새로운 RefreshToken이 발급이 되기 때문에 공격자가 탈취한 RefreshToken은 유효하지 않은 토큰으로 판단되어 접근이 거부되게 됩니다. 이로써 공격자는 RefreshToken을 탈취했음에도 지속적으로 AccessToken을 발급받아 사용할 수 없게 됩니다.
이대로만 되면 공격을 방어할 수 있지만, 고려하지 않은 점이 하나 존재합니다. 바로 공격자가 사용자보다 먼저 탈취한 Refresh 토큰을 이용해 재발급을 요청하는 경우 입니다.
2. 공격자가 사용자보다 먼저 RefreshToken으로 AccessToken을 발급받은 경우
공격자가 토큰을 탈취하였고, 사용자보다 먼저 재발급 요청을 보냈다고 가정해봅시다. 서버에서는 공격자의 요청을 받고, 공격자에게 RefreshToken과 AccessToken을 재발급 해줌과 동시에 DB에 공격자의 RefreshToken으로 업데이트합니다. 이후 정상 사용자가 RefreshToken을 가지고 서버에 재발급 요청을 하는 경우, 서버에서는 DB에 저장된 RefreshToken과 일치하지 않음을 알고 접근을 차단시킵니다.
이 경우, 사용자가 접속하지 못하는 동안 공격자는 악의적인 행동을 지속할 수 있습니다. 이런 어이없는 현상이 발생했을 때 다음과 같이 대응할 수 있습니다.
서버는 매칭되지 않은 RefreshToken 요청이 있기 때문에 악의적인 침투를 인지할 수 있고, 이것을 인지한 즉시 DB에 있는 리프레쉬 토큰을 날림과 동시에 사용자를 로그아웃 처리하고, 이후 사용자가 다시 로그인하도록 유도하면 됩니다.
결론이 뭔가요?
마지막으로, 결론을 요약해보자면 저는 아래와 같은 흐름의 인증 방식을 구현하려고 합니다.
- 클라이언트는 ID + PW의 조합으로 서버에 로그인 요청 -> /login
- 서버는 ID와 PW가 일치하면 accessToken, 그리고 RefreshToken이 담긴 쿠키를 담아 응답
- 클라이언트는 accessToken으로 서버에 접근.
- 클라이언트는 페이지 리로딩이나 accessToken 만료 1분 전에 로그인 연장 요청 -> /login-extension
- 쿠키에 담긴 refreshToken 정보가 DB에 있는 정보와 일치할 시 accessToken, refreshToken 재발급 하여 전송
- 만약 RefreshToken간 매칭이 되지 않을 경우, 서버는 이를 악의적인 침투가 있는것으로 간주,
- DB에 저장된 리프레쉬 토큰을 날리고 사용자를 로그아웃 처리
- 이후 클라이언트는 사용자에게 다시 로그인하도록 유도
추가로, 저는 Redis를 이용하여 accessToken 블랙리스트를 관리하려고 합니다. 저는 JWT Token 기반의 인증 방식이고, 이 방식의 최대 단점은 바로 토큰을 임의로 만료시키거나 폐기할 수 없다는 점입니다.
만약 공격자가 AccessToken을 탈취한 것을 알아차리더라도, 그 토큰을 임의로 폐기할 수 없어 아무 처리도 하지 못하지만, 만약 Redis에 로그아웃 하거나 탈취당한걸 인지한 AccessToken을 사용자 ID와 같이 저장해놓고, 요청이 들어온 액세스 토큰과 대조해서 블랙리스트에 올라있지 않은 토큰만 서버에 들어오게 한다면, 공격자가 가진 AccessToken이 유효할지라도 서버에 접근하는 것을 막을 수 있게 됩니다.
생각 해볼 점
만약, 공격자가 탈취한 RefreshToken으로 AccessToken을 정상 발급 받았지만, 이후 사용자가 장기적으로 접속을 하지 않는다면 서버는 악의적인 침투인지 알 길이 없어지고, 공격자는 RT를 가지고 지속적으로 재발급 받아 지속적으로 악의적인 행동을 수행할 수 있게 됩니다.
결국 이 방법도 완벽하게 모든것을 차단할 수 없다는것을 알았습니다. 괜히 찜찜함이 좀 남지만.. 지금 상황에서 선택할 수 있는 베스트 옵션이라는 생각이 들었고, 최우선적으로 토큰 탈취를 막기 위해 클라이언트와 더 많은 협의를 거쳐 대응하기로 하겠습니다. 또한 재발급 요청이 너무 빠른 시간안에 많이 들어온다던지 하는 비정상적인 행동을 탐지하기 위한 로직을 더 고민해보려고 합니다.
이번 포스팅은 여기서 마무리 하겠습니다. 아직 글을 잘 작성하지 못해 두서 없고 깔끔하지 못한 글이 되었지만,, 그럼에도 여기 까지 읽어주신 분들에게 감사를 표합니다.
다음 포스팅에서는 실제 구현을 시작하겠습니다.
출처 및 참고
https://velog.io/@denmark-choco/Web-Authentication%EC%9D%B8%EC%A6%9D
https://hasura.io/blog/best-practices-of-using-jwt-with-graphql
https://junior-datalist.tistory.com/352