본문 바로가기

web

[Web] JWT 로그인

프로젝트에서 로그인 기능을 구현하면서 막혔던 부분들과 해결 과정을 기록하기 위해 포스팅을 작성했다.
특히 프론트 배포 서버 도메인과 백엔드 배포 서버 도메인이 서로 달랐기 때문에 여러 문제점에 부딪혀볼 수 있었다. 
 
 

🔐 Session vs JWT

Session

처음에는 session id를 통해 로그인 기능을 구현하려 했다.
그 이유는 stateless한 HTTP 통신 특성상 클라이언트의 로그인 상태를 확인하기 위해서는 쿠키를 활용해야 하는데, session id 값에는 유저 정보가 담겨있지 않으므로 보안상 이점이 더 많다고 생각했기 때문이다.
하지만 토큰 값에 유저 정보를 담지 않는 대신, 서버측에서 각 브라우저의 세션 id 정보 및 클라이언트 정보를 직접 관리해야 하므로 서버 파트의 부담이 더 커질 수 밖에 없다.
따라서 session id로 로그인 기능 구현을 시작했지만 서버 개발 부담을 줄이기 위해 JWT 방식으로 변경하게 되었다.

JWT (JSON Web Token)

JWT 방식은 session id와 다르게 토큰 값에 유저 정보를 담고 있다. 그러므로 앞서 언급했다시피 공격자에 의해 토큰 값이 탈취당할 경우, 유저 정보가 함께 노출되므로 보안이 더 취약하다는 단점이 있다. 
이러한 이유로 토큰을 하나만 사용하기보단 각 클라이언트에게 Access Token과 Refresh Token을 발급하여 사용하는 것이 일반적이다.
 
여기서 Access Token은 유저 인증 과정에 직접 사용되는 값으로 짧은 유효 기간이 존재하며, 클라이언트가 해당 값을 발급 받으면 보통 코드 내에서 관리하게끔 설계한다. Refresh Token은 Access Token을 재발급 받을 때 사용되는 값으로, 클라이언트 측에서 해당 값을 저장소에 가지고 있어야 한다. 
 
클라이언트 저장소는 session storagelocal storagecookie 이렇게 크게 세 가지로 분류할 수 있다. 여기서 클라이언트 측에서 비교적 자유롭게 데이터를 관리할 수 있는 session storage(탭 종료시 소멸) 혹은 local storage(영구적, 클라이언트가 직접 삭제) 보다는 서버 측에서 설정한 expire time 만큼만 데이터를 유지할 수 있는 쿠키를 사용하는 것이 더 적합해 보인다. 
 

참고 👉🏻 cross site끼리 쿠키 정보를 공유해야 하는 경우 SameSite는 None으로, withCredentials 값은 true 로 설정해주어야 한다.

출처: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials

 

Axios - withCredentials 설정

// ... code

axios.create({
    baseURL: url,
    withCredentials: true,
    headers: {
      "Content-Type": `application/json`,
    },
    ...options,
});

// ... code

이번 프로젝트에서는 axios를 사용했기 때문에 위와 같이 설정했다.
사용하는 라이브러리에 따라 withCredentials 값을 설정하는 방법이 각각 다르기 때문에 이는 직접 찾아보고 적용하는 것이 좋다. 

 

😈 XSS 공격과 😇 httpOnly, secure 쿠키

XSS 공격은 사이트 내에 악의적으로 자바스크립트 코드를 추가하여 사용자의 정보를 탈취하는 방식이다. 예를 들어 악성 사이트로 연결하는 자바스크립트 코드를 추가하여 클라이언트가 해당 사이트에 요청을 보내도록 조작해, 사용자의 쿠키 정보를 탈취하는 것이다. 
이때, httpOnly 쿠키를 사용하면 브라우저만 쿠키 정보를 읽거나 수정할 수 있다. 즉, 자바스크립트 코드로 쿠키 정보를 읽거나 수정할 수 없으므로 XSS 공격을 방지할 수 있다. 
그리고 secure 쿠키를 사용하면 https에서만 쿠키를 전송하도록 허용할 수 있다. https는 데이터 전달 과정에서 암호화를 지원하기 때문에 중간에서 정보가 탈취되는 것을 어느정도 방지할 수 있다. 

secure, httpOnly는 https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies 에 더 자세히 설명되어 있다. 

 
따라서 JWT 기반으로 구성한 로그인 시나리오는 다음과 같다. 

 

📝 로그인 기능 시나리오 (프론트)

  • 유저가 사이트에 접속한다. 
    • 현재 유저가 가진 access token 값이 유효하면 메인 페이지로 리다이렉션 시키고, 유효하지 않다면 로그인 페이지를 렌더링한다. 
  • 유저가 아이디, 비밀번호를 입력하여 로그인을 요청한다. 
  • 서버는 아이디/비밀번호 정보의 유효성을 확인하고, access token 과 refresh token을 발급하여 유저에게 제공한다.
  • 유저는 응답받은 access token과 refresh token을 저장한다.
    • 이때, 클라이언트 쿠키는 HTTP request를 보낼 때마다 header에 자동 추가되어 전송된다.
    • 그렇기에 두 토큰 값을 모두 쿠키에 저장하지 않고, 기능에 맞게 따로 분리하여 저장한다.
      👉🏻 refresh token 값은 서버와의 통신 과정에 실제로 필요하다기 보다는, 클라이언트의 access token 재발급시 필요하므로(= 반영구적으로 저장해야 할 필요성) 브라우저 쿠키에 저장한다.
      👉🏻 access token 값은 서버와의 통신 과정에 실제로 필요한 값이며, 특히 refresh token에 비해 유지 기간이 짧으므로 브라우저 쿠키에는 저장하지 않는다. 그 대신 access token을 발급함과 동시에 클라이언트 HTTP request의 Authorization 헤더 값으로 설정해둔다.
    • 그리고 단순 쿠키로 토큰을 저장하는 방식은 XSS 공격에 취약하기 때문에 secure, httpOnly 쿠키 사용
      👉🏻 httpOnly를 사용함에 따라, 클라이언트가 직접 쿠키에 토큰 값을 저장하는 것이 아닌 서버측에서 set-cookie로 토큰 값을 직접 저장하는 것으로 변경
      👉🏻 이 방식에 따라 개발을 진행하였으나 결과적으로는 (프론트 / 백엔드 도메인이 서로 다른 경우) iOS 및 사파리 환경에서 문제가 발생했음

 

😒 구현은 했는데 iOS, 사파리에서 set-cookie가 안 먹힌다

위와 같은 방식으로 구현하여 배포 서버에서 테스트를 진행했는데, 크롬의 경우 정상적으로 동작했으나 사파리에서는 set-cookie가 동작하지 않았다(= response 헤더에 set-cookie가 정상적으로 포함되어 있음에도 불구하고 브라우저에 쿠키가 저장되지 않음). 
특히 iOS의 경우 사파리뿐만 아니라 크롬에서도 동작하지 않았다. (macOS의 경우 크롬에서는 정상 작동함)
 
일단 문제 원인은 response cookie에 domain 값을 설정하지 않아서 였다. 
https://stackoverflow.com/questions/1134290/cookies-on-localhost-with-explicit-domain#1188145
-> 결론을 먼저 말하자면, 안타깝게도 쿠키에 domain 값을 설정하더라도 금전적인 지원 없이는 이 문제를 완벽하게 해결할 수 없었다.
 
그래도 문제 원인을 하나씩 짚어보기 위해 우선 cookie에 domain 값을 설정해야 하는 이유를 살펴봤다. 

https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent

 
우선 쿠키는 도메인에 종속적이며, 기본적으로 쿠키가 생성된 서버의 도메인에서만 접근 가능하다. 따라서 쿠키를 공유하는 두 서버 도메인이 서로 다른 경우, 한 서버에서 생성한 쿠키를 다른 서버에서는 접근할 수 없다. 만약 쿠키를 생성한 서버 뿐만 아니라, 해당 서버의 서브 도메인에서도 쿠키를 사용하려면 domain 값을 설정해주어야 한다.
 
위의 모질라 문서 내용에 따르면, 결국 두 서버가 서로 다른 서브 도메인을 갖는 경우에는 한 쪽에서 생성한 쿠키를 다른 쪽에서는 접근할 수 없는 것으로 보인다. 그런데 현재 프로젝트에서는 프론트 도메인과 백엔드 도메인이 아예 달랐기 때문에, 이 문제를 깔끔하게 해결하려면 커스텀 도메인을 구입하는 수밖에 없었다.
 
하지만 금전적인 부담으로 인해 결국 Set-Cookie 헤더를 사용하여 브라우저에 쿠키를 설정하는 방식은 사용할 수 없게 되었고, 결국 이번 프로젝트에서는 쿠키 저장이 필요한 refresh token은 사용하지 않기로 했다.
물론 refresh token을 secure / httpOnly 옵션 없이 구현할 수는 있겠지만, 현재 상황에서는 회원정보가 포함되어 있는 토큰을 보호하지 않은 채 다루는 수 밖에 없다. 이러한 위험을 감수하면서까지 자동 재로그인 기능을 구현하는 것보다는, 과감하게 해당 기능을 제외하는 것이 낫겠다는 판단을 내렸다. 
 

결론

결국 위와 같은 이유로 자동 refresh 기능을 삭제하고, 아래와 같은 시나리오로 구현하게 되었다. 

  • 유저가 사이트에 접속한다. 
    • 현재 유저가 가진 access token 값이 유효하면 메인 페이지로 리다이렉션 시키고, 유효하지 않다면 로그인 페이지를 렌더링한다. 
  • 유저가 아이디, 비밀번호를 입력하여 로그인을 요청한다. 
  • 서버는 아이디/비밀번호 정보의 유효성을 확인하고, access token을 발급하여 response body로 제공한다.
  • 유저는 응답받은 access token을 request header의 Authorization 필드에 설정한다(ex. axios header config)
    • access token이 유효하지 않은 경우, 서버의 인증 에러를 감지하여 로그인이 만료되었음을 알리고 로그인 페이지로 리다이렉션
    • access token이 유효한 경우, 서버 측에서 성공 응답을 보냄

이번 기회에 쿠키에 대해 많이 조사하고 알아볼 수 있었다. 다음에는 이러한 경우에 대비하여 미리 프론트와 백엔드 레파지토리를 통일시킨다던지, 커스텀 도메인 구매를 고려해볼 수 있을 것 같다.