Programming/Spring framework

JWT 와 Session 적용기 (2024-01-22)

최동훈1 2024. 1. 22. 20:31

I. 서론

  • JWT와 Session 비교 및 JWT의 장점 소개

II. 본론

  • Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까?
  • Refresh Token Rotation Redis 저장 방식 변경

III. 결론

  • 정리
  • 생각해 볼 수 있는 문제

해결하고자 한 문제

JWT로 인증을 구현한 개발자라면 아래의 문제를 생각해 볼 수 있다.

 

1. 유효기간이 긴 Refresh Token이 탈취된 경우.

-> 이 경우는 간단히 refresh token rotation 을 떠올릴 수 있다. 하지만 아래의 문제까지 커버할 수 있을진 의문이다.

 

2. 탈취한 Refresh Token으로 정상 유저보다 먼저 Access Token을 재발급받는 경우

-> Refresh Token Rotation 해도, 정상유저보다 먼저 refresh token을 재발급받으면 어떡할 것인가?

 

3. (토큰 탈취된 경우) 한 명의 사용자에 refresh token이 여러개 생성되는 경우

자동 로그인 시스템을 구현하면서 위 3가지 문제에 대한 고민을 어떻게 접근했는지 단계적으로 풀어보겠다.

 

 

I. 서론

JWT와 Session 비교 및 JWT의 장점 소개 토큰 기반 인증을 선택한 이유

인증(Authorization)은 크게 세션과 토큰 기반 방식으로 나뉜다. 간략하게 두 방식의 특징을 알아보자면,

 

1) 세션 기반

사용자의 인증 정보가 서버 메모리(=세션 저장소)에 저장되는 방식. 여기서 메모리란 Database가 아닌 서버 자체의 메모리를 의미한다.

로그인 시 사용자의 인증 정보를 세션 저장소에 저장하고 Session ID 라는 식별자를 사용자에게 발급한다.

클라이언트(사용자)가 HTTP Cookie Header에 Session ID를 저장하여 전송하면, 서버는 전달받은 Session ID로 세션 저장소를 조회하여 사용자를 검증한다.

즉 세션 기반의 특징은 사용자의 정보가 서버 메모리에 보관 된다는 점이다.

 

2) 토큰 기반

서버 메모리를 활용하는 세션 방식과 달리, 토큰 기반 인증은 인증 정보를 클라이언트가 직접 들고 있다. 단 아예 날것의 정보가 아닌 토큰의 형태로 정보를 감춰 보관한다.

이 토큰은 서버로부터 생성되어 클라이언트에게 전달되는데, 사용자를 인증할 수 있는 정보(사용자 이메일, id값 등)를 담고 있다. 클라이언트는 HTTP 요청 시 Authorization Header에 토큰을 담아 보내고, 서버는 토큰의 유효기간과 유효성을 검증하여 토큰의 정보를 풀어 클라이언트를 인증한다.

즉 토큰 기반의 특징은, 세션 기반과 달리 별도로 세션을 저장할 메모리가 필요하지 않다. 그저 토큰을 생성하고, 전달받은 토큰을 다시 resolve 할 수 있는 기능만 갖추면 된다.

 

두 방식의 차이점

우선 사용자 정보를 보유한 위치의 차이가 있다.

세션 기반은 서버에서 사용자 정보를 모두 보유한 반면, 토큰 기반에선 개별 클라이언트가 토큰의 형태로 사용자 정보를 보유한다. 세션 인증방식은 사용자 정보가 서버 측에 있어 보안 측면에선 우위에 있지만, 사용자가 늘어날수록 서버 메모리에 부하가 커질 수 있다. 또한 다중 서버를 사용한다면, 개별 서버가 가진 세션 정보가 다르기 때문에 세션 불일치 문제를 겪게 된다. 반면 토큰 기반 인증은 서버 측엔 저장된 인증 정보가 없기 때문에 세션 불일치 문제로부터 자유로워 서버 확장성의 이점을 가진다.

하지만 안정성 측면에서는 세션 기반이 확실히 이점을 가진다. 토큰의 경우 악의적인 공격에 의해 탈취 당하면 답이 없지만, 세션 기반은 서버 메모리에 정보가 저장되기 때문에 상대적으로 안정성이 높다.

 

위의 이유로 토큰 기반 인증을 택했고, 대표적인 토큰 인증 방식인 JWT를 사용했다.

 

 

II. 본론

JWT : Access Token과 Refresh Token의 도입 이유

사용자 정보를 인증하기 위해서는 우선 하나의 토큰만 있으면 된다.

클라이언트가 (사용자 정보를 담고 있는) 토큰을 서버에 보내면, 서버는 유효한 사용자인지 검증할 수 있다.

사용자 A는 자신의 토큰(T1) 을 사용해서 서버 인증을 요청한다.

 

Access Token

Access Token 은 사용자 인증정보를 담는다. 하지만 탈취의 위험과 혹여나 토큰 내용을 풀어버릴 수 있다는 위험 때문에 최소한의 사용자 정보만 담는 것을 권장한다. 필자의 경우 사용자 이메일 정보만 토큰에 담았다.

토큰 기반 방식의 단점은 토큰이 탈취될 경우에 서버에서 이를 식별하지 못한다.

정상 유저 A 와 그의 토큰(T1)을 탈취한 해커 H가 있다고 하자.

정상유저는 Access Token을 사용해 서버 인증을 거쳐 서비스를 사용한다. 토큰의 stateless 한 특징으로 서버에선 토큰을 인증할 때 어떤 사용자가 토큰을 보냈는지 알 수 없다. 쉽게 말하면 A 사용자가 B 사용자의 토큰을 자신의 헤더에 넣고 서버에 던져도, 서버 측에선 뭐가 잘못됐는지 알 수 없다.

해커 H가 탈취한 토큰을 서버에 보내도, 서버는 똑같이 인증을 해준다.

그럼 이 Access Token(T1) 을 해커 H 가 탈취했다고 가정하자. H가 T1을 자신의 Header에 담아 서버에 요청을 보내도, 서버는 A가 아닌 H의 요청이란 걸 알 길이 없다. 이런 문제로 인해 Access Token의 유효기간을 짧게 설정한다. 토큰이 탈취돼도 유효기간이 짧기 때문에 금방 만료되어 더이상 서버에 인증을 할 수 없다.

그럼 정상 유저 A 는 어떻게 해야 할까?

Access Token 이 만료될 때마다 매번 로그인 과정을 거쳐 Access Token 을 재발급받아야 한다. 하지만 짧아진 Access Token 유효기간으로 인해 매번 로그인을 해야 하는 서비스가 좋은 사용자 경험을 줄 수 있을까? 이런 문제를 해결하기 위해 Refresh Token이 등장한다.

 

 

Refresh Token 의 등장

Refresh Token은 Access Token 만료 시 재발급 과정에 사용된다. Refresh Token 은 Access Token의 재발급에 사용되어 사용자가 매번 로그인 과정을 거치지 않도록 한다. 정리하면 Access Token 은 사용자 정보를 담아 인증과정에 사용되고, Refresh Token 은 유효기간이 짧아진 Access Token의 재발급에 사용돼 잦은 로그아웃 과정을 피하게 해 준다.

앞서 Access Token을 설명할 때 Access Token 은 사용자의 정보를 담는다고 했는데, Refresh Token은 사용자 개인 정보와는 관계없는 식별자용 UUID를 담아도 되고, Access Token처럼 사용자 정보를 담아도 된다.

 

 

유효기간

refresh Token 은 access token 이 만료됐을 때 재발급을 도와줘야 하기에, access token 보다 긴 유효기간을 갖는다. 필자는 Access Token은 2시간, Refresh Token은 2주의 유효기간을 갖도록 설정했다.

Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까?

 

이제 Access Token(이하 AT) 이 만료됐을 때 Refresh Token(RT) 이 어떻게 AT를 재발급하는지 살펴보자.

우선 AT는 사용자 정보를 담고 있다. AT를 재발급한다는 것은 어떤 토큰을 만들 때 사용자 정보를 넣어준다는 뜻으로, 즉 사용자 정보가 준비돼야 한다. RT는 서버에 전송되어 사용자 정보를 가져올 key 역할을 하는데, 사용자 저장소(RDB or Redis)에서 RT로 조회하여 사용자 정보를 꺼내올 수 있다. 그 후 가져온 정보를 매개로 AT를 재발급할 수 있다.

여기까지는 누구나 아는 이야기다.

 

Access Token 만료된 경우, Refresh Token으로 사용자 정보를 가져와 Access Token 을 갱신한다.

위에서 AT가 탈취된 경우를 고려해 유효기간을 짧게 설정한다고 밝혔다.

그럼 A-T와 R-T가 모두 탈취된 상황을 가정해 보자. RT는 AT 재발급 용도로 사용되기 때문에 유효기간도 길어서, 한번 탈취되면 만료 시까지 해커가 계속 사용할 수 있다. 심지어 토큰 기반 인증의 stateless 특징 때문에 서버는 토큰이 탈취된 지도 모른 채 계속 인증을 허가해 준다.

 

 

이런 문제를 해결하기 위해 Refresh Token Rotation 기법을 도입했다.

 

Refresh Token Rotation 도입

 기존에는 RT를 이용해 AT만 재발급했지만, Refresh Token Rotation 은 AT 뿐만 아니라 RT 도 같이 재발급한다. 서버 측에선 RT를 재발급 시 Redis나 RDB에 저장된 Refresh Token도 Update 하기 때문에, RT가 탈취됐다 하더라도 서버 측에서 이를 인지할 수 있다.

하지만 과연 이걸로 끝일까?

 

이 문제의 함정은 토큰 탈취범이 정상 유저보다 먼저 Refresh token을 Rotation 할 때 발생한다.

기존의 AT와 RT는 새로운 토큰으로 갱신되고, 저장소엔 해커가 재발급받은 Refresh Token이 저장된다.

그러면 정상유저가 AT를 재발급 받으려 해도 (정상이었던)Refresh Token이 Redis에 존재하지 않기 때문에 재발급을 받을 수 없다. 선수를 뺏길 때 문제가 발생하는 것이다.

 

즉 Refresh token Rotation 기법은 토큰 탈취범이 선수를 칠 때 상황이 꼬여버린다.

 

 

 

Redis 저장 방식 변경

Refresh Token은 단순 토큰이 아닌 사용자 정보를 담은 JWT 형태를 사용했고 이를 저장하기 위해 Redis 저장소를 선택했다.

보통은 Redis key로 Refresh Token을 사용해 value인 사용자의 정보를 저장, 조회한다. Access Token을 재발급 받아야 할 때, Refresh Token 으로 Redis 에서 사용자 정보를 조회하고 그 결과로 Access Token을 재발급 받는 프로세스다.

필자는 이 구조를 역으로 바꿔, "key : value = userPk : refresh token" 형태로 저장했다.

엥 반대로 하는게 맞지 않냐고 반문할 수 있다. 사실 필자도 처음엔 "key:value = refresh token: userPk" 형태로 저장했는데, 결국은 반대로 저장한 이유가 있다.

 

우선 정상 유저의 유즈케이스

정상 유저는 최초 로그인 시 AT 와 RT를 발급받고, Redis 엔 {userEmail : RT} 형태로 사용자 정보를 저장한다.

AT가 만료되면 Refresh Token Rotation 방법을 사용하여 RT와 AT를 모두 재발급 받는다.

 

이 과정을 상세히 보면,

앞서 사용자 정보를 담아 Refresh Token을 생성한다고 밝혔다. Refresh Token 에서 User의 정보(email)을 꺼낸다.

user email 을 key 로 Redis를 조회한다. 정상적으로 조회되면 해당 user 의 refresh token(value)를 가져올 수 있다.

Redis에서 조회한 refresh token과 클라이언트가 보낸 refresh Token을 비교한다.

두 토큰 값이 매칭되면 정상 유저로 간주하고, access token 과 refresh token을 모두 재발급 한다. (AT -> AT`, RT -> RT`)

Redis에 저장된 user email의 매핑 값을 갱신한다. {user email : RT => RT`}

 

 

뭐여 별로 다를게 없는데요 선생님?

 

자자 이제 토큰 탈취범이 RT, AT를 모두 탈취하고 재발급 과정도 선수쳤다고 가정하자.

즉 위의 1,2,3,4,5 과정을 토큰 탈취범이 먼저 진행한 이후, 정상 유저 A가 재발급 받는 과정을 살펴보자.

 

1. 정상유저의 RT에서 user 이메일을 꺼낸다.

2. user Email을 key로 Redis를 조회하면 이에 대응되는 RT`가 리턴된다.

3. Redis에서 조회된 RT`는 해커에 의해 먼저 재발급된 Refresh Token으로, 즉 정상 유저의 Refresh Token과 상이한 값이다. 즉 매칭되지 않는다.

4. RT간 매칭이 되지 않기 때문에 서버는 해당 유저에 대한 악의적인 침투를 인지할 수 있다.

5. Redis에서 해당 유저 이메일 key 를 삭제하고 재로그인 하도록 클라이언트를 리턴한다.

 

이전까지의 방법과 다른점은 Redis에 저장된 키를 RT가 아닌 userPK(email) 로 했다는 점이다.

이와 달리 RT를 키로 삼을 경우를 생각해보자. 사용자와 해커 모두 RT 재발급을 받으면 Redis 는 한 명의 유저 정보에 대해 다수의 key 를 가지며 어떤 key(refresh token)가 정상유저의 것인지 분간할 수 없다. 설상가상으로 둘 이상의 탈취범에게 토큰이 털리면, Redis엔 한명으로 부터 발급되는 여러개의 RT가 저장된다.

 

III. 결론

정리

처음에 설정한 문제를 다시 정리해보자.

 

1. 유효기간이 긴 Refresh Token이 탈취된 경우

2. 탈취한 Refresh Token으로 정상 유저보다 먼저 Access Token을 재발급 받는 경우

3. (토큰 탈취된 경우) 한 명의 사용자에 여러 refresh token 값이 저장되는 경우

1) Refresh token rotation(RTR) 을 사용해 1번 문제를 해결할 수 있었다. RT가 탈취되더라도 AT를 재발급 받을 때마다 RT를 갱신해 기존 RT를 무효화 할 수 있었다.

 

2) 3) 하지만 RTR 만으로는 2번 문제를 해결하지 못한다. 탈취범이 AT를 먼저 재발급 받아버리면, 오히려 정상 유저의 RT가 무효화 되는 꼴이 된다. 정상 유저야 다시 로그인해서 refresh token을 발급받으면 되지만, 해커는 기존에 탈취한 refresh token 을 지속적으로 재발급 받을 수 있는 가능성이 존재한다. 또한 이렇게 되면 한 명의 사용자에 대해 여러개의 refresh token 이 생성되는 꼴이다.

 

위 2) 3) 문제는 Redis에 저장될 key를 refresh token 이 아닌 userPk로 설정해 해결할 수 있었다. Redis 서버는 한명의 사용자에 대해 하나의 토큰만 존재함을 걸 강제할 수 있어 토큰이 탈취된 경우 이를 인지하고 대처(로그아웃 처리 등)할 수 있다.

 

생각해볼 수 있는 문제

앞서 간단하게 세션 기반 인증에 대해 얘기했다. 서버 메모리에 세션 저장소를 두고 사용자를 인증하는 방식인데, Redis에 사용자 정보와 refresh token을 저장하는 것이 세션기반 방식과 비슷하다고 느꼈다. 컨셉은 비슷할 수 있지만 결정적으로 redis를 사용한 토큰 인증방식은 사용자가 늘어난다고 해서 (메인)서버 부하가 늘지 않고, 서버 확장성 문제로부터도 자유롭다. 물론 redis의 고가용성을 설정해야 하지만 말이다.

 

또 Refresh Token 이 사용자 정보를 담고 있다는 점에서 accesss token 대신 refresh token 을 보내면 되는 것 아니냐고 생각할 수 있다. 유효기간이 짧은 access token 대신 유효기간이 긴 refresh token을 보내면 재발급 과정도 필요없겠다 보안이 숭숭 뚫리지 않겠냐는 의견이었는데, 다행히 각각의 토큰을 생성하는 secret key 를 별도로 생성했기 때문에 refresh token을 access token 대신 보낸다고 해서 해당 토큰이 resolve 될 일은 없다.

 

하지만 이 방법도 은탄환은 될 수 없다.

 

만약 정상 유저 A가 서비스에 오랫동안 접속하지 않은 상황에서 R-T가 탈취됐다고 하자. 서버는 해커의 R-T만 지속적으로 재발급하고 Redis에선 R-T비교 과정에서 예외를 던질 일 도 없어진다. 근데 이건 RTR의 문제라기 보다 토큰 기반 인증방식이 가지는 필연적인 문제일 수 밖에 없는 것 같다.

 

결국 은탄환은 없다는 찜찜한 결론만 내게 됐다. 이 모든 문제는 토큰을 탈취 당하지 않으면 어느정도 해결되기 때문에 클라이언트 쪽에서도 필요한 준비를 하는 식으로 같이 준비를 해야한다.