로그북 프로젝트를 진행하고 있는데 현재 jwt로그인 기능을 구현 중이다.
JWT단점에 대한 대비를 위해 공부하던 중 리프레시토큰탈취에 대한 대비법이 궁금해져서 검색해보았다.
출처:https://velog.io/@park2348190/JWT에서-Refresh-Token은-왜-필요한가
데이터 베이스에 <어세스토큰,리프레시토큰> 쌍으로 저장하고
사용자가 로그아웃버튼을 누르면 사용자의 데이터베이스의 토큰을 만료시킨다.
어세스,리프레시로 더블체크해서 비정상적인 접속일시 만료
이렇게 하면되나..?
Access Token, Refresh Token
위의 방식으로 받은 토큰은 언급했듯이 서버측 리소스에 접근할 때 클라이언트 본인을 인증할 수 있는 액세스 토큰으로 동작한다. 그런데 이 JWT는 Stateless한 방식이기 때문에 서버측에서는 이 토큰을 갖고 있는 클라이언트가 정말 클라이언트 본인이 맞는지 확인할 수 없다는 문제점이 있다.
그래서 이에 대한 보안 대책으로 리프레쉬 토큰이라는 추가적인 토큰을 활용할 수 있다. 이 리프레쉬 토큰은 사용자 인증이 아닌 새로운 액세스 토큰을 생성하는 용도로만 사용된다. 그러면 왜 굳이 별도의 토큰을 두고 새로운 액세스 토큰을 발급받도록 한 것일까? 이는 위의 JWT 유출 문제를 다음처럼 해결하기 위한 것이다.
- Access Token의 유효 기간을 짧게 설정한다.
- Refresh Token의 유효 기간은 길게 설정한다.
- 사용자는 Access Token과 Refresh Token을 둘 다 서버에 전송하여 전자로 인증하고 만료됐을 시 후자로 새로운 Access Token을 발급받는다.
- 공격자는 Access Token을 탈취하더라도 짧은 유효 기간이 지나면 사용할 수 없다.
- 정상적인 클라이언트는 유효 기간이 지나더라도 Refresh Token을 사용하여 새로운 Access Token을 생성, 사용할 수 있음.
즉 OTP 인증처럼 짧은 시간 동안에만 사용할 수 있도록 하고 주기적으로 재발급받도록 하여 토큰이 유출되더라도 그 피해를 최소화한다는 방식이다. 단순히 Access Token 만으로는 일일히 IP 주소의 위치를 파악해서 비교하는 게 아닌 이상 토큰의 탈취를 검증하기 어렵기 때문에 토큰이 탈취되더라도 그 피해(attack window)를 줄이기 위해 토큰의 사용 시간 자체를 줄이는 것이다.
하지만 그렇다면 정상적인 클라이언트도 짧은 주기마다 다시 로그인해서 Access Token을 발급받아야 한다는 단점이 있다. 그래서 여기서 유효 기간이 긴 Request Token을 사용하는데 정상적인 사용자는 Access Token이 만료됐다면 서버측에 Request Token을 전송하여 다시 로그인할 필요 없이 Access Token을 발급받을 수 있다. 당연히 이 Request Token이 없는 공격자는 다시 토큰을 발급받을 수 없기 때문에 보안 측면에서 좀 더 안전하다고 할 수 있다.
또 같은 사용자가 여러 디바이스(스마트폰, 태블릿, PC 등)에서 접근하는 경우 각 디바이스 타입에 맞는 Access Token, Refresh Token 쌍이 필요할 것이다.
Refresh Token의 탈취
그런데 이 Refresh Token 자체가 탈취당한다면 어떻게 할까? 공격자는 이 토큰의 유효 기간만큼 다시 Access Token을 생성해서 다시 정상적인 사용자인 척 위장할 수 있다. 그렇기 때문에 여기서는 서버측의 검증 로직이 필요한데 스택오버플로우의 답변을 보면 다음과 같은 방법을 제안하고 있다.
- 데이터베이스에 각 사용자에 1대1로 맵핑되는 Access Token, Refresh Token 쌍을 저장한다.
- 정상적인 사용자는 기존의 Access Token으로 접근하며 서버측에서는 데이터베이스에 저장된 Access Token과 비교하여 검증한다.
- 공격자는 탈취한 Refresh Token으로 새로 Access Token을 생성한다. 그리고 서버측에 전송하면 서버는 데이터베이스에 저장된 Access Token과 공격자에게 받은 Access Token이 다른 것을 확인한다.
- 만약 데이터베이스에 저장된 토큰이 아직 만료되지 않은 경우, 즉 굳이 Access Token을 새로 생성할 이유가 없는 경우 서버는 Refresh Token이 탈취당했다고 가정하고 두 토큰을 모두 만료시킨다.
- 이 경우 정상적인 사용자는 자신의 토큰도 만료됐으니 다시 로그인해야 한다. 하지만 공격자의 토큰 역시 만료됐기 때문에 공격자는 정상적인 사용자의 리소스에 접근할 수 없다.
중요한 것은 발급된 토큰 자체는 그냥 그 JWT 문자열 자체로 존재하는 것이기 때문에 클라이언트나 서버측에서 전역적으로 만료시킬 수 있는 개체가 아니다. 그렇기 때문에 토큰의 유효 기간이 지나기 전까지는 만료된 토큰을 NoSQL 같은 데이터베이스에 저장하여 관리할 필요가 있다.
만약 공격자가 Refresh Token을 탈취해서 정상적인 사용자가 Access Token을 다시 발급받기 전에 자기가 먼저 Access Token을 생성한다면 어떻게 될까? 이 경우에도 Access Token의 충돌이 일어나기 때문에 서버측에서는 두 토큰을 모두 폐기(만료 대신 폐기가 어울리는 용어인 것 같다)해야 할 것이다. 그래서 ietf 문서에서는 아예 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여 사용자가 한 번 Refresh Token으로 Access Token을 발급받았으면 Refresh Token도 다시 발급받도록 하는 것을 권장하고 있다.
그럼 만약에 공격자가 Access Token, Refresh Token을 둘 다 탈취한다면 어떻게 할까? 이 때는 방법이 없다. 프론트엔드나 백엔드 로직을 강화하여 토큰이 유출되지 않도록 보완하는 수 밖에 없을 것이다. 이를 답변에서는 다음처럼 표현했다.
but then again there is nothing like 100% security.
이런 토큰을 읽고 쓰는 데이터베이스는 빠른 속도가 필요하고 키-값 쌍으로 다루는 편이 적절하다고 생각하기 때문에 이 부분에 NoSQL 데이터베이스를 적용해 볼 수 있을 것 같다.
토큰의 저장 장소
서버에서는 NoSQL이나 기타 데이터베이스에 저장할 수 있다. 그럼 클라이언트에서는 어디에 저장할 수 있을까? 브라우저 환경의 경우 흔히 생각하는 방법은 쿠키, 로컬 스토리지 등 다양한 곳이 있지만 스택오버플로우에서는 http-only 속성이 부여된 쿠키에 저장하는 것을 권장하고 있다.
왜냐면 해당 속성이 부여된 쿠키는 자바스크립트 환경에서 접근할 수 없기 때문이다. 그래서 XSS나 CSRF가 발생하더라도 토큰이 누출되지 않는다. 일반 쿠키나 브라우저의 로컬 스토리지는 자바스크립트로 자유롭게 접근할 수 있기 때문에 보안 측면에서는 권장되지 않는다.
일반적인 순서
- 사용자 회원가입 정보 입력.
- 유효한 데이터가 들어왔다면 회원가입 처리(DB 등록)
- 사용자 정보와 권한이 들어가 있는 Access 토큰과 Refresh 토큰 발급 (이때 Refresh 토큰은 DB에 저장한다.)
- 클라이언트는 두 종류의 토큰을 받는다.
- 이후 사용자가 데이터를 요청할 때마다 Access 토큰을 동봉하여 보낸다.
- 서버는 사용자로부터 전달된 Access 토큰이 유효한지만 판단한다(어자피 사용자의 권한과 정보는 토큰에 자체적으로 있다.)
- Access 토큰이 유효하면 사용자의 요청을 처리해서 반환해준다.
- 이때, 유효기간이 짧은 Access 토큰이 만료됐다고 해보자.
- 사용자는 만료된 Access 토큰으로 데이터 요청을 보낸다.
- 서버에서는 토큰에 대한 유효성 검사를 통해 만료된 토큰임을 확인한다.
- 클라이언트에게 "너의 토큰은 만료되었으니 갱신하기위해 Refresh 토큰을 보내라" 라고 응답한다.
- 클라이언트는 Access 토큰 재발급을 위해 Access 토큰과 Refresh 토큰을 전송한다.
- 전달받은 Refresh 토큰이 그 자체로 유효한지 확인하고, 3번에서 DB에 저장해 두었던 원본 Refresh 토큰과도 비교하여 같은지 확인한다.
- 유효한 Refresh 토큰이면 Access 토큰을 재발급 해준다.
- 만약 Refresh 토큰도 만료됐다면 로그인을 다시하고 Access 토큰과 Refresh 토큰을 새로 발급해준다.
누군가가 Refresh 토큰을 토큰 자체로 반환하지 말고 DB에 저장된 곳의 인덱스(정수 or 해시값)만 반환한다면, 클라이언트 측에서는 무의미한 인덱스 숫자만 알게 되는 것이기에 보안적으로 조금 더 좋다는 의견을 제시했다.
이방법은 숫자로 때려 맞출수 있기 때문에 보안적으로 좋아 보이지 않는다. 그래서 따로 비밀코드(해시)를 생성하여 클라이언트에 넘겨주고
어세스코드와 비밀코드로 디비에서 확인하는 방법은 어떨까..
생각해보니 로테이션을 사용하여서 그냥 디비에서 어세스,리프레시로 확인하는 과정만 추가하면 될 것같다... 앱개발이라서 jwt를 쓰긴하지만 웹이라면 차라리 세션방식을 쓰는 것이 좋을 듯하다..
아래방법대로 구현하면될 듯
1. 클라이언트가 유저 로그인 요청
2. 서버에서 유저 DB에서 유저 정보 확인하고 클라이언트에게 Access Token, Refresh Token 발급
3. 서버의 DB에 Refresh Token 저장 (일반적으로 유저 테이블에 저장)
4. 클라이언트는 Refresh Token을 Local Storage에 저장하고 Access Token을 헤더에 실어 요청
5. 서버는 Access token을 검증하여 이에 맞는 응답
Access Token 만료된 경우
1. 클라이언트에서 Access Token의 PayLoad를 통해 유효기간을 확인하고 만료되었다면
Refresh Token과 Access Token을 함께 보내며 재발급 요청 (Refresh는 페이로드없고(?),access로유저확인)
2. 서버는 클라이언트가 보낸 Refresh Token과 유저 DB에 저장된 Refresh Token을 비교 확인하고
유효기간이 지나지 않았다면 새로운 ,Access Token 재발급 /Rotation기술사용시 Refresh도 재발급
결론: jwt는 편의와 보안의 타협점의 타협점이다. 보안측면에서는 세션,쿠키 방법이 더 좋다. 완벽한 보안은 없다..
'I leaned > Etc' 카테고리의 다른 글
마크다운 요약정리 (0) | 2023.04.24 |
---|---|
메모리영역 (0) | 2023.04.16 |
IntelliJ 단축키(Mac) (0) | 2023.04.16 |
lombok 사용시 주의점 (0) | 2023.02.18 |
REST API (0) | 2023.01.04 |