개요
- 우리 서비스에서는 '토큰 방식' 인증을 사용하고 있다.
- 사용자 인증에는 '액세스 토큰'을 사용하고, 액세스토큰 갱신을 위해 '리프레시 토큰'도 별도로 발급한다.
- 이전에 로그인 기능 설계에서 '액세스 토큰', '리프레시 토큰' 발급을 언급했는데, 리프레시토큰 관련 기능 설계는 이 글에서 다루는게 맞을 것 같아 이 글에
분리했다.
리프레시 토큰 도메인 모델
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 목록에 있는 지 확인 후 존재하지 않을 경우 예외 발생
- 로그아웃, 동시 유지 가능 갯수 제한 등으로 인해 사라졌을 수 있음