Skip to content

Commit

Permalink
Merge pull request #58 from YAPP-Github/feat/PC-175-oauth-apple
Browse files Browse the repository at this point in the history
[PC-175] 애플 소셜 로그인 기능
  • Loading branch information
Lujaec authored Feb 14, 2025
2 parents 8114f28 + c1e939b commit 5b779dd
Show file tree
Hide file tree
Showing 12 changed files with 365 additions and 83 deletions.
1 change: 1 addition & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies {
implementation project(':infra:ai')

implementation('org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0');
implementation('org.bouncycastle:bcpkix-jdk18on:1.76');
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.yapp.domain.auth.application.oauth.apple;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import java.math.BigInteger;
import java.net.URL;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import org.springframework.stereotype.Component;

@Component
public class AppleIdTokenDecoder {

private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";

public Claims decodeIdToken(String idToken) {
try {
JsonNode appleKeys = fetchApplePublicKeys();
String kid = extractKidFromIdToken(idToken);

JsonNode matchedKey = findMatchingKey(appleKeys, kid);
if (matchedKey == null) {
throw new RuntimeException("No matching key found for kid: " + kid);
}

PublicKey publicKey = buildPublicKey(matchedKey);

Jws<Claims> claimsJws = Jwts.parser()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(idToken);

return claimsJws.getBody(); // Payload 반환
} catch (Exception e) {
throw new RuntimeException("Failed to decode idToken", e);
}
}

private JsonNode fetchApplePublicKeys() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
URL url = new URL(APPLE_KEYS_URL);
return objectMapper.readTree(url).get("keys");
}

private String extractKidFromIdToken(String idToken) throws JsonProcessingException {
String[] parts = idToken.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid idToken format");
}
String header = new String(Base64.getDecoder().decode(parts[0]));
JsonNode headerJson = new ObjectMapper().readTree(header);
return headerJson.get("kid").asText();
}

private JsonNode findMatchingKey(JsonNode appleKeys, String kid) {
for (JsonNode key : appleKeys) {
if (key.get("kid").asText().equals(kid)) {
return key;
}
}
return null;
}

private PublicKey buildPublicKey(JsonNode keyInfo) throws Exception {
String n = keyInfo.get("n").asText();
String e = keyInfo.get("e").asText();

BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(n));
BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(e));

RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulus, exponent);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(publicKeySpec);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.yapp.domain.auth.application.oauth.apple;

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.yapp.core.exception.ApplicationException;
import org.yapp.core.exception.error.code.AuthErrorCode;
import org.yapp.domain.auth.application.oauth.OauthClient;
import org.yapp.domain.auth.application.oauth.apple.dto.AppleAuthResponse;
import org.yapp.global.config.AppleOauthProperties;

@Component
@RequiredArgsConstructor
@Slf4j
public class AppleOauthClient implements OauthClient {

private final AppleOauthHelper appleOauthHelper;
private final AppleOauthProperties appleOauthProperties;
private final RestTemplate restTemplate;

public AppleAuthResponse getAppleAuthResponse(
String clientId,
String clientSecret,
String grantType,
String authorizationCode
) {
String url = "https://appleid.apple.com/auth/token";
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
params.add("grant_type", grantType);
params.add("code", authorizationCode);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
try {
ResponseEntity<AppleAuthResponse> response = restTemplate.postForEntity(
url, entity, AppleAuthResponse.class
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return response.getBody();
} else {
throw new ApplicationException(AuthErrorCode.ID_TOKEN_NOT_FOUND);
}
} catch (Exception e) {
throw new ApplicationException(AuthErrorCode.OAUTH_ERROR);
}
}

@Override
public String getOAuthProviderUserId(String authorizationCode) {
String clientSecret = appleOauthHelper.generateClientSecret();
AppleAuthResponse appleAuthResponse = this.getAppleAuthResponse(
appleOauthProperties.getClientId(),
clientSecret,
"authorization_code",
authorizationCode
);
String idToken = appleAuthResponse.getIdToken();
Claims idTokenClaims = appleOauthHelper.decodeIdToken(idToken);
if (idTokenClaims.containsKey("sub")) {
return idTokenClaims.get("sub", String.class);
} else {
throw new ApplicationException(AuthErrorCode.OAUTH_ID_NOT_FOUND);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.yapp.domain.auth.application.oauth.apple;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import java.security.PrivateKey;
import java.security.Security;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.stereotype.Component;
import org.yapp.global.config.AppleOauthProperties;

@Component
@RequiredArgsConstructor
public class AppleOauthHelper {

private final AppleOauthProperties appleProperties;
private final AppleIdTokenDecoder appleIdTokenDecoder;

public String generateClientSecret() {
LocalDateTime expiration = LocalDateTime.now().plusMinutes(5);

return Jwts.builder()
.claim("iss", appleProperties.getTeamId())
.claim("aud", appleProperties.getAuthServer())
.claim("sub", appleProperties.getClientId())
.claim("exp", Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant()))
.claim("iat", new Date())
.header()
.add("kid", appleProperties.getKeyId())
.and()
.signWith(getPrivateKey())
.compact();
}

public Claims decodeIdToken(String idToken) {
return this.appleIdTokenDecoder.decodeIdToken(idToken);
}

private PrivateKey getPrivateKey() {
Security.addProvider(new BouncyCastleProvider());
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");

try {
byte[] privateKeyBytes = Base64.getDecoder().decode(appleProperties.getPrivateKey());

PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes);
return converter.getPrivateKey(privateKeyInfo);
} catch (Exception e) {
throw new RuntimeException("Error converting private key from String", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.yapp.domain.auth.application.oauth.apple;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.yapp.domain.auth.application.oauth.OauthClient;
import org.yapp.domain.auth.application.oauth.OauthProvider;
import org.yapp.domain.auth.domain.enums.OauthType;

@Component
@RequiredArgsConstructor
public class AppleOauthProvider implements OauthProvider {

private final AppleOauthClient appleOauthClient;

@Override
public OauthType getOauthType() {
return OauthType.APPLE;
}

@Override
public OauthClient getOAuthClient() {
return this.appleOauthClient;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.yapp.domain.auth.application.oauth.apple.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AppleAuthResponse {

@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("expires_in")
private int expiresIn;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty("id_token")
private String idToken;
}
Loading

0 comments on commit 5b779dd

Please sign in to comment.