들어가기 전에..
최근에 내가 속한 개발바닥 오픈카톡방에서 블로그에 대한 이야기가 나왔다. 주니어 개발자들의 무지성 복붙 포스팅으로 인한 잘못된 정보로 생기는 문제들이 주된 내용이였다. 나야 뭐 주저리주저리 내가 이해하고 공부하는 내용들을 적으니 누가 보더라도 걸러서 읽겠지만, 똑같이 구글링해서 정보를 얻다보면 주니어인 나조차도 이게 맞나 의구심이 들 때가 있어서 충분히 공감했다.
아마 이러한 이슈의 영향인지, 사실 현재 소속되어있는 회사의 코드도...스타트업에서 나같은 고만고만한 사람들이 전임자로 있었다보니 문제가 많았다. 그래서 흔히 알려져 있는 jwt로 로그인하는 절차를 복기하며 현재 진행중인 사이드 프로젝트에는 추가적인 보안절차를 더해보려 한다.
로그인은 어떤 방식이 있는가?
일반적으로 로그인은 2가지 방법을 흔하게 제안하고 있다. 세션 / 토큰을 사용한 로그인이다. 내가 이해하는 내용을 적어보겠다.
1. 세션 로그인
전통적인 방법으로, 로그인을 하면 임의의 토큰값을 생성해주고, 세션을 별도로 서버쪽에 저장함과 동시에 쿠키를 통해 세션id를 클라이언트에게 알려준다. 클라이언트는 세션을 서버에 전달해서 식별과정을 거쳐서 서비스를 이용하고, 로그아웃을 하면 서버는 해당 세션 데이터를 삭제하는 방법으로 클라이언트의 인증을 무효화시킨다.
장점
- DB나 서버 메모리 등 별도의 저장소에 세션을 저장하고 늘 확인하기 때문에 서버쪽에 있는 세션데이터를 삭제하는 방법으로 사용자의 인가에 대한 제어권을 가지게 된다. (사용자의 서버에서 제어할 수 있기에 후술할 토큰 로그인보다 비교적 안전하다.)
단점
- http의 특징 중 하나는 stateless 즉, 상태를 저장하지 않는다는 것인데 다수의 서버를 사용하게 될 경우 세션을 공유하지 않을 시 로그인한 후 다른 서버에 접속하게 되면 세션을 확인할 수 없어 유효하지 않게 된다.
2. 토큰 로그인
JsonWebToken, JWT라고 하는 토큰을 기본적으로 사용하는데, 로그인을 하면 토큰을 발급하고 클라이언트에게 전달하지만, 서버쪽에서 별도의 저장소에 저장하는 것이 아니라 인증정보를 토큰에 담아서 넣는 부분이 세션로그인과의 차이점이다. 인증정보를 토큰에 넣기 때문에 저장소가 필요 없고, 그렇기 때문에 토큰에서 바로 인증정보를 확인해서 DB에 접근하지 않아 리소스 절약의 장점이 있다. 하지만 서버에서는 별도의 제어권이 없어 탈취당했을 때 해커가 마음대로 사용할 수 있다는 단점이 있다.
장단점이 명확하기 때문에 단점을 보완하고자 토큰을 갈아주기도 하고, 추가로는 클라이언트에서 쿠키에 접근할 수 없도록 http only를 설정하기도 하고, https로 전송하여 보안을 강화하기도 한다.
토큰 탈취의 취약점을 보완하기 위해 access, refresh 토큰을 나눠서 발급하여 각각의 역할을 다르게 배부하기도 한다.
- access : 서비스를 이용하기 위해 사용자임을 입증하는 토큰 (짧은 만료기간을 둔다.)
- refresh : access토큰이 만료될 시 access토큰을 재발급하기 위해 사용하는 토큰 (비교적 긴 만료기간을 둔다.)
예를 들어 30분짜리 access토큰과 1주일짜리 refresh토큰을 같이 발급하고 refresh는 서버에서 가지고 있는다. 클라이언트는 access토큰으로 서비스를 이용하지만 30분이 지나게 되면 access 없이 refresh토큰만 남게 된다.
이 때 refresh토큰으로 바로 인가를 요청하는 것이 아니라 서버에서 알고 있는 refresh와 대조하여 일치할 경우 access토큰을 요청하여 재발급받는 것이다. 그 이후에 다시 access토큰으로 30분동안 서비스를 이용할 수 있고, 이를 반복하는 것이다.
그리고 1주일짜리 refresh토큰이 만료되면 refresh토큰을 재발급받기 위해 새로 로그인해야 한다.
그러나 역시 30분짜리 access토큰이 탈취되었을 때 access토큰의 만료기간동안 제어를 할 수 없다는 단점은 역시 존재하고, 훨씬 짧게 만료기간을 지정할 수도 있으나 결국 access토큰의 만료기간이 짧아질 수록 자주 재발급하는 것이기에 서버에 영향을 주는 빈도수가 늘어난다.
또한 access, refresh 둘 다 탈취당한다면 access기간이 만료되더라도 refresh 토큰으로 access토큰을 재발급할 수 있어 refresh토큰의 만료기간동안 사용이 가능하다.
3. 토큰로그인 보안 강화절차
1 ) access, refresh 토큰을 둘 다 재발급한다. (Refresh Token Rotation)
api를 요청했을 때 access 토큰이 만료되었다면 refresh토큰으로 access토큰만 재발급해주면 된다. 하지만 앞서 설명한 것처럼 refresh토큰을 탈취당하게 되면 access토큰을 재발급할 수 있기 때문에 사실상 refresh토큰의 만료기간동안은 취약하다.
그래서 refresh토큰을 이용해서 access토큰을 재발급할 때 refresh토큰도 재발급을 한다면 탈취자는 가지고 있던 refresh토큰이 무효하게 되므로 더 이상 서비스를 이용할 수 없게 된다. access토큰의 만료기간동안 취약한게 아니라 사실상 refresh토큰의 만료기간동안 취약하다고도 볼 수 있었는데, refresh토큰도 같이 재발급해준다면 access토큰이 만료되면 사실상 refresh토큰도 만료되는 것이다.
그러나 이 또한 access토큰의 만료기간동안은 취약하다는 아쉬움이 남는다.
2) refresh토큰은 access토큰을 재발급하는 용도이므로 유의미한 payload를 넣지 않는다.
처음 구현할 때 cookie에 access, refresh를 나눠서 전송하여 api요청이 들어올 때 각각의 쿠키를 확인하고 access가 만료되어 access없이 refresh만 들어왔을 때 refresh를 검증하여 access토큰을 재발급했는데, 문제는 access가 만료되어 없는 것인지, 탈취자가 refresh만 탈취하여 access가 없는 상태로 요청한 것인지 구분하기가 어려웠다.
access의 jwt만료기간이 별도로 있기 때문에 쿠키의 만료기간을 refresh와 같이 두고 구분하는 방법이 있긴 하겠으나, 생각해보니 이전에 로그인에 대해 구현할 때 Authorization으로 보냈던 기억이 났다. 어떻게 구현하든 개발자 마음이겠지만, 인증 토큰의 경우 기본적으로 Authorization으로 보내서 Bearer토큰으로 받게 되는데, 곧 OAuth도 연동해야 하는데 동일하게 Authorization으로 인증정보가 들어오기 때문에 맞춰주기 위해 Authorization으로 수정하기로 했다.
내가 현재 진행하는 프로젝트의 경우 access토큰에 userId, gender를 넣는다. 성별에 따라 api가 다르게 작동하고 있어 access토큰을 제시한다면 userId, gender를 기준으로 api가 작동하는데, refresh토큰에는 access토큰을 재발급할 정보가 필요하기 때문에 똑같이 userId, gender를 넣었다가 두 토큰의 용도를 구분하기 위해 refresh의 payload에 들어갈 정보를 줄이기로 했다. 예를 들어 userId, gender가 필요한데 refresh토큰에는 userId 혹은 gender 중 부족한 값이 있다면 refresh토큰으로 access토큰인 척 하고 api에 인가를 요청했을 때 정상적으로 api가 작동하지 않고 에러를 반환할 것이다.
문제는 Authorization으로 받게 될 경우 jwt의 payload에 access, refresh를 구분하지 않으면 어떤 토큰인지 알 방법이 없었다. 그렇다고 payload 를 넣어서 무게를 늘려주기도 애매하고, jwt를 풀어내는 방법은 너무나 간단하기에 탈취자에게 그조차도 알려주기가 싫었다...ㅋㅋ
방법을 고민하다가, access 인지 refresh인지 서버에서 구분하지 않고 받는 방법을 생각했다.
이미 클라이언트에 access, refresh를 나눠서 알려준 상태고, Authorization에 access를 기본적으로 요청하되, access가 만료되면 refresh를 보내주도록 프론트쪽에 확인을 받았다.
4. 인증전략을 정리하면 다음과 같다.
서버 입장에서는 access, refresh 구분하지 않고 똑같이 Authorization 요청을 받는다.
1) access토큰을 보내주는 경우
access가 들어오게 되면 바로 검증 후에 userId와 gender값을 보내주거나 토큰의 상태에 따라 에러를 전송한다.
- 토큰이 일치하지 않을 경우 jwt의 JsonWebTokenError 인스턴스 에러를 던진다.
- 만료된 토큰은 jwt의 TokenExpiredError 인스턴스 에러를 던진다.
- 이외에는 500으로 Internal Server Error 에러를 던진다.
일치하는 access토큰을 보냈을 때에는 payload를 api에서 가져갈 것이고,
일치하는 토큰임에도 만료가 되었다면 expired 에러가 반환되며
일치하지 않으면 jsonwebtoken 에러를 던질 것이다.
2) refresh토큰을 보내주는 경우
refresh가 들어오게 되면 1)의 과정을 거쳐 에러로 전송이 되는데, access토큰과 일치하지 않기 때문에 JsonWebToken에러로 넘겨지게 된다. try-catch로 구분하여 catch error중 JsonWebToken 인스턴스인 경우 받은 refresh 토큰을 검증한다.
refresh 토큰을 서버에서 저장해놓은 refresh와 비교하여 검증한다.
3) 일치하지 않는 경우
access, refresh가 둘 다 아닌 것이므로 다시 로그인을 해서 access 혹은 refresh 토큰을 받아야 할 것이다.
일치하는 경우 refresh토큰이므로 access토큰을 재발급해준다.
여기서 refresh토큰의 payload에 access 토큰을 발급하는데 필요한 정보가 없다면?
stateless한 서버에서 access토큰을 발급하기 위한 정보가 없어서 정상적으로 api를 이용할 access토큰을 발급할 수 없고, 그렇다고 refresh토큰에 access토큰의 payload를 담자니, 앞서 언급한 것처럼 각각의 역할이 다르기에 refresh에도 동일한 payload를 담아주기가 꺼려졌다.
생각해보니, refresh토큰을 검증하기 위해 현재 DB에 접근하고 있는데, refresh 토큰을 DB에 저장할때 유저의 ip, agent 등을 같이 업데이트하고 있어서 refresh토큰을 검사하면 userId를 알 수가 있었다.
그래서 userId는 refresh토큰을 검사한 이후 나온 값으로 api에서 사용할 수 있도록 해주고, gender만 refresh토큰에 담기로 했다. refresh토큰을 탈취해도 성별 자체는 민감한 정보도 아닐 뿐더러 boolean타입이라 남/여를 파악할 수도 없다.
userId를 알기 위해 별도의 db접근을 하지 않기 때문에 나름대로 합리적인 과정으로 보인다.
결론적으로 access토큰에는 userId, gender를 담아 이용하도록 하고, refresh토큰에는 gender만 담는다.
DB에서 refresh토큰을 검증했을 때 확인되는 userId와 jwt의 gender로 새롭게 access토큰을 재발급하고, 동시에 gender로 refresh토큰을 재발급하여 DB에 업데이트하도록 했다.
보안 강화로 진행한 결과는 다음과 같다.
1. access, refresh를 동시에 재발급하는 RTR 방식
2. refresh에는 access와 다르거나 부족하게 payload를 담아주는 것
1번의 결과로는 refresh가 탈취되더라도 access가 탈취된 것처럼 access의 만료기간까지만 사용이 가능하고,
2번의 결과로는 refresh를 access의 용도로 사용할 수 없고, access/refresh 구분을 알 수 없도록 했다.
남은 숙제는 토큰이 탈취되었을 때 access의 만료기간동안은 제어할 수가 없다는 것인데, 이번 프로젝트를 떠나서 계속해서 고찰해야 하는 내용으로 보인다. 결국엔 세션로그인으로 구현한 뒤 여러 아쉬운 점들을 보완하는 방법으로 가야할지도 모르겠지만, 보안의 안정성만큼 중요한 것은 현재 서비스에 접목할만한 가성비가 괜찮을지에 대한 여부라고 생각한다.
사실상 현재 사이드로 진행하는 프로젝트는 토큰에도 userId와 성별만 담고 있고, 회원정보에도 휴대번호와 직장 정도 외에는 크게 민감한 정보를 담고있지는 않기 때문에 토큰의 만료기간동안 탈취당해도 사실 개인정보를 빼내갈만한 무언가가 적어서 추가적인 보안에 대한 고민은 차차 해나가며 프로젝트를 고도화할 시점에 적용하면 좋을 것 같다.
'개발 > http, server' 카테고리의 다른 글
[ngrok | Jenkins] ci/cd 배포 과정에서 Jenkins까지 닿지 않을 때 (0) | 2023.12.23 |
---|---|
[http] 405 (Not Allowed) 해결.. (0) | 2023.11.28 |
[AWS | Lambda] 파일을 못찾는 에러 Cannot find module 'index' (0) | 2023.11.01 |
[cors] 오랜만에 만난 CORS에러.. 설정 후에도 별도의 메세지 확인되는 경우 (0) | 2023.10.31 |
[MYSQL] workbench에서 private subnet에 있는 RDS 접근 방법 (0) | 2023.10.28 |