-
Notifications
You must be signed in to change notification settings - Fork 0
feat(oauth): Apple 로그인 구현 #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)에서 사용자 정보를 추출한다. | ||
huhdy32 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| private AppleMemberInfoResponse parseIdToken(final String idToken) { | ||
| try { | ||
| final SignedJWT signedJWT = SignedJWT.parse(idToken); | ||
| final JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return new AppleMemberInfoResponse( | ||
| claims.getSubject(), | ||
| claims.getStringClaim("email") | ||
| ); | ||
| } catch (ParseException e) { | ||
| throw new InvalidOAuthLoginException("애플 서버로부터 사용할 수 없는 메시지를 받았습니다."); | ||
| } | ||
|
Comment on lines
+77
to
+79
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 포맷에 맞게 로그만 남겨주시면 감사하겠습니다!! |
||
| } | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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))) | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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-----", "") | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .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("애플 개인 키 로딩에 실패했습니다."); | ||
| } | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+122
to
+124
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .retrieve() | ||
| .toBodilessEntity() | ||
| .getStatusCode().is2xxSuccessful(); | ||
| } | ||
Mindev27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| 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 |
|---|---|---|
|
|
@@ -3,5 +3,6 @@ | |
| public enum OAuthProvider { | ||
| KAKAO, | ||
| GOOGLE, | ||
| NAVER | ||
| NAVER, | ||
| APPLE | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.