토큰 재갱신 기능 설계 - ttasjwi/board-system GitHub Wiki

개요

  • 우리 서비스에서는 '토큰 방식' 인증을 사용하고 있다.
  • 사용자 인증에는 '액세스 토큰'을 사용하고, 액세스토큰 갱신을 위해 '리프레시 토큰'도 별도로 발급한다.
  • 이전에 로그인 기능 설계에서 '액세스 토큰', '리프레시 토큰' 발급을 언급했는데, 리프레시토큰 관련 기능 설계는 이 글에서 다루는게 맞을 것 같아 이 글에 분리했다.

리프레시 토큰 도메인 모델

class RefreshToken
internal constructor(
    val userId: Long,
    val refreshTokenId: Long,
    val tokenType: String,
    val tokenValue: String,
    val issuer: String,
    val issuedAt: AppDateTime,
    val expiresAt: AppDateTime,
) {

    companion object {
        const val VALID_TOKEN_TYPE = "RefreshToken"
        const val VALID_ISSUER = "BoardSystem"

        fun create(
            userId: Long,
            refreshTokenId: Long,
            issuedAt: AppDateTime,
            expiresAt: AppDateTime,
            tokenValue: String,
        ): RefreshToken {
            return RefreshToken(
                userId = userId,
                refreshTokenId = refreshTokenId,
                tokenType = VALID_TOKEN_TYPE,
                tokenValue = tokenValue,
                issuer = VALID_ISSUER,
                issuedAt = issuedAt,
                expiresAt = expiresAt
            )
        }

        fun restore(
            userId: Long,
            refreshTokenId: Long,
            tokenValue: String,
            issuedAt: Instant,
            expiresAt: Instant
        ): RefreshToken {
            return RefreshToken(
                userId = userId,
                refreshTokenId = refreshTokenId,
                tokenType = VALID_TOKEN_TYPE,
                tokenValue = tokenValue,
                issuer = VALID_ISSUER,
                issuedAt = AppDateTime.from(issuedAt),
                expiresAt = AppDateTime.from(expiresAt)
            )
        }
    }

    fun isExpired(currentTime: AppDateTime): Boolean {
        return expiresAt <= currentTime
    }
}
  • 리프레시토큰은 다음 정보를 가진다.
    • 사용자 식별자 : userId (누구의 리프레시토큰인가?)
    • 리프레시토큰 식별자 : refreshTokenId (사용자가 가진 리프레시토큰들 사이에서도 서로 구분을 할 수 있도록 하기 위함)
    • 발행 시각 : issuedAt
    • 만료 시각 : expiresAt

리프레시 토큰 정책

  • 리프레시 토큰은 토큰 재갱신을 목적으로 하는 토큰으로서, 유효시간이 액세스토큰보다 길다.
  • 액세스토큰의 유효기간은 30분에 불과하지만, 리프레시토큰의 유효기간은 24시간으로 잡았다.
  • 보안 강화 목적을 위해, 한 사용자마다 리프레시토큰은 동시에 5개 유효하다.(동시다중기기 고려)
    • 오래된 리프레시토큰은 자동으로 만료된다.
    • 동시에 5개 리프레시토큰이 활성화되고 모두 유효한 상태에서 새로 로그인이 일어나면, 가장 오래된 리프레시토큰을 무효화해야한다.
  • 사용자별 동시 리프레시토큰을 통제하기 위해서는 서버에서 이들에 대해서는 데이터베이스에 별도로 보관해야한다.

저장소 : Redis

  • 리프레시토큰 저장소는 Redis(Redis) 를 활용한다.
    • 조회 속도가 빠르다.
    • 만료시간(ttl) 설정이 가능하기 때문에 자동으로 오래된 리프레시토큰은 만료할 수 있다.
@Component
class UserRefreshTokenIdListPersistenceAdapter(
    private val redisTemplate: StringRedisTemplate
) : UserRefreshTokenIdListPersistencePort {

    companion object {
        // board-system::user::{userId}::refresh-token-ids
        private const val KEY_FORMAT = "board-system::user::%s::refresh-token-ids"
    }

    override fun add(userId: Long, refreshTokenId: Long, limit: Long) {
        redisTemplate.executePipelined {
            val conn = it as StringRedisConnection
            val key = generateKey(userId)
            conn.zAdd(key, 0.0, toPaddedString(refreshTokenId)) // 동일한 score 일 경우 value 값에 의해 정렬 상태가 만들어짐
            conn.zRemRange(key, 0, -limit - 1) // 최신의 limit 건만 유지되도록 함
            null
        }
    }
  • 사용자마다 '사용자 리프레시토큰아이디 목록 저장소'를 관리한다. 이 곳에는 사용자마다 5개의 리프레시토큰을 보관하여 관리한다.
    • Redis 에서는 정렬 트리기반의 Set 구조인 ZSet 자료구조를 지원한다.
    • 사용자마다, 리프레시토큰 Id 기준(snowflake 알고리즘 기반이므로 시간순으로 오름차순을 갖는다.) 최신의 5건만 유지할 수 있게 할 수 있다.
@Component
class RefreshTokenIdPersistenceAdapter(
    private val redisTemplate: RedisTemplate<String, String>
) : RefreshTokenIdPersistencePort {

    companion object {

        // board-system::user::{userId}::refresh-token::{refreshTokenId}
        private const val KEY_FORMAT = "board-system::user::%s::refresh-token::%s"
    }

    override fun save(userId: Long, refreshTokenId: Long, expiresAt: AppDateTime) {
        val key = generateKey(userId, refreshTokenId)
        redisTemplate.opsForValue().set(key, refreshTokenId.toString())
        redisTemplate.expireAt(key, expiresAt.toInstant())
    }
  • 이와 별도로 '리프레시토큰아이디 저장소' 를 관리한다. ttl을 적용하여 만료될때 알아서 사라지게 한다.

생성 로직

  • 리프레시 토큰 생성
  • 회원 리프레시토큰 Id 목록 조회
  • 회원 리프레시토큰 Id 목록에는 존재하지만, 실제 '리프레시토큰 아이디 저장소'에 존재하지 않는 토큰이 있을 경우 '회원 리프레시토큰 Id' 목록에서 제거
  • 회원이 가질 수 있는 리프레시 토큰 갯수 제한에 도달했고, 위 과정에서 삭제된 토큰이 없을 경우 가장 오래된 토큰을 제거
  • 새로운 토큰을 저장
    • 회원 리프레시토큰 Id 목록에 추가
    • 리프레시토큰 Id 저장소에 추가

갱신로직

  • 현재시각이 리프레시토큰 만료시각 이전 8시간 전 혹은 그 이내일경우 리프레시 토큰 역시 재갱신한다.
  • 전달된 리프레시토큰 제거
    • 사용자 리프레시토큰 Id 목록에서 제거
    • 리프레시토큰 Id 저장소에서 제거

검증 로직

  • 파싱
  • 리프레시토큰 자체 유효시간이 만료됐다면 예외 발생
  • 회원 리프레시토큰 Id 목록에 있는 지 확인 후 존재하지 않을 경우 예외 발생
    • 로그아웃, 동시 유지 가능 갯수 제한 등으로 인해 사라졌을 수 있음