1111import lombok .RequiredArgsConstructor ;
1212import lombok .extern .slf4j .Slf4j ;
1313import org .springframework .stereotype .Component ;
14- import org .springframework .web .reactive .function .client .WebClient ;
15- import reactor .core .publisher .Mono ;
14+ import org .springframework .web .client .RestClient ;
1615
1716import java .math .BigInteger ;
1817import java .security .KeyFactory ;
1918import java .security .PublicKey ;
2019import java .security .spec .RSAPublicKeySpec ;
2120import java .util .Base64 ;
22- import java .util .Map ;
21+ import java .util .List ;
2322
2423@ Component
2524@ RequiredArgsConstructor
2625@ Slf4j
2726public class AppleAuthClient {
28- private final WebClient webClient ;
27+
28+ private final RestClient restClient ;
2929 private final ObjectMapper objectMapper ;
3030 private final AppleOAuthConfig appleOAuthConfig ;
3131
3232 private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys" ;
3333 private static final String APPLE_ISSUER = "https://appleid.apple.com" ;
3434
35- public Mono <AppleProfileDTO > verifyIdentityToken (String identityToken ) {
36- return Mono .fromCallable (() -> {
37- try {
38- // JWT 헤더에서 kid 추출
39- String [] chunks = identityToken .split ("\\ ." );
40- if (chunks .length != 3 ) {
41- throw new GlobalException (ErrorStatus .INVALID_TOKEN );
42- }
35+ /**
36+ * Apple Identity Token 검증 및 프로필 정보 추출
37+ */
38+ public AppleProfileDTO verifyIdentityToken (String identityToken ) {
39+ log .debug ("Verifying Apple identity token" );
4340
44- String header = new String ( Base64 . getUrlDecoder (). decode ( chunks [ 0 ]));
45- JsonNode headerJson = objectMapper . readTree ( header );
46- String kid = headerJson . get ( "kid" ). asText ( );
41+ try {
42+ // JWT 구조 검증
43+ validateJwtStructure ( identityToken );
4744
48- // Apple 공개 키 가져오기
49- PublicKey publicKey = getApplePublicKey ( kid );
45+ // JWT 헤더에서 kid 추출
46+ String kid = extractKidFromHeader ( identityToken );
5047
51- // JWT 검증 및 파싱
52- Claims claims = Jwts .parserBuilder ()
53- .setSigningKey (publicKey )
54- .build ()
55- .parseClaimsJws (identityToken )
56- .getBody ();
48+ // Apple 공개 키로 JWT 검증 및 Claims 추출
49+ Claims claims = verifyJwtWithApplePublicKey (identityToken , kid );
5750
58- // 발급자 검증
59- if (!APPLE_ISSUER .equals (claims .getIssuer ())) {
60- throw new GlobalException (ErrorStatus .INVALID_TOKEN );
61- }
51+ // Claims 검증 (issuer, audience, expiration)
52+ validateClaims (claims );
6253
63- // Audience 검증 (Client ID 확인)
64- Object audienceObj = claims .get ("aud" );
65- String audience = null ;
66-
67- if (audienceObj instanceof String ) {
68- audience = (String ) audienceObj ;
69- } else if (audienceObj instanceof java .util .List ) {
70- @ SuppressWarnings ("unchecked" )
71- java .util .List <String > audienceList = (java .util .List <String >) audienceObj ;
72- if (!audienceList .isEmpty ()) {
73- audience = audienceList .get (0 );
74- }
75- }
54+ // AppleProfileDTO 생성 및 반환
55+ AppleProfileDTO profile = createProfileFromClaims (claims );
56+ log .debug ("Verified Apple profile: {}" , profile );
7657
77- if (audience == null || !appleOAuthConfig .getClientId ().equals (audience )) {
78- log .error ("Invalid audience. Expected: {}, Got: {}" , appleOAuthConfig .getClientId (), audience );
79- throw new GlobalException (ErrorStatus .INVALID_TOKEN );
80- }
58+ return profile ;
8159
82- // 만료 시간 검증
83- if (claims .getExpiration ().getTime () < System .currentTimeMillis ()) {
84- throw new GlobalException (ErrorStatus .EXPIRED_TOKEN );
85- }
60+ } catch (GlobalException e ) {
61+ throw e ; // 이미 처리된 예외는 재던지기
62+ } catch (Exception e ) {
63+ log .error ("Apple identity token 검증 중 예상치 못한 오류 발생: {}" , e .getMessage (), e );
64+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
65+ }
66+ }
67+
68+ /**
69+ * JWT 구조 검증 (3개 부분으로 구성되어야 함)
70+ */
71+ private void validateJwtStructure (String identityToken ) {
72+ String [] chunks = identityToken .split ("\\ ." );
73+ if (chunks .length != 3 ) {
74+ log .error ("JWT 구조가 올바르지 않음: {} 개 부분" , chunks .length );
75+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
76+ }
77+ }
78+
79+ /**
80+ * JWT 헤더에서 Key ID (kid) 추출
81+ */
82+ private String extractKidFromHeader (String identityToken ) {
83+ try {
84+ String [] chunks = identityToken .split ("\\ ." );
85+ String header = new String (Base64 .getUrlDecoder ().decode (chunks [0 ]));
86+ JsonNode headerJson = objectMapper .readTree (header );
8687
87- // AppleProfileDTO 생성
88- return AppleProfileDTO .builder ()
89- .sub (claims .getSubject ())
90- .email (claims .get ("email" , String .class ))
91- .emailVerified (claims .get ("email_verified" , Boolean .class ))
92- .isPrivateEmail (claims .get ("is_private_email" , Boolean .class ))
93- .realUserStatus (claims .get ("real_user_status" , Integer .class ))
94- .aud (claims .getAudience ())
95- .iss (claims .getIssuer ())
96- .iat (claims .getIssuedAt ().getTime ())
97- .exp (claims .getExpiration ().getTime ())
98- .authTime (claims .get ("auth_time" , Long .class ))
99- .nonceSupported (claims .get ("nonce_supported" , Boolean .class ))
100- .build ();
101-
102- } catch (Exception e ) {
103- log .error ("Apple identity token 검증 실패: {}" , e .getMessage (), e );
88+ JsonNode kidNode = headerJson .get ("kid" );
89+ if (kidNode == null ) {
10490 throw new GlobalException (ErrorStatus .INVALID_TOKEN );
10591 }
106- });
92+
93+ return kidNode .asText ();
94+
95+ } catch (GlobalException e ) {
96+ throw e ;
97+ } catch (Exception e ) {
98+ log .error ("JWT 헤더에서 kid 추출 실패: {}" , e .getMessage (), e );
99+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
100+ }
107101 }
108102
103+ /**
104+ * Apple 공개 키로 JWT 검증 및 Claims 추출
105+ */
106+ private Claims verifyJwtWithApplePublicKey (String identityToken , String kid ) {
107+ try {
108+ PublicKey publicKey = getApplePublicKey (kid );
109+
110+ return Jwts .parserBuilder ()
111+ .setSigningKey (publicKey )
112+ .build ()
113+ .parseClaimsJws (identityToken )
114+ .getBody ();
115+
116+ } catch (Exception e ) {
117+ log .error ("JWT 검증 실패: {}" , e .getMessage (), e );
118+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
119+ }
120+ }
121+
122+ /**
123+ * Claims 검증 (issuer, audience, expiration)
124+ */
125+ private void validateClaims (Claims claims ) {
126+ // 발급자 검증
127+ if (!APPLE_ISSUER .equals (claims .getIssuer ())) {
128+ log .error ("Invalid issuer. Expected: {}, Got: {}" , APPLE_ISSUER , claims .getIssuer ());
129+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
130+ }
131+
132+ // Audience 검증 (Client ID 확인)
133+ String audience = extractAudienceFromClaims (claims );
134+ if (!appleOAuthConfig .getClientId ().equals (audience )) {
135+ log .error ("Invalid audience. Expected: {}, Got: {}" , appleOAuthConfig .getClientId (), audience );
136+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
137+ }
138+
139+ // 만료 시간 검증
140+ if (claims .getExpiration ().getTime () < System .currentTimeMillis ()) {
141+ log .error ("Token expired at: {}" , claims .getExpiration ());
142+ throw new GlobalException (ErrorStatus .EXPIRED_TOKEN );
143+ }
144+ }
145+
146+ /**
147+ * Claims에서 audience 추출 (String 또는 List<String> 처리)
148+ */
149+ private String extractAudienceFromClaims (Claims claims ) {
150+ Object audienceObj = claims .get ("aud" );
151+
152+ if (audienceObj instanceof String ) {
153+ return (String ) audienceObj ;
154+ } else if (audienceObj instanceof List ) {
155+ @ SuppressWarnings ("unchecked" )
156+ List <String > audienceList = (List <String >) audienceObj ;
157+ if (!audienceList .isEmpty ()) {
158+ return audienceList .get (0 );
159+ }
160+ }
161+
162+ log .error ("Invalid audience format: {}" , audienceObj );
163+ throw new GlobalException (ErrorStatus .INVALID_TOKEN );
164+ }
165+
166+ /**
167+ * Claims에서 AppleProfileDTO 생성
168+ */
169+ private AppleProfileDTO createProfileFromClaims (Claims claims ) {
170+ return AppleProfileDTO .builder ()
171+ .sub (claims .getSubject ())
172+ .email (claims .get ("email" , String .class ))
173+ .emailVerified (claims .get ("email_verified" , Boolean .class ))
174+ .isPrivateEmail (claims .get ("is_private_email" , Boolean .class ))
175+ .realUserStatus (claims .get ("real_user_status" , Integer .class ))
176+ .aud (claims .getAudience ())
177+ .iss (claims .getIssuer ())
178+ .iat (claims .getIssuedAt ().getTime ())
179+ .exp (claims .getExpiration ().getTime ())
180+ .authTime (claims .get ("auth_time" , Long .class ))
181+ .nonceSupported (claims .get ("nonce_supported" , Boolean .class ))
182+ .build ();
183+ }
184+
185+ /**
186+ * Apple 공개 키 가져오기
187+ */
109188 private PublicKey getApplePublicKey (String kid ) {
189+ log .debug ("Requesting Apple public key for kid: {}" , kid );
190+
110191 try {
111- // Apple 공개 키 요청
112- String response = webClient .get ()
113- .uri (APPLE_KEYS_URL )
114- .retrieve ()
115- .bodyToMono (String .class )
116- .block ();
192+ String response = restClient .get ()
193+ .uri (APPLE_KEYS_URL )
194+ .retrieve ()
195+ .onStatus (status -> status .is4xxClientError (), (request , responseEntity ) -> {
196+ log .error ("Apple 공개 키 요청 클라이언트 오류: 상태 코드 - {}" , responseEntity .getStatusCode ());
197+ throw new GlobalException (ErrorStatus .OAUTH_ERROR );
198+ })
199+ .onStatus (status -> status .is5xxServerError (), (request , responseEntity ) -> {
200+ log .error ("Apple 공개 키 요청 서버 오류: 상태 코드 - {}" , responseEntity .getStatusCode ());
201+ throw new GlobalException (ErrorStatus .OAUTH_ERROR );
202+ })
203+ .body (String .class );
117204
118205 if (response == null ) {
119206 throw new GlobalException (ErrorStatus .OAUTH_ERROR );
120207 }
121208
209+ return findPublicKeyByKid (response , kid );
210+
211+ } catch (GlobalException e ) {
212+ throw e ;
213+ } catch (Exception e ) {
214+ log .error ("Apple 공개 키 가져오기 중 예상치 못한 오류 발생: {}" , e .getMessage (), e );
215+ throw new GlobalException (ErrorStatus .OAUTH_ERROR );
216+ }
217+ }
218+
219+ /**
220+ * 응답에서 kid로 해당 공개 키 찾기
221+ */
222+ private PublicKey findPublicKeyByKid (String response , String kid ) {
223+ try {
122224 JsonNode keysJson = objectMapper .readTree (response );
123225 JsonNode keys = keysJson .get ("keys" );
124226
125- // kid로 해당 키 찾기
126227 for (JsonNode key : keys ) {
127228 if (kid .equals (key .get ("kid" ).asText ())) {
128229 return createPublicKey (key );
129230 }
130231 }
131232
233+ log .error ("해당 kid에 대한 공개 키를 찾을 수 없음: {}" , kid );
132234 throw new GlobalException (ErrorStatus .INVALID_TOKEN );
133235
236+ } catch (GlobalException e ) {
237+ throw e ;
134238 } catch (Exception e ) {
135- log .error ("Apple 공개 키 가져오기 실패: {}" , e .getMessage (), e );
239+ log .error ("공개 키 파싱 실패: {}" , e .getMessage (), e );
136240 throw new GlobalException (ErrorStatus .OAUTH_ERROR );
137241 }
138242 }
139243
244+ /**
245+ * RSA 공개 키 생성
246+ */
140247 private PublicKey createPublicKey (JsonNode keyNode ) {
141248 try {
142249 String nStr = keyNode .get ("n" ).asText ();
@@ -154,8 +261,8 @@ private PublicKey createPublicKey(JsonNode keyNode) {
154261 return factory .generatePublic (spec );
155262
156263 } catch (Exception e ) {
157- log .error ("공개 키 생성 실패: {}" , e .getMessage (), e );
264+ log .error ("RSA 공개 키 생성 실패: {}" , e .getMessage (), e );
158265 throw new GlobalException (ErrorStatus .OAUTH_ERROR );
159266 }
160267 }
161- }
268+ }
0 commit comments