Skip to content

Commit 7c87e11

Browse files
authored
feat: apple 로그인 구 (#9)
1 parent 2abf41e commit 7c87e11

13 files changed

Lines changed: 293 additions & 15 deletions

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ dependencies {
5656

5757
// slack
5858
implementation 'com.slack.api:slack-api-client:1.45.3'
59+
60+
// bouncycastle
61+
implementation 'org.bouncycastle:bcpkix-jdk18on:1.80'
5962
}
6063

6164
tasks.named('test') {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package bitnagil.bitnagil_backend.auth.apple.domain;
2+
3+
import lombok.Getter;
4+
5+
/**
6+
* 애플 ID 토큰 페이로드 클래스
7+
* 애플 로그인 후 받은 ID 토큰의 페이로드를 매핑하는 클래스
8+
*/
9+
@Getter
10+
public class AppleIdTokenPayload {
11+
12+
private String sub;
13+
14+
private String email;
15+
16+
private String name;
17+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package bitnagil.bitnagil_backend.auth.apple.domain;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
import org.springframework.boot.context.properties.ConfigurationProperties;
6+
import org.springframework.stereotype.Component;
7+
8+
/**
9+
* 애플 소셜 로그인 관련 설정 프로퍼티 클래스
10+
*/
11+
@Component
12+
@ConfigurationProperties(prefix = "social-login.provider.apple")
13+
@Getter
14+
@Setter
15+
public class AppleProperties {
16+
17+
private String grantType;
18+
private String clientId;
19+
private String keyId;
20+
private String teamId;
21+
private String audience;
22+
private String privateKey;
23+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package bitnagil.bitnagil_backend.auth.apple.response;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.Getter;
5+
6+
/**
7+
* 애플 소셜 로그인 토큰 정보 응답 클래스
8+
* 애플 로그인 후 받은 토큰 정보를 매핑하는 클래스
9+
*/
10+
@Getter
11+
public class AppleSocialTokenInfoResponse {
12+
13+
@JsonProperty("access_token")
14+
private String accessToken;
15+
16+
@JsonProperty("token_type")
17+
private String tokenType;
18+
19+
@JsonProperty("expires_in")
20+
private Long expiresIn;
21+
22+
@JsonProperty("refresh_token")
23+
private String refreshToken;
24+
25+
@JsonProperty("id_token")
26+
private String idToken;
27+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package bitnagil.bitnagil_backend.auth.apple.service;
2+
3+
import bitnagil.bitnagil_backend.auth.apple.response.AppleSocialTokenInfoResponse;
4+
import org.springframework.cloud.openfeign.FeignClient;
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.web.bind.annotation.PostMapping;
7+
import org.springframework.web.bind.annotation.RequestParam;
8+
9+
/**
10+
* 애플 인증관련 Feign 클라이언트
11+
*/
12+
@Component
13+
@FeignClient(
14+
name = "apple-auth",
15+
url = "${client.apple-auth.url}",
16+
configuration = AppleFeignClientConfiguration.class
17+
)
18+
public interface AppleAuthClient {
19+
20+
// 애플 토큰 검증 API
21+
@PostMapping("/auth/token")
22+
AppleSocialTokenInfoResponse getIdToken(
23+
@RequestParam("client_id") String clientId,
24+
@RequestParam("client_secret") String clientSecret,
25+
@RequestParam("grant_type") String grantType,
26+
@RequestParam("code") String code
27+
);
28+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package bitnagil.bitnagil_backend.auth.apple.service;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import org.springframework.context.annotation.Bean;
5+
6+
/**
7+
* AppleFeignClient에서 발생하는 예외를 핸들링 하기 위한 설정 클래스
8+
*/
9+
public class AppleFeignClientConfiguration {
10+
11+
@Bean
12+
public AppleFeignClientErrorDecoder appleFeignClientErrorDecoder() {
13+
return new AppleFeignClientErrorDecoder(new ObjectMapper());
14+
}
15+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package bitnagil.bitnagil_backend.auth.apple.service;
2+
3+
import bitnagil.bitnagil_backend.global.errorcode.ErrorCode;
4+
import bitnagil.bitnagil_backend.global.exception.CustomException;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import feign.Response;
7+
import feign.codec.ErrorDecoder;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
11+
import java.io.IOException;
12+
13+
/**
14+
* Feign 클라이언트 호출 중 오류가 발생했을 때 예외를 변환하는 ErrorDecoder 구현
15+
*/
16+
@Slf4j
17+
@RequiredArgsConstructor
18+
public class AppleFeignClientErrorDecoder implements ErrorDecoder {
19+
20+
private final ObjectMapper objectMapper;
21+
22+
/**
23+
* Feign Client 호출 시 HTTP 응답 코드가 300 이상인 경우 호출되는 메소드.
24+
* 응답 본문이 존재할 경우, ObjectMapper를 사용하여 디코딩을 시도하고 로그로 남깁니다.
25+
* 디코딩 실패 시에도 예외를 무시하고 로그만 남긴 후, 공통 CustomException을 반환합니다.
26+
* 모든 오류는 공통 에러 코드 (APPLE_FEIGN_CALL_FAILED)로 변환하여 상위 서비스에서 일관되게 처리할 수 있도록 합니다.
27+
*/
28+
@Override
29+
public Exception decode(String methodKey, Response response) {
30+
Object body = null;
31+
if (response != null && response.body() != null) {
32+
try {
33+
body = objectMapper.readValue(response.body().toString(), Object.class);
34+
} catch (IOException e) {
35+
log.error("Error decoding response body", e);
36+
}
37+
}
38+
39+
log.error("애플 소셜 로그인 Feign API Feign Client 호출 중 오류가 발생되었습니다. body: {}", body);
40+
41+
throw new CustomException(ErrorCode.APPLE_FEIGN_CALL_FAILED);
42+
}
43+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package bitnagil.bitnagil_backend.auth.apple.service;
2+
3+
import bitnagil.bitnagil_backend.auth.apple.domain.AppleIdTokenPayload;
4+
import bitnagil.bitnagil_backend.auth.apple.domain.AppleProperties;
5+
import bitnagil.bitnagil_backend.global.errorcode.ErrorCode;
6+
import bitnagil.bitnagil_backend.global.exception.CustomException;
7+
import io.jsonwebtoken.JwsHeader;
8+
import io.jsonwebtoken.Jwts;
9+
import io.jsonwebtoken.SignatureAlgorithm;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
13+
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
14+
import org.springframework.stereotype.Service;
15+
16+
import java.security.PrivateKey;
17+
import java.security.Security;
18+
import java.time.LocalDateTime;
19+
import java.time.ZoneId;
20+
import java.util.Base64;
21+
import java.util.Date;
22+
23+
@Slf4j
24+
@Service
25+
@RequiredArgsConstructor
26+
public class AppleUserInfoService {
27+
private final AppleAuthClient appleAuthClient;
28+
29+
private final AppleProperties appleProperties;
30+
31+
// 클라이언트에게 받은 authorization code를 통해 APPLE ID 토큰을 가져오고, 해당 토큰의 페이로드를 디코딩하여 반환
32+
public AppleIdTokenPayload get(String authorizationCode) {
33+
34+
String idToken = appleAuthClient.getIdToken(
35+
appleProperties.getClientId(),
36+
generateClientSecret(),
37+
appleProperties.getGrantType(),
38+
authorizationCode)
39+
.getIdToken();
40+
41+
return TokenDecoder.decodePayload(idToken, AppleIdTokenPayload.class);
42+
}
43+
44+
// Apple 인증을 위한 ClientSecret 생성
45+
private String generateClientSecret() {
46+
47+
LocalDateTime expiration = LocalDateTime.now().plusMinutes(5);
48+
49+
String clientSecret = Jwts.builder()
50+
.setHeaderParam(JwsHeader.KEY_ID, appleProperties.getKeyId())
51+
.setIssuer(appleProperties.getTeamId())
52+
.setAudience(appleProperties.getAudience())
53+
.setSubject(appleProperties.getClientId())
54+
.setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant()))
55+
.setIssuedAt(new Date())
56+
.signWith(getPrivateKey(), SignatureAlgorithm.ES256)
57+
.compact();
58+
59+
return clientSecret;
60+
}
61+
62+
// Apple 인증을 위한 PrivateKey 생성
63+
private PrivateKey getPrivateKey() {
64+
65+
Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
66+
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
67+
68+
try {
69+
byte[] privateKeyBytes = Base64.getDecoder().decode(appleProperties.getPrivateKey());
70+
71+
PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes);
72+
return converter.getPrivateKey(privateKeyInfo);
73+
} catch (Exception e) {
74+
throw new CustomException(ErrorCode.PRIVATE_KEY_CONVERT_ERROR);
75+
}
76+
}
77+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package bitnagil.bitnagil_backend.auth.apple.service;
2+
3+
import bitnagil.bitnagil_backend.global.errorcode.ErrorCode;
4+
import bitnagil.bitnagil_backend.global.exception.CustomException;
5+
import com.fasterxml.jackson.databind.DeserializationFeature;
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import java.util.Base64;
8+
9+
/**
10+
* 애플 로그인 후 받은 ID 토큰을 디코딩하여 페이로드를 추출
11+
*/
12+
public class TokenDecoder {
13+
14+
public static <T> T decodePayload(String token, Class<T> targetClass) {
15+
16+
String[] tokenParts = token.split("\\.");
17+
String payloadJWT = tokenParts[1];
18+
Base64.Decoder decoder = Base64.getUrlDecoder();
19+
String payload = new String(decoder.decode(payloadJWT));
20+
ObjectMapper objectMapper = new ObjectMapper()
21+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
22+
23+
try {
24+
return objectMapper.readValue(payload, targetClass);
25+
} catch (Exception e) {
26+
throw new CustomException(ErrorCode.TOKEN_DECODE_ERROR);
27+
}
28+
}
29+
}

src/main/java/bitnagil/bitnagil_backend/global/errorcode/ErrorCode.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ public enum ErrorCode {
3737
NOT_FOUND_USER("US001", HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."),
3838

3939
// 소셜 로그인 관련 에러 코드
40-
UNSUPPORTED_SOCIAL_TYPE("AU000", HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인 타입입니다.")
40+
UNSUPPORTED_SOCIAL_TYPE("SO000", HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인 타입입니다."),
41+
APPLE_FEIGN_CALL_FAILED("SO001", HttpStatus.BAD_GATEWAY, "애플 소셜 로그인 Feign API 호출에 실패했습니다."),
42+
TOKEN_DECODE_ERROR("SO002", HttpStatus.BAD_REQUEST, "토큰 디코드 중 오류가 발생했습니다."),
43+
PRIVATE_KEY_CONVERT_ERROR("SO003", HttpStatus.INTERNAL_SERVER_ERROR, "개인 키 변환 중 오류가 발생했습니다."),
4144
;
4245

4346

0 commit comments

Comments
 (0)