본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[JWT] Access Token의 한계와 Refresh Token의 필요성

by 개발하는 곰돌이 2023. 7. 19.
[수정사항]
2023-08-20 : 자바 코드의 TokenProvider 클래스에서 리프레시 토큰이 일치하는지 검사하는 메소드가 누락된 부분 수정

목차

    들어가기 전에

    이전에 스프링 시큐리티와 JWT를 이용한 사용자 인증을 구현한 프로젝트에 대한 글에서 리프레시 토큰을 언급한 적이 있다. 그래서 리프레시 토큰에 대해 다뤄보면서 기존에 JWT로 사용자를 검증하던 프로젝트에 리프레시 토큰을 적용해보려고 한다.

     

    다만 리프레시 토큰의 구현 방법에 대해 깊게 이해하고 있는 것이 아니기 때문에 틀린 내용이 있을 수 있다.

    Access Token? Refresh Token?

    액세스 토큰은 사용자에 대한 정보를 담고 있어서 서비스에 접근(Access)할 수 있는 토큰을 의미한다. 이전 예제 프로젝트에서 사용자 검증을 위해 사용한 JWT 또한 액세스 토큰에 해당된다.

     

    리프레시 토큰은 그 자체로는 별다른 정보를 담고있지 않다. 대신 Refresh라는 이름에 걸맞게 액세스 토큰이 만료되었을 때 서버에서 이를 확인하고 새로운 액세스 토큰을 발급해주기 위해 사용한다.

    액세스 토큰만 사용할 때의 한계

    JWT는 Stateless이기 때문에 서버에서 상태를 관리하지 않는다. 다시 말해 액세스 토큰으로 JWT를 사용하여 사용자 검증을 진행하면 서버에서 토큰의 상태를 제어할 수 없다.

     

    일반적으로 우리가 어떤 웹사이트에 로그인하여 이용하게 되면 웹사이트를 이용하는 동안에는 자동으로 로그아웃 되는 경우가 없다. 하지만 액세스 토큰만 사용하는 웹사이트는, 이용 중에 토큰이 만료되면 다음 서비스를 이용하려다가 갑자기 로그아웃되거나 오류 메시지를 보게 될 것이다.

     

    또한, 외부의 공격자가 토큰을 탈취한다면 탈취당한 토큰이 만료될 때까지 속수무책이라는 문제도 있다.

     

    이렇게 액세스 토큰만 사용하면 서버에서 토큰의 상태를 변경할 수 없기 때문에, 토큰의 유효시간이 짧다면 사용자는 서비스 이용중에 토큰의 유효시간이 갱신되지 않으니 자꾸 로그아웃되어 짜증날 것이고 토큰의 유효시간이 길다면 보안상의 문제가 될 수 있다.

    리프레시 토큰의 활용

    이러한 문제는 리프레시 토큰을 사용하여 어느정도 해결해볼 수 있다. 액세스 토큰의 유효시간을 짧게 하는 대신 유효시간이 긴 리프레시 토큰을 함께 발급하여 액세스 토큰 자체를 계속 갱신하는 것이다.

     

    액세스 토큰의 유효시간을 짧게 설정했으니 액세스 토큰을 탈취당했을 때의 위험성도 줄어들게 된다. 리프레시 토큰을 탈취당했을 때도 리프레시 토큰은 사용자 정보를 전혀 담고있지 않으니 상대적으로 안전하다고 볼 수 있다.

    어떻게 구현할 것인가?

    우선 기본적인 프로젝트의 틀은 [Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자에서 스프링 시큐리티 + JWT를 적용한 프로젝트에 예외처리까지 적용한 것을 바탕으로 한다.

     

    Github : Kotlin, Java

     

    위 프로젝트에서 회원 가입과 로그인을 제외한 API의 동작 과정을 간략히 나타내면 아래와 같다.

    이 시나리오에 리프레시 토큰을 추가하여 다음과 같은 시나리오로 변경할 것이다.

    그림에는 몇가지가 생략되었는데 간단히 적어보면 전체적인 시나리오는 다음과 같이 진행한다.

     

    1. 클라이언트에서 API를 호출하면 액세스 토큰이 유효한지 검사한다.
      • 액세스 토큰이 유효하면 API 응답을 반환한다.
    2. 액세스 토큰이 만료되었다면 클라이언트에서는 리프레시 토큰을 추가로 요청 헤더에 담아서 다시 한번 API를 호출한다.
      • 액세스 토큰이 만료된 것이 아닌 이유로 유효하지 않은 경우에는 예외를 발생시켜 오류 응답을 반환한다.
    3. 리프레시 토큰이 유효하면 새로운 액세스 토큰을 응답 헤더에 담아서 정상 응답을 반환한다.
      • 리프레시 토큰이 유효하지 않은 경우에는 예외를 발생시켜 오류 응답을 반환한다.
      • 만료된 액세스 토큰과 유효한 리프레시 토큰이 함께 요청 헤더에 담겨서 API가 호출될 때 액세스 토큰을 재발급하는 횟수를 제한한다.

     

    매 API 호출마다 리프레시 토큰을 전달하지 않는 이유는 리프레시 토큰 전달 횟수가 많아질수록 중간에 탈취당할 수 있는 경우의 수가 늘어나기 때문이다. 이 때문에 리프레시 토큰은 액세스 토큰이 만료되어 다시 API를 호출할 때만 서버에 전달한다.

     

    이 시나리오에서는 토큰 탈취의 위험성을 최소화하기 위해 액세스 토큰이 만료됐다고 해도 만료된 액세스 토큰과 유효한 리프레시 토큰을 함께 요청 헤더에 담아서 보냈을 경우에만 새로운 액세스 토큰을 발급한다. 이로 인해 만료된 기존 액세스 토큰이나 리프레시 토큰 중 하나만 갖고 있을 경우에는 인증 오류가 발생하여 다시 로그인을 해야 한다.

     

    클라이언트의 요청을 줄이기 위해 만료된 액세스 토큰과 유효한 리프레시 토큰을 함께 보내면 새로운 액세스 토큰과 함께 정상 응답을 반환하게 할 것이다. 다만 이 경우에는 만료된 액세스 토큰을 교체하지 않고 리프레시 토큰을 같이 보내기만 하면 정상 응답을 반환하게 된다는 문제점이 있다. 따라서 리프레시 토큰마다 액세스 토큰을 재발급하는 횟수를 제한하여 액세스 토큰을 교체하게 만들어야 한다.

     

    이를 위해 로그인을 할 때는 액세스 토큰과 함께 리프레시 토큰도 생성하여 반환할 것이다. 리프레시 토큰은 유효시간 외에는 아무 정보도 담고있지 않기 때문에 DB에 저장해놓고 요청 헤더에 담긴 리프레시 토큰이 해당 사용자의 리프레시 토큰이 맞는지 검증할 것이다.

    리프레시 토큰을 전달하기 위한 스웨거 설정 변경

    리프레시 토큰을 HTTP 헤더에 담아서 API를 호출할 것이기 때문에 SwaggerConfig에서 전역적으로 헤더를 추가하기 위해 아래 설정을 추가해준다.

     

    Kotlin

    @Configuration
    class SwaggerConfig {
        ...
        
        // 아래 내용 추가
        @Bean
        fun globalHeader() = OperationCustomizer { operation: Operation, _: HandlerMethod ->
            operation.addParametersItem(Parameter()
                .`in`(ParameterIn.HEADER.toString())
                .schema(StringSchema().name("Refresh-Token"))
                .name("Refresh-Token"))
            operation
        }
    }

    Java

    @Configuration
    public class SwaggerConfig {
        ...
        
        // 아래 내용 추가
        @Bean
        public OperationCustomizer globalHeader() {
            return (operation, handlerMethod) -> {
                operation.addParametersItem(new Parameter()
                        .in(ParameterIn.HEADER.toString())
                        .schema(new StringSchema().name("Refresh-Token"))
                        .name("Refresh-Token"));
                return operation;
            };
        }
    }

    이렇게 설정하고 애플리케이션을 실행해서 스웨거 문서를 실행해보면 아래와 같이 헤더를 추가할 수 있는 텍스트 필드가 추가된 것을 볼 수 있다. 회원 가입과 로그인은 리프레시 토큰이 필요하지 않고, 액세스 토큰이 만료된 경우에만 리프레시 토큰을 전달할 것이기 때문에 required 속성은 false로 설정했다.

    새로 추가된 Refresh-Token 파라미터에 값을 추가해서 API를 호출해보면 다음과 같이 헤더에 Refresh-Token이라는 값으로 담겨서 전달되는 것을 볼 수 있다.

    스웨거에서 리프레시 토큰을 담아서 API를 호출하기 위한 밑작업은 모두 끝났다. 이제 리프레시 토큰을 발급하기 위한 과정을 진행해보자.

    회원의 리프레시 토큰을 관리할 엔티티 추가

    앞서 이야기한 것처럼 리프레시 토큰은 DB에 저장해서 유효한지 검증하기 위해 회원의 리프레시 토큰을 관리할 테이블을 만들어야 한다. 아래와 같이 RefreshToken 엔티티를 작성해준다.

     

    Kotlin

    @Entity
    class MemberRefreshToken(
        @OneToOne(fetch = FetchType.LAZY)
        @MapsId
        @JoinColumn(name = "member_id")
        val member: Member,
        private var refreshToken: String
    ) {
        @Id
        val memberId: UUID? = null
        private var reissueCount = 0
    
        fun updateRefreshToken(refreshToken: String) {
            this.refreshToken = refreshToken
        }
    
        fun validateRefreshToken(refreshToken: String) = this.refreshToken == refreshToken
        
        fun increaseReissueCount() {
            reissueCount++
        }
    }

    Java

    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Getter
    @Entity
    public class MemberRefreshToken {
        @Id
        private UUID memberId;
        @OneToOne(fetch = FetchType.LAZY)
        @MapsId
        @JoinColumn(name = "member_id")
        private Member member;
        private String refreshToken;
        private int reissueCount = 0;
    
        public MemberRefreshToken(Member member, String refreshToken) {
            this.member = member;
            this.refreshToken = refreshToken;
        }
    
        public void updateRefreshToken(String refreshToken) {
            this.refreshToken = refreshToken;
        }
    
        public boolean validateRefreshToken(String refreshToken) {
            return this.refreshToken.equals(refreshToken);
        }
    
        public void increaseReissueCount() {
            reissueCount++;
        }
    }

    회원 한명당 1개의 리프레시 토큰만 가질 수 있게 할 것이므로 RefreshToken과 Member를 1:1 연관관계로 묶어준다. 그리고 연관된 회원ID를 외래키 겸 기본키로 지정해준다. 재발급 횟수를 제한할 것이기 때문에 리프레시 토큰마다 재발급 횟수를 저장할 프로퍼티도 추가한다.

     

    추가적으로 JpaRepository를 상속받는 레포지토리 인터페이스도 작성해준다.

     

    Kotlin

    interface MemberRefreshTokenRepository : JpaRepository<MemberRefreshToken, UUID> {
        fun findByMemberIdAndReissueCountLessThan(id: UUID, count: Long): MemberRefreshToken?
    }

    Java

    public interface MemberRefreshTokenRepository extends JpaRepository<MemberRefreshToken, UUID> {
        Optional<MemberRefreshToken> findByMemberIdAndReissueCountLessThan(UUID id, long count);
    }

    이 메소드는 소유자의 ID가 id면서 재발급 횟수가 count보다 작은 MemberRefreshToken 객체를 반환한다.

     

    여기까지 진행하고 애플리케이션을 실행하면 JPA 하이버네이트가 테이블을 자동으로 생성해준다. H2 콘솔에 접속해서 확인해보면 DB에 아래와 같이 새로운 테이블이 추가된 것을 볼 수 있다.

    로그인하면 리프레시 토큰을 발급해주자!

    로그인할 때 리프레시 토큰을 발급해주려면 API 응답을 수정하고 리프레시 토큰과 관련된 메소드를 작성해야 한다. 차례대로 진행해보자.

    리프레시 토큰을 생성하는 메소드 작성

    로그인할 때 리프레시 토큰을 발급받으려면 일단 리프레시 토큰을 생성하는 메소드가 있어야 한다. 또한 리프레시 토큰의 사용 목적이 액세스 토큰의 유효시간을 짧게 두고 리프레시 토큰 기간 내에는 자동으로 갱신하는 것이기 때문에 액세스 토큰의 유효시간을 줄이고, 리프레시 토큰의 유효시간을 별도로 둬야 한다.

     

    액세스 토큰의 유효시간을 줄이고, 리프레시 토큰의 유효시간을 늘리기 위해 jwt.yml의 내용을 조금 수정한다.

    secret-key: NiOeyFbN1Gqo10bPgUyTFsRMkJpGLXSvGP04eFqj5B30r5TcrtlSXfQ7TndvYjNvfkEKLqILn0j1SmKODO1Yw3JpBBgI3nVPEahqxeY8qbPSFGyzyEVxnl4AQcrnVneI
    expiration-minutes: 30	# 3시간 -> 30분
    refresh-expiration-hours: 24	# 리프레시 토큰 유효시간은 24시간
    issuer: colabear754

    여기서는 액세스 토큰의 유효시간을 30분으로 설정하고 리프레시 토큰의 유효시간을 24시간으로 설정했는데, 이 부분은 원하는만큼 적당히 설정하면 된다.

     

    이후 TokenProvider 클래스에 리프레시 토큰을 생성할 메소드를 작성한다.

     

    Kotlin

    @PropertySource("classpath:jwt.yml")
    @Service
    class TokenProvider(
        @Value("\${secret-key}")
        private val secretKey: String,
        @Value("\${expiration-minutes}")
        private val expirationMinutes: Long,	// hours -> minutes
        @Value("\${refresh-expiration-hours}")
        private val refreshExpirationHours: Long,	// 추가
        @Value("\${issuer}")
        private val issuer: String
    ) {
        ...
        
        fun createRefreshToken() = Jwts.builder()
            .signWith(SecretKeySpec(secretKey.toByteArray(), SignatureAlgorithm.HS512.jcaName))
            .setIssuer(issuer)
            .setIssuedAt(Timestamp.valueOf(LocalDateTime.now()))
            .setExpiration(Date.from(Instant.now().plus(refreshExpirationHours, ChronoUnit.HOURS)))
            .compact()!!
    
        ...
    }

    Java

    @PropertySource("classpath:jwt.yml")
    @Service
    public class TokenProvider {
        private final String secretKey;
        private final long expirationMinutes;	// hours -> minutes
        private final long refreshExpirationHours;
        private final String issuer;
    
        public TokenProvider(
                @Value("${secret-key}") String secretKey,
                @Value("${expiration-minutes}") long expirationMinutes,	// hours -> minutes
                @Value("${refresh-expiration-hours}") long refreshExpirationHours,	// 추가
                @Value("${issuer}") String issuer
        ) {
            this.secretKey = secretKey;
            this.expirationMinutes = expirationMinutes;
            this.refreshExpirationHours = refreshExpirationHours;	//추가
            this.issuer = issuer;
        }
        
        ...
    
        public String createRefreshToken() {
            return Jwts.builder()
                    .signWith(new SecretKeySpec(secretKey.getBytes(), SignatureAlgorithm.HS512.getJcaName()))
                    .setIssuer(issuer)
                    .setIssuedAt(Timestamp.valueOf(LocalDateTime.now()))
                    .setExpiration(Date.from(Instant.now().plus(refreshExpirationHours, ChronoUnit.HOURS)))
                    .compact();
        }
    
        ...
    }

    리프레시 토큰은 사용자와 관련된 정보를 전혀 담지 않을 것이기 때문에 subject는 따로 설정하지 않고 발급자와 발급시간, 만료시간만 설정한다. 

    로그인 API 응답에 리프레시 토큰 추가

    로그인 API의 응답에 리프레시 토큰을 추가하기 위해 SignInResponse를 다음과 같이 수정한다.

     

    Kotlin

    data class SignInResponse(
        @Schema(description = "회원 이름", example = "콜라곰")
        val name: String?,
        @Schema(description = "회원 유형", example = "USER")
        val type: MemberType,
        val accessToken: String,	// token -> accessToken으로 액세스 토큰임을 명시
        val refreshToken: String	// 리프레시 토큰 추가
    )

    Java

    public record SignInResponse(
            @Schema(description = "회원 이름", example = "콜라곰")
            String name,
            @Schema(description = "회원 유형", example = "USER")
            MemberType type,
            String accessToken,	// token -> accessToken으로 액세스 토큰임을 명시
            String refreshToken	// 리프레시 토큰 추가
    ) {}

    리프레시 토큰 생성 메소드도 작성했고 로그인 응답 DTO에 리프레시 토큰도 추가했으니 SignService에 리프레시 토큰 관련 내용을 추가한다.

     

    Kotlin

    @Service
    class SignService(
        private val memberRepository: MemberRepository,
        private val memberRefreshTokenRepository: MemberRefreshTokenRepository,	// 추가
        private val tokenProvider: TokenProvider,
        private val encoder: PasswordEncoder
    ) {
        ...
        
        @Transactional
        fun signIn(request: SignInRequest): SignInResponse {
            val member = memberRepository.findByAccount(request.account)
                ?.takeIf { encoder.matches(request.password, it.password) } ?: throw IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")
            val accessToken = tokenProvider.createAccessToken("${member.id}:${member.type}")	// token -> accessToken
            val refreshToken = tokenProvider.createRefreshToken()	// 리프레시 토큰 생성
            // 리프레시 토큰이 이미 있으면 토큰을 갱신하고 없으면 토큰을 추가
            memberRefreshTokenRepository.findByIdOrNull(member.id)?.updateRefreshToken(refreshToken)
                ?: memberRefreshTokenRepository.save(MemberRefreshToken(member, refreshToken))
            return SignInResponse(member.name, member.type, accessToken, refreshToken)
        }
    }

    Java

    @RequiredArgsConstructor
    @Service
    public class SignService {
        private final MemberRepository memberRepository;
        private final MemberRefreshTokenRepository memberRefreshTokenRepository;	// 추가
        private final TokenProvider tokenProvider;
        private final PasswordEncoder encoder;
    
        ...
    
        @Transactional
        public SignInResponse signIn(SignInRequest request) {
            Member member = memberRepository.findByAccount(request.account())
                    .filter(it -> encoder.matches(request.password(), it.getPassword()))
                    .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."));
            String accessToken = tokenProvider.createAccessToken(String.format("%s:%s", member.getId(), member.getType()));	// token -> accessToken
            String refreshToken = tokenProvider.createRefreshToken();	// 리프레시 토큰 생성
            // 리프레시 토큰이 이미 있으면 토큰을 갱신하고 없으면 토큰을 추가
            memberRefreshTokenRepository.findById(member.getId())
                    .ifPresentOrElse(
                            it -> it.updateRefreshToken(refreshToken),
                            () -> memberRefreshTokenRepository.save(new MemberRefreshToken(member, refreshToken))
                    );
            return new SignInResponse(member.getName(), member.getType(), accessToken, refreshToken);
        }
    }

    리프레시 토큰은 DB에 저장하여 검증하는데, 리프레시 토큰 테이블의 구조 상 사용자는 하나의 리프레시 토큰만 가질 수 있다. 이로 인해 로그인한 사용자가 리프레시 토큰을 갖고 있는지 확인한 후 이미 리프레시 토큰이 있다면 리프레시 토큰을 새로운 토큰으로 교체하고, 리프레시 토큰이 없다면 새로운 토큰을 저장하는 방식으로 구현했다.

     

    이제 로그인 API를 호출해보면 정상적으로 리프레시 토큰이 발급되고 DB에 저장되는 것을 볼 수 있다.

    리프레시 토큰을 통해 액세스 토큰을 갱신해보자!

    리프레시 토큰을 사용하기 위한 선행 준비가 모두 끝났으니 이제 액세스 토큰이 만료됐고 리프레시 토큰이 유효할 때 새로운 액세스 토큰을 발급해주는 로직을 구성해보자.

    리프레시 토큰 검증 및 새로운 액세스 토큰 발급

    새로운 액세스 토큰을 발급하려면 유효한 리프레시 토큰인지 확인해야 한다. 또한 액세스 토큰은 사용자 정보를 담고 있어야 하기 때문에 기존의 액세스 토큰에 담긴 사용자 정보를 그대로 사용할 것이다.

     

    이를 위해 TokenProvider에 다음 내용들을 추가해준다.

     

    Kotlin

    @PropertySource("classpath:jwt.yml")
    @Service
    class TokenProvider(
        ...
        private val memberRefreshTokenRepository: MemberRefreshTokenRepository	// 추가
    ) {
        private val reissueLimit = refreshExpirationHours * 60 / expirationMinutes	// 재발급 한도
        private val objectMapper = ObjectMapper()	// JWT를 역직렬화하기 위한 ObjectMapper
        
        ...
    
        @Transactional
        fun recreateAccessToken(oldAccessToken: String): String {
            val subject = decodeJwtPayloadSubject(oldAccessToken)
            memberRefreshTokenRepository.findByMemberIdAndReissueCountLessThan(UUID.fromString(subject.split(':')[0]), reissueLimit)
                ?.increaseReissueCount() ?: throw ExpiredJwtException(null, null, "Refresh token is expired.")
            return createAccessToken(subject)
        }
    
        @Transactional(readOnly = true)
        fun validateRefreshToken(refreshToken: String, oldAccessToken: String) {
            validateAndParseToken(refreshToken)
            val memberId = decodeJwtPayloadSubject(oldAccessToken).split(':')[0]
            memberRefreshTokenRepository.findByMemberIdAndReissueCountLessThan(UUID.fromString(memberId), reissueLimit)
                ?.takeIf { it.validateRefreshToken(refreshToken) } ?: throw ExpiredJwtException(null, null, "Refresh token is expired.")
        }
    
        private fun validateAndParseToken(token: String?) = Jwts.parserBuilder()	// validateTokenAndGetSubject()에서 따로 분리
            .setSigningKey(secretKey.toByteArray())
            .build()
            .parseClaimsJws(token)!!
    
        private fun decodeJwtPayloadSubject(oldAccessToken: String) =
            objectMapper.readValue(
                Base64.getUrlDecoder().decode(oldAccessToken.split('.')[1]).decodeToString(),
                Map::class.java
            )["sub"].toString()
    }

    Java

    @PropertySource("classpath:jwt.yml")
    @Service
    public class TokenProvider {
        private final String secretKey;
        private final long expirationMinutes;
        private final long refreshExpirationHours;
        private final String issuer;
        private final long reissueLimit;
        private final MemberRefreshTokenRepository memberRefreshTokenRepository;	// 추가
    
        private final ObjectMapper objectMapper = new ObjectMapper();	// JWT 역직렬화를 위한 ObjectMapper
    
        public TokenProvider(
                @Value("${secret-key}") String secretKey,
                @Value("${expiration-minutes}") long expirationMinutes,
                @Value("${refresh-expiration-hours}") long refreshExpirationHours,
                @Value("${issuer}") String issuer,
                MemberRefreshTokenRepository memberRefreshTokenRepository) {
            this.secretKey = secretKey;
            this.expirationMinutes = expirationMinutes;
            this.refreshExpirationHours = refreshExpirationHours;
            this.issuer = issuer;
            this.memberRefreshTokenRepository = memberRefreshTokenRepository;	// 추가
            reissueLimit = refreshExpirationHours * 60 / expirationMinutes;	// 재발급 한도
        }
    
        ...
    
        @Transactional
        public String recreateAccessToken(String oldAccessToken) throws JsonProcessingException {
            String subject = decodeJwtPayloadSubject(oldAccessToken);
            memberRefreshTokenRepository.findByMemberIdAndReissueCountLessThan(UUID.fromString(subject.split(":")[0]), reissueLimit)
                    .ifPresentOrElse(
                            MemberRefreshToken::increaseReissueCount,
                            () -> { throw new ExpiredJwtException(null, null, "Refresh token expired."); }
                    );
            return createAccessToken(subject);
        }
        
        @Transactional(readOnly = true)
        public void validateRefreshToken(String refreshToken, String oldAccessToken) throws JsonProcessingException {
            validateAndParseToken(refreshToken);
            String memberId = decodeJwtPayloadSubject(oldAccessToken).split(":")[0];
            memberRefreshTokenRepository.findByMemberIdAndReissueCountLessThan(UUID.fromString(memberId), reissueLimit)
                    .filter(memberRefreshToken -> memberRefreshToken.validateRefreshToken(refreshToken))
                    .orElseThrow(() -> new ExpiredJwtException(null, null, "Refresh token expired."));
        }
    
        private Jws<Claims> validateAndParseToken(String token) {	// validateTokenAndGetSubject에서 따로 분리
            return Jwts.parserBuilder()
                    .setSigningKey(secretKey.getBytes())
                    .build()
                    .parseClaimsJws(token);
        }
    
        private String decodeJwtPayloadSubject(String oldAccessToken) throws JsonProcessingException {
            return objectMapper.readValue(
                    new String(Base64.getDecoder().decode(oldAccessToken.split("\\.")[1]), StandardCharsets.UTF_8),
                    Map.class
            ).get("sub").toString();
        }
    }

    reissueLimit는 리프레시 토큰의 유효시간과 액세스 토큰의 유효시간을 토대로 계산하여 설정했다. 이 한도 횟수는 적당한 값으로 설정하면 된다.

     

    이제 private 메소드부터 차례대로 신규 메소드를 살펴보자.

     

    validateAndParseToken()은 기존의 validateTokenAndGetSubject()에서 분리한 메소드이다. 내부의 parseCliamJws()에서 JWT를 파싱할 때, 토큰이 유효한지 검사하여 예외를 던지기 때문에 토큰 검증에 사용할 수 있다.

     

    decodeJwtPayloadSubject()는 JWT를 복호화하고 데이터가 담겨있는 Payload에서 Subject를 반환한다. 이 Subject에는 회원ID와 회원 타입이 문자열 형태로 담겨있다. 이 메소드는 이미 만료된 액세스 토큰을 복호화할 것이기 때문에 유효한 토큰인지는 검사하지 않는다. 유효시간 만료가 아닌 이유로 무효한 토큰이라면 해당 메소드를 호출하지 않을 것이다.

     

    recreateAccessToken()은 기존 액세스 토큰을 토대로 새로운 액세스 토큰을 생성한다. MemberRefreshTokenRepository에서 기존 액세스 토큰에 담긴 회원ID의, 재발급 한도에 도달하지 않은 리프레시 토큰을 찾아내서 재발급 횟수를 +1하고 새로운 액세스 토큰을 반환한다. 이 과정에서 해당 조건의 리프레시 토큰을 발견하지 못하면 강제로 ExpiredJwtException을 발생시킨다. JPA의 영속성 컨텍스트를 사용하기 때문에 @Transactional 어노테이션을 붙여줘야 한다.

     

    validateRefreshToken()은 리프레시 토큰이 유효한 토큰인지를 검증한다. validateAndParseToken()을 호출하여 리프레시 토큰 자체가 유효한 토큰인지를 검사한다. 리프레시 토큰 자체가 유효한 토큰이라면 기존 액세스 토큰에 담긴 회원ID의, 재발급 한도에 도달하지 않은 리프레시 토큰을 찾아서 인자로 받은 리프레시 토큰과 비교한다. 해당하는 리프레시 토큰이 없으면 강제로 ExpiredJwtException을 발생시켜서 만료된 토큰으로 취급한다.

     

    이제 JwtAuthneticationFilter를 수정할 차례다. 기존에는 doFilterInternal()에서 요청 헤더의 액세스 토큰을 토대로 스프링 시큐리티 컨텍스트에 사용자 정보를 추가하고 있었는데, 이 부분을 변경해야 한다.

     

    Kotlin

    @Order(0)
    @Component
    class JwtAuthenticationFilter(private val tokenProvider: TokenProvider) : OncePerRequestFilter() {
        override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
            try {
                val accessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION)	// parseBearerToken() 변경에 따른 수정
                val user = parseUserSpecification(accessToken)
                UsernamePasswordAuthenticationToken.authenticated(user, accessToken, user.authorities)
                    .apply { details = WebAuthenticationDetails(request) }
                    .also { SecurityContextHolder.getContext().authentication = it }
            } catch (e: ExpiredJwtException) {	// 변경
                reissueAccessToken(request, response, e)
            } catch (e: Exception) {
                request.setAttribute("exception", e)
            }
    
            filterChain.doFilter(request, response)
        }
    
        // 매개변수에 헤더 이름 추가
        private fun parseBearerToken(request: HttpServletRequest, headerName: String) = request.getHeader(headerName)
            .takeIf { it?.startsWith("Bearer ", true) ?: false }?.substring(7)
    
        ...
    
        private fun reissueAccessToken(request: HttpServletRequest, response: HttpServletResponse, exception: Exception) {
            try {
                val refreshToken = parseBearerToken(request, "Refresh-Token") ?: throw exception
                val oldAccessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION)!!
                tokenProvider.validateRefreshToken(refreshToken, oldAccessToken)
                val newAccessToken = tokenProvider.recreateAccessToken(oldAccessToken)
                val user = parseUserSpecification(newAccessToken)
                UsernamePasswordAuthenticationToken.authenticated(user, newAccessToken, user.authorities)
                    .apply { details = WebAuthenticationDetails(request) }
                    .also { SecurityContextHolder.getContext().authentication = it }
    
                response.setHeader("New-Access-Token", newAccessToken)
            } catch (e: Exception) {
                request.setAttribute("exception", e)
            }
        }
    }

    Java

    @Order(0)
    @RequiredArgsConstructor
    @Component
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
        private final TokenProvider tokenProvider;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            try {
                String accessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION);	// parseBearerToken() 변경에 따른 수정
                User user = parseUserSpecification(accessToken);
                AbstractAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(user, accessToken, user.getAuthorities());
                authenticated.setDetails(new WebAuthenticationDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticated);
            } catch (ExpiredJwtException e) {	// 변경
                reissueAccessToken(request, response, e);
            } catch (Exception e) {
                request.setAttribute("exception", e);
            }
    
            filterChain.doFilter(request, response);
        }
    
        // 매개변수에 헤더이름 추가
        private String parseBearerToken(HttpServletRequest request, String headerName) {
            return Optional.ofNullable(request.getHeader(headerName))
                    .filter(token -> token.substring(0, 7).equalsIgnoreCase("Bearer "))
                    .map(token -> token.substring(7))
                    .orElse(null);
        }
    
        ...
    
        private void reissueAccessToken(HttpServletRequest request, HttpServletResponse response, Exception exception) {
            try {
                String refreshToken = parseBearerToken(request, "Refresh-Token");
                if (refreshToken == null) {
                    throw exception;
                }
                String oldAccessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION);
                tokenProvider.validateRefreshToken(refreshToken, oldAccessToken);
                String newAccessToken = tokenProvider.recreateAccessToken(oldAccessToken);
                User user = parseUserSpecification(newAccessToken);
                AbstractAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(user, newAccessToken, user.getAuthorities());
                authenticated.setDetails(new WebAuthenticationDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticated);
    
                response.setHeader("New-Access-Token", newAccessToken);
            } catch (Exception e) {
                request.setAttribute("exception", e);
            }
        }
    }

    parseBearerToken()는 Authorization 이외에 Refresh-Token 헤더의 토큰도 파싱하기 위해 매개변수에 헤더이름을 추가한다. 기존에 doFilterInternal()에서 사용하던 것도 수정해준다.

     

    액세스 토큰이 만료되었을 때만 리프레시 토큰 관련 로직을 수행할 것이기 때문에 doFilterInternal()catch 블록을 수정해준다. ExpiredJwtException이 발생하면 액세스 토큰 재발급 메소드를 실행하고 그 외의 예외가 발생하면 기존처럼 엔트리포인트에 예외를 넘겨준다.

     

    reissueAccessToken()에서 본격적으로 리프레시 토큰과 기존의 만료된 액세스 토큰을 기반으로 새로운 액세스 토큰을 발급하고 사용자 인증을 다시 진행한다. 그 후, 응답 헤더에 새로운 액세스 토큰을 담아서 반환한다. 이 과정에서 예외가 발생하면 기존과 동일하게 엔트리포인트에 예외를 넘겨준다.

     

    이제 테스트를 위해 토큰 만료시간을 적당히 짧게 설정하고 테스트해보면 다음과 같은 결과를 얻을 수 있다. 이 때, 스웨거로 테스트하려면 Refresh-Token에 Bearer {token} 형태로 리프레시 토큰을 입력해야 한다.

    액세스 토큰이 유효할 때
    액세스 토큰이 만료되었을 때
    액세스 토큰이 만료되어 리프레시 토큰을 함께 전달하면 정상 응답과 함께 새로운 액세스 토큰이 반환된다.
    토큰 재발급 횟수도 정상적으로 증가하고 있다.

    리프레시 토큰 적용 완료!

    완성된 프로젝트 Github : Kotlin, Java

    마치며

    리프레시 토큰과 관련된 글들을 보면 각기 다른 방식으로 구현한 것을 볼 수 있다. 하지만 근본적으로 리프레시 토큰은 사용자 인증에 관여하지 않고 액세스 토큰 재발급에만 관여한다는 점은 동일하다고 생각한다.

     

    클라이언트에서 요청을 보내는 횟수를 줄이기 위해 서버에서 액세스 토큰 재발급과 동시에 정상 응답을 반환하게 구현하긴 했지만, 이게 올바른 방법이라고 확신하진 않는다. 리프레시 토큰에 대한 지식이 부족한 상태로 일단 무작정 구현해보자는 생각으로 진행하면서 작동 과정을 파악해보려고 한 것이기 때문에 비효율적인 코드나 잘못된 내용이 있을 수도 있다.

     

    실제로는 사용자가 로그아웃할 때 리프레시 토큰을 삭제해야 하지만 프론트 화면을 구성하지 않고 스웨거로만 구성했기 때문에 이 부분은 생략했다.

     

    리프레시 토큰을 사용해서 자동 로그인을 구현할 수도 있다고 한다. 나중에는 자동 로그인도 한번 구현해 보는 것도 좋을 것 같다.

    참조 링크

     

    Is a Refresh Token really necessary when using JWT token authentication?

    I'm referencing another SO post that discusses using refresh tokens with JWT. JWT (JSON Web Token) automatic prolongation of expiration I have an application with a very common architecture where my

    stackoverflow.com

     

    3 Scenarios Where You Can Store JWT Token in Your DB

    Know when and why

    betterprogramming.pub

     

    An in-depth look at refresh tokens in the browser

    An in-depth look at refresh tokens in the browser Single Page Applications can use refresh tokens in the browser. Yes, you read that right. This new development is awesome, because it makes access token renewal much more elegant. However, refresh tokens in

    pragmaticwebsecurity.com

     

    🌐 Access Token & Refresh Token 원리

    Access Token & Refresh Token 이번 포스팅에서는 기본 JWT 방식의 인증(보안) 강화 방식인 Access Token & Refresh Token 인증 방식에 대해 알아보겠다. 먼저 JWT(Json Web Token) 에 대해 잘 모르는 독자들은 다음 포스

    inpa.tistory.com

    댓글