이 글은 공부를 하면서 알게 된 내용들을 기록하는 글 입니다. 오류나 고쳐야 할 사항들이 있다면 지적 부탁드립니다!
또한 프로젝트 코드를 리팩토링/업데이트하는 과정에서 수정 사항이 발생하면, 밑의 내용들도 변경될 수 있습니다 :)
마지막 수정일자: 2024-07-09
✅ JWT의 보안 이슈
🔥 보안 이슈가 발생하는 이유
JWT는 Token 인증 방식으로 사용자의 인증 정보를 서버 측이 아닌 클라이언트 측에 저장하는 방식이다.
토큰 인증 방식은 `Session`과는 달리 `Stateless`하기 때문에 확장성이 우수하며 모바일에서도 작동이 잘 된다는 장점이 있지만,
토큰이 탈취되었을 때 문제점이 발생한다.
`서버에서 인증 상태를 관리하는 것이 아니`기 때문에, `토큰을 강제로 만료시킬 수 없`다. 따라서 토큰이 탈취되었을 때 토큰의 유효기간이 만료되기 전까지는 탈취한 토큰을 계속 사용할 수 있다는 것이다.
🔥 보안 이슈 종류
1️⃣ XSS 공격
XSS 공격은 `Cross Site Scripting`의 약자이다.
사용자가 특정 사이트를 신뢰한다는 사실을 악용하는 방식으로, 공격자가 심어둔 악의적인 스크립트가 실행된다.
주로 사용자의 권한 & 토큰을 탈취하는 것을 목적으로 하며, `스크립트로 접근할 수 있는 곳에 토큰이 저장`되어 있다면 이 XSS 공격에 취약해진다.
`localStorage` 혹은 `httpOnly`로 설정되어 있지 않은 Cookie에 토큰이 저장이 되어 있는 경우, 스크립트가 실행되면 토큰이 탈취될 수 있다.
2️⃣ CSRF 공격
CSRF 공격은 `Cross Site Request Forgery`의 약자로, 인증된 사용자가 자신의 의지와는 무관하게 특정 요청을 보내도록 하는 방식이다.
CSRF 공격의 특징은 토큰을 탈취하지 않더라도 `Cookie에 담긴 토큰`은 요청할 때 자동으로 담겨 전달된다는 것이다.
따라서 서버 측에서는 정상적인 요청과 비정상적인 요청을 구분할 수 없다.
CSRF 공격은 사용자 정보 탈취의 목적보다는 특정 작업을 무단으로 실행하는 것에 목적을 두고 있다.
✅ 보안 조치 1) JWT의 저장 위치
백엔드에서 JWT를 전달했으니 프론트엔드에서도 JWT(Access token, Refresh token)을 저장하고 이를 활용해야 할 것이다.
프론트엔드에서는 `JWT`를 주로 1️⃣ `localStorage` 2️⃣ `Session Storage` 3️⃣ `Cookie` 셋 중 하나를 선택하는 것 같았다.
토큰 인증 방식 자체가 클라이언트 측에서 토큰을 저장해야하고, 저장 방식에 따라 보안 처리 방식이 달라지기 때문에 신중하게 선택할 필요가 있었다.
🔥 localStorage vs Cookie
먼저 `Session storage`의 경우에는 `localStorage`와 유사한 성격을 띄고 있지만, `localStorage`보다 더 제한적이라고 한다.
현재 떠 있는 Tab에서만 값이 유지되기 때문에, 새로고침에 의한 영향은 없지만, 탭을 닫고 다시 열게 되면 데이터가 사라진다고 한다.
그래서 프론트엔드에서는 대부분 토큰을 저장할 때 `localStorage`를 이용하는 방식을 많이 채택한다고 한다. 브라우저를 닫고 다시 열어도 데이터들이 유지되기 때문에 사용자 편의성 부분에서 높은 점수를 얻는 듯 했다.
- localStorage 특징
- 스크립트로 접근이 가능하기 때문에, 스크립트를 이용하는 공격인 `XSS`에 취약하다
- Cookie 특징
- 네트워크 요청 시 자동으로 포함되어 전송 → 프론트엔드 측에서 저장소에 토큰을 저장하고 전달하는 등의 추가적인 처리 필요 X
- 토큰을 탈취하는 방식이 아닌 `CSRF` 공격에 취약하다.
- Http only & secure 옵션을 활성화하여 스크립트로 쿠키를 읽지 못하게 할 수 있다. 즉, `XSS` 공격에 대비할 수 있다.
🔥 Cookie + HttpOnly + secure + Same Site 옵션
많은 글들을 읽다보니, 대체로 토큰의 저장 장소로 `Cookie`를 선호했다.
`HttpOnly` 옵션을 키면 script를 통해 `Cookie`를 읽어올 수 없기 때문에 `XSS` 공격에 대해 방어할 수 있고, `CSRF` 공격의 경우에는 밑과 같이 몇 가지 보편적인 방법이 있기 때문이다.
1️⃣ CSRF Token
임의의 난수(CSRF Token)을 생성하여 서버 측에 저장하고, 클라이언트에게도 전달한다.
클라이언트는 중요한 요청(생성, 수정, 삭제)을 보낼 때 CSRF Token도 같이 보내 검증을 한다.
CSRF 공격을 당해도 Cookie에 들어있던 토큰만 자동으로 보낼 뿐, CSRF Token의 경우에는 서버에 전달되지 않기 때문에 방어가 가능하다.
Spring Security의 경우 기본적으로 요청 헤더에 CSRF token이 존재하지 않으면 403 Forbidden 에러가 발생한다.
Spring Security Filter에서 CSRF Token을 만들고 클라이언트에게 전달하는데, 다음 요청때 마다 Spring Security Filter가 요청에 포함된 토큰 값을 비교해서 검증된 유저인지 여부를 확인한다고 한다!
2️⃣ Cookie referer check
HTTP 요청을 보내면 요청을 보낸 Domain을 확인할 수 있는데, 해당 Domain이 허용한 Domain에서 들어온 요청인지 확인하는 방법이다.
일반적으로 `Referer check`를 통해서 대부분의 CSRF 공격을 방어할 수 있다고 한다.
3️⃣ Cookie SameSite
Cookie는 `SameSite`라는 속성을 통해 쿠키를 전송할 범위를 설정할 수 있으며, 속성은 총 3가지가 있다.
- `Strict`
현재 페이지의 도메인과 요청받는 도메인이 같아야만 쿠키를 전송 - `Lax`
Strict에서 <a herf> <link herf> GET Method 요청에만 Cookie를 전송해주며, 그 외의 요청에는 Strict와 같은 룰을 적용한다. - `None`
도메인 검증을 하지 않는다. 즉, 모든 요청에 Cookie를 전송한다. None으로 설정한 경우에는 Cookie의 secure 옵션을 필수로 붙여야 한다.
위의 옵션들 중 `Strict`가 제일 좋겠지만, 힘들다면 적어도 `Lax`로 설정하는 것이 바람직하다. 크롬의 경우 기본적으로 `Lax`로 설정되어 있다.
CSRF Token의 경우 추가적인 구현을 통해 적용을 할 수 있었지만, 보안을 위한 보안(DB와 같은 저장소에 저장까지 필요...)인 느낌이다보니 토큰 인증 방식의 장점이 사라지는 것 같았다.
자료조사를 하다보니 Spring Security Filter에서 자체적으로 csrf Token을 운영하기 때문에 추가적으로 구현해야하나..?라는 생각이 들었다.
따라서 Cookie에 JWT를 저장하고, HttpOnly 옵션을 통해 XSS 공격을 막고, secure 옵션을 통해 https에만 적용되게 했으며, Same Site 옵션을 통해 CSRF 공격에 대비하는 방식을 선택했다.
✅ 보안 조치 2) 내부 로직을 통해 대비하기
`XSS`, `CSRF` 공격에 대해 대비를 했다고 해도, 토큰을 탈취 당하는 일이 발생할 수 있다.
백엔드 코드 내에서 토큰 재발급 & 예외를 던지는 코드를 어떻게 작성하냐에 따라서 토큰이 탈취되었을 때의 대비 정도도 달라진다.
토큰이 탈취되었을 때 Access token 및 Refresh token를 재발급 받게 되면 문제가 더 커진
각 토큰이 탈취되었을 때 어떻게 대응할 수 있을까?
🔥 Access token만으로 재발급 불가하도록 처리
Access token은 서버 측에 요청을 보냈을 때 권한이 있는지 여부를 직접적으로 판단하는 토큰이다. 따라서 토큰의 유효기간이 30분부터 3시간(길어봐야 24시간)정도로 설정한다.
Access token의 유효기간은 짧기 때문에 탈취되어도 잠시동안만 사용이 가능할 것이다.
`JwtAuthenticationFilter`를 재정의할 때 Access token을 탈취했다 하더라도 해당 토큰만으로는 Access token을 재발급할 수 없도록 한다.
Access token의 유효성(validation)을 확인하고, 만료되었을 때에만 Refresh token을 확인하고 유효할 때에만 재발급 절차를 밟도록 한다.
🔥 Refresh token만으로 Access token 재발급 불가하도록 처리
Refresh token의 경우 Access token에 비해서 유효기간이 길기 때문에 탈취되었을 때 문제가 될 가능성이 높다.
🧐 Refresh token을 통해 Access token을 재발급 받으면 어떻게 하지?
Refresh token 존재의 주 목적은 유효기간이 짧은 Access token으로 인한 사용성 저하이다.
Access token이 만료되었을 때 Refresh token이 유효하다면, 자동으로 Access token을 재발급하도록 설정했기 때문에 `탈취한 Refresh token만으로 Access token을 새로 발급`받을 수 있게 된다면 문제가 발생할 것이다.
그렇다면 Refresh token 만으로 Access token을 재발급 받지 못하도록 처리하면, Refresh token을 탈취하더라도 할 수 있는 일이 없을 것이다.
💡 Access token이 존재하지 않으면 or 유효하지 않으면 예외가 발생하게끔 처리!
`JwtAuthenticationFilter`에서 `doFilterInternal()`를 통해 Access token의 유효성을 먼저 확인하고,
`Access token 자체가 유효하지 않은 경우` or `Access token이 존재하지 않는 경우`에는 예외를 날리도록 처리하자.
그렇게 하면 Refresh token만 탈취했을 때에도 안전할 것이다.
⚠️ 위의 방식은 Access token을 Cookie에 저장하지 않았다는 가정 하에 유효한 방법입니다.
Cookie의 경우에 Max-Age 혹은 Expires를 설정한 경우, 유효기간인 지나면 자동으로 삭제가 되기 때문에 Access token의 유효 기간이 지났는지 여부를 내부 코드에서 확인할 수 없습니다. (확인하기 전에 삭제되서 없어짐...)
(+ 2024-07-09 기준으로 기존 코드에서는 Access token 또한 Cookie에 저장해서 문제가 존재합니다..)
✅ RTR (Refresh Token Rotation) 사용
`RTR`은 Refresh Token Rotation의 약자로,
Access token이 만료되어 Refresh token을 통해 Access token을 재발급 받을 때, Refresh token 또한 재발급을 받는 것을 말한다.
발급받은 Refresh token은 1회성(One Time Use Only)이 되는 것이다.
🔥 NoSQL에 사용자에게 할당된 Refresh token 저장
RTR 방식을 사용하기 위해서는 특정 사용자에게 발급된 Refresh token이 무엇인지 확인할 수 있어야 한다.
이를 위해 MongoDB, Redis와 같은 NoSQL에 해당 정보를 넣어 Refresh token의 유효성을 확인할 때 발급된 Refresh token인지 확인할 수 있어야 한다.
1️⃣ 최초에 Access token, Refresh token 발급 시, `Refresh token - 사용자 식별자` 쌍을 NoSQL에 저장
2️⃣ Access token의 유효기간이 만료된 경우, Refresh token의 유효성을 확인한다. 이 때 NoSQL에 저장되어 있던 Refresh token 정보를 확인하고, 일치하는지 확인한다.
이 때, 보유하고 있던 Refresh token과 NoSQL에 저장되어 있던 Refresh token이 일치한다면 Access token 재발급을 진행한다.
3️⃣ Access token 재발급할 때, Refresh token 또한 재발급하며 NoSQL에 저장되어 있던 정보도 수정한다. (새로운 Refresh token으로 변경)
4️⃣ 만일 보유하고 있던 Refresh token과 NoSQL에 저장되어 있던 Refresh token이 일치하지 않는다면, 유효한 요청이 아니므로 Logout 처리를 한다.
🔥 RTR로도 해결할 수 없는 한계
1️⃣ Access token 탈취 시, 즉각 대응 할 수 없음
Refresh token에 대해 조치를 취한다고 하더라도, Access token이 탈취 당하면 유효한 시간동안 악용될 수 있다는 위험성은 그대로이다.
✅ 참고 자료 & 링크
- XSS, CSRF 공격에 대한 대비
https://cjw-awdsd.tistory.com/48 - Spring security에서의 csrf token
https://sdy-study.tistory.com/234 - RTR 기법
https://seungyong20.tistory.com/entry/JWT-Access-Token%EA%B3%BC-Refresh-Token-%EA%B7%B8%EB%A6%AC%EA%B3%A0-RTR-%EA%B8%B0%EB%B2%95%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90 - RTR 기법의 한계
https://mgyo.tistory.com/830 - RTR 기법 2
https://mgyo.tistory.com/832