𝓡𝓮𝓯𝓻𝓮𝓼𝓱 𝓣𝓸𝓴𝓮𝓷

2024. 7. 29. 21:09TIL

✔오늘 배운 중요한 🔑 point

  • Refresh Token을 사용하는 이유는 탈취의 위험성 때문이다.
  • Access Token의 수명은 짧게, Refresh Token의 수명은 길게
  • Refresh Token은 탈취당해서는 안되기 때문에 Http Only 설정을 하자(FE)

🎯 오늘 배운 내용

 

Refresh Token이 필요한 이유?

 

val accessToken = Jwts.builder()
    .subject(memberId.toString())
    .claims(claims)
    .issuer(issuer)
    .expiration(Date(System.currentTimeMillis() + accessTokenValidity))
    .signWith(key)
    .compact()

accessToken의 만료기간이 1시간이라면 1시간뒤에는 토큰이 만료되어버린다.

accessToken의 만료기간을 1주일,1달 이렇게 길게 설정할 수도 있지만 토큰이 탈취당했을 경우 보안이 매우 취약해진다.

따라서 토큰이 탈취당했을 경우에도 해당 토큰의 만료시간을 짧게 가져간다면 공격자가 해당 토큰을 사용할수 있는 시간이 매우 적어지기 때문에 보안에 유리하다.

 

따라서 AcessToken 의 시간은 짧게 설정하고 RefreshToken을 이용하여 AcessToken을 재발급하는 방식을 취하는 것이다.

 

Refresh Token 저장 방식

 

Refrsh Token을 발급해준다면 클라이언트는 HttpOnly 쿠키에 담아서 JavaScript를 통한 접근이 불가능해져 XSS 공격에 대한 방어를 할 수 있다.

 

Refresh Token 기능 구현

 

private val accessTokenValidity = 3600 * 1000 // 1 hour
private val refreshTokenValidity = 7 * 24 * 3600 * 1000 // 1 week

private val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))

fun generateToken(memberId: Long, memberRole: MemberRole): Map<String, String> {
    val claims: Claims = Jwts.claims().add(mapOf("memberRole" to memberRole)).build()

    val accessToken = Jwts.builder()
        .subject(memberId.toString())
        .claims(claims)
        .issuer(issuer)
        .expiration(Date(System.currentTimeMillis() + accessTokenValidity))
        .signWith(key)
        .compact()

    val refreshToken = Jwts.builder()
        .subject(memberId.toString())
        .issuer(issuer)
        .expiration(Date(System.currentTimeMillis() + refreshTokenValidity))
        .signWith(key)
        .compact()

    return mapOf("accessToken" to accessToken, "refreshToken" to refreshToken)
}

처음에 토큰을 발급해줄때 AccessToken의 만료시간은 1시간

RefreshToken의 만료시간은 1주일로 설정하도록 수정

 

 

interface RefreshTokenRepository {
    fun save(memberId: Long, refreshToken: String)
    fun delete(memberId: Long)
    fun findByToken(refreshToken: String): Long?
}

RefreshToken 값을 삭제하고 저장하고 찾는 Repository 생성 (현재 예시에서는 인메모리 저장 방식)

package sparta.nbcamp.wachu.infra.security.oauth.repository

import org.springframework.stereotype.Repository

@Repository
class InMemoryRefreshTokenRepository : RefreshTokenRepository {
    private val storage = mutableMapOf<String, Long>()

    override fun save(memberId: Long, refreshToken: String) {
        storage[refreshToken] = memberId
    }

    override fun delete(memberId: Long) {
        storage.values.remove(memberId)
    }

    override fun findByToken(refreshToken: String): Long? {
        return storage[refreshToken]
    }
}

 

 

 

package sparta.nbcamp.wachu.infra.security.oauth.controller

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import sparta.nbcamp.wachu.domain.member.dto.TokenResponse
import sparta.nbcamp.wachu.domain.member.entity.MemberRole
import sparta.nbcamp.wachu.domain.member.repository.MemberRepository
import sparta.nbcamp.wachu.infra.security.jwt.JwtTokenManager
import sparta.nbcamp.wachu.infra.security.oauth.dto.RefreshTokenRequest
import sparta.nbcamp.wachu.infra.security.oauth.repository.RefreshTokenRepository

@RestController
@RequestMapping("/api/auth")
class AuthController(
    private val jwtTokenManager: JwtTokenManager,
    private val memberRepository: MemberRepository,
    private val refreshTokenRepository: RefreshTokenRepository
) {

    @PostMapping("/logout")
    fun logout(@RequestBody refreshTokenRequest: RefreshTokenRequest): ResponseEntity<Void> {
        val refreshToken = refreshTokenRequest.refreshToken
        refreshTokenRepository.findByToken(refreshToken)?.let {
            refreshTokenRepository.delete(it)
        }
        return ResponseEntity.ok().build()
    }

    @PostMapping("/refresh")
    fun refreshAccessToken(@RequestBody refreshTokenRequest: RefreshTokenRequest): ResponseEntity<TokenResponse> {
        val refreshToken = refreshTokenRequest.refreshToken

        return jwtTokenManager.validateToken(refreshToken).fold(
            onSuccess = { claimsJws ->
                val claims = claimsJws.body
                val memberId = claims.subject.toLong()
                val member =
                    memberRepository.findById(memberId) ?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
                val tokens = jwtTokenManager.generateToken(member.id!!, MemberRole.MEMBER)
                refreshTokenRepository.save(member.id!!, tokens["refreshToken"]!!)
                ResponseEntity.ok(TokenResponse(tokens["accessToken"]!!, tokens["refreshToken"]!!))
            },
            onFailure = {
                ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
            }
        )
    }
}

logout함수는 사용자가 로그아웃을 할때 RefreshToken을 삭제해버리는 로직을 가지고있다. 이로써 사용자가 로그아웃을 한다면 더이상 새로운 accessToken을 발급받을수 없다.

 

refreshAccessToken함수는 사용자로부터 refreshToken을 dto형태로 받아서  해당 refreshToken의 유효성을 검증하고 검증이 되었다면 새로운 Refresh Token과 AccessToken을 발급해준다.

🤔 어떻게 활용할까?

Refresh Token을 이용하여 토큰의 탈취 위험으로부터 보안을 강화할 수 있게 되었다.

📓 오늘의 한줄

"The greater the difficulty, the more glory in surmounting it. Skillful pilots gain their reputation from storms and tempests."

- Epictetus -