프로젝트 개발을 시작하기에 앞서 기능 역할 분담을 하고 있었다. 프로그래머스 데브코스의 최종 프로젝트인 만큼 실시간 채팅, 위치 기반 API 등 기존에 해보지 못했던 기능들을 담당하고 싶었지만 동시에 내가 프로젝트에서 로그인/회원가입을 담당했던 적이 있었던가? 라는 생각이 들었다.
곧 데브코스의 수료를 앞두고 있었을 때라 Spring Security 및 JWT를 활용한 인증 / 인가는 한 번쯤은 제대로 경험을 해보고 싶다는 생각이 들었다.
그래서 나는 로그인 / 회원가입은 내가 해보고 싶다고 앞서 팀원들께 말씀을 드렸고 그 외의 알림, 멤버, 가족, 산책 분석 도메인 개발을 맡았다. 경험해보지 못했던 부분이라 많이 공부하고 그만큼 많은 고민을 하게 되었다. 이러한 내용을 기록하기 위해 이번 포스팅을 작성한다.
이번 글은 JWT를 사용하며 토큰 탈취 안정성을 고민했던 나의 생각을 작성한 글이다.
JWT의 Access Token과 Refresh Token
제 3자에게 Token을 탈취당한다면 서버는 탈취당한 사실 여부를 알 수 없기에 Access Token, Refresh Token을 나누어 보안성을 강화한다.
Access Token
Access Token은 사용자의 정보를 담고 있는 토큰으로써, 사용자 인증 / 인가를 위한 토큰이다.
서버는 Access Token을 받으면 토큰의 정보를 사용하여 사용자 정보를 제공해 주는 역할을 한다.
Access Token은 탈취를 당했을 때를 대비해 만료시간을 짧게 잡는다. 보통 30분정도를 잡는다.
Refresh Token
Access Token의 만료시간이 다 되면 새로운 토큰을 발급해주는 토큰이다. 제 3자를 통해 Access Token을 탈취당해도 만료시간이 지나면 새로운 토큰을 발급하기 때문에 보안성을 조금이라도 강화시킨다.
Refresh Token은 Access Token을 새로 발급하기 위한 수단으로 Access Token보다 만료시간을 길게 잡기 때문에 보통 14일 정도를 잡는다.
Access Token의 비밀키와 Refresh Token의 비밀키는 다르게 설정하는 것이 좋다.
- 사용자가 로그인 시도 또는 인증 서버에 요청을 한다.
- 서버는 로그인을 성공하면 Access Token과 Refresh Token을 반환해준다.
- Access Token을 사용하여 요청을 한다.
- Access Token에 정보를 기반하여 응답한다.
- 만료된 Access Token을 보낸다.
- 서버는 만료된 Access Token이라고 401(Unauthorization)을 반환한다.
- 만료된 Access Token이라는 걸 안 사용자는 인증 서버에 Access, Refresh Token을 보낸다.
- 서버는 만료된 Access Token과 Refresh Token이 문제가 없다면 새로운 Access Token을 발급한다.
위 그림을 보고 RefreshToken이 탈취된다면 AccessToken을 계속 생성할 수 있다는 것을 알 수 있다. 이러한 취약점을 대비하기 위해 RTR 기법을 사용한다.
RTR 기법이란
Refresh Token Rotation 방식의 약자로, Refresh Token을 한 번만 사용할 수 있게 만드는 방법이다.
RefreshToken을 사용하여 만료된 AccessToken을 재발급받을 때, Refresh Token도 재발급하는 방법이다.
이러한 방식이 나온 이유는, RefreshToken이 탈취된다면 AccessToken을 계속 생성할 수 있기 때문이다.
RefreshToken은 만료 기간이 길기 때문에 이러한 상황이 된다면 상당히 위험해진다.
따라서, Refresh Token를 AccessToken 재발급 시 같이 재발급하여, 만료 기간을 줄이는 방법이다.
위의 AccessToken 만료 후 인증 과정에서도 RTR 방식을 적용했기 때문에 AccessToken을 재발급할 때 RefreshToken까지 재발급하여 DB에 업데이트해 주는 것이다.
탈취 시나리오 고민
- 유효기간이 긴 Refresh Token이 탈취된 경우
- 탈취한 Refresh Token으로 정상 유저보다 먼저 Access Token을 재발급받는 경우
- (토큰 탈취된 경우) 한 명의 사용자에 여러 refresh token 값이 저장되는 경우
1) Refresh Token Rotation(RTR)을 사용해 1번 문제를 해결할 수 있다. Refresh Token이 탈취되더라도 Access Token을
재발급받을 때마다 Refresh Token를 갱신해 기존 Refresh Token를 무효화할 수 있다.
2) 3) 하지만 RTR 만으로는 2번 문제를 해결하지 못한다. 탈취범이 Access Token을 먼저 재발급받아버리면, 오히려 정상 유저의 Refresh Token이 무효화되는 꼴이 된다. 정상 유저야 다시 로그인해서 Refresh Token을 발급받으면 되지만, 해커는 기존에 탈취한 Refresh Token 지속적으로 재발급받을 수 있는 가능성이 존재한다. 또한 이렇게 되면 한 명의 사용자에 대해 여러 개의 Refresh Token 이 생성되는 꼴이다.
해결했던 방법
Redis 저장 방식 변경
Refresh Token은 단순 토큰이 아닌 사용자 정보를 담은 JWT 형태를 사용했고 Token을 빠르게 처리하기 위해 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가 재발급받는 과정을 살펴보자.
- 정상유저의 RT에서 user 이메일을 꺼낸다.
- user Email을 key로 Redis를 조회하면 이에 대응되는 RT`가 리턴된다.
- Redis에서 조회된 RT`는 해커에 의해 먼저 재발급된 Refresh Token으로, 즉 정상 유저의 Refresh Token과 상이한 값이다. 즉 매칭되지 않는다.
- RT 간 매칭이 되지 않기 때문에 서버는 해당 유저에 대한 악의적인 침투를 인지할 수 있다.
- Redis에서 해당 유저 이메일 key를 삭제하고 재로그인하도록 클라이언트를 리턴한다.
이전까지의 방법과 다른 점은 Redis에 저장된 키를 RT가 아닌 userPK(email)로 했다는 점이다.
이와 달리 RT를 키로 삼을 경우를 생각해 보자.
사용자와 해커 모두 RT 재발급을 받으면 Redis는 한 명의 유저 정보에 대해 다수의 key를 가지며 어떤 key(refresh token)가 정상유저의 것인지 분간할 수 없다.
설상가상으로 둘 이상의 탈취범에게 토큰이 털리면, Redis엔 한 명으로부터 발급되는 여러 개의 RT가 저장된다.
말로 해서 꽤 복잡해 보이는데 아래 그림에 있는 흐름이 전부이다.
결론
사실 완벽한 보안이란 없는 것 같다. 내가 생각한 로직도 분명 취약점이 존재할 것이고 더 좋은 방법이 분명 많을 것이라 생각한다.
도메인 개발을 할 때마다 비즈니스 로직, 성능 최적화에 대한 고민만 해봤지 보안에 대해 깊이 고민한 적은 이번이 처음인 것 같다.
꼬리가 꼬리를 물 듯, 생각이 생각을 자꾸 키운다. 나의 성장에는 물론 도움이 되겠지만 프로젝트에는 마감 기한이라는 더욱 중요한 게 있다.
이번 프로젝트에서는 이쯤에서 마무리하겠지만 계속해서 공부해 볼 예정이다.
출처 / 참고
https://ksh-coding.tistory.com/59
'트러블 슈팅' 카테고리의 다른 글
Spring Batch ItemReader 성능개선기 (1) | 2024.12.16 |
---|---|
[Spring Batch] ItemProcessor에서의 데이터 필터링 문제 해결 (1) | 2024.10.14 |