Kotlin/Kotlin | Spring 학습기록

[Kotlin] Refresh Token을 Redis에 저장하는 코드 작성 및 고찰

kth990303 2023. 3. 8. 10:33
반응형

인증 방법에는 토큰을 이용하는 방식, 세션을 이용하는 방식이 존재한다.

나는 주로 토큰을 이용하는 방식을 선호하는데 그 이유는 아래와 같다.

  • 세션 기반 인증은 쿠키를 사용해야 한다. 그러나 쿠키는 네이티브 앱에서 지원하지 않고 브라우저에서만 사용할 수 있다.
  • 다중 WAS 환경에서 세션은 동기화 이슈가 존재한다. 따라서 확장성이 낮다. sticky session, session clustering 등 다양한 방법이 존재하지만, 토큰 방식은 이러한 점을 고려할 필요가 없다.

 

물론 토큰 방식은 별도의 구현이 좀 더 필요하지만, 한 번 구현해놓으면 어렵지 않고 편리하게 이용가능하기 때문에 큰 거부감이 없는 편이다.

 

토큰 방식의 인증은 또 두 가지 방법으로 나뉜다.

Access Token만을 이용하는 방식과 Refresh Token + Access Token을 이용하는 방식이다.

당연히 권장되는 것은 후자의 방식이다. 왜 후자의 방식이 더 선호되는지, refresh token을 어디에 저장하면 좋을지 고민한 내용들을 이번 포스팅에 작성해볼 예정이다. 그리고 아래 환경에서 작성한 프로덕션 코드와 테스트 코드를 올릴 것이다.

 

환경

  • Mac M1
  • Kotlin
  • Kotest 
  • Spring Boot 2.7.7
  • JWT
  • Redis

 

참고

Refresh Token 저장 위치에 대한 정답은 없다.  이번 포스팅은 개인적인 고찰에 대한 내용이므로 사람들마다 방법이 조금씩 다를 수 있다.

또한, 아직 부족한 주니어 개발자이기 때문에 지적 댓글은 환영!


Refresh Token의 필요성

Refresh Token 도입 전에는 Access Token만으로 인증이 진행됐을 것이다. 

나는 주로 JWT 인증 방식을 사용해왔으며, 이 방식은 아래와 같이 인증이 진행된다.

 

  • Authorization 헤더에 `Bearer: {jwt.token}`값이 포함된 NativeWebRequest가 요청으로 온다.
  • 해당 NativeWebRequest의 Authorization 헤더값에서 적절한 파싱을 통해 token을 추출한다.
  • token 값에서 payload를 꺼내 유저에 대한 식별값을 얻는다. 

 

위 방식에서도 알 수 있듯이 Access Token은 서버에 별도의 저장소 없이 클라이언트 측에서 요청 헤더에 담아서 보내주게 된다. 서버에 별도의 저장소를 필요로 하는 세션 방식 인증과의 차이점이기도 하며, 덕분에 다중 WAS 환경에서 별도의 동기화 이슈를 고민할 필요가 없다.

 

사용자는 Access Token으로 인증을 완료하고 여러 기능을 이용할 수 있다. 다시 말해, Access Token은 특정 유저의 권한을 얻어 그 권한으로 여러 기능을 사용할 수 있기 때문에, 그리고 서버 측에서 Access Token를 따로 로그아웃시켜줄 수 없기 때문에 적절한 보안 조치가 필요하다. 보통은 Access Token의 만료시간을 짧게 가져가게 하는 방법으로 보안 조치를 취한다. 또, Access Token은 보통 브라우저에 저장하여 HTTP 통신을 통해 전달하게 된다. 때문에 HTTPS가 적용이 안된 HTTP 환경에서 HTTP 하이재킹이 일어나 토큰이 탈취당하게 될 수 있다. 그렇기 때문에 인증서를 얻어 HTTPS 환경에서 사용하는 것을 권장한다.

 

Access Token 만료시간을 짧게 하면 보안성은 좋아진다. 그러나 Access Token의 만료시간을 짧게 가져갈수록 사용자는 불편함을 호소하게 된다. 어떠한 작업을 하고 있는 상태에서 로그아웃이 된다고 생각해보자. 다시 로그인해야되는 번거로움과 불편함. 상당히 거슬리지 않겠는가. 그리고 그 텀이 짧아지면 짧아질수록 사용자는 더더욱 불편해할 것이다. 이러한 이유로 Access Token을 재발급하기 위한 용도로 Refresh Token이 생겨나게 된다

 


Refresh Token 저장 위치에 대한 고찰

Refresh Token은 위에서 말했다시피 Access Token을 재발급하기 위한 용도만으로 사용된다. 그 외에 별도의 인증이 필요한 작업에는 쓰이지 않는다.

 

만약 Refresh Token을 브라우저의 쿠키에 담게 된다면 Access Token 값을 담는 쿠키에 함께 담지 않도록 조심해야 한다. 만약 같은 쿠키에 두 개의 값을 담게 된다면 사실상 Access Token 만료시간을 늘려준 것에 불과하므로 오히려 보안성만 떨어뜨리는 행위가 된다. Refresh Token은 Access Token의 짧은 만료시간으로 인한 불편함을 보완하기 위해 생겨난 것이므로, 쿠키에 저장한다면 HTTP 통신 하이재킹 방지를 위해 HTTPS 환경은 사실상 필수적으로 걸어둬야 한다. 또한 쿠키는 위에서 말했다시피 Native 앱 환경에서는 사용이 불가능하다는 점, CSRF 공격에 취약하다는 점을 가지고 있어 이 방법은 지양하는 것이 좋다고 결론을 내렸다.

 

만약 Refresh Token을 브라우저의 세션 스토리지에 담게 된다면 어떻게 될까? 브라우저의 세션 스토리지에 담는다는 것은 결국 자바스크립트로 추출하여 탈취하는 XSS 공격의 취약성을 가지고 있다는 것이다. 다행히 요즘은 렌더링 전 이스케이프 작업을 React 등에서 기본적으로 실시해주어 XSS 공격을 어느 정도 방지해주긴 한다. 그렇지만 100% 보안이 보장되는 것은 아니기 때문에 조심해야 할 것이다. 

 

나는 Refresh Token을 Redis 저장소에 담는 방식을 채택했다. 

그 이유는 아래와 같다.

  • Key - Value 방식, 인메모리 DB 방식으로 빠르게 접근 가능
  • 브라우저에 비해 탈취 가능성이 낮다고 생각하는 redis 서버에 저장하는 방식
  • Refresh Token은 영구적으로 저장되는 데이터가 아님.

 

Refresh token은 영구적으로 저장될 필요가 없기 때문에 In-Memory DB를 사용해도 충분하며, 이로 인한 성능 이점을 챙겨보려 하는 것이다. Refresh token 요청이 사실 빈번하지 않기 때문에 I/O 비용을 In-Memory DB 접근으로 줄여서 얻는 성능차이가 크진 않겠지만 조금이라도 줄여보는 것이 좋지 않겠는가. 또한, 브라우저에 저장하는 것보다 비교적 안전하리라 생각했다. 

 

그럼 이제 Redis를 설치하고 프로덕션 코드 및 테스트 코드를 작성해보겠다.


Redis 설치 및 실행

기본적으로 Redis가 설치돼있어야 로컬 환경에서 돌려볼 수 있다.

그렇기 때문에 Redis를 설치해주자. 

# 설치
brew install redis

# 버전확인
redis-server --version

# 실행
brew services start redis

# 터미널 접속
redis-cli -h localhost -p 6379

# 중단
brew services stop redis

# 전체 키 조회
keys *

# 키 전체 삭제
flushall

참고로 테스트 코드를 위한 redis는 별도 의존성을 추가해주어야 한다.

이후에 언급할 예정.


프로덕션 코드

코드를 살펴보도록 하자.

참고로 아래 코드는 실제 배포된 애플리케이션 코드가 아니다. 필요에 따라 추가적인 보안 조치를 취하는 것을 권장한다.

 

build.gradle.kts

dependencies {
    // spring data redis
	implementation("org.springframework.boot:spring-boot-starter-data-redis")

	annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}

 

RefreshToken

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.index.Indexed
 
@RedisHash(value = "refreshToken", timeToLive = 60)
class RefreshToken(
        @Id
        @Indexed
        val refreshToken: String,
 
        val memberId: Long,
)
 
cs

주의할 점은 @Id 어노테이션을 주입할 때 java.persistence.id가 아닌 org.springframework.data.annotation.Id 를 import해야된다는 점이다. Refresh Token은 Redis 저장소에 넣어줄 것이기 때문에 JPA 의존성이 필요하지 않다.

@Indexed 어노테이션이 없으면 RedisRepository의 findBy 작업을 수행할 수 없으므로 주의하자. @Id와 @Indexed를 같이 사용했을 때에 별도의 큰 문제가 없었기 때문에 함께 사용하도록 했다. @Id와 @Indexed를 같이 사용했을 때 Redis 저장소에 저장되는 토큰 값 및 토큰 개수와 @Id, @Indexed를 분리했을 때 토큰 값 및 토큰 개수는 서로 동일했었다.

 

@RedisHash 어노테이션은 Redis Lettuce를 사용하기 위해 붙여주어야 한다.

value는 redis key 값으로 사용된다. redis 저장소의 key로는 {value 값}:{@Id 어노테이션을 붙여준 refreshToken 프로퍼티 값}이 저장되게 된다. "refreshToken:8d1c8b91-e2fb-485b-8758-83525b779105" 와 같이 말이다.

timeToLive는 유효시간 값으로 초 단위를 의미한다. 위 코드에서는 60초(1분)의 refresh token 유효시간을 가지도록 설정했다. 실제로는 더 길게 가져가는 경우가 훨씬 많지만, 우리는 예제 상황이므로 만료되는지 여부를 확인하기 위해 짧게 가져가보도록 하자.

 

 

Redis Client로는 크게 두 가지 오픈소스가 존재한다. Jedis와 Lettuce. 그러나 Lettuce가 비교적 코드도 간단하고 레퍼런스도 많으며, 성능도 좋기 때문에 Lettuce를 사용하는 것을 권장한다. 자세한 내용은 jojoldu님이 쓰신 아래 글을 참고하자.

https://jojoldu.tistory.com/418

 

Jedis 보다 Lettuce 를 쓰자

Java의 Redis Client는 크게 2가지가 있습니다. Jedis Lettuce 둘 모두 몇천개의 Star를 가질만큼 유명한 오픈소스입니다. 이번 시간에는 둘 중 어떤것을 사용해야할지에 대해 성능 테스트 결과를 공유하

jojoldu.tistory.com

 

RefreshTokenRepository

1
2
3
4
5
6
7
import org.springframework.data.repository.CrudRepository
 
interface RefreshTokenRepository : CrudRepository<RefreshToken, String> {
 
    fun findByRefreshToken(refreshToken: String?): RefreshToken?
}
 
cs

나는 RedisTemplate 방식이 아닌 CrudRepository를 상속받는 RedisRepository 방식을 이용했다.

별도의 Configuration 의존성 추가가 필요하지 않고 RedisTemplate 방식보다 훨씬 구현이 간편하기 때문이다. RedisTemplate에 비해 기능이 부족한 것도 아니다.

JpaRepository를 상속받지 않도록 주의하자. 위에서 말했다시피 JPA 의존성이 필요하지 않다.

 

 

AuthService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
class AuthService(
        private val memberRepository: MemberRepository,
        private val refreshTokenRepository: RefreshTokenRepository,
        private val jwtTokenProvider: JwtTokenProvider,
) {
 
    fun generateMemberRefreshToken(request: RefreshTokenRequest): RefreshTokenResponse {
        val member = memberRepository.findByEmail(request.email)
                ?: throw UnauthenticatedException.loginFail()
        if (!member.isSamePassword(Password(request.password))) {
            throw UnauthenticatedException.loginFail()
        }
        val refreshToken = RefreshToken(UUID.randomUUID().toString(), member.id)
        refreshTokenRepository.save(refreshToken)
 
        val accessToken = jwtTokenProvider.generateToken(member.email)
        return RefreshTokenResponse(accessToken, refreshToken.refreshToken)
    }
 
    fun generateMemberAccessToken(request: AccessTokenRequest): AccessTokenResponse {
        val refreshToken = refreshTokenRepository.findByRefreshToken(request.refreshToken)
                ?: throw UnauthenticatedException.loginNeeded()
        val member = memberRepository.findByIdOrNull(refreshToken.memberId)
                ?: throw UnauthenticatedException.loginFail()
        val token = jwtTokenProvider.generateToken(member.email)
        return AccessTokenResponse(token)
    }
}
cs

refresh token 발급은 사용자가 로그인을 진행하면 넘겨주도록 작성했다. RefreshToken Request에는 email, password를 넘겨주도록 하여 식별이 완료되면 refresh token, access token을 모두 포함하는 response를 넘겨주도록 했다. 

access token은 두 가지 상황에서 발급된다. 첫째로는 위에서 말한 로그인 시 refresh token, access token을 모두 발급하는 상황이다. 둘째로는 refresh token으로 access token 재발급을 받는 상황이다. RefreshTokenRepository에서 findByRefreshToken으로 해당 refresh token이 유효한지 확인하여 access token을 발급해준다.

 

RefreshToken으로 UUID.randomUUID().toString()을 넘겨주었다. JwtTokenProvider에서 발급해주는 방식도 가능하지만, JwtTokenProvider는 인증을 위한 모든 요청에 대해 payload를 꺼내기 위한 목적이기 때문에 UUID 방식을 사용하는 것으로 결정했다. 이러한 결정까지는 아래 글이 도움이 됐다.

https://stackoverflow.com/questions/73823170/refresh-tokens-stored-as-uuid-or-jwt

 

Refresh tokens stored as UUID or JWT?

I'm creating a refresh token endpoint in my backend using spring boot, and I have a few questions. What's better when creating refresh tokens, should they be a JWT or a simple UUID will suffice? For

stackoverflow.com

 

 

AuthController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/auth")
class AuthController(
        private val authService: AuthService,
) {
 
    @PostMapping("/login")
    fun generateRefreshToken(@RequestBody @Valid request: RefreshTokenRequest): ResponseEntity<RefreshTokenResponse> {
        val refreshTokenResponse = authService.generateMemberRefreshToken(request)
        return ResponseEntity.ok(refreshTokenResponse)
    }
 
    @PostMapping("/access-token")
    fun login(@RequestBody @Valid request: AccessTokenRequest): ResponseEntity<AccessTokenResponse> {
        val loginResponse = authService.generateMemberAccessToken(request)
        return ResponseEntity.ok(loginResponse)
    }
}
cs

refresh token 발급은 로그인 시에, access token 재발급은 별도의 api를 따로 만들어주었다.

 

Postman 실행화면

1. 로그인 시

accessToken, refreshToken을 모두 발급해주는 모습
keys * 명령어로 redis 저장소에 있는 키 모두 조회

 

 

2. access token 만료 시간이 지날 경우

accessToken이 만료되면 인증이 필요한 작업을 수행할 수 없어 401 에러를 반환하게 했다.

 

 

3. refresh token으로 access token 재발급 요청

accessToken이 만료됐을 때, refreshToken으로 재발급받을 수 있다


테스트 코드

테스트 환경에 사용할 redis 의존성은 별도로 추가해주어야 한다.

 

build.gradle.kts

dependencies {
	testImplementation("it.ozimov:embedded-redis:0.7.2")
}

embedded redis를 사용하기 위한 의존성을 추가한다.

 

application-test.yml

spring:
  redis:
    port: 16379
    host: localhost
  config:
    activate:
      on-profile: "test"

실제 redis를 6379 포트에서 사용하므로 충돌나지 않게 16379 포트를 사용하도록 했다.

또한, test 환경에서만 사용할 것이므로 activate.on-profile을 "test"로 설정해주었다.

 

EmbeddedRedisConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import redis.embedded.RedisServer
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
 
@TestConfiguration
class EmbeddedRedisConfig(
        @Value("\${spring.redis.port}") var port: Int,
        @Value("\${spring.redis.host}") val host: String,
) {
 
    lateinit var redisServer: RedisServer
 
    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        return LettuceConnectionFactory(host, port)
    }
 
    @PostConstruct
    fun startRedis() {
        redisServer = RedisServer(port)
        redisServer.start()
    }
 
    @PreDestroy
    fun stopRedis() {
        redisServer.stop()
    }
}
cs

 

@PostConstruct 어노테이션으로 테스트 환경에서 스프링 빈이 초기화될 때 redisServer.start() 작업을 실시하도록 했으며, 스프링 빈 종료 콜백이 발생할 때 redisServer.stop() 작업이 일어나도록 했다.

 

Ideally, we'd like to start it on the random available port but embedded Redis doesn't have this feature yet. What we could do right now is to get the random port via the ServerSocket API.
출처: https://www.baeldung.com/spring-embedded-redis

Redis에선 2023년 3월 기준, 사용하지 않는 임의의 port를 선택하여 실행하는 기능을 아직 지원하지 않는다고 한다.

그렇기 때문에 사용중인 port라는 에러가 뜬다면 netstat 명령어를 이용하여 Runtime.getRunTime().exec()으로 실행시키는 코드를 추가해주도록 하자.

나의 경우는 다행히 별 문제가 없어서 위와 같이 단순하게 작성하였다.

 

RedisRepositoryTest

@DataRedisTest 어노테이션으로 @SpringBootTest와 달리 필요한 빈만 등록하여 빠르게 테스트할 수 있다.

@DataJpaTest랑 비슷하다고 생각하면 된다.

나는 별도로 RedisRepositoryTest는 진행하지 않았다.

 

AuthServiceTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(EmbeddedRedisConfig::class)
@ActiveProfiles("test")
class AuthServiceTest(
        @Autowired private val memberRepository: MemberRepository,
        @Autowired private val refreshTokenRepository: RefreshTokenRepository,
        @Autowired private val authService: AuthService,
) {
 
    @Test
    fun `refresh token 보유 시에 access token을 반환한다`() {
        val savedMember = memberRepository.save(Member("김태현""kth990303@naver.com", Password("12345")))
        val refreshToken = RefreshToken(UUID.randomUUID().toString(), savedMember.id)
        refreshTokenRepository.save(refreshToken)
 
        val actual = authService.generateMemberAccessToken(AccessTokenRequest(refreshToken.refreshToken))
 
        actual shouldNotBe null
    }
 
    @Test
    fun `refresh token 보유하지 않고 로그인 시 refresh token, access token을 반환한다`() {
        memberRepository.save(Member("김태현""kth990303@naver.com", Password("12345")))
 
        val actual = authService.generateMemberRefreshToken(RefreshTokenRequest("kth990303@naver.com""12345"))
        actual.refreshToken shouldNotBe null
        actual.accessToken shouldNotBe null
    }
 
    @Test
    fun `refresh token 보유하지 않고 로그인 실패 시 예외를 반환한다`() {
        memberRepository.save(Member("김태현""kth990303@naver.com", Password("12345")))
 
        shouldThrow<UnauthenticatedException> {
            authService.generateMemberRefreshToken(RefreshTokenRequest("kth990303@naver.com""1234"))
        }
    }
}
 
cs

서비스 통합테스트는 위와 같이 작성해주었다.

@SpringBootTest를 이용했으며, EmbeddedRedisConfig::class를 import해주지 않으면 테스트가 깨지므로 주의하자.

테스트가 통과하는 것을 확인할 수 있다!


출처

 

반응형