๐ก Refresh Token์ ์ฌ์ฉํ์ฌ ์์ ํ API ํต์ ์ ๋ง๋ ๋ค
โก๏ธ 1ํ ๋ฐ๋ก๊ฐ๊ธฐ: ์ค์ ๋ฐ ํ์๊ฐ์
โก๏ธ 2ํ ๋ฐ๋ก๊ฐ๊ธฐ: JWT ๋ก๊ทธ์ธ ๋ฐ ํ์ ์ ๋ณด ์กฐํ
AccessToken / RefreshToken
`AccessToken` ์ ์ฌ์ฉ์์ ์ธ์ฆ ์ ๋ณด๋ฅผ ๊ฐ์ง๊ณ ์๋ค.
`AccessToken` ์ ํ์ทจ ๋นํ ๊ฒฝ์ฐ, ์ฌ์ฉ์์ ์ธ์ฆ ์ ๋ณด๊ฐ ํ์ทจ์(๊ณต๊ฒฉ์)์๊ฒ ๊ทธ๋๋ก ๋ ธ์ถ ๋ ์ ์๋ค. JWT๋ Statelessํ๊ธฐ ๋๋ฌธ์ ์๋ฒ์์๋ ํด๋น ํ ํฐ์ ๊ฐ์ง๊ณ ์๋ ํด๋ผ์ด์ธํธ๊ฐ ๊ณต๊ฒฉ์์ธ์ง๋ ๊ตฌ๋ถํ ์ ์๊ธฐ ๋๋ฌธ์ AccessToken์ ํ์ทจ๋ ๋งค์ฐ ์ํํ๋ค!
RefreshToken
์ด๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด AccessToken์ ๋ง๋ฃ ์ฃผ๊ธฐ๋ฅผ ์งง๊ฒ ์ค์ ํ๊ณ , ์ด๋ฅผ ๋ณด์ํ ์ ์๋ RefreshToken์ ๋์ ํ๋ค.
`RefreshToken`์ `AccessToken`๊ณผ ๋ค๋ฅด๊ฒ ์ฌ์ฉ์์ ์ธ์ฆ ์ ๋ณด๋ฅผ ๋ด๊ณ ์์ง ์์ผ๋ฉฐ, ์ค์ง ๋ง๋ฃ ์๊ฐ ์ ๋ณด๋ง์ ๊ฐ์ง๊ณ ์๋ค. `RefreshToken`์ ๋ง๋ฃ ์ฃผ๊ธฐ๋ ๊ธธ๊ฒ ์ค์ ํ๋ค. ์ฆ, `AccessToken์ด` ๋ง๋ฃ๋์์ ๊ฒฝ์ฐ, ์ฌ์ฉ์๋ `RefreshToken`์ ํตํด ์๋ก์ด AccessToken์ ๋ฐ๊ธ ๋ฐ๋๋ค.
์๋ฒ๋ ํ์๋ง๋ค ๋ฐ๊ธ๋ RefreshToken์ ์ ์ฅํ๋ฉฐ, ์ด๋ฅผ ๋น๊ตํ์ฌ ์์ฒญ ํค๋์ ๋ด๊ธด RefreshToken์ ์ ํจ์ฑ์ ๊ฒ์ฌํ๋ค.
์์ฒญ ๋ฐฉ์
- Client → Server: ์ด์ ์ ๋ฐ๊ธ๋ Access-Token์ ํค๋์ ๋ฃ์ด ์์ฒญ์ ๋ณด๋
- Server → Client: ํ ํฐ์ด ๋ง๋ฃ๋์์ ๊ฒฝ์ฐ, ์ํ์ฝ๋์ ํจ๊ป Expired ๋ฉ์ธ์ง๋ฅผ ์๋ต
- Client → Server: ๋ง๋ฃ๋ Access-Token๊ณผ ํจ๊ป Refresh-Token์ ํค๋์ ํจ๊ป ๋ฃ์ด ์ฌ์์ฒญ
- Server → Client: ์์ฒญ์ ๋ํ ์๋ต๊ณผ ํจ๊ป ํค๋์ ์๋กญ๊ฒ ๋ฐ๊ธ๋ Access-Token ๋ฃ์ด ์๋ต
Member RefreshToken
Member์๊ฒ ๋ฐ๊ธ๋ RefreshToken์ ๊ด๋ฆฌํ๋ Entity ๋ฐ Repository ์์ฑ
@Entity
class MemberRefreshToken(
@Id
val memberId: UUID? = null,
private var refreshToken: String
) {
fun updateRefreshToken(refreshToken: String) {
this.refreshToken = refreshToken
}
fun validateRefreshToken(refreshToken: String) =
this.refreshToken == refreshToken
}
interface MemberRefreshTokenRepository: JpaRepository<MemberRefreshToken, UUID>
RefreshToken ๋ฐ๊ธ
์ต์ด RefreshToken์ ๋ก๊ทธ์ธ ์ ๋ฐ๊ธ๋๋ค.
ํ ํฐ ์์ฑ
๋ง๋ฃ๊ธฐ๊ฐ๋ง์ ๋ด์ RefreshToken์ ์์ฑํ๊ณ , ์ด๋ฅผ Member์ RefreshToken์ผ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ
@Component
class TokenProvider(
@Value("\\${jwt.secret}")
private val secret: String,
@Value("\\${jwt.expiration-minutes}")
private val expirationMinutes: Long,
@Value("\\${jwt.refresh-expiration-days}")
private val refreshExpirationDays: Long,
private val memberRefreshTokenRepository: MemberRefreshTokenRepository
) {
private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)) }
...
fun createRefreshToken(authentication: Authentication): String {
val memberId = (authentication.principal as CustomUser).id
val refreshToken = Jwts.builder()
.setIssuedAt(Timestamp.valueOf(LocalDateTime.now()))
.setExpiration(Date.from(Instant.now().plus(refreshExpirationDays, ChronoUnit.DAYS)))
.signWith(key, SignatureAlgorithm.HS256).compact()
memberRefreshTokenRepository.findByIdOrNull(memberId)?.updateRefreshToken(refreshToken)
?: memberRefreshTokenRepository.save(MemberRefreshToken(memberId, refreshToken))
return refreshToken
}
}
- `memberId` : `Authentication` ์ ์ ์ฅ๋์ด ์๋ UserDetails ์ฆ CustomUser์ Id๊ฐ
- ๋ง๋ฃ ๊ธฐ๊ฐ์ ๋ด์ `RefreshToken`์ ์์ฑํ๊ณ , memberRefreshToken ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ
- ํด๋น ๋ฉค๋ฒ์๊ฒ ๋ฐ๊ธ๋ RefreshToken์ด ์กด์ฌํ ๊ฒฝ์ฐ, ์๋ก ๋ฐ๊ธ๋ ํ ํฐ์ผ๋ก update
- ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ, ์๋ก ์์ฑ
๋ก๊ทธ์ธ
@Transactional
@Service
class SignService(
...
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
private val tokenProvider: TokenProvider
) {
...
fun signIn(signInRequest: SignInRequest): TokenInfo {
val authenticationToken = UsernamePasswordAuthenticationToken(signInRequest.email, signInRequest.password)
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
val accessToken = tokenProvider.createAccessToken(authentication)
val refreshToken = tokenProvider.createRefreshToken(authentication) // ์ถ๊ฐ
return TokenInfo(accessToken = accessToken, refreshToken = refreshToken)
}
}
๋ก๊ทธ์ธ ์, refreshToken ์ ํจ๊ป ๋ฐ๊ธํ๊ณ , TokenInfo ๊ฐ์ฒด์ ํจ๊ป ๋ฃ์ด ๋ฐํ
ํ ํฐ ๊ฒ์ฆ
Filter
- ์์ฒญ ํค๋์ ๋ด๊ธด `AccessToken`์ ๊ฒ์ฌํ๋ค.
- ๋ง์ฝ `AccessToken` ๊ฒ์ฆ ๋จ๊ณ์์ `ExpiredJwtException`์ด ๋ฐ์ํ ๊ฒฝ์ฐ, ํค๋์ `RefreshToken`์ ๊ฐ์ ธ์จ๋ค.
- RefreshToken์ด ์์ ๊ฒฝ์ฐ, `401 Unauthorization` ์๋ฌ๋ฅผ ๋ด๋ฆฐ๋ค.
- `RefreshToken`์ ๊ฒ์ฆํ ํ, ์๋ก์ด `AccessToken`์ ๋ฐ๊ธํ๋ค.
@Component
class JwtAuthenticationFilter(
private val tokenProvider: TokenProvider
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain
) {
resolveToken(request, TokenInfo.TokenType.ACCESS_TOKEN.value)?.let { token ->
handleAuthServlet(request, response) {
try {
// 1. ์์ฒญ ํค๋์ ๋ด๊ธด AccessToken์ ๊ฒ์ฌํ๋ค.
(tokenProvider.getAuthentication(token) to token)
} catch (e: ExpiredJwtException) {
// 2. AccesToken Expired ์์ธ๊ฐ ๋ฐ์ํ ๊ฒฝ์ฐ, RefreshToken์ ๊ฐ์ ธ์จ๋ค
val prevAccessToken = resolveToken(request, TokenInfo.TokenType.ACCESS_TOKEN.value) ?: throw e
val refreshToken = resolveToken(request, TokenInfo.TokenType.REFRESH_TOKEN.value) ?: throw e
// RefreshToken์ ๊ฒ์ฆํ ํ, ์๋ก์ด AccessToken์ ๋ฐ๊ธํ๋ค.
reissueAccessToken(prevAccessToken, refreshToken)
}
}
}
filterChain.doFilter(request, response)
}
private fun reissueAccessToken(prevAccessToken: String, refreshToken: String): Pair<Authentication, String> {
val authentication = tokenProvider.validateRefreshToken(prevAccessToken, refreshToken)
val newAccessToken = tokenProvider.createAccessToken(authentication)
return (authentication to newAccessToken)
}
private fun handleAuthServlet(...) {...}
private fun resolveToken(...) {...}
}
- `reissueAccessToken`
- `validateRefreshToken` : ๋ง๋ฃ๋ ์ด์ AccessToken๊ณผ Refresh ํ ํฐ์ ํ์ฉํด์, ๋๊ฐ์ง ํ ํฐ์ ์ ํจ์ฑ์ ๊ฒ์ฌํ ํ authentication์ ๋ฐํ ๋ฐ๋๋ค.
- `createAccessToken` : ํด๋น authentication์ ์ฌ์ฉํ์ฌ, ์๋ก์ด AccessToken์ ๋ฐ๊ธ๋ฐ๋๋ค.
@Component
class TokenProvider(
...
) {
...
/**
* Refresh Token Validation ๋ฐ Authentication ๋ฐํ
*/
fun validateRefreshToken(prevAccessToken: String, refreshToken: String): Authentication = try{
// Token Validation -> check expired
getClaimsWithValidation(refreshToken)
// User - Token Match Validation
val oldTokenClaims = try {
getClaimsWithValidation(prevAccessToken)
} catch (e: ExpiredJwtException) {
e.claims
}
val userId = oldTokenClaims["userId"] as String? ?: throw RuntimeException("๊ถํ ์ ๋ณด๊ฐ ์์ต๋๋ค.")
memberRefreshTokenRepository.findByIdOrNull(UUID.fromString(userId))?.takeIf {
it.validateRefreshToken(refreshToken)
} ?: throw ExpiredJwtException(null, null, "Refresh token is expired.")
getAuthentication(oldTokenClaims)
} catch (e: ExpiredJwtException) {
throw ExpiredJwtException(null, null, "refresh token is expired")
}
}
- `getClaimsWithValidation(refreshToken)` : RefreshToken์ ๋ง๋ฃ์๊ฐ๋ง์ ๊ฐ์ง๊ณ ์์ผ๋ฏ๋ก ํด๋น ๋ฉ์๋๋ฅผ ํตํด ์ ํจ๊ธฐ๊ฐ์ ๋ํ Validation์ ์งํํ๋ค.
- `oldTokenClaims`: ๋ง๋ฃ๋ ํ ํฐ์์ claim์ ์ป๋๋ค → ์ด๋ฅผ ํตํด, ์์ฒญํ ์ ์ ๋ฅผ ์๋ณํ ์ ์๋ค.
- `oldTokenClaims`์ ๊ฐ์ง๊ณ ์๋ UserId๋ฅผ ๊บผ๋ด, ํด๋น User(Member)์ ์ ์ฅ๋ RefreshToken์ ๊ฐ์ ธ์ ์์ฒญ์ ๋ด๊ธด RefreshToken๊ณผ ๋น๊ตํ์ฌ ์ต์ข ์ ์ผ๋ก ์ ํจ์ฑ ๊ฒ์ฌ๋ฅผ ์๋ฃํ๋ค.
๊ฐ์ ์ฌํญ
์ด๋ก์จ, RefreshToken๊น์ง์ ๊ตฌํ์ด ์๋ฃ๋์๋ค. ๊ฐ์ ์ฌํญ์ ๋๋ฌด ๋ง์ด.. ๋จ์๋ค..๐ฅน
- ์๋ฌ์ฒ๋ฆฌ: ExceptionHandler๊ฐ ์ ๋๋ก ์ ์๋์ง ์์๊ณ , ๊ฐ ์ํฉ์ ๋ง์ง์๋ ์๋ฌ๋ค์ ๋ด๋ ค์ฃผ๊ณ ์๋ค. ํ๋ฒ์ ์ ๋ฆฌ๊ฐ ํ์!
- RefreshToken ์ฌ๋ฐ๊ธ: RefreshToken์ด ๋ง๋ฃ๋๋ฉด ์ฌ๋ก๊ทธ์ธ์ ์ ๋ํ๋ ๊ฒ์ด ์ผ๋ฐ์ ์ด์ง๋ง, ์ฌ์ฉ์์ ํธ์์ ๋ ๊ฐ๋ ฅํ ๋ณด์(?)์ ์ํด ์ ํจ๊ธฐ๊ฐ์ด ์ผ๋ง ์๋จ์์ ๊ฒฝ์ฐ, ์๋ก์ด RefreshToken์ ๋ฐ๊ธํด์ฃผ๋ คํ๋ค.
- ๐RefreshToken ๋ฐ AccessToken ๋ง๋ฃ ์ ์ด๐: AccessToken์ด ๋ง๋ฃ๋์ง ์์๋๋ฐ, RefreshToken๊ณผ ํจ๊ป ์์ฒญ์ด ๋ค์ด์จ ๊ฒฝ์ฐ, ์ด๋ฅผ ํ์ทจ ๋นํ ์ํฉ์ผ๋ก ๋ณด๊ณ , ๋ ํ ํฐ ๋ชจ๋ ๋ง๋ฃ์ฒ๋ฆฌํ๋ ๋ก์ง์ด ํ์ํ๋ค!!!!!
โ๏ธ์ฝ๋๋ ์๋์์โ๏ธ
https://github.com/jeongum/spring-security-kotlin