Skip to content

Commit 2f5dd8b

Browse files
authored
release: 1.7.0 (#185)
2 parents 171c713 + 4fe8572 commit 2f5dd8b

File tree

11 files changed

+178
-31
lines changed

11 files changed

+178
-31
lines changed

docs/api/identity/login-apple.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
### Request Body
1010
```json
1111
{
12-
"name": "유저의 이름",
13-
"profileImage": "유저의 프로필 이미지"
12+
"accessToken": "..." // accessToken을 전달합니다.
1413
}
1514
```
1615

src/main/kotlin/org/gitanimals/core/HttpClientErrorHandler.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.gitanimals.core
22

3+
import org.slf4j.LoggerFactory
34
import org.springframework.http.HttpMethod
45
import org.springframework.http.HttpStatus
56
import org.springframework.http.client.ClientHttpResponse
@@ -16,13 +17,24 @@ class HttpClientErrorHandler : ResponseErrorHandler {
1617
override fun handleError(url: URI, method: HttpMethod, response: ClientHttpResponse) {
1718
val body = response.body.bufferedReader().use { it.readText() }
1819
when {
19-
response.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED) ->
20+
response.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED) -> run {
21+
logger.info("[HttpClientErrorHandler] Unauthorization exception \"$body\"")
2022
throw AuthorizationException(body)
23+
}
2124

22-
response.statusCode.is4xxClientError ->
25+
response.statusCode.is4xxClientError -> run {
26+
logger.warn("[HttpClientErrorHandler] IllegalArgumentException \"$body\"")
2327
throw IllegalArgumentException(body)
28+
}
2429

25-
else -> error(body)
30+
else -> run {
31+
logger.error("[HttpClientErrorHandler] Something went wrong. \"$body\"")
32+
error(body)
33+
}
2634
}
2735
}
36+
37+
companion object {
38+
private val logger = LoggerFactory.getLogger(this::class.simpleName)
39+
}
2840
}
Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,125 @@
11
package org.gitanimals.identity.app
22

3+
import com.fasterxml.jackson.core.type.TypeReference
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import io.jsonwebtoken.Jwts
6+
import org.gitanimals.core.AUTHORIZATION_EXCEPTION
7+
import org.gitanimals.identity.app.AppleOauth2Api.AppleAuthKeyResponse
38
import org.gitanimals.identity.domain.EntryPoint
49
import org.gitanimals.identity.domain.UserService
10+
import org.slf4j.LoggerFactory
511
import org.springframework.beans.factory.annotation.Value
612
import org.springframework.stereotype.Component
13+
import java.math.BigInteger
14+
import java.security.KeyFactory
15+
import java.security.PublicKey
16+
import java.security.spec.RSAPublicKeySpec
17+
import java.util.*
718

819
@Component
920
class AppleLoginFacade(
10-
@Value("\${login.secret}") private val loginSecret: String,
1121
private val tokenManager: TokenManager,
1222
private val userService: UserService,
23+
private val appleOauth2Api: AppleOauth2Api,
24+
private val objectMapper: ObjectMapper,
25+
@Value("\${login.secret}") private val loginSecret: String,
1326
) {
1427

15-
fun login(loginSecret: String, username: String, profileImage: String): String {
16-
require(loginSecret == this.loginSecret) {
17-
"Fail to login cause wrong loginSecret"
18-
}
28+
private val logger = LoggerFactory.getLogger(this::class.simpleName)
1929

20-
val isExistsUser = userService.existsUser(username, EntryPoint.APPLE)
30+
fun login(loginSecret: String, accessToken: String): String {
31+
require(this.loginSecret == loginSecret) { throw AUTHORIZATION_EXCEPTION }
32+
val appleUserInfo = getAppleUserInfo(accessToken)
33+
34+
val isExistsUser = userService.existsByEntryPointAndAuthenticationId(
35+
authenticationId = appleUserInfo.sub,
36+
entryPoint = EntryPoint.APPLE
37+
)
2138

2239
val user = when (isExistsUser) {
23-
true -> userService.getUserByNameAndEntryPoint(username, EntryPoint.APPLE)
40+
true -> userService.getUserByNameAndEntryPoint(appleUserInfo.email, EntryPoint.APPLE)
2441
false -> {
2542
userService.newUser(
26-
username = username,
43+
username = appleUserInfo.email,
2744
entryPoint = EntryPoint.APPLE,
28-
profileImage = profileImage,
45+
profileImage = defaultProfileImage,
2946
contributionPerYears = mapOf(),
30-
authenticationId = username,
47+
authenticationId = appleUserInfo.sub,
3148
)
3249
}
3350
}
3451

3552
return tokenManager.createToken(user).withType()
3653
}
54+
55+
private fun getAppleUserInfo(accessToken: String): AppleUserInfo {
56+
val tokenHeaders = parseHeaders(accessToken)
57+
val appleAuthKeys = appleOauth2Api.getAuthKeys()
58+
val publicKey =
59+
generatePublicKey(tokenHeaders = tokenHeaders, appleAuthKeys = appleAuthKeys)
60+
val claims = runCatching {
61+
Jwts.parser()
62+
.verifyWith(publicKey)
63+
.build()
64+
.parseSignedClaims(accessToken)
65+
.payload
66+
}.getOrElse {
67+
logger.error("[AppleLoginFacade] Cannot parse claims from accessToken.")
68+
throw it
69+
}
70+
71+
return AppleUserInfo(
72+
sub = claims["sub"] as String,
73+
email = claims["email"] as String,
74+
)
75+
}
76+
77+
private fun parseHeaders(token: String): Map<String, String> = runCatching {
78+
val encodedHeader: String =
79+
token
80+
.split("\\.".toRegex())
81+
.dropLastWhile { it.isEmpty() }
82+
.toTypedArray()[0]
83+
val decodedHeader = String(Base64.getUrlDecoder().decode(encodedHeader))
84+
objectMapper.readValue(
85+
decodedHeader,
86+
object : TypeReference<Map<String, String>>() {},
87+
)
88+
}.getOrElse {
89+
logger.error("[AppleLoginFacade] Cannot parse token from header. ${it.message}", it)
90+
throw it
91+
}
92+
93+
private fun generatePublicKey(
94+
tokenHeaders: Map<String, String>,
95+
appleAuthKeys: AppleAuthKeyResponse,
96+
): PublicKey {
97+
val publicKeys = appleAuthKeys.keys
98+
val publicKey = publicKeys.find {
99+
it.alg == tokenHeaders["alg"] && it.kid == tokenHeaders["kid"]
100+
} ?: run {
101+
val message = "Cannot find matched public key."
102+
logger.error("[AppleLoginFacade] $message alg: \"${tokenHeaders["alg"]}\", kid: \"${tokenHeaders["kid"]}\"")
103+
error(message)
104+
}
105+
106+
val n = Base64.getUrlDecoder().decode(publicKey.n)
107+
val e = Base64.getUrlDecoder().decode(publicKey.e)
108+
109+
val publicKeySpec = RSAPublicKeySpec(BigInteger(1, n), BigInteger(1, e))
110+
111+
val keyFactory = KeyFactory.getInstance(publicKey.kty)
112+
113+
return keyFactory.generatePublic(publicKeySpec)
114+
}
115+
116+
data class AppleUserInfo(
117+
val sub: String,
118+
val email: String,
119+
)
120+
121+
private companion object {
122+
private const val defaultProfileImage =
123+
"https://avatars.githubusercontent.com/u/171903401?s=200&v=4"
124+
}
37125
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package org.gitanimals.identity.app
2+
3+
import org.springframework.web.service.annotation.GetExchange
4+
5+
fun interface AppleOauth2Api {
6+
7+
@GetExchange("/auth/keys")
8+
fun getAuthKeys(): AppleAuthKeyResponse
9+
10+
data class AppleAuthKeyResponse(
11+
val keys: List<AuthKey>
12+
) {
13+
data class AuthKey(
14+
val kty: String,
15+
val kid: String,
16+
val use: String,
17+
val alg: String,
18+
val n: String,
19+
val e: String,
20+
)
21+
}
22+
}

src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import org.springframework.stereotype.Service
66

77
@Service
88
class GithubLoginFacade(
9-
private val oauth2Api: Oauth2Api,
9+
private val githubOauth2Api: GithubOauth2Api,
1010
private val userService: UserService,
1111
private val contributionApi: ContributionApi,
1212
private val tokenManager: TokenManager,
1313
) {
1414

1515
fun login(code: String): String {
16-
val oauthUserResponse = oauth2Api.getOauthUsername(oauth2Api.getToken(code))
16+
val oauthUserResponse = githubOauth2Api.getOauthUsername(githubOauth2Api.getToken(code))
1717

1818
val user = when (userService.existsByEntryPointAndAuthenticationId(
1919
entryPoint = EntryPoint.GITHUB,

src/main/kotlin/org/gitanimals/identity/app/Oauth2Api.kt renamed to src/main/kotlin/org/gitanimals/identity/app/GithubOauth2Api.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package org.gitanimals.identity.app
22

3-
interface Oauth2Api {
3+
interface GithubOauth2Api {
44

55
fun getToken(temporaryToken: String): String
66

src/main/kotlin/org/gitanimals/identity/controller/Oauth2Controller.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,12 @@ class Oauth2Controller(
5151
@PostMapping("/logins/oauth/apple")
5252
@ResponseStatus(HttpStatus.OK)
5353
fun loginWithApple(
54-
@RequestHeader(name = "Login-Secret") loginSecret: String,
5554
@RequestBody appleLoginRequest: AppleLoginRequest,
55+
@RequestHeader("Login-Secret") loginSecret: String,
5656
): TokenResponse {
5757
val token = appleLoginFacade.login(
58-
username = appleLoginRequest.name,
59-
profileImage = appleLoginRequest.profileImage,
6058
loginSecret = loginSecret,
59+
accessToken = appleLoginRequest.accessToken,
6160
)
6261

6362
return TokenResponse(token)
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.gitanimals.identity.controller.request
22

33
data class AppleLoginRequest(
4-
val name: String,
5-
val profileImage: String,
4+
val accessToken: String,
65
)

src/main/kotlin/org/gitanimals/identity/infra/GithubOauth2Api.kt renamed to src/main/kotlin/org/gitanimals/identity/infra/GithubGithubOauth2Api.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
package org.gitanimals.identity.infra
22

33
import com.fasterxml.jackson.annotation.JsonProperty
4-
import org.gitanimals.identity.app.Oauth2Api
4+
import org.gitanimals.identity.app.GithubOauth2Api
55
import org.springframework.beans.factory.annotation.Value
66
import org.springframework.http.HttpHeaders
77
import org.springframework.http.MediaType
88
import org.springframework.stereotype.Component
99
import org.springframework.web.client.RestClient
1010

1111
@Component
12-
class GithubOauth2Api(
12+
class GithubGithubOauth2Api(
1313
@Value("\${oauth.client.id.github}") private val clientId: String,
1414
@Value("\${oauth.client.secret.github}") private val clientSecret: String,
15-
) : Oauth2Api {
15+
) : GithubOauth2Api {
1616

1717
private val githubClient = RestClient.create("https://github.com")
1818
private val githubApiClient = RestClient.create("https://api.github.com")
@@ -35,7 +35,7 @@ class GithubOauth2Api(
3535
return "${tokenResponse.tokenType} ${tokenResponse.accessToken}"
3636
}
3737

38-
override fun getOauthUsername(token: String): Oauth2Api.OAuthUserResponse {
38+
override fun getOauthUsername(token: String): GithubOauth2Api.OAuthUserResponse {
3939
val userResponse = githubApiClient.get()
4040
.uri("/user")
4141
.header(HttpHeaders.AUTHORIZATION, token)
@@ -47,7 +47,7 @@ class GithubOauth2Api(
4747
)
4848
}
4949

50-
return Oauth2Api.OAuthUserResponse(
50+
return GithubOauth2Api.OAuthUserResponse(
5151
username = userResponse.login,
5252
id = userResponse.id,
5353
profileImage = userResponse.avatarUrl,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org.gitanimals.identity.infra
2+
3+
import org.gitanimals.core.HttpClientErrorHandler
4+
import org.gitanimals.identity.app.AppleOauth2Api
5+
import org.springframework.context.annotation.Bean
6+
import org.springframework.context.annotation.Configuration
7+
import org.springframework.web.client.RestClient
8+
import org.springframework.web.client.support.RestClientAdapter
9+
import org.springframework.web.service.invoker.HttpServiceProxyFactory
10+
11+
@Configuration
12+
class HttpClientConfigurer {
13+
14+
@Bean
15+
fun appleOauth2Api(): AppleOauth2Api {
16+
val restClient = RestClient
17+
.builder()
18+
.defaultStatusHandler(HttpClientErrorHandler())
19+
.baseUrl("https://appleid.apple.com")
20+
.build()
21+
22+
val httpServiceProxyFactory = HttpServiceProxyFactory
23+
.builderFor(RestClientAdapter.create(restClient))
24+
.build()
25+
26+
return httpServiceProxyFactory.createClient(AppleOauth2Api::class.java)
27+
}
28+
}

0 commit comments

Comments
 (0)