Skip to content

Commit

Permalink
feat: 애플 소셜 로그인 기능 구현 (#53)
Browse files Browse the repository at this point in the history
* feat: 애플 회원가입 구현

* refactor: append 메서드를 체이닝 방식으로 리팩토링

* feat: 애플 로그인 구현

* feat: 애플 회원탈퇴 구현

* style: 사용하지 않는 의존성 주입 제거

* fix: 애플 API 통신을 위해 @JsonNaming으로 snake case로 컬럼명 변환

* fix: 사용하지 않는 Transasctional 어노테이션 제거

* style: sonarLint에 따라 변수명 변경

* chore: apple revoke-uri 추가

* fix: 설정 파일에 맞게 키 값 수정

* chore: application-test.yml에 애플 uri 값 추가

* chore: flyway 스크립트 업데이트

* style: sonarLint에 따라 private 변수를 메서드 안으로 이동
  • Loading branch information
leeeeeyeon authored Jan 20, 2024
1 parent 311e45c commit ced7331
Show file tree
Hide file tree
Showing 15 changed files with 438 additions and 23 deletions.
4 changes: 4 additions & 0 deletions packy-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ dependencies {

// sentry
implementation 'io.sentry:sentry-logback:6.26.0'

// apple login
implementation 'org.bouncycastle:bcprov-jdk15on:1.69'
implementation 'org.bouncycastle:bcpkix-jdk14:1.72'
}

test {
Expand Down
194 changes: 194 additions & 0 deletions packy-api/src/main/java/com/dilly/auth/application/AppleService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package com.dilly.auth.application;

import java.io.Reader;
import java.io.StringReader;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;

import com.dilly.auth.AppleAccount;
import com.dilly.auth.model.AppleAccountInfo;
import com.dilly.auth.model.ApplePublicKey;
import com.dilly.auth.model.ApplePublicKey.Key;
import com.dilly.auth.model.AppleToken;
import com.dilly.global.exception.InternalServerException;
import com.dilly.global.response.ErrorCode;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class AppleService {

@Value("${security.oauth2.provider.apple.audience-uri}")
private String appleAudienceUri;
@Value("${security.oauth2.provider.apple.token-uri}")
private String appleTokenUri;
@Value("${security.oauth2.provider.apple.key-uri}")
private String appleKeyUri;
@Value("${security.oauth2.provider.apple.revoke-uri}")
private String appleRevokeUri;
@Value("${security.oauth2.provider.apple.team-id}")
private String appleTeamId;
@Value("${security.oauth2.provider.apple.client-id}")
private String appleClientId;
@Value("${security.oauth2.provider.apple.key-id}")
private String appleKeyId;
@Value("${security.oauth2.provider.apple.private-key}")
private String applePrivateKey;

public AppleToken getAppleToken(String providerAccessToken) {
try {
WebClient webClient = WebClient.builder()
.baseUrl(appleTokenUri)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.build();

MultiValueMap<String, String> bodyData = new LinkedMultiValueMap<>();
bodyData.add("client_id", appleClientId);
bodyData.add("client_secret", getAppleClientSecret());
bodyData.add("code", providerAccessToken);
bodyData.add("grant_type", "authorization_code");

return webClient.post()
.body(BodyInserters.fromFormData(bodyData))
.retrieve()
.bodyToMono(AppleToken.class)
.block();
} catch (Exception e) {
throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR);
}
}

public AppleAccountInfo getAppleAccountInfo(String idToken) {
try {
WebClient webClient = WebClient.builder()
.baseUrl(appleKeyUri)
.build();

ApplePublicKey applePublicKey = webClient.get()
.retrieve()
.bodyToMono(ApplePublicKey.class)
.block();

String headerOfIdToken = idToken.substring(0, idToken.indexOf("."));

Map<String, String> header = new ObjectMapper().readValue(
new String(Base64.getUrlDecoder().decode(headerOfIdToken), StandardCharsets.UTF_8),
Map.class
);
Key key = applePublicKey.getMatchedKeyBy(header.get("kid"), header.get("alg"))
.orElseThrow(() -> new NullPointerException("Apple ID 서버에서 공개키를 가져오지 못했습니다."));

byte[] nBytes = Base64.getUrlDecoder().decode(key.getN());
byte[] eBytes = Base64.getUrlDecoder().decode(key.getE());

BigInteger n = new BigInteger(1, nBytes);
BigInteger e = new BigInteger(1, eBytes);

RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
KeyFactory keyFactory = KeyFactory.getInstance(key.getKty());
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);

Claims memberInfo = Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(idToken).getBody();

Map<String, Object> expectedMap = new HashMap<>(memberInfo);

return new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.convertValue(expectedMap, AppleAccountInfo.class);
} catch (Exception e) {
throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR);
}
}

private String getAppleClientSecret() {
Date expirationDate = Date.from(
LocalDateTime.now().plusDays(180).atZone(ZoneId.systemDefault()).toInstant()
);

try {
return Jwts.builder()
.setHeaderParam("kid", appleKeyId)
.setHeaderParam("alg", "ES256")
.setIssuer(appleTeamId)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(expirationDate)
.setAudience(appleAudienceUri)
.setSubject(appleClientId)
.signWith(getApplePrivateKey(), SignatureAlgorithm.ES256)
.compact();
} catch (Exception e) {
throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR);
}
}

private PrivateKey getApplePrivateKey() {
try {
ClassPathResource resource = new ClassPathResource(applePrivateKey);
String privateKey = new String(resource.getInputStream().readAllBytes());
Reader pemReader = new StringReader(privateKey);
PEMParser pemParser = new PEMParser(pemReader);
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
PrivateKeyInfo object = (PrivateKeyInfo)pemParser.readObject();

return converter.getPrivateKey(object);
} catch (Exception e) {
throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR);
}
}

public void revokeAppleAccount(AppleAccount appleAccount) {
try {
String appleRefreshToken = appleAccount.getRefreshToken();

WebClient webClient = WebClient.builder()
.baseUrl(appleRevokeUri)
.build();

MultiValueMap<String, String> bodyData = new LinkedMultiValueMap<>();
bodyData.add("client_id", appleClientId);
bodyData.add("client_secret", getAppleClientSecret());
bodyData.add("token", appleRefreshToken);
bodyData.add("token_type_hint", "refresh_token");

webClient.post()
.body(BodyInserters.fromFormData(bodyData))
.retrieve()
.bodyToMono(Void.class)
.block();

} catch (Exception e) {
throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.dilly.auth.AppleAccount;
import com.dilly.auth.KakaoAccount;
import com.dilly.auth.domain.AppleAccountReader;
import com.dilly.auth.domain.AppleAccountWriter;
import com.dilly.auth.domain.KakaoAccountReader;
import com.dilly.auth.domain.KakaoAccountWriter;
import com.dilly.auth.dto.request.SignupRequest;
import com.dilly.auth.dto.response.SignInResponse;
import com.dilly.auth.model.AppleAccountInfo;
import com.dilly.auth.model.AppleToken;
import com.dilly.auth.model.KakaoResource;
import com.dilly.gift.ProfileImage;
import com.dilly.global.exception.UnsupportedException;
Expand Down Expand Up @@ -40,11 +45,14 @@ public class AuthService {

private final JwtService jwtService;
private final KakaoService kakaoService;
private final AppleService appleService;
private final MemberReader memberReader;
private final MemberWriter memberWriter;
private final ProfileImageReader profileImageReader;
private final KakaoAccountReader kakaoAccountReader;
private final KakaoAccountWriter kakaoAccountWriter;
private final AppleAccountReader appleAccountReader;
private final AppleAccountWriter appleAccountWriter;
private final JwtReader jwtReader;
private final JwtWriter jwtWriter;

Expand All @@ -65,6 +73,17 @@ public JwtResponse signUp(String providerAccessToken, SignupRequest signupReques

case "apple" -> {
provider = APPLE;
AppleToken appleToken = appleService.getAppleToken(providerAccessToken);
AppleAccountInfo appleAccountInfo = appleService.getAppleAccountInfo(appleToken.idToken());
appleAccountReader.isAppleAccountPresent(appleAccountInfo.sub());

member = memberWriter.save(signupRequest.toEntity(provider, profileImage));
appleAccountWriter.save(AppleAccount.builder()
.id(appleAccountInfo.sub())
.member(member)
.refreshToken(appleToken.refreshToken())
.build()
);
}

default -> throw new UnsupportedException(ErrorCode.UNSUPPORTED_LOGIN_TYPE);
Expand All @@ -81,6 +100,12 @@ public SignInResponse signIn(String provider, String providerAccessToken) {
member = kakaoAccountReader.getMemberById(kakaoResource.getId());
}

case "apple" -> {
AppleToken appleToken = appleService.getAppleToken(providerAccessToken);
AppleAccountInfo appleAccountInfo = appleService.getAppleAccountInfo(appleToken.idToken());
member = appleAccountReader.getMemberById(appleAccountInfo.sub());
}

default -> throw new UnsupportedException(ErrorCode.UNSUPPORTED_LOGIN_TYPE);
}

Expand Down Expand Up @@ -110,7 +135,13 @@ public String withdraw() {
kakaoService.unlinkKakaoAccount(kakaoAccount);
kakaoAccountWriter.delete(kakaoAccount);
}


case APPLE -> {
AppleAccount appleAccount = appleAccountReader.findByMember(member);
appleService.revokeAppleAccount(appleAccount);
appleAccountWriter.delete(appleAccount);
}

default -> throw new UnsupportedException(ErrorCode.UNSUPPORTED_LOGIN_TYPE);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;

import com.dilly.auth.KakaoAccount;
import com.dilly.auth.domain.KakaoAccountReader;
import com.dilly.auth.model.KakaoResource;
import com.dilly.global.exception.InternalServerException;
import com.dilly.global.response.ErrorCode;
Expand All @@ -32,28 +30,25 @@

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class KakaoService {

private final KakaoAccountReader kakaoAccountReader;

@Value("${security.oauth2.provider.kakao.token-uri}")
private String KAKAO_TOKEN_URI;
private String kakaoTokenUri;
@Value("${security.oauth2.provider.kakao.user-info-uri}")
private String KAKAO_USER_INFO_URI;
private String kakaoUserInfoUri;
@Value("${security.oauth2.provider.kakao.unlink-uri}")
private String KAKAO_UNLINK_URI;
private String kakaoUnlinkUri;
@Value("${security.oauth2.provider.kakao.client-id}")
private String KAKAO_CLIENT_ID;
private String kakaoClientId;
@Value("${security.oauth2.provider.kakao.admin-key}")
private String KAKAO_ADMIN_KEY;
private String BEARER_PREFIX = "Bearer ";
private String kakaoAdminKey;

public KakaoResource getKaKaoAccount(String kakaoAccessToken) {
String bearerPrefix = "Bearer ";
WebClient webClient = WebClient.builder()
.baseUrl(KAKAO_USER_INFO_URI)
.defaultHeader(HttpHeaders.AUTHORIZATION, BEARER_PREFIX + kakaoAccessToken)
.baseUrl(kakaoUserInfoUri)
.defaultHeader(HttpHeaders.AUTHORIZATION, bearerPrefix + kakaoAccessToken)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.build();

Expand All @@ -71,18 +66,19 @@ public String getKakaoAccessToken(String code) {
String access_Token = "";

try {
URL url = new URL(KakaoService.this.KAKAO_TOKEN_URI);
URL url = new URL(KakaoService.this.kakaoTokenUri);
HttpURLConnection conn = (HttpURLConnection)url.openConnection();

conn.setRequestMethod("POST");
conn.setDoOutput(true);

BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
StringBuilder sb = new StringBuilder();
sb.append("grant_type=authorization_code");
sb.append("&client_id=" + KAKAO_CLIENT_ID);
sb.append("&redirect_uri=http://127.0.0.1:9000/kakaocallback");
sb.append("&code=" + code);
sb.append("grant_type=authorization_code")
.append("&client_id=")
.append(kakaoClientId)
.append("&redirect_uri=http://127.0.0.1:9000/kakaocallback")
.append("&code=" + code);
bw.write(sb.toString());
bw.flush();

Expand All @@ -106,8 +102,8 @@ public String getKakaoAccessToken(String code) {

public void unlinkKakaoAccount(KakaoAccount kakaoAccount) {
WebClient webClient = WebClient.builder()
.baseUrl(KAKAO_UNLINK_URI)
.defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + KAKAO_ADMIN_KEY)
.baseUrl(kakaoUnlinkUri)
.defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoAdminKey)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.build();

Expand Down
Loading

0 comments on commit ced7331

Please sign in to comment.