땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 21. 리프레시토큰을 통한 토큰 재갱신 기능 구현 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 21. 리프레시토큰을 통한 토큰 재갱신 기능 구현

ttasjwi 2024. 11. 19. 09:41

로그인 기능을 통해 액세스토큰과 리프레시 토큰을 발급했습니다.

 

액세스토큰은 유효시간을 짧게 가지는 인증을 위한 토큰이고,

리프레시토큰은 좀 더 유효시간을 길게 잡는 대신 인증을 위해 사용하지 않고, 액세스토큰 재갱신을 위해 사용하는 토큰입니다.

 

액세스토큰은 상대적으로 짧은 시간(제 기능 기준 30분마다)마다 무효화되다보니 리프레시 토큰을 통해 별도로 액세스토큰을 재발급할 수 있는 API가 필요합니다. 이를 위해 토큰 재갱신 API 를 만들어보도록 하겠습니다.


1. 기능 개요

사용자는 요청을 통해 리프레시토큰 값을 보내고 애플리케이션은 리프레시 토큰을 확인하여 유효하다 판단되면, 리프레시 토큰을 내려줘야합니다.

 

그런데 리프레시 토큰이 무한정으로 계속 발급될 수 있으면, 여러 곳에서 동시에 서비스에 접근하는 걸 통제하기 어려워지는 문제가 있습니다. 그래서 제 서비스 기준으로는 사용자마다 동시에 존재할 수 있는 리프레시 토큰의 종류를 통제하고 있습니다.

 

 

저는 이를 구현하기 위해 사용자마다 1개의 리프레시토큰 홀더라는 개념으로 리프레시 토큰을 묶어두고, 리프레시 토큰 홀더에 최대 5개의 리프레시 토큰을 저장할 수 있게 하여 사용자의 리프레시토큰을 저장하여 관리하고 있습니다. 이에 대한 구현 내용은 지난 글을 참고해주시면 될 것 같습니다.

 

 

제가 기능을 구현할 때는 어떤 형태로 기능을 구현할 지 흐름을 짜봤습니다.

 

1. 토큰값을 받아 RefreshToken 인스턴스 복원(파싱)

이 기능은 이미 구현됐습니다.(RefreshTokenManager)

 

2. 리프레시토큰홀더 저장소에서 리프레시 토큰홀더 조회

이 기능은 이미 구현됐습니다.(RefreshTokenHolderFinder)

 

3. 사용자 리프레시토큰이 현 시점에 유효하고, 리프레시토큰 홀더에 동일한 토큰이 있는 지 확인

새로 구현해야합니다.

 

4. 액세스토큰 재생성

이 기능은 이미 기능이 구현됐습니다. (AccessTokenManager)

 

5. 리프레시토큰 재생성?

액세스 토큰을 재갱신하면서 리프레시 토큰을 재갱신할 것인가 한번 고민해봤습니다.

 

제 서비스 기준 리프레시 토큰의 유효시간은 24시간입니다. 리프레시토큰에 대한 별도의 재갱신 기능이 제공되지 않는다면, 사용자는 30분마다 리프레시토큰 재갱신을 통해 서비스를 이용하다가(이부분은 브라우저/모바일 애플리케이션에서 알아서 해주기 때문에 최종 사용자입장에선 잘 모를 것입니다.), 리프레시토큰이 만료되는 24시간 간격으로 반드시 수동 로그인을 해야합니다. 이 경우 사용자 경험이 약간 안 좋아질 수 있습니다.

 

매번 리프레시토큰도 재갱신시키면 사용자가 30분마다 리프레시토큰을 재갱신하러 올 때, 매번 리프레시토큰 홀더의 수정이 일어나고 레디스에 변경된 리프레시토큰 홀더를 계속 수정해서 저장해야합니다. 이렇게 하면 서버 부하가 커질 것 같습니다.

 

저는 이 중간 지점으로, 24시간의 1/3에 해당하는 만료시간 전 8시간부터, 토큰을 새로운 값으로 교체해서 내려주기로 했습니다. 이렇게 할 경우 사용자는 중간중간 서비스를 이용하다가 다음날 접속해서 우리 서비스를 이용하는 과정에서 자연스럽게 리프레시토큰을 재갱신하여, 수동 로그인을 안 해도 되는거죠.

 

리프레시 토큰을 생성하는 기능은 이미 구현됐고, 문제는 리프레시 토큰을 새로 갱신할 지 말 것인지 정하는 기능이 새로 있어야하고, 리프레시토큰홀더에 위치한 기존 리프레시 토큰을 새 리프레시 토큰으로 교체하는 기능이 있어야겠네요.

 

6. 토큰 재발급 / 리프레시토큰 홀더 업데이트

4,5 에서 발급된 토큰을 다시 응답으로 내려줍니다.

 

대략 이런 흐름으로 돌아가지 않을까 싶네요. 기능을 새로 구현해보겠습니다.


2. 도메인 기능 구현

2.1 리플레시토큰의 현재 유효성 검증

package com.ttasjwi.board.system.auth.domain.service

import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.auth.domain.model.RefreshTokenHolder
import com.ttasjwi.board.system.member.domain.model.MemberId
import java.time.ZonedDateTime

interface RefreshTokenManager {

    fun generate(memberId: MemberId, issuedAt: ZonedDateTime): RefreshToken
    fun parse(tokenValue: String): RefreshToken

    fun checkCurrentlyValid(refreshToken: RefreshToken, refreshTokenHolder: RefreshTokenHolder, currentTime: ZonedDateTime)
    fun isRefreshRequired(refreshToken: RefreshToken, currentTime: ZonedDateTime): Boolean
}
override fun checkCurrentlyValid(
    refreshToken: RefreshToken,
    refreshTokenHolder: RefreshTokenHolder,
    currentTime: ZonedDateTime
) {
    // 리프레시 토큰 현재 유효성 검증
    refreshToken.checkCurrentlyValid(currentTime)

    // 리프레시토큰이 리프레시토큰 홀더에 있는 지 검증
    refreshTokenHolder.checkRefreshTokenExist(refreshToken)
}

 

리프레시토큰이 현재 유효한지 검증하는 도메인 기능은 RefreshTokenManager 에 뒀습니다.

checkCurrentlyValid 메서드를 통해 검증을 위임합니다.

 

여기서는 리프레시토큰에 대해 checkCurrentlyValid 를 통해 리프레시토큰의 현재 유효성을 확인하고

리프레시토큰 홀더에 대해 checkRefreshTokenExist 를 호출해 리프레시 토큰이 있는 지 확인합니다.

/**
 * 리프레시토큰이 현재 유효한 지 검증
 */
internal fun checkCurrentlyValid(currentTime: ZonedDateTime) {
    if (currentTime >= this.expiresAt) {
        val ex = RefreshTokenExpiredException(
            debugMessage = "리프레시토큰 유효시간이 경과되어 만료됨(currentTime=${currentTime},expiresAt=${this.expiresAt})"
        )
        log.warn(ex)
        throw ex
    }
}

 

리프레시토큰은 현재 시간을 전달받아 자신의 만료시간과 같은 시각, 혹은 그 이후의 시각일 경우 만료 예외를 발생시키도록 했어요.

/**
 * 같은 리프레시토큰이 있는지 검증. 없다면 리프레시토큰이 무효화된 것
 */
internal fun checkRefreshTokenExist(refreshToken: RefreshToken) {
    // 토큰이 없을 때
    if (!_tokens.containsKey(refreshToken.refreshTokenId)) {
        val ex = RefreshTokenExpiredException(
            debugMessage = "리프레시 토큰이 로그아웃 또는 동시토큰 제한 등의 이유로 토큰이 만료됨. (memberId=${refreshToken.memberId.value},refreshTokenId=${refreshToken.refreshTokenId.value})"
        )
        log.warn(ex)
        throw ex
    }
}

 

리프레시토큰 홀더는 리프레시토큰을 전달받아 동일한 Id의 리프레시토큰이 있는 지 확인해요.

없다면 동시 토크 제한, 로그아웃 등으로 인해 무효화된 토큰이므로 이 역시 예외를 발생시킵니다.

 

package com.ttasjwi.board.system.auth.domain.exception

import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus

class RefreshTokenExpiredException(
    debugMessage: String,
) : CustomException(
    status = ErrorStatus.UNAUTHENTICATED,
    code = "Error.RefreshTokenExpired",
    args = emptyList(),
    source = "refreshToken",
    debugMessage = debugMessage
)

 

여기서 사용된 RefreshTokenExpiredException 은 토큰 만료 예외인데, 사용자에게 굳이 리프레시 토큰이 구체적으로 왜 만료됐는 지는 알리지 않을 목적으로 디버깅 메시지만 파라미터로 받아요.

 

개발자,관리자 입장에서 디버깅용으로 왜 예외가 발생했는 지 debugMessage 를 확인하고, 사용자에게 응답을 내려줄 때는 단순히 리프레시 토큰이 만료됐다는 응답만 내려줄 예정이에요.

 

2.2 리프레시 토큰 갱신여부 결정 기능

package com.ttasjwi.board.system.auth.domain.service

import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.auth.domain.model.RefreshTokenHolder
import com.ttasjwi.board.system.member.domain.model.MemberId
import java.time.ZonedDateTime

interface RefreshTokenManager {

    fun generate(memberId: MemberId, issuedAt: ZonedDateTime): RefreshToken
    fun parse(tokenValue: String): RefreshToken

    fun checkCurrentlyValid(refreshToken: RefreshToken, refreshTokenHolder: RefreshTokenHolder, currentTime: ZonedDateTime)
    fun isRefreshRequired(refreshToken: RefreshToken, currentTime: ZonedDateTime): Boolean
}

 

리프레시 토큰을 새로 갱신해야할 지 말지 결정하는 기능은 RefreshTokenManager 의 isRefreshRequired 에 뒀어요.

 

override fun isRefreshRequired(refreshToken: RefreshToken, currentTime: ZonedDateTime): Boolean {
    return refreshToken.isRefreshRequired(currentTime)
}

 

리프레시토큰에게 현재 시간을 전달해서 리프레시(재갱신)가 필요한지 확인하고 그 결과를 반환합니다.

 

   companion object {

        internal const val VALIDITY_HOURS = 24L
        internal const val REFRESH_REQUIRE_THRESHOLD_HOURS = 8L // 리프레시 토큰 재갱신 기준점
/**
 * 만료시간 기준으로 [REFRESH_REQUIRE_THRESHOLD_HOURS] 이전 혹은 그 이후일 경우
 * 재갱신이 필요함을 알리기 위해 true를 반환하고,
 * 그렇지 않을 경우 false 를 반환합니다.
 */
internal fun isRefreshRequired(currentTime: ZonedDateTime): Boolean {
    return currentTime >= this.expiresAt.minusHours(REFRESH_REQUIRE_THRESHOLD_HOURS)
}

 

리프레시 토큰은 자신의 재갱신 필요 여부를 반환할 책임이 있습니다.

현재시간이 만료시간으로부터 정확히 8시간 전 또는 그 이후일 경우 true

만료시간 8시간 전, 바로 그 이전일 경우 false 를 반환합니다.

 

2.3 리프레시토큰 홀더에서 기존 리프레시토큰을 새 리프레시토큰으로 교체

package com.ttasjwi.board.system.auth.domain.service

import com.ttasjwi.board.system.auth.domain.model.AuthMember
import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.auth.domain.model.RefreshTokenHolder

interface RefreshTokenHolderManager {

    fun createRefreshTokenHolder(authMember: AuthMember): RefreshTokenHolder
    fun addNewRefreshToken(refreshTokenHolder: RefreshTokenHolder, refreshToken: RefreshToken): RefreshTokenHolder
    fun changeRefreshToken(refreshTokenHolder: RefreshTokenHolder, previousToken: RefreshToken, newToken: RefreshToken): RefreshTokenHolder
}
override fun changeRefreshToken(
    refreshTokenHolder: RefreshTokenHolder,
    previousToken: RefreshToken,
    newToken: RefreshToken
): RefreshTokenHolder {
    return refreshTokenHolder.changeRefreshToken(previousToken, newToken)
}

리프레시토큰 홀더에서의 토큰 교체 기능은 RefreshTokenHolderManager 에 뒀어요.

리프레시토큰 홀더에게 위임하여, 토큰을 교체시킵니다.

 

internal fun changeRefreshToken(previousToken: RefreshToken, newToken: RefreshToken): RefreshTokenHolder {
    // 새로 추가시키는 리프레시토큰의 발행시점을 현재 시각으로 삼아, 오래된 토큰들을 만료시킴
    val currentTime = newToken.issuedAt
    removeExpiredTokens(currentTime)

    // 기존 토큰 제거
    _tokens.remove(previousToken.refreshTokenId)

    // 신규 토큰 추가
    _tokens[newToken.refreshTokenId] = newToken
    return this
}

 

리프레시토큰은 현재 시간 기준 오래된 토큰을 만료시키고

기존 토큰을 제거하고, 신규 토큰을 추가합니다.

 

여기서 현재 시간 기준 오래된 토큰을 제거하는 로직이 사용됐는데 이 기능은 리프레시토큰을 새로 추가하는 기능에도 있습니다. 이 부분은 나쁜 냄새가 나는데 나중에 새로 수정하겠습니다. 리프레시토큰을 찾아오는 행위를 하는 곳에서 오래된 토큰을 만료시킨 채로 가져오도록 변경해서, 사용하는 측에서는 만료된 토큰에 대한 처리는 별도로 하지 않도록 하는게 맞을 것 같아요.

 

2.4 토큰 재갱신됨 이벤트 생성기

package com.ttasjwi.board.system.auth.domain.service

import com.ttasjwi.board.system.auth.domain.event.LoggedInEvent
import com.ttasjwi.board.system.auth.domain.event.TokenRefreshedEvent
import com.ttasjwi.board.system.auth.domain.model.AccessToken
import com.ttasjwi.board.system.auth.domain.model.RefreshToken

interface AuthEventCreator {

    fun onLoginSuccess(accessToken: AccessToken, refreshToken: RefreshToken): LoggedInEvent
    fun onTokenRefreshed(accessToken: AccessToken, refreshToken: RefreshToken, refreshTokenRefreshed: Boolean): TokenRefreshedEvent
}
override fun onTokenRefreshed(
    accessToken: AccessToken,
    refreshToken: RefreshToken,
    refreshTokenRefreshed: Boolean
): TokenRefreshedEvent {
    return TokenRefreshedEvent(
        accessToken = accessToken.tokenValue,
        accessTokenExpiresAt = accessToken.expiresAt,
        refreshToken = refreshToken.tokenValue,
        refreshTokenExpiresAt = refreshToken.expiresAt,
        refreshTokenRefreshed = refreshTokenRefreshed,
        refreshedAt = accessToken.issuedAt
    )
}

 

그리고 AuthEventCreator 에 토큰 재갱신됨 이벤트 생성기를 뒀습니다.

여기서는 토큰이 재갱신됐다는 이벤트를 만듭니다.


3. 애플리케이션 서비스 구현

 

애플리케이션 계층의 구조는 기존 다른  API 들과 거의 같습니다.

 

3.1 유즈케이스,  애플리케이션 서비스

package com.ttasjwi.board.system.auth.application.usecase

import java.time.ZonedDateTime

interface TokenRefreshUseCase {
    fun tokenRefresh(request: TokenRefreshRequest): TokenRefreshResult
}

data class TokenRefreshRequest(
    val refreshToken: String?,
)

data class TokenRefreshResult(
    val accessToken: String,
    val accessTokenExpiresAt: ZonedDateTime,
    val refreshToken: String,
    val refreshTokenExpiresAt: ZonedDateTime,
    val refreshTokenRefreshed: Boolean,
)

 

토큰 재갱신 유즈케이스는 사용자요청의 리프레시 토큰값을 전달받아, 액세스토큰/리프레시토큰 재갱신을 담당합니다.

 

package com.ttasjwi.board.system.auth.application.service

import com.ttasjwi.board.system.auth.application.mapper.TokenRefreshCommandMapper
import com.ttasjwi.board.system.auth.application.processor.TokenRefreshProcessor
import com.ttasjwi.board.system.auth.application.usecase.TokenRefreshRequest
import com.ttasjwi.board.system.auth.application.usecase.TokenRefreshResult
import com.ttasjwi.board.system.auth.application.usecase.TokenRefreshUseCase
import com.ttasjwi.board.system.auth.domain.event.TokenRefreshedEvent
import com.ttasjwi.board.system.core.annotation.component.ApplicationService
import com.ttasjwi.board.system.core.application.TransactionRunner
import com.ttasjwi.board.system.logging.getLogger

@ApplicationService
internal class TokenRefreshApplicationService(
    private val commandMapper: TokenRefreshCommandMapper,
    private val processor: TokenRefreshProcessor,
    private val transactionRunner: TransactionRunner,
) : TokenRefreshUseCase {

    companion object {
        private val log = getLogger(TokenRefreshApplicationService::class.java)
    }

    override fun tokenRefresh(request: TokenRefreshRequest): TokenRefreshResult {
        log.info { "토큰 재갱신 요청을 받았습니다." }

        // 유효성 검사를 거쳐서 명령으로 변환
        val command = commandMapper.mapToCommand(request)

        // 프로세서에 위임
        val event = transactionRunner.run {
            processor.tokenRefresh(command)
        }

        log.info { "토큰 재갱신됨" }

        // 처리 결과로 가공, 반환
        return makeResult(event)
    }

    private fun makeResult(event: TokenRefreshedEvent): TokenRefreshResult {
        return TokenRefreshResult(
            accessToken = event.data.accessToken,
            accessTokenExpiresAt = event.data.accessTokenExpiresAt,
            refreshToken = event.data.refreshToken,
            refreshTokenExpiresAt = event.data.refreshTokenExpiresAt,
            refreshTokenRefreshed = event.data.refreshTokenRefreshed,
        )
    }
}

 

사용자 요청을 받아, CommandMapper를 통해 애플리케이션 명령인 Command 를 생성합니다.

그리고 이를 Processor 에 전달하여 실행합니다. (TransactionRunner 를 통해 트랜잭션에서 실행)

그 후 토큰 재갱신됨 이벤트를 받아 결과를 가공하고 반환합니다.

 

3.2 커맨드, 커맨드 매퍼

package com.ttasjwi.board.system.auth.application.dto

import java.time.ZonedDateTime

internal class TokenRefreshCommand(
    val refreshToken: String,
    val currentTime: ZonedDateTime,
)

 

커맨드는 null 이 아닌 리프레시토큰과 현재 시간 정보를 담습니다.

package com.ttasjwi.board.system.auth.application.mapper

import com.ttasjwi.board.system.auth.application.dto.TokenRefreshCommand
import com.ttasjwi.board.system.auth.application.usecase.TokenRefreshRequest
import com.ttasjwi.board.system.core.annotation.component.ApplicationCommandMapper
import com.ttasjwi.board.system.core.exception.NullArgumentException
import com.ttasjwi.board.system.core.time.TimeManager

@ApplicationCommandMapper
internal class TokenRefreshCommandMapper(
    private val timeManager: TimeManager
) {

    fun mapToCommand(request: TokenRefreshRequest): TokenRefreshCommand {
        if (request.refreshToken == null) {
            throw NullArgumentException("refreshToken")
        }
        return TokenRefreshCommand(request.refreshToken, timeManager.now())
    }
}

 

커맨드 매퍼는 사용자 요청을 읽고, 토큰값이 null 이 아닌 지 검증한 뒤 현재 시간 정보를 함께 담아 Command 를 반환하는 식으로 구성됩니다.

3.3 프로세서

@ApplicationProcessor
internal class TokenRefreshProcessor(
    private val refreshTokenManager: RefreshTokenManager,
    private val refreshTokenHolderFinder: RefreshTokenHolderFinder,
    private val accessTokenManager: AccessTokenManager,
    private val refreshTokenHolderManager: RefreshTokenHolderManager,
    private val refreshTokenHolderAppender: RefreshTokenHolderAppender,
    private val authEventCreator: AuthEventCreator,
) {
    companion object {
        private val log = getLogger(TokenRefreshProcessor::class.java)
    }

    fun tokenRefresh(command: TokenRefreshCommand): TokenRefreshedEvent {
        // 리프레시 토큰 파싱
        val refreshToken = refreshTokenManager.parse(command.refreshToken)

        // 리프레시토큰 홀더 조회
        val refreshTokenHolder = getRefreshTokenHolder(refreshToken, command.currentTime)

        // 리프레시토큰 유효성 확인
        refreshTokenManager.checkCurrentlyValid(refreshToken, refreshTokenHolder, command.currentTime)

        // 액세스 토큰 생성
        val accessToken = accessTokenManager.generate(refreshTokenHolder.authMember, command.currentTime)

        // 리프레시 토큰 재갱신 여부 확인
        val isRefreshTokenRefreshRequired = refreshTokenManager.isRefreshRequired(refreshToken, command.currentTime)

        // 엑세스 토큰 / 리프레시 토큰 재갱신 결과를 이벤트로 생성해서 반환
        // 이 때 리프레시 토큰 재갱신이 필요하면 재갱신하고, 필요하지 않으면 재갱신하지 않음
        return if (isRefreshTokenRefreshRequired) {
            handleRefreshToken(accessToken, refreshToken, refreshTokenHolder, command.currentTime)
        } else {
            authEventCreator.onTokenRefreshed(accessToken, refreshToken, false)
        }
    }
}

 

토큰 재갱신의 실질적 처리를 담당하는 프로세서입니다.

 

1. refreshTokenManager.parse

리프레시 토큰값을 파싱 후 RefreshToken 인스턴스를 복원합니다.

복원이 안 되면 이 안에서 예외가 발생할텐데 그건 프로세서가 알 바는 아니죠.

 

2. getRefreshTokenHolder

리프레시토큰 홀더를 가져오는 로직입니다. (찾아오는데 실패하면 예외 발생)

 

3. refreshTokenManager.chkeckCurrentlyValid

리프레시토큰이 현재 유효한지 확인합니다. 제가 구현해둔 것에 따르면, 유효하지 않을 시 안에서 예외가 발생할겁니다.

 

4. accessTokenManager.generate

액세스토큰을 생성합니다.

 

5. refreshTokenManager.isRefreshRequired

리프레시토큰이 현재 재갱신이 필요한지 확인합니다.

재갱신이 필요 없으면 액세스토큰과 기존 리프레시토큰을 담은 이벤트를 생성하고(리프레시토큰 재갱신됨 false)

재갱신이 필요하면 handleRefreshToken 을 호출하여 재갱신 처리 및 이벤트 생성, 반환을 합니다.

 

private fun getRefreshTokenHolder(refreshToken: RefreshToken, currentTime: ZonedDateTime): RefreshTokenHolder {
    val findRefreshTokenHolder = refreshTokenHolderFinder.findByMemberIdOrNull(refreshToken.memberId)

    if (findRefreshTokenHolder == null) {
        val ex = RefreshTokenExpiredException(
            "리프레시 토큰 홀더가 조회되지 않았음. 따라서 리프레시토큰이 만료된 것으로 간주됨 (리프레시토큰 만료시각=${refreshToken.expiresAt},현재시각=${currentTime})"
        )
        log.warn(ex)
        throw ex
    }
    return findRefreshTokenHolder
}

 

gerRefreshTokenManager 에서는 RefreshTokenHolderFinder 를 통해 리프레시토큰 홀더를 조회하고 없다면 예외를 발생시킵니다. 리프레시토큰 홀더 통채로 없다는건 그 안의 토큰도 만료됐다는 것이니 만료예외를 발생시킵니다.

 

/**
 * 리프레시 토큰을 재갱신해야할 때, 재갱신 처리
 */
private fun handleRefreshToken(
    accessToken: AccessToken,
    refreshToken: RefreshToken,
    refreshTokenHolder: RefreshTokenHolder,
    currentTime: ZonedDateTime,
): TokenRefreshedEvent {

    // 리프레시 토큰 재생성
    val newRefreshToken = refreshTokenManager.generate(refreshToken.memberId, currentTime)

    // 리프레시 토큰 홀더에서 기존 리프레시토큰을 새로운 리프레시 토큰으로 변경
    val changedRefreshTokenHolder =
        refreshTokenHolderManager.changeRefreshToken(refreshTokenHolder, refreshToken, newRefreshToken)

    // 변경된 리프레시 홀더를 저장소에 반영
    refreshTokenHolderAppender.append(refreshToken.memberId, changedRefreshTokenHolder, currentTime)

    // 이벤트 생성 및 반환
    return authEventCreator.onTokenRefreshed(accessToken, newRefreshToken, true)
}

 

handleRefreshToken 에서는 리프레시 토큰 재갱신이 필요할 때의 처리를 담당합니다.

 

RefreshTokenManager 를 통해 리프레시토큰을 새로 생성하고

RefreshTokenHolderManager를 통해 기존 홀더에서 리프레시토큰을 교체합니다.

 

그 후 AuthEventCreator 를 통해 이벤트를 생성 후 반환합니다. (이 떄 리프레시토큰 재갱신됨 true)

 

여기까지 하면 애플리케이션 기능 구현이 완료됩니다.


4. 컨트롤러

@RestController
class TokenRefreshController(
    private val useCase: TokenRefreshUseCase,
    private val messageResolver: MessageResolver,
    private val localeManager: LocaleManager,
) {

    companion object {
        internal const val TOKEN_TYPE = "Bearer"
    }

    @PostMapping("/api/v1/auth/token-refresh")
    fun login(@RequestBody request: TokenRefreshRequest): ResponseEntity<SuccessResponse<TokenRefreshResponse>> {
        // 유즈케이스에 요청 처리를 위임
        val result = useCase.tokenRefresh(request)

        // 처리 결과로부터 응답 메시지 가공
        val response = makeResponse(result)

        // 200 상태코드와 함께 HTTP 응답
        return ResponseEntity.ok(response)
    }

    private fun makeResponse(result: TokenRefreshResult): SuccessResponse<TokenRefreshResponse> {
        val code = "TokenRefresh.Complete"
        val locale = localeManager.getCurrentLocale()
        return SuccessResponse(
            code = code,
            message = messageResolver.resolve("$code.message", locale),
            description = messageResolver.resolve("$code.description", locale),
            data = TokenRefreshResponse(
                tokenRefreshResult = TokenRefreshResponse.TokenRefreshResult(
                    accessToken = result.accessToken,
                    accessTokenExpiresAt = result.accessTokenExpiresAt,
                    refreshToken = result.refreshToken,
                    refreshTokenExpiresAt = result.refreshTokenExpiresAt,
                    refreshTokenRefreshed = result.refreshTokenRefreshed
                )
            )
        )
    }
}

data class TokenRefreshResponse(
    val tokenRefreshResult: TokenRefreshResult,
) {

    data class TokenRefreshResult(
        val accessToken: String,
        val accessTokenExpiresAt: ZonedDateTime,
        val tokenType: String = TOKEN_TYPE,
        val refreshToken: String,
        val refreshTokenExpiresAt: ZonedDateTime,
        val refreshTokenRefreshed: Boolean,
    )
}

 

토큰 재갱신 컨트롤러쪽에서는 유즈케이스 호출 결과를 받아 가공해서(메시지, 국제화 처리) 응답으로 내려줍니다.


5. 그외

5.1 접근권한 설정

@Configuration
class FilterChainConfig(
    private val accessTokenManager: AccessTokenManager,
    private val timeManager: TimeManager,
    @Qualifier(value = "handlerExceptionResolver")
    private val handlerExceptionResolver: HandlerExceptionResolver,
) {

    @Bean
    @Order(0)
    fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            securityMatcher("/api/**")
            authorizeHttpRequests {
                authorize(HttpMethod.GET, "/api/v1/deploy/health-check", permitAll)

                authorize(HttpMethod.GET, "/api/v1/members/email-available", permitAll)
                authorize(HttpMethod.GET, "/api/v1/members/username-available", permitAll)
                authorize(HttpMethod.GET, "/api/v1/members/nickname-available", permitAll)

                authorize(HttpMethod.POST, "/api/v1/members/email-verification/start", permitAll)
                authorize(HttpMethod.POST, "/api/v1/members/email-verification", permitAll)
                authorize(HttpMethod.POST, "/api/v1/members", permitAll)

                authorize(HttpMethod.POST, "/api/v1/auth/login", permitAll)
                authorize(HttpMethod.POST, "/api/v1/auth/token-refresh", permitAll)

                authorize(anyRequest, authenticated)
            }

 

external-security 모듈의 필터체인 설정에서, 토큰 재갱신 엔드포인트에 대해서 permitAll 을 열어뒀습니다.

 

5.2 메시지

 

external-message 모듈에 위치한 메시지/국제화 파일에 새로운 코드들에 대응하는 메시지를 추가로 작성했습니다.

 


6. 실행

 

로그인을 해서 액세스토큰, 리프레시토큰을 얻어오고

 

 

재갱신을 요청을 보내면 재갱신이 되어 돌아옵니다. 이 때 리프레시 토큰은 재갱신되지 않는 것을 볼 수 있는데 만료시각 8시간 전부터 재갱신이 되긴 할거에요.

127.0.0.1:6379> del Board-System:RefreshTokenHolder:1
(integer) 1

 

이 상태에서 홀더를 레디스에서 제거하고 새로받은 리프레시 토큰으로 재갱신 요청을 보내보겠습니다..

 

 

토큰이 더 이상 유효하지 않다고 응답이 옵니다. 저장소의 리프레시토큰 홀더가 더 이상 없기 때문입니다.


 

리프레시토큰 재갱신 기능을 이렇게 전부 구현했습니다.

이제 사용자(사용자가 직접 api를 호출하진 않을 거고 정확히는 API를 호출하는 다른 시스템)는 30분 간격으로 액세스토큰이 만료될 떄 리프레시토큰을 보내 토큰들을 재갱신할 수 있을 듯 합니다.

 

몇 가지 아쉬운 부분들이나 향후 요구사항 변경에 따라 바뀔 부분들이 있을 수 있긴한데 전체적으로 구조를 그렇게 고치기 어렵지 않을 것 같아서 큰 문제가 될 것 같지는 않습니다.

 

글 읽어주셔서 감사합니다!


리포지토리

https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

 

 

관련 PR

리프레시토큰을 통한 토큰 재갱신 기능 구현

https://github.com/ttasjwi/board-system/pull/55

 

Feature: (BRD-75) 리프레시토큰을 통한 토큰 재갱신 기능 구현 by ttasjwi · Pull Request #55 · ttasjwi/board-sy

JIRA 티켓 BRD-75 작업 내역 토큰 재갱신 기능 구현 제약 조건 유효한 리프레시토큰을 가져오면 액세스토큰은 무조건 갱신 리프레시토큰의 기간이 만료됐거나, 동시 리프레시 토큰 제약으로 인해

github.com

 

Comments