๐Ÿ’ป ๊ฐœ๋ฐœ ์ผ์ง€/SpringBoot

[SpringBoot/Kotlin] JWT + SpringSecurity๋ฅผ ํ™œ์šฉํ•œ ํšŒ์› API ๊ตฌํ˜„ (3) - Refresh Token

์ ์ด 2023. 11. 14. 21:56
๋ฐ˜์‘ํ˜•
๐Ÿ’ก Refresh Token์„ ์‚ฌ์šฉํ•˜์—ฌ ์•ˆ์ „ํ•œ API ํ†ต์‹ ์„ ๋งŒ๋“ ๋‹ค

 

โžก๏ธ 1ํƒ„ ๋ฐ”๋กœ๊ฐ€๊ธฐ: ์„ค์ • ๋ฐ ํšŒ์›๊ฐ€์ž…

โžก๏ธ 2ํƒ„ ๋ฐ”๋กœ๊ฐ€๊ธฐ: JWT ๋กœ๊ทธ์ธ ๋ฐ ํšŒ์› ์ •๋ณด ์กฐํšŒ


AccessToken / RefreshToken

`AccessToken` ์€ ์‚ฌ์šฉ์ž์˜ ์ธ์ฆ ์ •๋ณด๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค.

 

`AccessToken` ์„ ํƒˆ์ทจ ๋‹นํ•  ๊ฒฝ์šฐ, ์‚ฌ์šฉ์ž์˜ ์ธ์ฆ ์ •๋ณด๊ฐ€ ํƒˆ์ทจ์ž(๊ณต๊ฒฉ์ž)์—๊ฒŒ ๊ทธ๋Œ€๋กœ ๋…ธ์ถœ ๋  ์ˆ˜ ์žˆ๋‹ค. JWT๋Š” Statelessํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์„œ๋ฒ„์—์„œ๋Š” ํ•ด๋‹น ํ† ํฐ์„ ๊ฐ€์ง€๊ณ  ์žˆ๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๊ณต๊ฒฉ์ž์ธ์ง€๋„ ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— AccessToken์˜ ํƒˆ์ทจ๋Š” ๋งค์šฐ ์œ„ํ—˜ํ•˜๋‹ค!

RefreshToken

์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด AccessToken์˜ ๋งŒ๋ฃŒ ์ฃผ๊ธฐ๋ฅผ ์งง๊ฒŒ ์„ค์ •ํ•˜๊ณ , ์ด๋ฅผ ๋ณด์™„ํ•  ์ˆ˜ ์žˆ๋Š” RefreshToken์„ ๋„์ž…ํ•œ๋‹ค.

 

`RefreshToken`์€ `AccessToken`๊ณผ ๋‹ค๋ฅด๊ฒŒ ์‚ฌ์šฉ์ž์˜ ์ธ์ฆ ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ์ง€ ์•Š์œผ๋ฉฐ, ์˜ค์ง ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์ •๋ณด๋งŒ์„ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค. `RefreshToken`์˜ ๋งŒ๋ฃŒ ์ฃผ๊ธฐ๋Š” ๊ธธ๊ฒŒ ์„ค์ •ํ•œ๋‹ค. ์ฆ‰, `AccessToken์ด` ๋งŒ๋ฃŒ๋˜์—ˆ์„ ๊ฒฝ์šฐ, ์‚ฌ์šฉ์ž๋Š” `RefreshToken`์„ ํ†ตํ•ด ์ƒˆ๋กœ์šด AccessToken์„ ๋ฐœ๊ธ‰ ๋ฐ›๋Š”๋‹ค.

 

์„œ๋ฒ„๋Š” ํšŒ์›๋งˆ๋‹ค ๋ฐœ๊ธ‰๋œ RefreshToken์„ ์ €์žฅํ•˜๋ฉฐ, ์ด๋ฅผ ๋น„๊ตํ•˜์—ฌ ์š”์ฒญ ํ—ค๋”์— ๋‹ด๊ธด RefreshToken์˜ ์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•œ๋‹ค.

์š”์ฒญ ๋ฐฉ์‹

  1. Client → Server: ์ด์ „์— ๋ฐœ๊ธ‰๋œ Access-Token์„ ํ—ค๋”์— ๋„ฃ์–ด ์š”์ฒญ์„ ๋ณด๋ƒ„
  2. Server → Client: ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์„ ๊ฒฝ์šฐ, ์ƒํƒœ์ฝ”๋“œ์™€ ํ•จ๊ป˜ Expired ๋ฉ”์„ธ์ง€๋ฅผ ์‘๋‹ต
  3. Client → Server: ๋งŒ๋ฃŒ๋œ Access-Token๊ณผ ํ•จ๊ป˜ Refresh-Token์„ ํ—ค๋”์— ํ•จ๊ป˜ ๋„ฃ์–ด ์žฌ์š”์ฒญ
  4. 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

  1. ์š”์ฒญ ํ—ค๋”์— ๋‹ด๊ธด `AccessToken`์„ ๊ฒ€์‚ฌํ•œ๋‹ค.
  2. ๋งŒ์•ฝ `AccessToken` ๊ฒ€์ฆ ๋‹จ๊ณ„์—์„œ `ExpiredJwtException`์ด ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ, ํ—ค๋”์— `RefreshToken`์„ ๊ฐ€์ ธ์˜จ๋‹ค.
    • RefreshToken์ด ์—†์„ ๊ฒฝ์šฐ, `401 Unauthorization` ์—๋Ÿฌ๋ฅผ ๋‚ด๋ฆฐ๋‹ค.
  3. `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๊นŒ์ง€์˜ ๊ตฌํ˜„์ด ์™„๋ฃŒ๋˜์—ˆ๋‹ค. ๊ฐœ์„  ์‚ฌํ•ญ์€ ๋„ˆ๋ฌด ๋งŽ์ด.. ๋‚จ์•˜๋‹ค..๐Ÿฅน

  1. ์—๋Ÿฌ์ฒ˜๋ฆฌ: ExceptionHandler๊ฐ€ ์ œ๋Œ€๋กœ ์ •์˜๋˜์ง€ ์•Š์•˜๊ณ , ๊ฐ ์ƒํ™ฉ์— ๋งž์ง€์•Š๋Š” ์—๋Ÿฌ๋“ค์„ ๋‚ด๋ ค์ฃผ๊ณ  ์žˆ๋‹ค. ํ•œ๋ฒˆ์˜ ์ •๋ฆฌ๊ฐ€ ํ•„์š”!
  2. RefreshToken ์žฌ๋ฐœ๊ธ‰: RefreshToken์ด ๋งŒ๋ฃŒ๋˜๋ฉด ์žฌ๋กœ๊ทธ์ธ์„ ์œ ๋„ํ•˜๋Š” ๊ฒƒ์ด ์ผ๋ฐ˜์ ์ด์ง€๋งŒ, ์‚ฌ์šฉ์ž์˜ ํŽธ์˜์™€ ๋” ๊ฐ•๋ ฅํ•œ ๋ณด์•ˆ(?)์„ ์œ„ํ•ด ์œ ํšจ๊ธฐ๊ฐ„์ด ์–ผ๋งˆ ์•ˆ๋‚จ์•˜์„ ๊ฒฝ์šฐ, ์ƒˆ๋กœ์šด RefreshToken์„ ๋ฐœ๊ธ‰ํ•ด์ฃผ๋ คํ•œ๋‹ค.
  3. ๐ŸŒŸRefreshToken ๋ฐ AccessToken ๋งŒ๋ฃŒ ์ œ์–ด๐ŸŒŸ: AccessToken์ด ๋งŒ๋ฃŒ๋˜์ง€ ์•Š์•˜๋Š”๋ฐ, RefreshToken๊ณผ ํ•จ๊ป˜ ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๊ฒฝ์šฐ, ์ด๋ฅผ ํƒˆ์ทจ ๋‹นํ•œ ์ƒํ™ฉ์œผ๋กœ ๋ณด๊ณ , ๋‘ ํ† ํฐ ๋ชจ๋‘ ๋งŒ๋ฃŒ์ฒ˜๋ฆฌํ•˜๋Š” ๋กœ์ง์ด ํ•„์š”ํ•˜๋‹ค!!!!!

โœ”๏ธ์ฝ”๋“œ๋Š” ์•„๋ž˜์—์„œโœ”๏ธ

https://github.com/jeongum/spring-security-kotlin

 

GitHub - jeongum/spring-security-kotlin

Contribute to jeongum/spring-security-kotlin development by creating an account on GitHub.

github.com

 

๋ฐ˜์‘ํ˜•