Skip to content

Commit c74d8d6

Browse files
authored
Merge pull request #78 from ConnectCo/fix/coupon
feat: 로그인 API 완료
2 parents 5504451 + 87002b0 commit c74d8d6

7 files changed

Lines changed: 160 additions & 21 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ dependencies {
4545
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
4646
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
4747
implementation 'com.auth0:java-jwt:4.4.0'
48+
implementation("com.nimbusds:nimbus-jose-jwt:9.37")
4849

4950
// WebClient
5051
implementation 'org.springframework.boot:spring-boot-starter-webflux'
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.connectCo.domain.member.client;
2+
3+
import com.connectCo.global.exception.CustomApiException;
4+
import com.connectCo.global.exception.ErrorCode;
5+
import com.nimbusds.jose.JOSEException;
6+
import com.nimbusds.jose.JWSObject;
7+
import com.nimbusds.jose.jwk.JWKSet;
8+
import com.nimbusds.jose.jwk.JWK;
9+
import com.nimbusds.jose.jwk.KeyUse;
10+
import com.nimbusds.jwt.SignedJWT;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.stereotype.Service;
15+
16+
import java.io.IOException;
17+
import java.net.URL;
18+
import java.security.PublicKey;
19+
import java.security.interfaces.RSAPublicKey;
20+
import java.text.ParseException;
21+
import java.time.Instant;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Objects;
25+
import java.util.concurrent.ConcurrentHashMap;
26+
27+
@Slf4j
28+
@Service
29+
@RequiredArgsConstructor
30+
public class AppleMemberClient {
31+
32+
private static final String APPLE_PUBLIC_KEYS_URL = "https://appleid.apple.com/auth/keys";
33+
@Value("${social.apple.client-id}")
34+
private String APPLE_CLIENT_ID;
35+
private static final long CACHE_TTL = 10 * 60 * 1000; // 10분
36+
37+
private volatile JWKSet cachedJwkSet;
38+
private volatile long lastFetchedTime = 0;
39+
40+
public String getAppleUserId(String identityToken) {
41+
try {
42+
SignedJWT signedJWT = SignedJWT.parse(identityToken);
43+
String keyId = signedJWT.getHeader().getKeyID();
44+
45+
// 애플 공개 키 가져오기 (캐싱 적용)
46+
JWKSet jwkSet = getAppleJwkSet();
47+
List<JWK> keys = jwkSet.getKeys();
48+
49+
// Key ID에 맞는 공개 키 찾기
50+
JWK matchedKey = keys.stream()
51+
.filter(k -> Objects.equals(k.getKeyID(), keyId) && k.getKeyUse() == KeyUse.SIGNATURE)
52+
.findFirst()
53+
.orElseThrow(() -> {
54+
log.warn("Apple public key not found for keyId: {}", keyId);
55+
return new CustomApiException(ErrorCode.INVALID_APPLE_TOKEN);
56+
});
57+
58+
// 공개 키로 JWT 검증
59+
PublicKey publicKey = matchedKey.toRSAKey().toPublicKey();
60+
if (!signedJWT.verify(new com.nimbusds.jose.crypto.RSASSAVerifier((RSAPublicKey) publicKey))) {
61+
log.warn("Apple identityToken signature verification failed");
62+
throw new CustomApiException(ErrorCode.INVALID_APPLE_TOKEN);
63+
}
64+
65+
// 만료 시간(exp) 검증
66+
Instant expirationTime = signedJWT.getJWTClaimsSet().getExpirationTime().toInstant();
67+
if (Instant.now().isAfter(expirationTime)) {
68+
log.warn("Apple identityToken is expired");
69+
throw new CustomApiException(ErrorCode.EXPIRED_APPLE_TOKEN);
70+
}
71+
72+
// Audience(aud) 검증
73+
String audience = signedJWT.getJWTClaimsSet().getAudience().get(0);
74+
if (!APPLE_CLIENT_ID.equals(audience)) {
75+
log.warn("Invalid Apple identityToken audience: {}", audience);
76+
throw new CustomApiException(ErrorCode.INVALID_APPLE_TOKEN_AUDIENCE);
77+
}
78+
79+
// 검증 후 'sub' 값 반환 (애플의 고유 사용자 ID)
80+
return signedJWT.getJWTClaimsSet().getSubject();
81+
82+
} catch (ParseException | JOSEException | IOException e) {
83+
log.error("AppleMemberClient.getAppleUserId() error", e);
84+
throw new CustomApiException(ErrorCode.INVALID_APPLE_TOKEN);
85+
}
86+
}
87+
88+
private JWKSet getAppleJwkSet() throws IOException, ParseException {
89+
long currentTime = System.currentTimeMillis();
90+
if (cachedJwkSet == null || currentTime - lastFetchedTime > CACHE_TTL) {
91+
synchronized (this) {
92+
if (cachedJwkSet == null || currentTime - lastFetchedTime > CACHE_TTL) {
93+
cachedJwkSet = JWKSet.load(new URL(APPLE_PUBLIC_KEYS_URL));
94+
lastFetchedTime = currentTime;
95+
log.info("Fetched new Apple public keys from {}", APPLE_PUBLIC_KEYS_URL);
96+
}
97+
}
98+
}
99+
return cachedJwkSet;
100+
}
101+
}
Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
package com.connectCo.domain.member.client;
22

3-
import com.auth0.jwt.JWT;
4-
import com.auth0.jwt.interfaces.DecodedJWT;
3+
import com.connectCo.domain.member.dto.client.GoogleMemberResponse;
4+
import com.connectCo.global.exception.CustomApiException;
5+
import com.connectCo.global.exception.ErrorCode;
56
import org.springframework.stereotype.Component;
7+
import org.springframework.web.reactive.function.client.WebClient;
68

79
@Component
810
public class GoogleMemberClient {
911

12+
private final WebClient webClient;
13+
14+
public GoogleMemberClient(WebClient.Builder webClientBuilder) {
15+
this.webClient = webClientBuilder.build();
16+
}
17+
1018
public String getGoogleUserId(String idToken) {
11-
try {
12-
DecodedJWT decodedJWT = JWT.decode(idToken);
13-
return decodedJWT.getClaim("sub").asString(); // 사용자 고유 ID 반환
14-
} catch (Exception e) {
15-
throw new RuntimeException("Invalid Google ID Token", e);
19+
GoogleMemberResponse response = webClient.get()
20+
.uri("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken)
21+
.retrieve()
22+
.bodyToMono(GoogleMemberResponse.class)
23+
.block();
24+
25+
if (response != null) {
26+
return response.getSub(); // 사용자 고유 ID 반환
1627
}
28+
29+
throw new CustomApiException(ErrorCode.INVALID_GOOGLE_TOKEN);
1730
}
1831
}

src/main/java/com/connectCo/domain/member/client/KakaoMemberClient.java

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,38 @@ public class KakaoMemberClient {
1212
private final WebClient webClient;
1313

1414
public KakaoMemberClient(WebClient.Builder webClientBuilder) {
15-
this.webClient = webClientBuilder
16-
.baseUrl("https://kapi.kakao.com/v2/user/me")
17-
.build();
15+
this.webClient = webClientBuilder.build();
1816
}
1917

2018
public String getKakaoUserId(String accessToken) {
19+
// 🔹 1. accessToken 검증 (유효하지 않으면 예외 발생)
20+
validateKakaoToken(accessToken);
21+
22+
// 🔹 2. 사용자 정보 가져오기
2123
KakaoMemberResponse response = webClient
22-
.get()
23-
.header("Authorization", "Bearer " + accessToken)
24-
.retrieve()
25-
.bodyToMono(KakaoMemberResponse.class)
26-
.block();
27-
if(response != null) {
24+
.get()
25+
.uri("https://kapi.kakao.com/v2/user/me")
26+
.header("Authorization", "Bearer " + accessToken)
27+
.retrieve()
28+
.bodyToMono(KakaoMemberResponse.class)
29+
.block();
30+
31+
if (response != null) {
2832
return response.getId();
2933
}
3034
throw new CustomApiException(ErrorCode.INVALID_KAKAO_TOKEN);
3135
}
36+
37+
private void validateKakaoToken(String accessToken) {
38+
try {
39+
webClient.get()
40+
.uri("https://kapi.kakao.com/v1/user/access_token_info")
41+
.header("Authorization", "Bearer " + accessToken)
42+
.retrieve()
43+
.toBodilessEntity()
44+
.block(); // 🔹 토큰이 유효하지 않으면 여기서 예외 발생
45+
} catch (Exception e) {
46+
throw new CustomApiException(ErrorCode.INVALID_KAKAO_TOKEN);
47+
}
48+
}
3249
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package com.connectCo.domain.member.dto.client;
22

3-
import lombok.AllArgsConstructor;
43
import lombok.Getter;
54
import lombok.NoArgsConstructor;
65

76
@Getter
87
@NoArgsConstructor
9-
@AllArgsConstructor
108
public class GoogleMemberResponse {
119
private String sub;
12-
private String name;
10+
private String aud;
11+
private String iss;
12+
private String email;
1313
}

src/main/java/com/connectCo/domain/member/service/AuthServiceImpl.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.connectCo.config.security.jwt.JwtToken;
44
import com.connectCo.config.security.jwt.JwtTokenProvider;
55
import com.connectCo.config.security.jwt.RefreshTokenInfo;
6+
import com.connectCo.domain.member.client.AppleMemberClient;
67
import com.connectCo.domain.member.client.GoogleMemberClient;
78
import com.connectCo.domain.member.client.KakaoMemberClient;
89
import com.connectCo.domain.member.client.NaverMemberClient;
@@ -33,6 +34,7 @@ public class AuthServiceImpl implements AuthService{
3334
private final NaverMemberClient naverMemberClient;
3435
private final KakaoMemberClient kakaoMemberClient;
3536
private final GoogleMemberClient googleMemberClient;
37+
private final AppleMemberClient appleMemberClient;
3638

3739
private final AuthMapper authMapper;
3840
private final MemberRepository memberRepository;
@@ -126,7 +128,8 @@ private String getClientIdByProvider(String accessToken, LoginType provider) {
126128
case NAVER -> naverMemberClient.getNaverUserId(accessToken);
127129
case KAKAO -> kakaoMemberClient.getKakaoUserId(accessToken);
128130
case GOOGLE -> googleMemberClient.getGoogleUserId(accessToken);
129-
default -> throw new IllegalArgumentException("지원하지 않는 LoginType입니다: " + provider);
131+
case APPLE -> appleMemberClient.getAppleUserId(accessToken);
132+
default -> throw new CustomApiException(ErrorCode.INVALID_LOGIN_TYPE);
130133
};
131134
}
132135

src/main/java/com/connectCo/global/exception/ErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ public enum ErrorCode {
3131
INVALID_LOCATION(HttpStatus.BAD_REQUEST, "MAP01", "잘못된 위치 정보입니다."),
3232
INVALID_RADIUS(HttpStatus.BAD_REQUEST, "MAP02", "반경은 양의 정수 값이어야 합니다."),
3333

34-
// Auth
34+
// Login
3535
INVALID_GOOGLE_TOKEN(HttpStatus.BAD_REQUEST, "AUTH401", "잘못된 구글 토큰입니다."),
3636
INVALID_KAKAO_TOKEN(HttpStatus.BAD_REQUEST, "AUTH402", "잘못된 카카오 토큰입니다."),
3737
INVALID_NAVER_TOKEN(HttpStatus.BAD_REQUEST, "AUTH403", "잘못된 네이버 토큰입니다."),
38+
INVALID_APPLE_TOKEN(HttpStatus.BAD_REQUEST, "AUTH404", "잘못된 애플 토큰입니다."),
39+
INVALID_LOGIN_TYPE(HttpStatus.BAD_REQUEST, "AUTH405", "잘못된 로그인 타입입니다."),
40+
EXPIRED_APPLE_TOKEN(HttpStatus.BAD_REQUEST, "AUTH406", "만료된 애플 토큰입니다."),
41+
INVALID_APPLE_TOKEN_AUDIENCE(HttpStatus.BAD_REQUEST, "AUTH407", "잘못된 애플 토큰 audience입니다."),
3842

3943
// Member
4044
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER401", "사용자를 찾을 수 없습니다."),

0 commit comments

Comments
 (0)