JWT 개발 적용기
JWT를 적용하게 된 배경
기존에 개발했던 솔루션은 온프레미스 서버에 구축되는 솔루션이었기에 JWT와 같은 구현이 따로 필요하지 않았다.
Spring Security를 이용해 세션으로 사용자 인증을 진행하였으며, 다중화된 서버는 L4장비에서 Hash 방식등을 이용하거나
톰캣 세션 클러스터링을 구현하였기 때문에 문제가 되지 않았다.
그러나 클라우드 서버에 구축할 SaaS 서비스를 새로 개발하게 되면서 AutoScaling을 구현하려니 Stateless한 인증은 필수가 되었다.
최초 구축한 JWT
현재는 JWT를 이용하면 security를 이용할 필요 없이 Filter로 구현하면 된다는 글이 자주 보이는 듯 하지만,
내가 서치할 당시에는 JWT + Security 의 글이 가장 많이 보였다.
인가 처리 때문에 security를 사용하면 편하다는 것 같았으나 내가 개발하는 페이지는 메뉴 권한을 테넌트별로 커스텀이 가능했기 때문에
인가처리를 직접 해줘야해서 의미없어 보였다.
그래서 JWTFilter를 만들어 JWT를 검증하고 메뉴 접근 권한을 확인하도록 하였다.
Refresh Token에 대한 반감
JWT를 공부해보니 세션과 달리 사용자 요청에 대한 슬라이딩 세션 기능을 구현하려면
Filter에서 매 요청마다 JWT를 30분 뒤 만료로 새로 쿠키에 저장해줘야 하는 것 같았다.
로그인을 하려면 2차 인증까지 해야 하는데 30분 지나면 다시 2차 인증을 거치는 게 좀 이상하게 느껴졌다.
(내가 사용하는 구글 로그인은 2주씩은 로그인이 유지되는 것 같은데..)
그러나 적용을 안하기엔 또 AccessToken의 만료를 길게 잡아두고 발급했는데 탈취 당하면 손쓸 수 없는 게 걸렸다.
그래서 Refresh Token 이 존재했는데 이것도 이상했다.
결국 Refresh Token을 이용하려면 DB를 이용해야했다.
우선 플로우는 아래와 같다.
로그인 요청 시
- 사용자 로그인 정보 확인 후 로그인 성공 시 AccessToken, RefreshToken 발급
이때 AccessToken에는 로그인 인증/인가에 필요한 모든 정보를 담고, RefreshToken에는 email만 담는다. - AcceseToken과 RefreshToken을 모두 클라이언트 cookie에 저장한다.
- RefreshToken은 DB에 저장한다. (Redis에 담았는데 Key: email / value: RefreshToken이 된다.)
로그인 후(JWT를 쿠키에 저장된 상태에서) 플로우는 아래와 같다.
- 사용자가 권한이 필요한 페이지를 요청하면 JWTFilter에서 RefreshToken을 우선 검증한다.
이때 만료여부를 검증하고 디코딩하여 email을 가져올 수 있다.
요청한 RefreshToken이 디코딩하여 나온 email 키로 저장된 DB의 refreshToken과 다르다면 검증에 실패한다.(이것으로 1계정 1세션또한 구현되었다.) - 검증에 성공하면 AccessToken을 검증한다.
이때 만약 만료되었다면 RefreshToken에서 가져온 email을 이용하여 자동으로 재로그인 과정을 거쳐 AccessToken을 재발급하여 cookie에 덮어씌워준다.
위와같은 플로우에서 이제 RefreshToken은 DB를 이용하기 때문에 탈취당했을 경우
DB에서 해당 사용자의 RefreshToken을 지워준다면 원격으로 로그아웃이 가능해진다.
그러나 Filter를 거치는 매 요청마다 DB를 연결 해야하기 때문에 이상하다고 느꼈으며 비효율적이라는 생각이 들었다.
결론적으로는 1계정 1세션을 구현해야하기 때문에 RefreshToken은 내 불만은 뒤로하고 구현할 수 밖에 없었다.
잦고 많은 DB연결이 고민되어 어차피 사용하게 될 Redis DB를 이용하였더니 속도도 빠르고 보안도 해결 된 것 같아 마음이 좀 놓였다.
Cookie Max Size 로 인한 JWT 저장 오류
위에서 언급했다시피 사용자마다 접근 가능한 메뉴의 권한이 커스텀 가능했기 때문에
로그인 한 사용자가 접근 가능한 메뉴를 사용자 세션에 저장해야했다.
아무생각없이 AccessToken payload 내에 menuAuth배열로 접근가능한 url을 패턴화 하여 전부 담았다.
토큰이 좀 길어진 것 같은데 뭐 어떻냐 하고 로그인을 시도했다.
로그인에 실패했다.
쿠키가 자꾸 없어진다.
알고보니 도메인마다 저장할 수 있는 모든 쿠키의 max size가 있었다.
쿠키값이 여러개여도 총합의 max size였다.
구글 bard 답변
쿠키의 최대 크기는 브라우저마다 다를 수 있지만, 일반적으로 4096바이트이라는 제한이 있습니다.
즉, 쿠키 하나에 저장할 수 있는 데이터의 양은 4096바이트를 넘을 수 없다는 의미죠.
하지만 이는 쿠키의 값만을 의미합니다.
쿠키 자체는 실제 데이터 외에도 이름, 도메인, 경로, 만료 시간 등 추가 정보를 포함하기 때문에 실제 사용 가능한 공간은 약간 줄어듭니다.
대략 4000바이트 정도를 사용할 수 있다고 생각하면 됩니다.
또한, 브라우저마다 쿠키 저장 개수에도 제한이 있습니다.
대부분의 브라우저는 사이트 당 20개까지 쿠키를 저장할 수 있으며,
전체적으로는 사용자 당 3000개 정도까지 저장할 수 있습니다.
결국 url을 담지 못하고 menu id(pk) 만 담았으며, menu id와 매핑되는 url들은
어플리케이션 부팅 시 메모리에 담아 map
에서 찾아올 수 있도록 처리하였다.
JWT 저장 위치에 대한 고민
JWT를 저장할 수 있는 브라우저 위치는 cookie와 localstorage 두군데이다.
브라우저에 저장 한다는거 자체가 보안적인 취약점이 존재하는 것 같았다.
하지만 로컬 스토리지는 XSS 공격으로부터 방어할 수 있는 방법이 없는 것 같았고,
쿠키는 CSRF 공경에 취약하지만 Http Only, Secure 옵션을 이용하면 방어할 수 있는 듯 하였다.
그렇기에 cookie 에 저장하기로 했으며 httponly, secure 옵션을 아래와 같이 설정하여 처리하였다.
cookie = ResponseCookie.from(cookieName, cookieValue)
.httpOnly(true) // 스크립트 공격 방어
.sameSite("Strict") // 가장 보수적인 정책으로 크로르 사이트 요청에는 전송되지 않도록 처리
.secure(true) // 네트워크를 통한 탈취 방어
.path("/")
.build();
위 설정으로 인해 Set-Cookie 값 맨 뒤 HttpOnly가 붙은 걸 확인 할 수 있다.
Set-Cookie: AccessToken=Bearer+{토큰값}; path=/; HttpOnly