Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/

moa-secret/
*.p8
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies {
runtimeOnly("com.mysql:mysql-connector-j")

// etc
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("tools.jackson.module:jackson-module-kotlin")
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
Expand Down
36 changes: 36 additions & 0 deletions src/main/kotlin/com/moa/common/auth/ExchangeCodeStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.moa.common.auth

import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.Ticker
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.Duration

/**
* OIDC 데스크톱 핸드오프용 1회용 교환 코드 저장소
*
* 단일 인스턴스 배포를 전제로 한 인메모리(Caffeine) 구현이다
* — 다중 인스턴스/롤링 배포에서는 외부 저장소로 교체해야 한다.
*/
Comment on lines +9 to +14
@Component
class ExchangeCodeStore(
@Value("\${auth.exchange-code.ttl-seconds:120}") ttlSeconds: Long = 120,
@Value("\${auth.exchange-code.max-size:10000}") maxSize: Long = 10_000,
ticker: Ticker = Ticker.systemTicker(), // 테스트에서 시간을 제어하기 위한 주입
) {
private val cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(ttlSeconds))
.maximumSize(maxSize)
.ticker(ticker)
.build<String, String>()

/** 검증된 OIDC subject 를 1회용 코드로 봉인한다. 회원 생성은 consume 이후로 미룬다. */
fun issue(subject: String): String {
val code = OpaqueTokenGenerator.generate()
cache.put(code, subject)
return code
}

/** 원자적 1회 소비. 없거나 만료면 null. */
fun consume(code: String): String? = cache.asMap().remove(code)
}
15 changes: 15 additions & 0 deletions src/main/kotlin/com/moa/common/auth/OpaqueTokenGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.moa.common.auth

import java.security.SecureRandom
import java.util.Base64

/** URL-safe 불투명 토큰 생성기. refresh token 과 exchange code 가 같은 엔트로피 정책을 공유한다. */
object OpaqueTokenGenerator {

private val random = SecureRandom()

fun generate(byteLength: Int = 32): String {
val bytes = ByteArray(byteLength).also(random::nextBytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
}
10 changes: 1 addition & 9 deletions src/main/kotlin/com/moa/common/auth/RefreshTokenHasher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,11 @@ package com.moa.common.auth

import org.springframework.stereotype.Component
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64

@Component
class RefreshTokenHasher {

private val random = SecureRandom()

fun generate(): String {
val bytes = ByteArray(TOKEN_BYTES)
random.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
fun generate(): String = OpaqueTokenGenerator.generate(TOKEN_BYTES)

fun hash(plain: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(plain.toByteArray(Charsets.UTF_8))
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/moa/common/exception/ErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum class ErrorCode(
UNAUTHORIZED("UNAUTHORIZED", "인증되지 않은 사용자입니다"),
INVALID_ID_TOKEN("INVALID_ID_TOKEN", "유효하지 않은 ID 토큰입니다"),
EXPIRED_TOKEN("EXPIRED_TOKEN", "토큰이 만료되었습니다"),
INVALID_EXCHANGE_CODE("INVALID_EXCHANGE_CODE", "유효하지 않은 교환 코드입니다"),

// 403
ONBOARDING_INCOMPLETE("ONBOARDING_INCOMPLETE", "온보딩이 완료되지 않았습니다"),
Expand Down
38 changes: 38 additions & 0 deletions src/main/kotlin/com/moa/common/oidc/AppleClientSecretGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.moa.common.oidc

import io.jsonwebtoken.Jwts
import org.springframework.stereotype.Component
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Duration
import java.time.Instant
import java.util.*

@Component
class AppleClientSecretGenerator(config: OidcProviderConfig) {
private val apple = config.apple

private val privateKey: PrivateKey = parse(apple.privateKey)

private fun parse(raw: String): PrivateKey {
val body = raw
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("\\s".toRegex(), "")
val der = Base64.getDecoder().decode(body)
return KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(der))
}

fun generate(now: Instant = Instant.now()): String {
return Jwts.builder()
.header().keyId(apple.keyId).and()
.issuer(apple.teamId)
.subject(apple.clientId)
.audience().add("https://appleid.apple.com").and()
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(Duration.ofMinutes(5))))
.signWith(privateKey, Jwts.SIG.ES256)
.compact()
}
}
63 changes: 63 additions & 0 deletions src/main/kotlin/com/moa/common/oidc/AppleTokenClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.moa.common.oidc

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.moa.common.exception.ErrorCode
import com.moa.common.exception.UnauthorizedException
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestClient
import java.net.http.HttpClient
import java.time.Duration

@Component
class AppleTokenClient(
private val clientSecretGenerator: AppleClientSecretGenerator,
config: OidcProviderConfig,
// 사용자 요청 스레드에서 호출되므로 타임아웃 필수 — Apple 장애가 톰캣 스레드풀 고갈로 번지는 것을 막는다.
private val restClient: RestClient = RestClient.builder()
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(config.apple.tokenConnectTimeoutSeconds)).build(),
).apply { setReadTimeout(Duration.ofSeconds(config.apple.tokenReadTimeoutSeconds)) },
)
.build(),
) {
private val apple = config.apple
private val log = LoggerFactory.getLogger(javaClass)

fun exchangeCodeForIdToken(code: String): String {
val form = LinkedMultiValueMap<String, String>().apply {
add("client_id", apple.clientId)
add("client_secret", clientSecretGenerator.generate())
add("code", code)
add("grant_type", "authorization_code")
add("redirect_uri", apple.redirectUri)
}
val body = try {
restClient.post()
.uri(apple.tokenUri)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(AppleTokenResponse::class.java)
} catch (ex: Exception) {
// 원본 예외(네트워크/5xx/4xx/파싱)를 로그로 보존해 진단 가능하게 — 응답은 통일된 INVALID_ID_TOKEN.
log.warn("Apple token exchange failed", ex)
throw UnauthorizedException(ErrorCode.INVALID_ID_TOKEN)
}
Comment on lines +40 to +51
return body?.idToken ?: run {
log.warn("Apple token exchange returned no id_token: error={}", body?.error)
throw UnauthorizedException(ErrorCode.INVALID_ID_TOKEN)
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
private data class AppleTokenResponse(
@JsonProperty("id_token") val idToken: String? = null,
val error: String? = null,
)
}
25 changes: 24 additions & 1 deletion src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,28 @@ data class OidcProviderConfig(
data class AppleProviderProperties(
val jwksUri: String,
val cacheTtlSeconds: Long = 3600,
)
// 데스크톱 로그인 자격증명 — 전부 필수. 누락은 바인딩 실패
val clientId: String,
val teamId: String,
val keyId: String,
val privateKey: String,
val redirectUri: String,
val desktopRedirectUri: String = "http://127.0.0.1:17171/callback",
val tokenUri: String = "https://appleid.apple.com/auth/token",
val tokenConnectTimeoutSeconds: Long = 3,
val tokenReadTimeoutSeconds: Long = 5,
) {
init {
val required = mapOf(
"client-id" to clientId,
"team-id" to teamId,
"key-id" to keyId,
"private-key" to privateKey,
"redirect-uri" to redirectUri,
)
require(required.values.none { it.isBlank() }) {
"oidc.apple 필수 설정이 비어 있습니다: ${required.filterValues { it.isBlank() }.keys}"
}
}
}
}
27 changes: 27 additions & 0 deletions src/main/kotlin/com/moa/controller/AuthController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,26 @@ package com.moa.controller
import com.moa.common.auth.Auth
import com.moa.common.auth.AuthMemberInfo
import com.moa.common.response.ApiResponse
import com.moa.service.AppleDesktopAuthService
import com.moa.service.AuthService
import com.moa.service.dto.*
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
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.RequestMethod
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URI

@Tag(name = "Auth", description = "인증 API")
@RestController
class AuthController(
private val authService: AuthService,
private val appleDesktopAuthService: AppleDesktopAuthService,
) {

@PostMapping("/api/v1/auth/kakao")
Expand Down Expand Up @@ -43,4 +50,24 @@ class AuthController(
): ResponseEntity<ApiResponse<TokenRefreshResponse>> {
return ResponseEntity.ok(ApiResponse.success(authService.refresh(request)))
}

// GET = 기본(scope 미요청). POST = Apple 이 scope(name/email) 요청 시 강제하는 response_mode=form_post 대비.
@RequestMapping(
"/api/v1/auth/apple/desktop/callback",
method = [RequestMethod.GET, RequestMethod.POST],
)
fun appleCallback(
@RequestParam(required = false) code: String?,
@RequestParam(required = false) state: String?,
@RequestParam(required = false) error: String?,
): ResponseEntity<Void> {
val location = appleDesktopAuthService.callback(code, state, error)
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(location)).build()
}

@PostMapping("/api/v1/auth/apple/desktop/complete")
fun appleDesktopComplete(
@RequestBody @Valid request: DesktopCompleteRequest,
): ResponseEntity<ApiResponse<SignInUpResponse>> =
ResponseEntity.ok(ApiResponse.success(appleDesktopAuthService.complete(request.exchangeCode)))
Comment on lines +54 to +72
}
57 changes: 57 additions & 0 deletions src/main/kotlin/com/moa/service/AppleDesktopAuthService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.moa.service

import com.moa.common.auth.ExchangeCodeStore
import com.moa.common.exception.ErrorCode
import com.moa.common.exception.UnauthorizedException
import com.moa.common.oidc.AppleTokenClient
import com.moa.common.oidc.OidcIdTokenValidator
import com.moa.common.oidc.OidcProviderConfig
import com.moa.entity.ProviderType
import com.moa.service.dto.SignInUpResponse
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.web.util.UriComponentsBuilder

@Service
class AppleDesktopAuthService(
private val appleTokenClient: AppleTokenClient,
private val oidcIdTokenValidator: OidcIdTokenValidator,
private val authService: AuthService,
private val exchangeCodeStore: ExchangeCodeStore,
config: OidcProviderConfig,
) {
private val desktopRedirectUri = config.apple.desktopRedirectUri
private val log = LoggerFactory.getLogger(javaClass)

/** Apple callback 처리 → localhost 리다이렉트 URL(성공/실패 모두 앱이 처리). */
fun callback(code: String?, state: String?, appleError: String? = null): String {
if (code.isNullOrBlank()) {
log.warn("Apple desktop callback without code: error={}", appleError)
return redirect("error", appleError?.takeIf { it.isNotBlank() } ?: "login_failed", state)
}
return try {
val idToken = appleTokenClient.exchangeCodeForIdToken(code)
val userInfo = oidcIdTokenValidator.validate(ProviderType.APPLE, idToken)
// 앱이 complete 를 호출할 때 회원 생성하도록 분리
redirect("exchangeCode", exchangeCodeStore.issue(userInfo.subject), state)
} catch (ex: Exception) {
log.warn("Apple desktop callback failed", ex)
redirect("error", "login_failed", state)
}
}

fun complete(exchangeCode: String): SignInUpResponse {
val subject = exchangeCodeStore.consume(exchangeCode)
?: throw UnauthorizedException(ErrorCode.INVALID_EXCHANGE_CODE)
// signInUp 이 회원 확정 + 토큰 발급을 한 트랜잭션으로 묶는다 — 토큰 발급 실패 시 회원 생성도 롤백.
return authService.signInUp(ProviderType.APPLE, subject)
}

private fun redirect(key: String, value: String, state: String?): String =
UriComponentsBuilder.fromUriString(desktopRedirectUri)
.queryParam(key, value)
.apply { if (!state.isNullOrBlank()) queryParam("state", state) }
.build()
.encode()
.toUriString()
}
Loading
Loading