From 14e47baac97e4be7a9d6799fb33d233102bddbec Mon Sep 17 00:00:00 2001 From: Mindev27 Date: Tue, 10 Feb 2026 15:47:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(oauth):=20Apple=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domain/mathrank-auth-domain/build.gradle | 1 + .../auth/client/AppleConfiguration.java | 22 +++ .../auth/client/AppleMemberInfoResponse.java | 11 ++ .../domain/auth/client/AppleOAuthClient.java | 150 ++++++++++++++++++ .../auth/client/AppleTokenResponse.java | 9 ++ .../auth/client/OAuthConfiguration.java | 7 + .../domain/auth/entity/OAuthProvider.java | 3 +- 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleConfiguration.java create mode 100644 domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleMemberInfoResponse.java create mode 100644 domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleOAuthClient.java create mode 100644 domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleTokenResponse.java diff --git a/domain/mathrank-auth-domain/build.gradle b/domain/mathrank-auth-domain/build.gradle index 9bf95732..343c1480 100644 --- a/domain/mathrank-auth-domain/build.gradle +++ b/domain/mathrank-auth-domain/build.gradle @@ -4,6 +4,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.nimbusds:nimbus-jose-jwt:9.47' implementation project(':common:mathrank-role') implementation project(':common:mathrank-outbox') diff --git a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleConfiguration.java b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleConfiguration.java new file mode 100644 index 00000000..0e9de465 --- /dev/null +++ b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleConfiguration.java @@ -0,0 +1,22 @@ +package kr.co.mathrank.domain.auth.client; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Configuration +@Getter +@Setter +@ConfigurationProperties("oauth.apple") +@ConditionalOnProperty(prefix = "oauth.apple", name = "clientId") +@ToString(exclude = "privateKey") +class AppleConfiguration { + private String clientId; + private String teamId; + private String keyId; + private String privateKey; +} diff --git a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleMemberInfoResponse.java b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleMemberInfoResponse.java new file mode 100644 index 00000000..e4c556c7 --- /dev/null +++ b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleMemberInfoResponse.java @@ -0,0 +1,11 @@ +package kr.co.mathrank.domain.auth.client; + +record AppleMemberInfoResponse( + String sub, + String email +) implements MemberInfoResponse { + @Override + public MemberInfo toInfo() { + return new MemberInfo(sub, null, email, null, null); + } +} diff --git a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleOAuthClient.java b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleOAuthClient.java new file mode 100644 index 00000000..cb387a07 --- /dev/null +++ b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleOAuthClient.java @@ -0,0 +1,150 @@ +package kr.co.mathrank.domain.auth.client; + +import java.security.KeyFactory; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.text.ParseException; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; + +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import kr.co.mathrank.domain.auth.dto.OAuthLoginCommand; +import kr.co.mathrank.domain.auth.entity.OAuthProvider; +import kr.co.mathrank.domain.auth.exception.InvalidOAuthLoginException; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +class AppleOAuthClient implements OAuthClientHandler { + private final AppleConfiguration appleConfiguration; + + // 토큰 발급 URL + // https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens + private static final String TOKEN_URL = "https://appleid.apple.com/auth/token"; + // 토큰 폐기 URL + private static final String TOKEN_REVOKE_URL = "https://appleid.apple.com/auth/revoke"; + + private final RestClient tokenClient = RestClient.builder() + .baseUrl(TOKEN_URL) + .build(); + + private final RestClient revokeClient = RestClient.builder() + .baseUrl(TOKEN_REVOKE_URL) + .build(); + + @Override + public MemberInfoResponse getMemberInfo(OAuthLoginCommand command) { + final AppleTokenResponse token = getAccessToken(command); + final MemberInfoResponse infoResponse = parseIdToken(token.id_token()); + + return new MemberInfoRefreshTokenAdapter(infoResponse.toInfo(), token.refresh_token(), token.token_type()); + } + + private AppleTokenResponse getAccessToken(final OAuthLoginCommand command) { + final String clientSecret = generateClientSecret(); + + return tokenClient.post() + .uri(uriBuilder -> uriBuilder + .queryParam("client_id", appleConfiguration.getClientId()) + .queryParam("client_secret", clientSecret) + .queryParam("code", command.code()) + .queryParam("grant_type", "authorization_code") + .build()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(AppleTokenResponse.class); + } + + // Apple은 별도의 사용자 정보 조회 API가 없으며, id_token(JWT)에서 사용자 정보를 추출한다. + private AppleMemberInfoResponse parseIdToken(final String idToken) { + try { + final SignedJWT signedJWT = SignedJWT.parse(idToken); + final JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + return new AppleMemberInfoResponse( + claims.getSubject(), + claims.getStringClaim("email") + ); + } catch (ParseException e) { + throw new InvalidOAuthLoginException("애플 서버로부터 사용할 수 없는 메시지를 받았습니다."); + } + } + + // Apple OAuth의 client_secret은 ES256으로 서명된 JWT이다. + // https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens + private String generateClientSecret() { + try { + final ECPrivateKey ecPrivateKey = loadPrivateKey(appleConfiguration.getPrivateKey()); + final Instant now = Instant.now(); + + final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(appleConfiguration.getKeyId()) + .build(); + + final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(appleConfiguration.getTeamId()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plusSeconds(300))) + .audience("https://appleid.apple.com") + .subject(appleConfiguration.getClientId()) + .build(); + + final SignedJWT signedJWT = new SignedJWT(header, claimsSet); + signedJWT.sign(new ECDSASigner(ecPrivateKey)); + + return signedJWT.serialize(); + } catch (JOSEException e) { + throw new InvalidOAuthLoginException("애플 client_secret 생성에 실패했습니다."); + } + } + + private ECPrivateKey loadPrivateKey(final String privateKey) { + try { + final String cleanedKey = privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + final byte[] keyBytes = Base64.getDecoder().decode(cleanedKey); + final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + final KeyFactory keyFactory = KeyFactory.getInstance("EC"); + + return (ECPrivateKey)keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new InvalidOAuthLoginException("애플 개인 키 로딩에 실패했습니다."); + } + } + + @Override + public boolean supports(OAuthProvider provider) { + return OAuthProvider.APPLE.equals(provider); + } + + // 애플은 refreshToken으로 직접 토큰 폐기 가능 + // https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens + @Override + public boolean revoke(String refreshToken) { + final String clientSecret = generateClientSecret(); + + return revokeClient.post() + .uri(uriBuilder -> uriBuilder + .queryParam("client_id", appleConfiguration.getClientId()) + .queryParam("client_secret", clientSecret) + .queryParam("token", refreshToken) + .queryParam("token_type_hint", "refresh_token") + .build()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .toBodilessEntity() + .getStatusCode().is2xxSuccessful(); + } +} diff --git a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleTokenResponse.java b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleTokenResponse.java new file mode 100644 index 00000000..15f06e95 --- /dev/null +++ b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/AppleTokenResponse.java @@ -0,0 +1,9 @@ +package kr.co.mathrank.domain.auth.client; + +record AppleTokenResponse( + String token_type, + String access_token, + String refresh_token, + String id_token +) { +} diff --git a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/OAuthConfiguration.java b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/OAuthConfiguration.java index c77b4b05..14d17e0d 100644 --- a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/OAuthConfiguration.java +++ b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/client/OAuthConfiguration.java @@ -30,4 +30,11 @@ NaverOAuthClient naverOAuthClient(final NaverConfiguration configuration) { log.info("[OAuthClientConfiguration] naver oauth client registered - configuration: {}", configuration); return new NaverOAuthClient(configuration); } + + @Bean + @ConditionalOnBean(AppleConfiguration.class) + AppleOAuthClient appleOAuthClient(final AppleConfiguration configuration) { + log.info("[OAuthClientConfiguration] apple oauth client registered - configuration: {}", configuration); + return new AppleOAuthClient(configuration); + } } diff --git a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/entity/OAuthProvider.java b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/entity/OAuthProvider.java index c8d9f364..23ab1712 100644 --- a/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/entity/OAuthProvider.java +++ b/domain/mathrank-auth-domain/src/main/java/kr/co/mathrank/domain/auth/entity/OAuthProvider.java @@ -3,5 +3,6 @@ public enum OAuthProvider { KAKAO, GOOGLE, - NAVER + NAVER, + APPLE } From bc7402b7467b01c3e2f993bb6b9e0d33fd283e6b Mon Sep 17 00:00:00 2001 From: Mindev27 Date: Tue, 10 Feb 2026 16:11:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(oauth):=20nimbus-jose-jwt=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=209.47=20->=2010.7=20=EC=97=85=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20(CVE-2025-53864)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domain/mathrank-auth-domain/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/mathrank-auth-domain/build.gradle b/domain/mathrank-auth-domain/build.gradle index 343c1480..e8ca0df0 100644 --- a/domain/mathrank-auth-domain/build.gradle +++ b/domain/mathrank-auth-domain/build.gradle @@ -4,7 +4,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'com.nimbusds:nimbus-jose-jwt:9.47' + implementation 'com.nimbusds:nimbus-jose-jwt:10.7' implementation project(':common:mathrank-role') implementation project(':common:mathrank-outbox')