본문 바로가기
프로젝트/개인 프로젝트 V3

다중 토큰: Refresh 토큰과 생명 주기

by Thumper 2024. 12. 4.

이번 글에서는 프로젝트에서 로그인과 고객 정보를 위한 보안 처리를 어떻게 구현했는지에 대해 정리합니다.
Spring Security와 JWT 기반 인증 로직을 어떻게 구현하여 JWT 토큰을 관리하려고 했는지 다루어 보도록 하겠습니다.

(해당 포스팅 내용은 Github wiki에도 요약 되어있다.)

기본 설정

아래와 같이, Jwt 사용을 위해 jsonwebtoken 관련 라이브러리와 Spring Security 라이브러리를 추가했습니다.

image

1. 단일 토큰으로 했을 때의 문제점

이전 프로젝트에서 스프링 시큐리티를 단일 토큰으로 구현했었습니다.
구체적으로, 토큰을 다음과 같이 관리하여 사용하였습다.

  1. 로그인 성공 시, JWT 발급한다. : 서버측에서 클라이언트로 JWT를 발급한다.
  2. 권한이 필요한 모든 요청에 JWT를 서버에 전달한다. : 클라이언트는 서버측에 JWT를 전송한다.

로그인을 성공했을 때 JWT를 발급 받을 수 있으며, 권한이 필요한 모든 요청에 JWT를 서버로 전달하는 방식을 사용했었습니다.
이 방식 로그인 성공 후 발급받은 JWT를 요청 시마다 서버로 전달하여 인증 및 권한 검사를 수행한다는 점입니다.


또한 JWT 유효기간 문제가 있었습니다. JWT 유효시간을 30분으로 설정했기에 보안적으로 위험에 노출되어 JWT 탈취 위험이 있었습니다.

2. 다중 토큰: Refresh 토큰과 생명 주기

위와 같은 보안 문제가 발생하지 않도록 Access, Refresh 토큰으로 관리하기로 했습니다.
권한이 필요한 요청에 쓰이는 토큰은 생명주기를 짧게 설정하고,
이 토큰이 만료되었을 때 함께 받은 Refresh 토큰으로 토큰을 재발급할 수 있도록 하기로 했습니다.



짧은 생명주기는 약 10분, 재발급을 위한 토큰의 생명주기는 약 24시간으로 설정했습니다.
생명주기를 짧게 했기 때문에 JWT 보안 취약점을 개선할 수 있었습니다.
짧은 생명주기 토큰만 있다면 매번 로그인을 진행해야 하는 번거로움이 있기 때문에,
재발급을 위한 토큰을 추가했으며 이 토큰은 생명주기를 길게 설정했습니다.

로그인 성공 시, 생명주기와 활용도가 다른 토큰 2개 발급: Access/Refresh

  1. Access 토큰: 권한이 필요한 모든 요청 헤더에 사용될 JWT로 탈취 위험을 낮추기 위해 약 10분 정도의 짧은 생명주기를 가진다.
  2. Refresh 토큰: Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용되며 약 24시간 이상의 긴 생명주기를 가진다.

권한이 필요한 모든 요청에 Access 토큰을 함께 전송합니다.

권한이 필요한 요청일 경우, 요청 헤더에 Access 토큰만 넣어주면 됩니다.
Access 토큰만 사용하여 요청하기 때문에 Refresh 토큰은 호출 및 전송을 빈도가 낮습니다.

Access 토큰이 만료된 경우, Refresh 토큰으로 Access 토큰 발급받을 수 있습니다.
Access토큰이 만료되었다는 메시지를 받았을 경우, Refresh 토큰을 가지고 Access 토큰을 새로 재발급 받는 요청을 통해 갱신하도록 구현했습니다.

구현 포인트

  1. 로그인이 완료되면 handler에서 Access/Refresh 토큰 2개를 발급해 응답한다.
    토큰은 각기 다른 생명주기, payload 정보로 설정한다.
  2. Access 토큰 요청을 검증하는 JWTFilter에서 Access 토큰이 만료된 경우는, 요청에 맞는 상태코드와 메시지를 응답하도록 구현하려고 한다.
  3. 프론트측 Javascript 로직에서, Access 토큰만료인 경우에는 Refresh 토큰을 서버측으로 전송하고 Access 토큰을 발급 받는 로직을 처리하도록 한다.
    재발급 받을 때, 기존 Access는 DB에서 제거한다.
  4. 서버측에서 컨트롤러 단에서 Refresh 토큰을 검증하고 Access를 응답하도록 구현한다.

3. Refresh 토큰도 보안적으로 위험할까?

1개 -> 2개 토큰으로 변경했을 때, 자주 사용되는 Access 토큰이 탈취되더라도 생명주기가 짧아 위험 확률이 줄었습니다.
하지만 Refresh 토큰 또한 사용되는 빈도만 적을뿐 탈취될 수 있습니다.
그렇기에 Refresh 토큰에 대한 보안도 필요하지 않을까 의문이 들었습니다.

Access/Refresh 토큰의 저장 위치

로컬/세션 스토리지 및 쿠키에 어떻게 저장해야 할지 알맞는 저장소 위치에 대해 고민을 했습니다.
클라이언트에서 발급 받은 JWT는 다음과 같이 저장하기로 했습니다.

  • 로컬 스토리지: Access 토큰을 저장한다.
  • 쿠키: Refresh 토큰을 저장한다.

 

로컬 스토리지

Access 토큰은 짧은 생명 주기를 가지므로, 클라이언트 측에서 빠르게 관리하고 갱신할 수 있습니다.
로컬 스토리지는 XSS 공격에 취약하지만, 이를 인식하고 XSS 방어 로직을 구현하여 보안을 강화할 수 있다고 합니다.

쿠키

쿠키는 CSRF 공격에 취약하지만, httpOnly 및 secure 속성을 설정함으로써 XSS 공격으로부터 보호할 수 있습니다.
Refresh 토큰은 긴 생명 주기를 가지고 있어, 서버와의 인증 세션을 유지하는 데 적합하다고 합니다.
쿠키는 해당 도메인으로 요청을 보낼 때 자동으로 전송되므로, 사용자가 수동으로 토큰을 관리할 필요가 없습니다.


Refresh 토큰 Rotate

 

Refresh 토큰의 보안을 강화하기 위한 Refresh Rotate 방식을 채택했습니다.
Access 토큰이 만료되면, 클라이언트는 Refresh 토큰을 사용해 서버에 새로운 Access 토큰을 요청합니다.

이때 서버는 Refresh 토큰의 유효성을 확인하고, 새로운 Access 토큰과 함께 새로운 Refresh 토큰을 발급합니다.
클라이언트는 새로운 Refresh 토큰을 DB에 저장하고, 이전 Refresh 토큰은 만료시키고 DB에서 삭제합니다.

로그인, JWT 재발급, 로그아웃에 대한 자세한 서비스 시퀀스는 Github에 정리해두었으니 참고해주세요.

로그인 요청

JWT 재발급 요청

로그아웃 요청

4. Spring Security와 JWT 기반 인증 로직: 요청 흐름 도식화 및 구현

요청흐름

jwt request logic drawio


Client에서 Server측에 요청을 보내고 처리하는 요청흐름을 도식화하여 정리했습니다.
사용자의 웹에서 로그인 요청을 하면
Client에서 Server측에 요청을 보내고, Server는 이를 처리하여 JWT 데이터를 발급하는 방식으로 구현했습니다.

이때 권한이 필요한 API 요청일 경우 Access 토큰을 요청 헤더에 첨부하여 Server로 전송합니다.
Server측에서는 Jwt Filter가 Access Token을 검증하는 역할을 맡습니다.
만약 Access Token이 만료되었다면, Server는 401 상태코드와 함께 JWT 만료에 대한 메시지를 응답하도록 처리했습니다.

Security Config 파일

config

 

  • 예외처리
    • 인증이 실패 시, jwtAuthEntryPoint가 호출되도록 작성했다.
    • jwtAuthEntryPoint는 인증이 필요한 요청에 대해 적절한 오류 메시지와 상태 코드를 반환하는 역할을 한다.
    • 사용자가 인증되지 않은 상태에서 보호된 리소스에 접근하려고 할 때 이 jwtAuthEntryPoint가 호출된다.
  • 로그아웃 필터
    • 커스텀 로그아웃 필터를 추가하여 로그아웃 요청을 처리했다.
    • customLogoutFilter에 JWT 로그아웃 처리 로직을 작성했다. JWT를 초기화하고 삭제하는 기능을 포함한다.

JWT 로그인을 위한 UserDetails, UserDetailsService 구현

user


UserDetails 인터페이스는 User 엔티티 클래스에 정의해두었다.
사용자이름 username은 User의 email 필드로 정의하여 사용했습니다.


userdetails


CustomUserDetailsService는 JWT 인증 과정에서 UserDetailService를 사용하여 사용자의 자격증명을 검증하고 UserDetail를 통해 정보를 조회합니다.
UserDetailService를 호출하여 사용자 정보를 조회하고 User 엔티티를 CustomUserDetails로 변환하여 사용합니다.

로그인요청을 처리하는 JwtLoginService 구현

login service


로그인 요청을 처리할 때, 먼저 사용자가 입력한 이메일과 비밀번호에 대한 사용자 인증을 검사합니다.
인증된 사용자일 경우, 입력된 비밀번호가 데이터베이스에 저장된 인코딩된 비밀번호와 일치하는지 검증합니다.
검증이 완료되면, JWT Utils를 통해 JWT 토큰을 생성합니다. 생성된 리프레시 토큰은 데이터베이스에 저장하고, 쿠키에 리프레시 토큰을 추가합니다.
이 과정을 통해 인증된 사용자에게 필요한 토큰을 발급하여 이후 요청에 대한 인증을 가능하도록 구현했습니다.

JWT 검증 필터

1

 

2

 

3

JwtAuthenticationFilter는 JWT 토큰의 유효성을 검사하고 인증 과정을 처리하는 클래스입니다.
이 필터는 요청에 포함된 JWT를 검증하여 사용자의 인증 상태를 설정하는 중요한 역할을 수행합니다.

doFilterInternal() 메서드 동작 과정은 다음과 같습니다.

  • 요청 헤더에서 토큰 추출
    • 요청 헤더에서 JWT 토큰을 꺼낸다.
  • 토큰 존재 여부 확인
    • 토큰이 없을 경우, 다음 필터로 요청을 넘긴다.
  • 토큰 만료 확인
    • 토큰이 만료된 경우 401 상태 코드와 함께 응답한다.
  • 토큰 타입 확인
    • 토큰의 타입이 access token이 맞는지 확인한다.
    • 유효하지 않은 경우 401 상태 코드와 함께 응답한다.
  • 사용자 정보 조회 및 인증
    • 토큰에서 사용자 이름(여기선 email 정보를 의미)을 가져와 사용자 정보를 가져온다.
    • 인증 토큰을 생성하여 SecurityContextHolder에 설정함으로써 이후 요청에서 인증된 사용자로 처리된다.

JWT Util 클래스

j1



j2



JwtProperties 클래스는 JWT를 생성하고 검증하는 기능을 추가한 클래스입니다.
Access, Refresh 토큰을 포함한 다중 토큰 관리를 구현했습니다.
토큰의 시간을 설정하였고, Access 토큰의 유효시간을 짧게 설정하여 보안을 높이고자 했습니다.
주요 메서드는 다음과 같습니다.

  • createJwt()
    • type 값으로 Access/Refresh 토큰 타입을 구분한다.
    • 회원 요청을 처리하기 위한 JWT이므로, role 값은 ROLE_USER으로 가입된 회원으로 설정했다.
    • 만료시간은 Access 토큰은 10분, Refresh 토큰은 24시간으로 설정하여 관리했다.

Refresh 토큰 갱신

r1

 

r2


JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기 까지 서버측에서는 그것을 막을 수 없습니다.
프론트측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제가 되었다면 피해를 입을 수 있습니다.

이런 문제를 해결하기 위해 생명주기가 긴 Refresh 토큰은 발급시 DB에 저장했습니다.
그리고 토큰을 재발급할 때, 기존 Refresh 토큰은 초기화하고 DB에서도 삭제하도록 구현했습니다.
ReissueService 클래스의 reissue 메서드는 Refresh 토큰을 사용하여 새로운 Access 토큰과 Refresh 토큰을 발급하는 로직을 처리합니다.

ressiue() 메서드는 다음과 같습니다.

  • Refresh 토큰 확인
    • 요청에서 쿠키를 통해 Refresh 토큰을 검사한다.
    • Refresh 토큰이 없다면 RefreshTokenNotFoundException 예외 발생한다.
  • 토큰 만료 체크
    • Refresh 토큰이 만료되었다면 InvalidRefreshTokenException 예외 발생한다.
  • 토큰 타입 검증
    • Refresh 토큰의 타입 올바르지 않는 경우, InvalidRefreshTokenException 예외 발생한다.
  • DB에서 Refresh 토큰 조회
    • DB에 저장되어 있지 않는 토큰인 경우, InvalidRefreshTokenException 예외 발생한다.
  • 사용자 정보 조회
    +Refresh 토큰에서 사용자 정보를 조회한다.
  • 새로운 JWT 생성
    • 사용자 정보로 새로운 Access, Refresh 토큰을 발급한다.
  • Refresh 토큰 업데이트
    • DB에 기존 Refresh 토큰을 삭제하고, 새로운 Refresh 토큰을 저장한다.
  • 응답
    • 새로운 Access 토큰을 응답 헤더에 추가하고, 새로운 Refresh 토큰을 쿠키에 설정한다.

5. 로그아웃 커스텀 필터

logout

 

로그아웃 버튼 클릭시 아래와 같이 동작하도록 구현했습니다.

  • 프론트엔드측: 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버측 로그아웃 경로로 Refresh 토큰을 전송한다.
  • 백엔드측: 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화 후 DB에서 해당 Refresh 토큰을 삭제한다.
    username -> email 기반으로 Refresh 토큰을 삭제하는 방식이다.

백엔드에서 로그아웃 수행작업

  • DB에 저장하고 있는 Refresh 토큰을 삭제한다.
  • Refresh 토큰 쿠키를 null로 변경한다.

스프링 시큐리티에서의 로그아웃 구현의 위치

일반적으로 스프링 시큐리티 의존성을 프로젝트에 추가했을 경우, 기본 로그아웃 기능이 활성화됩니다.
저의 경우, 커스텀 필터를 구현하여 해당 로그아웃을 수행하도록 했습니다.

로그아웃 필터 구현

SecurityConfig에도 커스텀 로그아웃 필터를 등록해줍니다.


후기

 

전체 코드는 Github에서 확인할 수 있습니다.
JWT Access Token, Refresh Token 관리에 대한 내용은 개발자 유미 -스프링 JWT 심화 자료를 참고하였습니다.
이번 시간에 이야기한 스프링 시큐리티 JWT에 대한 개념에 자료를 찾으신다면, 해당 참고링크를 통하여 참고하시면 도움이 될 것 같습니다.
끝까지 읽어주셔서 감사합니다:)

댓글