diff --git a/.gitignore b/.gitignore index a5c006b..adf2b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +moa-secret/ +*.p8 diff --git a/build.gradle.kts b/build.gradle.kts index b2bfcf9..7772a2b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/src/main/kotlin/com/moa/common/auth/ExchangeCodeStore.kt b/src/main/kotlin/com/moa/common/auth/ExchangeCodeStore.kt new file mode 100644 index 0000000..3737dac --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/ExchangeCodeStore.kt @@ -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) 구현이다 + * — 다중 인스턴스/롤링 배포에서는 외부 저장소로 교체해야 한다. + */ +@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() + + /** 검증된 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) +} diff --git a/src/main/kotlin/com/moa/common/auth/OpaqueTokenGenerator.kt b/src/main/kotlin/com/moa/common/auth/OpaqueTokenGenerator.kt new file mode 100644 index 0000000..b95635b --- /dev/null +++ b/src/main/kotlin/com/moa/common/auth/OpaqueTokenGenerator.kt @@ -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) + } +} diff --git a/src/main/kotlin/com/moa/common/auth/RefreshTokenHasher.kt b/src/main/kotlin/com/moa/common/auth/RefreshTokenHasher.kt index d67a683..3639803 100644 --- a/src/main/kotlin/com/moa/common/auth/RefreshTokenHasher.kt +++ b/src/main/kotlin/com/moa/common/auth/RefreshTokenHasher.kt @@ -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)) diff --git a/src/main/kotlin/com/moa/common/exception/ErrorCode.kt b/src/main/kotlin/com/moa/common/exception/ErrorCode.kt index 04a53ce..a6a74cf 100644 --- a/src/main/kotlin/com/moa/common/exception/ErrorCode.kt +++ b/src/main/kotlin/com/moa/common/exception/ErrorCode.kt @@ -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", "온보딩이 완료되지 않았습니다"), diff --git a/src/main/kotlin/com/moa/common/oidc/AppleClientSecretGenerator.kt b/src/main/kotlin/com/moa/common/oidc/AppleClientSecretGenerator.kt new file mode 100644 index 0000000..214ad2c --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/AppleClientSecretGenerator.kt @@ -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() + } +} diff --git a/src/main/kotlin/com/moa/common/oidc/AppleTokenClient.kt b/src/main/kotlin/com/moa/common/oidc/AppleTokenClient.kt new file mode 100644 index 0000000..6d1b8da --- /dev/null +++ b/src/main/kotlin/com/moa/common/oidc/AppleTokenClient.kt @@ -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().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) + } + 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, + ) +} diff --git a/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt b/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt index 7641267..76f1e60 100644 --- a/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt +++ b/src/main/kotlin/com/moa/common/oidc/OidcProviderConfig.kt @@ -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}" + } + } + } } diff --git a/src/main/kotlin/com/moa/controller/AuthController.kt b/src/main/kotlin/com/moa/controller/AuthController.kt index 8a48c44..74bd872 100644 --- a/src/main/kotlin/com/moa/controller/AuthController.kt +++ b/src/main/kotlin/com/moa/controller/AuthController.kt @@ -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") @@ -43,4 +50,24 @@ class AuthController( ): ResponseEntity> { 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 { + 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> = + ResponseEntity.ok(ApiResponse.success(appleDesktopAuthService.complete(request.exchangeCode))) } diff --git a/src/main/kotlin/com/moa/service/AppleDesktopAuthService.kt b/src/main/kotlin/com/moa/service/AppleDesktopAuthService.kt new file mode 100644 index 0000000..9912a05 --- /dev/null +++ b/src/main/kotlin/com/moa/service/AppleDesktopAuthService.kt @@ -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() +} diff --git a/src/main/kotlin/com/moa/service/AuthService.kt b/src/main/kotlin/com/moa/service/AuthService.kt index 724c5a0..3aece0b 100644 --- a/src/main/kotlin/com/moa/service/AuthService.kt +++ b/src/main/kotlin/com/moa/service/AuthService.kt @@ -27,79 +27,34 @@ class AuthService( @Transactional fun kakaoSignInUp(request: KaKaoSignInUpRequest): SignInUpResponse { val userInfo = oidcIdTokenValidator.validate(ProviderType.KAKAO, request.idToken) - - val member = memberRepository.findByProviderAndProviderSubject( - provider = userInfo.provider, - providerSubject = userInfo.subject, - ) - - member?.let { - request.fcmDeviceToken?.let { token -> fcmTokenService.registerToken(member.id, token) } - return SignInUpResponse( - userId = member.id, - accessToken = jwtTokenProvider.createAccessToken(member.id), - refreshToken = refreshTokenService.issue(member.id), - ) - } - - val registeredMember = memberRepository.save( - Member( - provider = ProviderType.KAKAO, - providerSubject = userInfo.subject, - ) - ) - - request.fcmDeviceToken?.let { token -> fcmTokenService.registerToken(registeredMember.id, token) } - - val registerToken = jwtTokenProvider.createAccessToken( - registeredMember.id - ) - - return SignInUpResponse( - userId = registeredMember.id, - accessToken = registerToken, - refreshToken = refreshTokenService.issue(registeredMember.id), - ) + return signInUp(ProviderType.KAKAO, userInfo.subject, request.fcmDeviceToken) } @Transactional fun appleSignInUp(request: AppleSignInUpRequest): SignInUpResponse { val userInfo = oidcIdTokenValidator.validate(ProviderType.APPLE, request.idToken) + return signInUp(ProviderType.APPLE, userInfo.subject, request.fcmDeviceToken) + } - val member = memberRepository.findByProviderAndProviderSubject( - provider = userInfo.provider, - providerSubject = userInfo.subject, - ) - - member?.let { - request.fcmDeviceToken?.let { token -> fcmTokenService.registerToken(member.id, token) } - return SignInUpResponse( - userId = member.id, - accessToken = jwtTokenProvider.createAccessToken(member.id), - refreshToken = refreshTokenService.issue(member.id), - ) - } - - val registeredMember = memberRepository.save( - Member( - provider = ProviderType.APPLE, - providerSubject = userInfo.subject, - ) - ) - - request.fcmDeviceToken?.let { token -> fcmTokenService.registerToken(registeredMember.id, token) } - - val registerToken = jwtTokenProvider.createAccessToken( - registeredMember.id - ) + /** subject 는 검증이 끝난 값이어야 한다. 회원 확정과 토큰 발급을 한 트랜잭션으로 묶는다. */ + @Transactional + fun signInUp(provider: ProviderType, subject: String, fcmDeviceToken: String? = null): SignInUpResponse { + val memberId = resolveMemberId(provider, subject) + fcmDeviceToken?.let { fcmTokenService.registerToken(memberId, it) } + return issueTokens(memberId) + } - return SignInUpResponse( - userId = registeredMember.id, - accessToken = registerToken, - refreshToken = refreshTokenService.issue(registeredMember.id), - ) + private fun resolveMemberId(provider: ProviderType, subject: String): Long { + memberRepository.findByProviderAndProviderSubject(provider, subject)?.let { return it.id } + return memberRepository.save(Member(provider = provider, providerSubject = subject)).id } + private fun issueTokens(memberId: Long): SignInUpResponse = SignInUpResponse( + userId = memberId, + accessToken = jwtTokenProvider.createAccessToken(memberId), + refreshToken = refreshTokenService.issue(memberId), + ) + @Transactional(noRollbackFor = [UnauthorizedException::class]) fun refresh(request: TokenRefreshRequest): TokenRefreshResponse { val rotation = refreshTokenService.rotate(request.refreshToken) diff --git a/src/main/kotlin/com/moa/service/dto/DesktopCompleteRequest.kt b/src/main/kotlin/com/moa/service/dto/DesktopCompleteRequest.kt new file mode 100644 index 0000000..8f614fd --- /dev/null +++ b/src/main/kotlin/com/moa/service/dto/DesktopCompleteRequest.kt @@ -0,0 +1,8 @@ +package com.moa.service.dto + +import jakarta.validation.constraints.NotBlank + +data class DesktopCompleteRequest( + @field:NotBlank + val exchangeCode: String, +) diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index e04f79c..86e71e6 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -75,7 +75,10 @@ class NotificationDispatchService( pendingLogs.groupingBy { it.notificationType.name }.eachCount() .forEach { (type, n) -> attemptsCounter(type).increment(n.toDouble()) } - val (expiredLogs, activeLogs) = pendingLogs.partition { notificationTtlPolicy.isExpired(it) } + // TTL 판정도 인자로 받은 시각을 쓴다 — 한 디스패치 실행의 시간 기준을 단일화해야 + // KST 고정 clock 과 러너 시스템 존이 어긋나는 시간대(UTC 15~24시)에도 결정적이다. + val dispatchNow = date.atTime(currentTime) + val (expiredLogs, activeLogs) = pendingLogs.partition { notificationTtlPolicy.isExpired(it, dispatchNow) } expiredLogs.groupingBy { it.notificationType.name }.eachCount() .forEach { (type, n) -> expiredCounter(type).increment(n.toDouble()) } expiredLogs.forEach { diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e8a0842..0e9c744 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -31,3 +31,8 @@ oidc: apple: jwks-uri: ${secret.oauth.apple.jwks-uri} cache-ttl-seconds: ${secret.oauth.cache-ttl-seconds} + client-id: ${secret.oauth.apple.client-id} + team-id: ${secret.oauth.apple.team-id} + key-id: ${secret.oauth.apple.key-id} + private-key: ${secret.oauth.apple.private-key} + redirect-uri: ${secret.oauth.apple.redirect-uri} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f1f9bb2..8e233d1 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -41,6 +41,11 @@ oidc: apple: jwks-uri: ${secret.oauth.apple.jwks-uri} cache-ttl-seconds: ${secret.oauth.cache-ttl-seconds} + client-id: ${secret.oauth.apple.client-id} + team-id: ${secret.oauth.apple.team-id} + key-id: ${secret.oauth.apple.key-id} + private-key: ${secret.oauth.apple.private-key} + redirect-uri: ${secret.oauth.apple.redirect-uri} logging: discord: diff --git a/src/main/resources/moa-secret b/src/main/resources/moa-secret index c3ce3b5..73f2886 160000 --- a/src/main/resources/moa-secret +++ b/src/main/resources/moa-secret @@ -1 +1 @@ -Subproject commit c3ce3b5e28ab6e0966c4e2eacfa084e7e1bfc98a +Subproject commit 73f2886d102c1077909c992b52715c02c5e15954 diff --git a/src/test/kotlin/com/moa/common/auth/ExchangeCodeStoreTest.kt b/src/test/kotlin/com/moa/common/auth/ExchangeCodeStoreTest.kt new file mode 100644 index 0000000..6ae06a4 --- /dev/null +++ b/src/test/kotlin/com/moa/common/auth/ExchangeCodeStoreTest.kt @@ -0,0 +1,63 @@ +package com.moa.common.auth + +import com.github.benmanes.caffeine.cache.Ticker +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ExchangeCodeStoreTest { + private val sut = ExchangeCodeStore(ttlSeconds = 120, maxSize = 10_000) + + @Test + fun `issue 후 consume 하면 subject 를 돌려준다`() { + val code = sut.issue(subject = "apple-sub-42") + assertThat(sut.consume(code)).isEqualTo("apple-sub-42") + } + + @Test + fun `consume 은 1회용 - 두 번째는 null`() { + val code = sut.issue("sub-7") + sut.consume(code) + assertThat(sut.consume(code)).isNull() + } + + @Test + fun `존재하지 않는 코드는 null`() { + assertThat(sut.consume("nope")).isNull() + } + + @Test + fun `issue 는 매번 다른 코드를 만든다`() { + assertThat(sut.issue("sub-1")).isNotEqualTo(sut.issue("sub-1")) + } + + @Test + fun `ttl 이 지나면 만료되어 null`() { + val ticker = FakeTicker() + val store = ExchangeCodeStore(ttlSeconds = 120, maxSize = 10, ticker = ticker) + val code = store.issue("sub-1") + + ticker.advanceSeconds(121) + + assertThat(store.consume(code)).isNull() + } + + @Test + fun `ttl 이전에는 아직 유효하다`() { + val ticker = FakeTicker() + val store = ExchangeCodeStore(ttlSeconds = 120, maxSize = 10, ticker = ticker) + val code = store.issue("sub-1") + + ticker.advanceSeconds(119) + + assertThat(store.consume(code)).isEqualTo("sub-1") + } + + /** 시간을 수동으로 흘리는 가짜 시계 — 실시간 sleep 없이 만료를 결정적으로 검증한다. */ + private class FakeTicker : Ticker { + private var nanos = 0L + fun advanceSeconds(seconds: Long) { + nanos += seconds * 1_000_000_000L + } + override fun read(): Long = nanos + } +} diff --git a/src/test/kotlin/com/moa/common/oidc/AppleClientSecretGeneratorTest.kt b/src/test/kotlin/com/moa/common/oidc/AppleClientSecretGeneratorTest.kt new file mode 100644 index 0000000..512c49e --- /dev/null +++ b/src/test/kotlin/com/moa/common/oidc/AppleClientSecretGeneratorTest.kt @@ -0,0 +1,40 @@ +package com.moa.common.oidc + +import io.jsonwebtoken.Jwts +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.security.KeyPairGenerator +import java.security.spec.ECGenParameterSpec +import java.util.Base64 + +class AppleClientSecretGeneratorTest { + @Test + fun `생성한 client_secret 은 공개키로 검증되고 클레임이 맞다`() { + val gen = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) } + val kp = gen.generateKeyPair() + val privB64 = Base64.getEncoder().encodeToString(kp.private.encoded) + + val sut = AppleClientSecretGenerator( + OidcProviderConfig( + kakao = OidcProviderConfig.KakaoProviderProperties(jwksUri = "unused"), + apple = OidcProviderConfig.AppleProviderProperties( + jwksUri = "unused", + clientId = "kr.moa-official.web", + teamId = "TEAM123456", + keyId = "KEY1234567", + privateKey = privB64, + redirectUri = "https://api.moa.example/apple/desktop/callback", + ), + ), + ) + + val jwt = sut.generate() + + val parsed = Jwts.parser().verifyWith(kp.public).build().parseSignedClaims(jwt) + assertThat(parsed.header["kid"]).isEqualTo("KEY1234567") + assertThat(parsed.header.algorithm).isEqualTo("ES256") + assertThat(parsed.payload.issuer).isEqualTo("TEAM123456") + assertThat(parsed.payload.subject).isEqualTo("kr.moa-official.web") + assertThat(parsed.payload.audience).contains("https://appleid.apple.com") + } +} diff --git a/src/test/kotlin/com/moa/controller/AuthControllerTest.kt b/src/test/kotlin/com/moa/controller/AuthControllerTest.kt index d194e26..69b4631 100644 --- a/src/test/kotlin/com/moa/controller/AuthControllerTest.kt +++ b/src/test/kotlin/com/moa/controller/AuthControllerTest.kt @@ -1,5 +1,6 @@ package com.moa.controller +import com.moa.service.AppleDesktopAuthService import com.moa.service.AuthService import com.moa.service.dto.TokenRefreshRequest import com.moa.service.dto.TokenRefreshResponse @@ -11,7 +12,8 @@ import org.junit.jupiter.api.Test class AuthControllerTest { private val authService = mockk() - private val sut = AuthController(authService) + private val appleDesktopAuthService = mockk() + private val sut = AuthController(authService, appleDesktopAuthService) @Test fun `refresh 는 서비스 결과를 ApiResponse 로 감싸 반환한다`() { diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt index 5cfe308..acb2a53 100644 --- a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt @@ -70,11 +70,7 @@ class NotificationDispatchServiceMetricsTest { } } private val registry = SimpleMeterRegistry() - private val clock = mockk().apply { - // 기본: 오늘 11:15. 11시 예약 알림이 어떤 type 이어도 not expired 인 상태. - // CLOCK_IN TTL=30분 → deadline=11:30 > 11:15 → not expired. - every { now() } returns java.time.LocalDateTime.of(TODAY, java.time.LocalTime.of(11, 15)) - } + private val clock = mockk() private val ttlPolicy = NotificationTtlPolicy(clock) private val sut = NotificationDispatchService( @@ -97,7 +93,7 @@ class NotificationDispatchServiceMetricsTest { fun `토큰 없는 회원의 알림은 failed_no_token 카운터를 증가시킨다`() { notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_IN) - sut.processNotifications(TODAY, NOON) + sut.processNotifications(TODAY, DISPATCH_TIME) val attempts = registry.find("moa.notification.dispatch.attempts") .tag("notification_type", "CLOCK_IN").counter() @@ -114,7 +110,7 @@ class NotificationDispatchServiceMetricsTest { fcmTokens += FcmToken(memberId = 1L, token = "tok-1") every { messageBuilder.buildMessage(any(), any()) } throws IllegalStateException("boom") - sut.processNotifications(TODAY, NOON) + sut.processNotifications(TODAY, DISPATCH_TIME) val failed = registry.find("moa.notification.dispatch.failed") .tag("notification_type", "CLOCK_OUT").tag("reason", "build").counter() @@ -133,7 +129,7 @@ class NotificationDispatchServiceMetricsTest { ) } - sut.processNotifications(TODAY, NOON) + sut.processNotifications(TODAY, DISPATCH_TIME) val fallback = registry.find("moa.notification.message.fallback") .tag("notification_type", "CLOCK_OUT").tag("reason", "earnings_error").counter() @@ -153,7 +149,7 @@ class NotificationDispatchServiceMetricsTest { List(reqs.size) { false } } - sut.processNotifications(TODAY, NOON) + sut.processNotifications(TODAY, DISPATCH_TIME) val failed = registry.find("moa.notification.dispatch.failed") .tag("notification_type", "PAYDAY").tag("reason", "fcm").counter() @@ -173,7 +169,7 @@ class NotificationDispatchServiceMetricsTest { notificationLogs += expiredLog fcmTokens += FcmToken(memberId = 1L, token = "tok-1") - sut.processNotifications(TODAY, NOON) + sut.processNotifications(TODAY, DISPATCH_TIME) val expired = registry.find("moa.notification.dispatch.expired") .tag("notification_type", "CLOCK_IN").counter() @@ -195,5 +191,9 @@ class NotificationDispatchServiceMetricsTest { companion object { private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) private val NOON: LocalTime = LocalTime.NOON + + // 11시 예약 알림(NOON-1h)이 어떤 type 이어도 not expired 인 디스패치 시각. + // CLOCK_IN TTL=30분 → deadline=11:30 > 11:15. + private val DISPATCH_TIME: LocalTime = LocalTime.of(11, 15) } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 9f39211..e552d40 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -14,3 +14,10 @@ oidc: apple: jwks-uri: ${secret.oauth.apple.jwks-uri} cache-ttl-seconds: ${secret.oauth.cache-ttl-seconds} + # ── 아래 5개는 테스트 전용 더미 값 (컨텍스트 부팅용, 실제 Apple 서비스와 무관) ── + client-id: test-client-id.do-not-use + team-id: TESTTEAM00 + key-id: TESTKEY000 + # 테스트 전용 일회용 EC P-256 키 — 실서비스 값이 아닌 테스트 값 + private-key: MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgl4zl/NtEbMusENwEktm8yxGgW7kBI4odCayvCA20sQWhRANCAATTiSGLlOJOUbj+nfVceVSPt7zNMd10H9pQqyctHqxXqnS9FQ3HYDKIBqg3xXT1l6HbtgMAcMScHHOFincw6hx6 + redirect-uri: https://test.invalid/apple/desktop/callback