Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions domain/mathrank-auth-domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.nimbusds:nimbus-jose-jwt:10.7'

implementation project(':common:mathrank-role')
implementation project(':common:mathrank-outbox')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.co.mathrank.domain.auth.client;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Configuration
@Getter
@Setter
@ConfigurationProperties("oauth.apple")
@ConditionalOnProperty(prefix = "oauth.apple", name = "clientId")
@ToString(exclude = "privateKey")
class AppleConfiguration {
private String clientId;
private String teamId;
private String keyId;
private String privateKey;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.co.mathrank.domain.auth.client;

record AppleMemberInfoResponse(
String sub,
String email
) implements MemberInfoResponse {
@Override
public MemberInfo toInfo() {
return new MemberInfo(sub, null, email, null, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package kr.co.mathrank.domain.auth.client;

import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.text.ParseException;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;

import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.ECDSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;

import kr.co.mathrank.domain.auth.dto.OAuthLoginCommand;
import kr.co.mathrank.domain.auth.entity.OAuthProvider;
import kr.co.mathrank.domain.auth.exception.InvalidOAuthLoginException;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
class AppleOAuthClient implements OAuthClientHandler {
private final AppleConfiguration appleConfiguration;

// 토큰 발급 URL
// https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
private static final String TOKEN_URL = "https://appleid.apple.com/auth/token";
// 토큰 폐기 URL
private static final String TOKEN_REVOKE_URL = "https://appleid.apple.com/auth/revoke";

private final RestClient tokenClient = RestClient.builder()
.baseUrl(TOKEN_URL)
.build();

private final RestClient revokeClient = RestClient.builder()
.baseUrl(TOKEN_REVOKE_URL)
.build();

@Override
public MemberInfoResponse getMemberInfo(OAuthLoginCommand command) {
final AppleTokenResponse token = getAccessToken(command);
final MemberInfoResponse infoResponse = parseIdToken(token.id_token());

return new MemberInfoRefreshTokenAdapter(infoResponse.toInfo(), token.refresh_token(), token.token_type());
}

private AppleTokenResponse getAccessToken(final OAuthLoginCommand command) {
final String clientSecret = generateClientSecret();

return tokenClient.post()
.uri(uriBuilder -> uriBuilder
.queryParam("client_id", appleConfiguration.getClientId())
.queryParam("client_secret", clientSecret)
.queryParam("code", command.code())
.queryParam("grant_type", "authorization_code")
.build())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.retrieve()
.body(AppleTokenResponse.class);
}

// Apple은 별도의 사용자 정보 조회 API가 없으며, id_token(JWT)에서 사용자 정보를 추출한다.
private AppleMemberInfoResponse parseIdToken(final String idToken) {
try {
final SignedJWT signedJWT = SignedJWT.parse(idToken);
final JWTClaimsSet claims = signedJWT.getJWTClaimsSet();

return new AppleMemberInfoResponse(
claims.getSubject(),
claims.getStringClaim("email")
);
} catch (ParseException e) {
throw new InvalidOAuthLoginException("애플 서버로부터 사용할 수 없는 메시지를 받았습니다.");
}
Comment on lines +77 to +79
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

포맷에 맞게 로그만 남겨주시면 감사하겠습니다!!

}

// Apple OAuth의 client_secret은 ES256으로 서명된 JWT이다.
// https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
Comment on lines +82 to +83
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

관련 링크 감사합니다

private String generateClientSecret() {
try {
final ECPrivateKey ecPrivateKey = loadPrivateKey(appleConfiguration.getPrivateKey());
final Instant now = Instant.now();

final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256)
.keyID(appleConfiguration.getKeyId())
.build();

final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer(appleConfiguration.getTeamId())
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plusSeconds(300)))
.audience("https://appleid.apple.com")
.subject(appleConfiguration.getClientId())
.build();

final SignedJWT signedJWT = new SignedJWT(header, claimsSet);
signedJWT.sign(new ECDSASigner(ecPrivateKey));

return signedJWT.serialize();
} catch (JOSEException e) {
throw new InvalidOAuthLoginException("애플 client_secret 생성에 실패했습니다.");
}
Comment on lines +105 to +107
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

포맷에 맞게 로그만 남겨주시면 감사하겠습니다!!

}

private ECPrivateKey loadPrivateKey(final String privateKey) {
try {
final String cleanedKey = privateKey
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");

final byte[] keyBytes = Base64.getDecoder().decode(cleanedKey);
final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
final KeyFactory keyFactory = KeyFactory.getInstance("EC");

return (ECPrivateKey)keyFactory.generatePrivate(keySpec);
} catch (Exception e) {
throw new InvalidOAuthLoginException("애플 개인 키 로딩에 실패했습니다.");
}
Comment on lines +122 to +124
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

포맷에 맞게 로그만 남겨주시면 감사하겠습니다!!

}

@Override
public boolean supports(OAuthProvider provider) {
return OAuthProvider.APPLE.equals(provider);
}

// 애플은 refreshToken으로 직접 토큰 폐기 가능
// https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens
@Override
public boolean revoke(String refreshToken) {
final String clientSecret = generateClientSecret();

return revokeClient.post()
.uri(uriBuilder -> uriBuilder
.queryParam("client_id", appleConfiguration.getClientId())
.queryParam("client_secret", clientSecret)
.queryParam("token", refreshToken)
.queryParam("token_type_hint", "refresh_token")
.build())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.retrieve()
.toBodilessEntity()
.getStatusCode().is2xxSuccessful();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.mathrank.domain.auth.client;

record AppleTokenResponse(
String token_type,
String access_token,
String refresh_token,
String id_token
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,11 @@ NaverOAuthClient naverOAuthClient(final NaverConfiguration configuration) {
log.info("[OAuthClientConfiguration] naver oauth client registered - configuration: {}", configuration);
return new NaverOAuthClient(configuration);
}

@Bean
@ConditionalOnBean(AppleConfiguration.class)
AppleOAuthClient appleOAuthClient(final AppleConfiguration configuration) {
log.info("[OAuthClientConfiguration] apple oauth client registered - configuration: {}", configuration);
return new AppleOAuthClient(configuration);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
public enum OAuthProvider {
KAKAO,
GOOGLE,
NAVER
NAVER,
APPLE
}