Skip to content

Commit ca7ee99

Browse files
committed
feat(auth): refactor Apple and Kakao OAuth clients to use RestClient and improve error handling
1 parent 33fd6ac commit ca7ee99

File tree

4 files changed

+294
-135
lines changed

4 files changed

+294
-135
lines changed

src/main/java/com/gdg/poppet/auth/application/service/AuthServiceImpl.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class AuthServiceImpl implements AuthService {
3232
public OAuthResult kakaoOAuthLogin(String accessCode) {
3333
try {
3434
// 인가코드로 토큰 발급 후 바로 프로필 조회
35-
KakaoProfileDTO kakaoProfile = kakaoAuthClient.requestTokenAndProfile(accessCode).block();
35+
KakaoProfileDTO kakaoProfile = kakaoAuthClient.requestTokenAndProfile(accessCode);
3636
if (kakaoProfile == null) {
3737
throw new GlobalException(ErrorStatus.PROFILE_ERROR);
3838
}
@@ -58,7 +58,7 @@ public OAuthResult kakaoOAuthLogin(String accessCode) {
5858
public OAuthResult kakaoOAuthLoginWithTokens(String accessToken) {
5959
try {
6060
// 액세스 토큰으로 프로필 조회
61-
KakaoProfileDTO profile = kakaoAuthClient.verifyAccessToken(accessToken).block();
61+
KakaoProfileDTO profile = kakaoAuthClient.verifyAccessToken(accessToken);
6262
if (profile == null) {
6363
throw new GlobalException(ErrorStatus.PROFILE_ERROR);
6464
}
@@ -84,7 +84,7 @@ public OAuthResult kakaoOAuthLoginWithTokens(String accessToken) {
8484
public OAuthResult appleOAuthLoginWithTokens(String identityToken) {
8585
try {
8686
// Identity Token 검증 및 프로필 조회
87-
AppleProfileDTO profile = appleAuthClient.verifyIdentityToken(identityToken).block();
87+
AppleProfileDTO profile = appleAuthClient.verifyIdentityToken(identityToken);
8888
if (profile == null) {
8989
throw new GlobalException(ErrorStatus.PROFILE_ERROR);
9090
}
@@ -100,7 +100,7 @@ public OAuthResult appleOAuthLoginWithTokens(String identityToken) {
100100
String jwt = jwtService.createAccessToken(user.getUserId(), user.getProvider());
101101
return new OAuthResult(jwt, UserDto.of(user.getUsername()));
102102
} catch (Exception e) {
103-
log.error("Apple 토큰 로그인 실패: {}", e.getMessage(), e);
103+
log.error("애플 토큰 로그인 실패: {}", e.getMessage(), e);
104104
throw new GlobalException(ErrorStatus.OAUTH_ERROR);
105105
}
106106
}

src/main/java/com/gdg/poppet/auth/infra/util/AppleAuthClient.java

Lines changed: 183 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -11,132 +11,239 @@
1111
import lombok.RequiredArgsConstructor;
1212
import lombok.extern.slf4j.Slf4j;
1313
import 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

1716
import java.math.BigInteger;
1817
import java.security.KeyFactory;
1918
import java.security.PublicKey;
2019
import java.security.spec.RSAPublicKeySpec;
2120
import java.util.Base64;
22-
import java.util.Map;
21+
import java.util.List;
2322

2423
@Component
2524
@RequiredArgsConstructor
2625
@Slf4j
2726
public 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

Comments
 (0)