Skip to content

Commit 7f45e57

Browse files
committed
feat: refresh token 을 추가한다
1 parent 2f5dd8b commit 7f45e57

File tree

9 files changed

+289
-0
lines changed

9 files changed

+289
-0
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Create refresh token
2+
3+
리프레시 토큰을 생성합니다.
4+
5+
> [!IMPORTANT]
6+
> 새로운 리프레시 토큰을 발급시, 이전의 리프레시 토큰은 삭제됩니다.
7+
> 리프레시 토큰의 유효기간은 7일 입니다.
8+
9+
## Request
10+
### HTTP METHOD : `POST`
11+
### url : `https://api.gitanimals.org/users/refresh-tokens`
12+
### request headers
13+
- Authorization : "bearer ..." // 유저가 현재 로그인한 jwt
14+
- Login-Secret: 내부 로그인 토큰을 전달 하세요.
15+
16+
## Response
17+
18+
```json
19+
{
20+
"refreshToken": "..."
21+
}
22+
```
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Create refresh token
2+
3+
리프레시 토큰을 이용해 로그인 합니다.
4+
5+
> [!IMPORTANT]
6+
> 리프레시 토큰의 유효기간은 7일 입니다.
7+
8+
## Request
9+
### HTTP METHOD : `POST`
10+
### url : `https://api.gitanimals.org/logins/refresh-tokens`
11+
### request headers
12+
- Login-Secret: 내부 로그인 토큰을 전달 하세요.
13+
14+
### request body
15+
```json
16+
{
17+
"refreshToken": "..."
18+
}
19+
```
20+
21+
## Response
22+
23+
```json
24+
{
25+
"token": "bearer ..."
26+
}
27+
```
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package org.gitanimals.identity.app
2+
3+
import org.gitanimals.core.AUTHORIZATION_EXCEPTION
4+
import org.gitanimals.identity.domain.UserService
5+
import org.springframework.beans.factory.annotation.Qualifier
6+
import org.springframework.beans.factory.annotation.Value
7+
import org.springframework.data.redis.connection.RedisStringCommands
8+
import org.springframework.data.redis.core.StringRedisTemplate
9+
import org.springframework.data.redis.core.types.Expiration
10+
import org.springframework.stereotype.Component
11+
import java.security.SecureRandom
12+
import java.util.*
13+
import kotlin.time.Duration.Companion.days
14+
import kotlin.time.toJavaDuration
15+
16+
@Component
17+
class RefreshTokenFacade(
18+
private val userService: UserService,
19+
private val tokenManager: TokenManager,
20+
@Value("\${login.secret}") private val loginSecret: String,
21+
@Qualifier("gitanimalsRedisTemplate") private val redisTemplate: StringRedisTemplate,
22+
) {
23+
24+
fun getTokenByRefreshToken(loginSecret: String, refreshToken: String): Token {
25+
if (loginSecret != this.loginSecret) {
26+
throw AUTHORIZATION_EXCEPTION
27+
}
28+
29+
val userId = redisTemplate.execute { conn ->
30+
val userId: String = conn.stringCommands().get(refreshToken.toByteArray())?.let {
31+
String(it)
32+
} ?: throw AUTHORIZATION_EXCEPTION
33+
34+
val latestToken: String = conn.stringCommands().get("refresh:$userId".toByteArray())?.let {
35+
String(it)
36+
} ?: throw AUTHORIZATION_EXCEPTION
37+
38+
if (latestToken != refreshToken) {
39+
throw AUTHORIZATION_EXCEPTION
40+
}
41+
42+
userId
43+
} ?: throw AUTHORIZATION_EXCEPTION
44+
45+
val user = userService.getUserById(userId.toLong())
46+
47+
return tokenManager.createToken(user)
48+
}
49+
50+
fun generateRefreshToken(loginSecret: String, token: String): String {
51+
if (loginSecret != this.loginSecret) {
52+
throw AUTHORIZATION_EXCEPTION
53+
}
54+
val userId = tokenManager.getUserId(Token.from(token))
55+
56+
val user = userService.getUserById(userId)
57+
58+
return redisTemplate.execute {
59+
val refreshToken = RefreshToken.from(userId)
60+
val key = "refresh:${user.id}".toByteArray()
61+
62+
it.watch(key)
63+
64+
val oldRefreshToken: ByteArray? = it.stringCommands().get(key)
65+
it.multi()
66+
oldRefreshToken?.let { token ->
67+
it.stringCommands().getDel(token)
68+
}
69+
70+
it.stringCommands().set(
71+
refreshToken.key.toByteArray(),
72+
refreshToken.userId.toString().toByteArray(),
73+
Expiration.from(7.days.toJavaDuration()),
74+
RedisStringCommands.SetOption.UPSERT,
75+
)
76+
it.stringCommands().set(
77+
key,
78+
refreshToken.key.toByteArray(),
79+
Expiration.from(7.days.toJavaDuration()),
80+
RedisStringCommands.SetOption.UPSERT,
81+
)
82+
83+
it.exec()
84+
refreshToken
85+
}?.key ?: throw IllegalArgumentException("Cannot create refresh token")
86+
}
87+
88+
data class RefreshToken(
89+
val key: String,
90+
val userId: Long,
91+
) {
92+
companion object {
93+
private val rng = SecureRandom()
94+
private val b64 = Base64.getUrlEncoder().withoutPadding()
95+
96+
fun from(userId: Long): RefreshToken {
97+
val bytes = ByteArray(32) // 256-bit
98+
rng.nextBytes(bytes)
99+
b64.encodeToString(bytes)
100+
101+
return RefreshToken(
102+
key = b64.encodeToString(bytes),
103+
userId = userId,
104+
)
105+
}
106+
}
107+
}
108+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.gitanimals.identity.controller
2+
3+
import org.gitanimals.identity.app.RefreshTokenFacade
4+
import org.gitanimals.identity.controller.request.LoginByRefreshTokenRequest
5+
import org.gitanimals.identity.controller.response.RefreshTokenResponse
6+
import org.gitanimals.identity.controller.response.TokenResponse
7+
import org.springframework.http.HttpHeaders
8+
import org.springframework.web.bind.annotation.*
9+
10+
@RestController
11+
class RefreshTokenController(
12+
private val refreshTokenFacade: RefreshTokenFacade,
13+
) {
14+
15+
@PostMapping("/users/refresh-tokens")
16+
fun generateRefreshToken(
17+
@RequestHeader("Login-Secret") loginSecret: String,
18+
@RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String,
19+
): RefreshTokenResponse {
20+
val refreshToken = refreshTokenFacade.generateRefreshToken(
21+
loginSecret = loginSecret,
22+
token = authorization,
23+
)
24+
25+
return RefreshTokenResponse(refreshToken)
26+
}
27+
28+
@PostMapping("/logins/refresh-tokens")
29+
fun loginByRefreshToken(
30+
@RequestHeader("Login-Secret") loginSecret: String,
31+
@RequestBody loginByRefreshTokenRequest: LoginByRefreshTokenRequest,
32+
): TokenResponse {
33+
val token = refreshTokenFacade.getTokenByRefreshToken(
34+
loginSecret = loginSecret,
35+
refreshToken = loginByRefreshTokenRequest.refreshToken,
36+
)
37+
38+
return TokenResponse(token.withType())
39+
}
40+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.gitanimals.identity.controller.request
2+
3+
data class LoginByRefreshTokenRequest(
4+
val refreshToken: String
5+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.gitanimals.identity.controller.response
2+
3+
data class RefreshTokenResponse(
4+
val refreshToken: String
5+
)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package org.gitanimals.identity.app
2+
3+
import io.kotest.assertions.throwables.shouldNotThrowAny
4+
import io.kotest.assertions.throwables.shouldThrowExactly
5+
import io.kotest.core.spec.style.DescribeSpec
6+
import io.kotest.matchers.should
7+
import io.kotest.matchers.shouldBe
8+
import org.gitanimals.core.AuthorizationException
9+
import org.gitanimals.core.redis.RedisConfiguration
10+
import org.gitanimals.identity.domain.UserRepository
11+
import org.gitanimals.identity.domain.UserService
12+
import org.gitanimals.identity.domain.user
13+
import org.gitanimals.identity.infra.JwtTokenManager
14+
import org.rooftop.netx.meta.EnableSaga
15+
import org.springframework.beans.factory.annotation.Value
16+
import org.springframework.boot.autoconfigure.domain.EntityScan
17+
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
18+
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
19+
import org.springframework.test.context.ContextConfiguration
20+
import org.springframework.test.context.TestPropertySource
21+
22+
@EnableSaga
23+
@DataJpaTest
24+
@ContextConfiguration(
25+
classes = [
26+
RedisContainer::class,
27+
RedisConfiguration::class,
28+
JwtTokenManager::class,
29+
UserService::class,
30+
RefreshTokenFacade::class,
31+
]
32+
)
33+
@EntityScan(basePackages = ["org.gitanimals.identity.domain"])
34+
@EnableJpaRepositories(basePackages = ["org.gitanimals.identity.domain"])
35+
@TestPropertySource("classpath:test.properties")
36+
class RefreshTokenFacadeTest(
37+
private val refreshTokenFacade: RefreshTokenFacade,
38+
@Value("\${login.secret}") private val loginSecret: String,
39+
private val userRepository: UserRepository,
40+
private val tokenManager: TokenManager,
41+
) : DescribeSpec({
42+
43+
afterEach { userRepository.deleteAll() }
44+
45+
describe("generateRefreshToken 메소드는") {
46+
47+
context("처음으로 refresh token을 생성하는 유저라면,") {
48+
val user = userRepository.save(user())
49+
val token = tokenManager.createToken(user)
50+
51+
it("생성된 토큰을 응답한다") {
52+
shouldNotThrowAny {
53+
refreshTokenFacade.generateRefreshToken(loginSecret, token.withType())
54+
}
55+
}
56+
}
57+
58+
context("이미 refresh token이 존재한다면,") {
59+
val user = userRepository.save(user())
60+
val token = tokenManager.createToken(user)
61+
62+
val oldToken = refreshTokenFacade.generateRefreshToken(loginSecret, token.withType())
63+
it("기존 refresh token을 새로운 refresh token으로 교체한다") {
64+
val newToken = refreshTokenFacade.generateRefreshToken(loginSecret, token.withType())
65+
66+
val shouldNotThrowWhenNewToken = shouldNotThrowAny {
67+
refreshTokenFacade.getTokenByRefreshToken(loginSecret, newToken)
68+
}
69+
val shouldThrowWhenOldToken = shouldThrowExactly<AuthorizationException> {
70+
refreshTokenFacade.getTokenByRefreshToken(loginSecret, oldToken)
71+
}
72+
73+
shouldNotThrowWhenNewToken::class shouldBe Token::class
74+
shouldThrowWhenOldToken::class shouldBe AuthorizationException::class
75+
}
76+
}
77+
}
78+
})

src/test/kotlin/org/gitanimals/identity/app/UserStatisticScheduleTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package org.gitanimals.identity.app
22

33
import io.kotest.assertions.nondeterministic.eventually
44
import io.kotest.core.spec.style.DescribeSpec
5+
import org.gitanimals.core.redis.RedisConfiguration
56
import org.gitanimals.identity.IdentityTestRoot
67
import org.springframework.boot.test.context.SpringBootTest
78
import org.springframework.test.context.TestPropertySource
@@ -12,6 +13,7 @@ import kotlin.time.Duration.Companion.seconds
1213
IdentityTestRoot::class,
1314
RedisContainer::class,
1415
IdentitySagaCapture::class,
16+
RedisConfiguration::class,
1517
]
1618
)
1719
@TestPropertySource("classpath:test.properties")

src/test/kotlin/org/gitanimals/quiz/app/CreateQuizFacadeTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import org.gitanimals.quiz.domain.not_approved.NotApprovedQuizService
2828
import org.gitanimals.quiz.domain.prompt.QuizCreatePrompt
2929
import org.gitanimals.quiz.domain.prompt.QuizCreatePromptRepository
3030
import org.gitanimals.quiz.domain.prompt.QuizCreatePromptService
31+
import org.gitanimals.quiz.domain.prompt.rag.QuizCreateRagService
3132
import org.gitanimals.quiz.domain.quiz.notApprovedQuiz
3233
import org.gitanimals.quiz.domain.quiz.quiz
3334
import org.gitanimals.quiz.infra.event.NewQuizCreated
@@ -61,6 +62,7 @@ import kotlin.time.Duration.Companion.seconds
6162
QuizSolveContextDoneHibernateEventListener::class,
6263
QuizDeletedHibernateEventListener::class,
6364
HibernateEventListenerConfiguration::class,
65+
QuizCreateRagService::class,
6466
]
6567
)
6668
@EntityScan(basePackages = ["org.gitanimals.quiz.domain"])

0 commit comments

Comments
 (0)