Skip to content

Commit dca8e64

Browse files
authored
Merge pull request #113 from Recipe-Project/feature/apple-login
feat: 애플 로그인 기능 추가
2 parents 3109a93 + 95d4d60 commit dca8e64

File tree

9 files changed

+208
-3
lines changed

9 files changed

+208
-3
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ dependencies {
4646
implementation 'org.springframework.boot:spring-boot-starter-aop'
4747

4848
implementation 'org.springframework.boot:spring-boot-starter-security'
49-
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
49+
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
5050
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
5151
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
5252

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.recipe.app.src.common.client.apple;
2+
3+
import com.recipe.app.src.common.client.apple.dto.ApplePublicKeysResponse;
4+
import org.springframework.cloud.openfeign.FeignClient;
5+
import org.springframework.web.bind.annotation.GetMapping;
6+
7+
@FeignClient(name = "apple-oauth-client", url = "https://appleid.apple.com/auth")
8+
public interface AppleOAuthFeignClient {
9+
10+
@GetMapping(value = "/keys")
11+
ApplePublicKeysResponse getPublicKeys();
12+
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.recipe.app.src.common.client.apple.dto;
2+
3+
import com.recipe.app.src.user.domain.User;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
@Getter
8+
@Builder
9+
public class AppleAuthResponse {
10+
11+
private String sub;
12+
private String email;
13+
private String name;
14+
15+
public User toEntity(String fcmToken) {
16+
17+
return User.builder()
18+
.socialId("apple_" + sub)
19+
.nickname(name != null ? name : "Apple User")
20+
.email(email)
21+
.deviceToken(fcmToken)
22+
.build();
23+
}
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.recipe.app.src.common.client.apple.dto;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class ApplePublicKeyResponse {
7+
8+
private String kty;
9+
private String kid;
10+
private String use;
11+
private String alg;
12+
private String n;
13+
private String e;
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.recipe.app.src.common.client.apple.dto;
2+
3+
import lombok.Getter;
4+
5+
import java.util.List;
6+
7+
@Getter
8+
public class ApplePublicKeysResponse {
9+
10+
private List<ApplePublicKeyResponse> keys;
11+
12+
public ApplePublicKeyResponse getMatchKey(String alg, String kid) {
13+
14+
return this.keys
15+
.stream()
16+
.filter(key -> key.getAlg().equals(alg) && key.getKid().equals(kid))
17+
.findFirst()
18+
.orElseThrow(() -> new IllegalArgumentException("Apple 로그인 시 필요한 key 값이 존재하지 않습니다."));
19+
}
20+
}

src/main/java/com/recipe/app/src/common/utils/JwtUtil.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.recipe.app.src.common.utils;
22

3+
import com.recipe.app.src.common.client.apple.dto.ApplePublicKeyResponse;
34
import io.jsonwebtoken.*;
45
import io.jsonwebtoken.security.SignatureException;
56
import jakarta.servlet.http.HttpServletRequest;
@@ -12,7 +13,11 @@
1213
import org.springframework.util.StringUtils;
1314

1415
import javax.crypto.spec.SecretKeySpec;
16+
import java.math.BigInteger;
1517
import java.security.Key;
18+
import java.security.KeyFactory;
19+
import java.security.PublicKey;
20+
import java.security.spec.RSAPublicKeySpec;
1621
import java.time.Duration;
1722
import java.util.Base64;
1823
import java.util.Date;
@@ -135,4 +140,39 @@ public void setAccessTokenBlacklist(String accessToken) {
135140

136141
redisTemplate.opsForValue().set(accessToken, ACCESS_TOKEN_BLACKLIST_VALUE, Duration.ofMillis(accessTokenValidMillisecond));
137142
}
143+
144+
public Claims parseAppleIdToken(String idToken, ApplePublicKeyResponse publicKey) {
145+
146+
try {
147+
PublicKey key = generateApplePublicKey(publicKey);
148+
149+
return Jwts.parserBuilder()
150+
.setSigningKey(key)
151+
.build()
152+
.parseClaimsJws(idToken)
153+
.getBody();
154+
} catch (Exception e) {
155+
logger.error("Apple id_token 검증 실패", e);
156+
throw new IllegalArgumentException("Apple id_token 검증에 실패했습니다.", e);
157+
}
158+
}
159+
160+
private PublicKey generateApplePublicKey(ApplePublicKeyResponse publicKey) {
161+
162+
try {
163+
byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.getN());
164+
byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.getE());
165+
166+
BigInteger n = new BigInteger(1, nBytes);
167+
BigInteger e = new BigInteger(1, eBytes);
168+
169+
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
170+
KeyFactory keyFactory = KeyFactory.getInstance(publicKey.getKty());
171+
172+
return keyFactory.generatePublic(publicKeySpec);
173+
} catch (Exception exception) {
174+
logger.error("Apple Public Key 생성 실패", exception);
175+
throw new IllegalArgumentException("Apple Public Key 생성에 실패했습니다.", exception);
176+
}
177+
}
138178
}

src/main/java/com/recipe/app/src/user/api/UserController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ public UserSocialLoginResponse googleLogin(@Parameter(name = "로그인 요청
7777
return userService.googleLogin(request);
7878
}
7979

80+
@Operation(summary = "애플 로그인 API")
81+
@PostMapping("/apple-login")
82+
public UserSocialLoginResponse appleLogin(@Parameter(name = "로그인 요청 정보", required = true)
83+
@RequestBody UserLoginRequest request) {
84+
85+
return userService.appleLogin(request);
86+
}
87+
8088
@Operation(summary = "유저 프로필 조회 API")
8189
@GetMapping
8290
@LoginCheck

src/main/java/com/recipe/app/src/user/application/UserAuthClientService.java

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
package com.recipe.app.src.user.application;
22

3+
import com.recipe.app.src.common.client.apple.AppleOAuthFeignClient;
4+
import com.recipe.app.src.common.client.apple.dto.AppleAuthResponse;
5+
import com.recipe.app.src.common.client.apple.dto.ApplePublicKeyResponse;
6+
import com.recipe.app.src.common.client.apple.dto.ApplePublicKeysResponse;
37
import com.recipe.app.src.common.client.google.GoogleOAuthFeignClient;
48
import com.recipe.app.src.common.client.kakao.KakaoFeignClient;
59
import com.recipe.app.src.common.client.kakao.KakaoOAuthFeignClient;
610
import com.recipe.app.src.common.client.naver.NaverFeignClient;
711
import com.recipe.app.src.common.client.naver.NaverOAuthFeignClient;
812
import com.recipe.app.src.common.client.naver.dto.NaverAuthResponse;
13+
import com.recipe.app.src.common.utils.JwtUtil;
914
import com.recipe.app.src.user.application.dto.UserLoginRequest;
1015
import com.recipe.app.src.user.domain.User;
1116
import com.recipe.app.src.user.exception.ForbiddenAccessException;
17+
import io.jsonwebtoken.Claims;
18+
import org.slf4j.Logger;
19+
import org.slf4j.LoggerFactory;
1220
import org.springframework.beans.factory.annotation.Value;
1321
import org.springframework.stereotype.Service;
1422

23+
import java.util.Base64;
24+
1525
@Service
1626
public class UserAuthClientService {
1727

@@ -32,20 +42,26 @@ public class UserAuthClientService {
3242
@Value("${google.redirect-uri}")
3343
private String googleRedirectURI;
3444

45+
private final Logger logger = LoggerFactory.getLogger(UserAuthClientService.class);
3546
private final NaverFeignClient naverFeignClient;
3647
private final NaverOAuthFeignClient naverOAuthFeignClient;
3748
private final KakaoFeignClient kakaoFeignClient;
3849
private final KakaoOAuthFeignClient kakaoOAuthFeignClient;
3950
private final GoogleOAuthFeignClient googleOAuthFeignClient;
51+
private final AppleOAuthFeignClient appleOAuthFeignClient;
52+
private final JwtUtil jwtUtil;
4053

4154
public UserAuthClientService(NaverFeignClient naverFeignClient, NaverOAuthFeignClient naverOAuthFeignClient,
4255
KakaoFeignClient kakaoFeignClient, KakaoOAuthFeignClient kakaoOAuthFeignClient,
43-
GoogleOAuthFeignClient googleOAuthFeignClient) {
56+
GoogleOAuthFeignClient googleOAuthFeignClient, AppleOAuthFeignClient appleOAuthFeignClient,
57+
JwtUtil jwtUtil) {
4458
this.naverFeignClient = naverFeignClient;
4559
this.naverOAuthFeignClient = naverOAuthFeignClient;
4660
this.kakaoFeignClient = kakaoFeignClient;
4761
this.kakaoOAuthFeignClient = kakaoOAuthFeignClient;
4862
this.googleOAuthFeignClient = googleOAuthFeignClient;
63+
this.appleOAuthFeignClient = appleOAuthFeignClient;
64+
this.jwtUtil = jwtUtil;
4965
}
5066

5167
public UserLoginRequest getNaverLoginRequest(String code, String state) {
@@ -101,4 +117,59 @@ public User getUserByGoogleAuthInfo(UserLoginRequest request) {
101117
return googleOAuthFeignClient.getAuthInfo(request.getAccessToken())
102118
.toEntity(request.getFcmToken());
103119
}
120+
121+
public User getUserByAppleAuthInfo(UserLoginRequest request) {
122+
123+
String idToken = request.getAccessToken();
124+
125+
// 1. Apple Public Keys 조회
126+
ApplePublicKeysResponse publicKeys = appleOAuthFeignClient.getPublicKeys();
127+
128+
// 2. id_token 헤더에서 kid, alg 추출
129+
String kid = getKidFromIdToken(idToken);
130+
String alg = getAlgFromIdToken(idToken);
131+
132+
// 3. 매칭되는 Public Key 찾기
133+
ApplePublicKeyResponse matchedKey = publicKeys.getMatchKey(alg, kid);
134+
135+
// 4. JWT 검증 및 Claims 추출
136+
Claims claims = jwtUtil.parseAppleIdToken(idToken, matchedKey);
137+
138+
// 5. User 엔티티 생성
139+
return AppleAuthResponse.builder()
140+
.sub(claims.get("sub", String.class))
141+
.email(claims.get("email", String.class))
142+
.name(null)
143+
.build()
144+
.toEntity(request.getFcmToken());
145+
}
146+
147+
public String getKidFromIdToken(String idToken) {
148+
149+
try {
150+
String header = idToken.split("\\.")[0];
151+
String decodedHeader = new String(Base64.getUrlDecoder().decode(header));
152+
153+
String kid = decodedHeader.split("\"kid\":\"")[1].split("\"")[0];
154+
return kid;
155+
} catch (Exception e) {
156+
logger.error("id_token 헤더에서 kid 추출 실패", e);
157+
throw new IllegalArgumentException("id_token 헤더에서 kid를 추출할 수 없습니다.", e);
158+
}
159+
}
160+
161+
private String getAlgFromIdToken(String idToken) {
162+
163+
try {
164+
String header = idToken.split("\\.")[0];
165+
String decodedHeader = new String(Base64.getUrlDecoder().decode(header));
166+
167+
String alg = decodedHeader.split("\"alg\":\"")[1].split("\"")[0];
168+
return alg;
169+
} catch (Exception e) {
170+
logger.error("id_token 헤더에서 alg 추출 실패", e);
171+
throw new IllegalArgumentException("id_token 헤더에서 alg를 추출할 수 없습니다.", e);
172+
}
173+
}
174+
104175
}

src/main/java/com/recipe/app/src/user/application/UserService.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package com.recipe.app.src.user.application;
22

33
import com.google.common.base.Preconditions;
4-
import com.recipe.app.src.common.utils.JwtUtil;
54
import com.recipe.app.src.common.utils.BadWordFiltering;
5+
import com.recipe.app.src.common.utils.JwtUtil;
66
import com.recipe.app.src.user.application.dto.UserDeviceTokenRequest;
77
import com.recipe.app.src.user.application.dto.UserLoginRequest;
88
import com.recipe.app.src.user.application.dto.UserLoginResponse;
@@ -97,6 +97,21 @@ public UserSocialLoginResponse googleLogin(UserLoginRequest request) {
9797
return UserSocialLoginResponse.from(user, accessToken, refreshToken);
9898
}
9999

100+
@Transactional
101+
public UserSocialLoginResponse appleLogin(UserLoginRequest request) {
102+
103+
Preconditions.checkArgument(StringUtils.hasText(request.getAccessToken()), "id_token을 입력해주세요.");
104+
105+
User user = create(userAuthClientService.getUserByAppleAuthInfo(request));
106+
107+
user.changeRecentLoginAt(LocalDateTime.now());
108+
109+
String accessToken = jwtUtil.createAccessToken(user.getUserId());
110+
String refreshToken = jwtUtil.createRefreshToken(user.getUserId());
111+
112+
return UserSocialLoginResponse.from(user, accessToken, refreshToken);
113+
}
114+
100115
private User create(User user) {
101116

102117
return userRepository.findBySocialId(user.getSocialId())

0 commit comments

Comments
 (0)