네부캠 그룹프로젝트 - 2. 인증 로직에 대한 고찰

date
Dec 4, 2022
thumbnail
slug
naver-camp-project2
author
status
Published
tags
Project
summary
Oauth2.0과 JWT를 이용한 인증을 구현하면서 들었던 고민들과 해결법
type
Post
updatedAt
Mar 26, 2023 12:54 PM
boostcampwm-2022/web21-devrank
Oauth2.0 및 JWT 인증 로직을 구현하면서 들었던 고민 및 선택한 방식과 그에 대한 이유에 대해 적어보려 한다. 주요 고민들은 아래와 같았다.
  • Oauth로 얻은 Github token을 그냥 인증 유지에 사용할 지
  • OpenID Connect(OIDC)를 인증에 사용할 지
  • 왜 세션이 아닌 JWT를 선택했는지Refresh Token을 사용할 지
  • JWT를 통한 인증 시, 어디까지 보안과 타협할 지Access Token과 Refresh Token을 어디에 저장할 지

1️⃣ Oauth로 얻은 토큰을 인증에 사용하면 안될까?

사실 현재 구현할 기능들만을 생각하면 얻은 토큰을 인증의 목적으로 사용해도 되긴 한다. 이유는 아래와 같다.
  • 대부분의 기능이 Github에 종속적인 간단한 서비스이다.
  • Github token에 repo에 대한 read scope만 명시하기도 하고, Github에서 해당 토큰을 사용자가 직접 무효화할 수도 있기 때문에 탈취 시 보안상 문제가 크지 않다.
하지만 따로 클라이언트측 앱(클라이언트-백엔드API서버)의 인증/인가을 위해 JWT를 사용하는 이유는 아래와 같다.
  • Oauth의 주는 토큰은 클라이언트측 앱을 위한 토큰이 아니다. 즉 인증(Authentication)이 아닌 Github측 자원에 대한 인가(Authorization)에 대한 토큰이다.
  • 물론 OpenID Connect같은 인증 프로토콜을 사용하면 인증까지 위임할 수 있다. 하지만 추후 커뮤니티 기능이나 이력서 기능까지 확장성을 고려할 때 사용자에 대한 추가적인 정보가 요구될 수 있으므로 백엔드만의 인증 체계를 사용하는 편이 더 좋아보였다.
  • 또한 OpenID Connect를 사용할 때 공급자가 여럿이면 편하겠지만, 서비스 특성상 Github만을 대상으로 하므로 괜히 복잡성만 커진다고 판단했다.

2️⃣ JWT 기반 인증의 장점

쿠키/세션방식과 대비해서 JWT 방식를 사용할 때의 생각되는 장점은 아래와 같다.
  • 별도의 인증을 위한 저장소가 필요하지 않다.
  • 여러 플랫폼(웹, 모바일 등)의 호환성을 크게 고려하지 않아도 된다.
  • 여러 서버들이 클러스터링된 환경에서 처리가 더 편하다.
하지만 인증을 위한 저장소가 필요하지 않다는 것은 무상태인 HTTP의 특성상 서버측의 제어가 불가능하다는 의미와 같다. 즉 JWT 토큰이 탈취 당하면, 토큰이 만료가 될 때 까지 어떠한 대응도 불가능해진다.이를 어느정도 보완하기 위해 토큰의 유효 기간을 짧게 가져가자니, 짧은 주기로 로그인을 다시 해야하는 사용자 경험의 불편함이 생긴다.
따라서 API의 인증/인가를 담당하는 Access Token과 함께 새로운 Access Token의 발급을 보장하는 Refresh Token을 함께 사용한다. 그리고 서버측 제어를 위해 이 Refresh Token을 저장소에 저장한다.

Refresh token을 저장해야 하는 거면, 별도의 저장소가 필요하니까 JWT의 장점이 사라지지 않는가?

Refresh Token을 검증하기 위해선 다시 서버측에 별도의 저장소가 필요하므로 JWT의 장점인 서버측 리소스 감소에 의미가 없어지지 않나 생각할 수 있다.하지만 여전히 세션 방식에 비해 갖는 장점이 크다.
  • I/O 작업이 월등하게 줄어든다. 세션 방식은 요청마다 메모리 혹은 디스크에 접근하여 해당 세션이 존재하는 지 확인하는 과정이 필요하다.
  • 하지만 위의 JWT 방식은 Access Token을 단순히 디코딩 하면 된다. JWT는 base64로 인코딩되므로 이를 디코딩하는 비용이 I/O 작업에 비해 훨씬 싸다.
  • 그리고 Access Token이 만료된 경우에만 Refresh Token을 통한 I/O가 발생한다.

3️⃣ JWT는 만능이 아니다. 성능과 보안의 Trade Off

Access Token과 Refresh Token은 결국 보안성, 성능, 사용편이성 등등을 적절하게 타협한 결과이다. 보안만 추구한다면 더 좋은 방법들이 많이 있을 것이다.
어떤 기술이든 모든 면에서 절대적으로 완벽한 기술은 없으므로, 결국 서비스의 특성에 따라 타협해야 할 것 같다.
아래와 같은 경우들을 생각해보았다.

Access Token이 탈취당했을 때, 만료 기간이 남아있는 경우

결국 서버측에서 매 요청마다 Access Token의 검증에 깊게 관여하지 않는다면, 이 잘못된 요청을 막을 수 없다.
물론 Access Token을 서버측에서 저장하고 매 요청마다 검증하면 이를 막기위한 무효화 처리도 당연히 가능하긴 하다. 하지만 Access Token의 무효화는 Refresh Token의 무효화보다 성능에 지대한 영향을 미친다.
  • 만약 리소스 서버와 인증 서버가 분리된 경우, 매번 인증 서버에 검증 여부를 물어봐야 하고, 이는 곧 인증 서버의 병목 지점이 될 것이다.
  • 인증 서버가 분리되지 않았다고 하더라도, 서버측 저장소가 필요하고 결국 latency에 악영향을 줄 수 밖에 없다.

Refresh Token이 탈취당하는 경우

네이버 로그인 API나 카카오 로그인 API를 살펴보면, Refresh Token과 함께 Client_ID, Client_Secret 같은 값을 함께 보내야 refresh가 되는 방식을 가져가는 것 같다.
물론 Refresh Token만 사용할 때 보다는 보안적으로 더 이점이 있지만, Client가 갖는 값들은 결국 노출의 위험이 있기 마련이다. 결국 보안 상 trade off 문제로 인해 적절히 타협할 수 밖에 없는 듯 하다.

4️⃣ Devrank가 선택한 인증 구조

최종적으로 선택한 인증 구조는 아래와 같다.
notion image
  • Access Token 무효화는 지원하지 않는다. 위에서 적은 것처럼 포기해야 하는 JWT만의 장점이 너무 크기 때문이다. 또한 서비스의 특성상 Access Token으로 할 수 있는 기능이 모두 Read-Only한 기능들이고, 여기에 민감한 정보는 포함되지 않는다.
  • Refresh Token은 REDIS에 저장하여 관리한다. 만료 기간이 긴 특성상 Refresh Token에 대한 무효화는 필요하기 때문이다.
  • RTR(Refresh Token Rotation)방식을 사용한다.
    • Refresh Token을 통해 새로운 Access Token을 발급하면, Refresh Token도 새롭게 발급하는 방식이다.
    • Refresh Token을 1회 사용하고 버리게 되므로, 성능에 크게 영향을 안미치면서도 보안적인 이점을 더 챙길 수 있다고 판단했다.
    • 그렇지만 여전히 사용되지 않은 Refresh Token이 탈취당하는 경우에 보안상 허점은 어쩔 수 없다. 따라서 이미 사용된 Refresh Token으로 요청이 들어오면 해당 유저에 대한 모든 토큰을 무효화시키는 방향으로 로직을 구현하였다.

5️⃣ 클라이언트 측에서 보는 인증 구조

로그인이 완료되면 서버는 response body에 Access Token을 응답한다. 그리고 HTTP Set-Cookie 응답 헤더에 Refresh Token을 설정한다.
  • React 클라이언트에서 CSR을 통한 라우팅에서는 Access Token을 상태로 관리하면서 인증을 유지하면 된다.
  • 새로고침으로 인해 상태가 날라가는 경우, 페이지 로드 시 /refresh같은 서버측 API를 호출하여 자동적으로 쿠키에 있는 Refresh Token을 통해 새로운 Access Token을 받아 인증 유지가 가능해진다.
    • 우리는 Next.js를 사용하므로 SSR시에 refresh를 위한 API를 호출하면 된다.

Cookie에 Refresh Token을 저장하는 보안적인 이유

새로고침을 하는 경우 React가 갖고 있는 state가 휘발되기 때문에 인증을 유지할 수 없게 된다. 따라서 Access Token을 state에 저장하여 인증을 유지하다가, 새로고침 시 인증이 풀리게 된다.
이를 위해 결국은 브라우저 측 저장소에 Refresh Token을 저장할 수 밖에 없다.
  • Local Storage, Session Storage 등 여러 선택지가 있지만 이들은 결국 Javascript 코드로 접근이 가능하므로 XSS 공격에 노출될 수 밖에 없다.
  • 하지만 Cookie는 HttpOnly 옵션을 설정하여 XSS 공격 방지가 가능하다. 또한 Secure옵션도 설정 가능하다.
  • 추후에 SameSite까지 설정하여 CSRF 공격에 대해 보안적으로 이점을 더 가져갈 수 있다.
    • 물론 CSRF 공격을 막기 위해 서버측이 CORS를 제대로 설정하고, referer를 검사하는 등의 설정은 필요하다.

Access Token 만료 시, refresh 요청 자동화

만약 클라이언트측에서 HTTP Authorization 헤더에 Access Token을 실어 보냈는 데 만료가 된 경우라면, /refresh로 요청을 보내서 새로운 Access 토큰을 발급받아 재요청 해야한다.
모든 API 요청마다 위의 로직을 반복적으로 구현하는 것은 매우 번거롭기 때문에 방법을 찾아보니, 이 블로그 글과 같이 axios instance를 만들어서 자동화할 수 있었다.

참고