Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cf491a1
refactor: kakao oauth 관련 값을 ConfigurationProperties 로 변경
nayonsoso Feb 4, 2025
a0b5252
refactor: Oauth -> OAuth 용어 통일
nayonsoso Feb 6, 2025
49cdfea
refactor: SignInService 가 로그인만 담당하도록
nayonsoso Feb 6, 2025
0b9e7c5
refactor: 사용자 정보를 가져오는 의미가 드러나도록 함수명 변경
nayonsoso Feb 6, 2025
84ccc90
refactor: 다양한 OAuth 종류를 포괄하도록 이름 변경
nayonsoso Feb 6, 2025
b761b9b
refactor: OAuthService 추상화
nayonsoso Feb 6, 2025
8c3f986
refactor: oauth 관련 서비스 패키지 이동
nayonsoso Feb 6, 2025
4afb17d
feat: 애플 OAuth 설정 관리 클래스 생성
nayonsoso Feb 5, 2025
e21d8ad
feat: 애플 client secret 생성 클래스 생성
nayonsoso Feb 6, 2025
34bf7a6
feat: 애플 OAuthClient 구현
nayonsoso Feb 6, 2025
14f3510
feat: 애플 OAuthService 구현
nayonsoso Feb 6, 2025
d4d4c52
chore: 주석 내용 수정
nayonsoso Feb 6, 2025
37181d5
refactor: 회원가입 토큰에 가입 방법이 포함되도록 SignUpTokenProvider 수정
nayonsoso Feb 6, 2025
c9d62c3
refactor: 다양한 회원 가입이 가능하도록 회원 가입 로직 수정
nayonsoso Feb 6, 2025
f6ee7d6
feat: 애플 인증 엔드포인트 추가
nayonsoso Feb 6, 2025
9e732e9
feat: 애플 공개키를 가져와서 id_token 을 하도록
nayonsoso Feb 6, 2025
428de7a
refactor: 공개키를 캐싱하도록
nayonsoso Feb 6, 2025
3aca3f9
refactor: 잘못된 만료 시간 수정
nayonsoso Feb 6, 2025
ce1d144
refactor: 응답 필드 명 변경
nayonsoso Feb 6, 2025
eded031
refactor: code 요청 유효성 검사 추가
nayonsoso Feb 6, 2025
473cd20
chore: 주석 수정
nayonsoso Feb 7, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.example.solidconnection.auth.client;

import com.example.solidconnection.auth.dto.oauth.AppleTokenDto;
import com.example.solidconnection.auth.dto.oauth.AppleUserInfoDto;
import com.example.solidconnection.config.client.AppleOAuthClientProperties;
import com.example.solidconnection.custom.exception.CustomException;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
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 java.security.PublicKey;
import java.util.Objects;

import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED;
import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN;

/*
* 애플 인증을 위한 OAuth2 클라이언트
* https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens
* */
@Component
@RequiredArgsConstructor
public class AppleOAuthClient {

private final RestTemplate restTemplate;
private final AppleOAuthClientProperties properties;
private final AppleOAuthClientSecretProvider clientSecretProvider;
private final ApplePublicKeyProvider publicKeyProvider;

public AppleUserInfoDto processOAuth(String code) {
String idToken = requestIdToken(code);
PublicKey applePublicKey = publicKeyProvider.getApplePublicKey(idToken);
return new AppleUserInfoDto(parseEmailFromToken(applePublicKey, idToken));
}

public String requestIdToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> formData = buildFormData(code);

try {
ResponseEntity<AppleTokenDto> response = restTemplate.exchange(
properties.tokenUrl(),
HttpMethod.POST,
new HttpEntity<>(formData, headers),
AppleTokenDto.class
);
return Objects.requireNonNull(response.getBody()).idToken();
} catch (Exception e) {
throw new CustomException(APPLE_AUTHORIZATION_FAILED, e.getMessage());
}
}

private MultiValueMap<String, String> buildFormData(String code) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", properties.clientId());
formData.add("client_secret", clientSecretProvider.generateClientSecret());
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", properties.redirectUrl());
return formData;
}

private String parseEmailFromToken(PublicKey applePublicKey, String idToken) {
try {
return Jwts.parser()
.setSigningKey(applePublicKey)
.parseClaimsJws(idToken)
.getBody()
.get("email", String.class);
} catch (Exception e) {
throw new CustomException(INVALID_APPLE_ID_TOKEN);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.example.solidconnection.auth.client;

import com.example.solidconnection.config.client.AppleOAuthClientProperties;
import com.example.solidconnection.custom.exception.CustomException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.stereotype.Component;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;
import java.util.stream.Collectors;

import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY;

/*
* 애플 OAuth 에 필요하 클라이언트 시크릿은 매번 동적으로 생성해야 한다.
* 클라이언트 시크릿은 애플 개발자 계정에서 발급받은 개인키(*.p8)를 사용하여 JWT 를 생성한다.
* https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
* */
@Component
@RequiredArgsConstructor
public class AppleOAuthClientSecretProvider {

private static final String KEY_ID_HEADER = "kid";
private static final long TOKEN_DURATION = 1000 * 60 * 10; // 10min
private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8";

private final AppleOAuthClientProperties appleOAuthClientProperties;
private PrivateKey privateKey;

@PostConstruct
private void initPrivateKey() {
privateKey = readPrivateKey();
}

public String generateClientSecret() {
Date now = new Date();
Date expiration = new Date(now.getTime() + TOKEN_DURATION);

return Jwts.builder()
.setHeaderParam("alg", "ES256")
.setHeaderParam(KEY_ID_HEADER, appleOAuthClientProperties.keyId())
.setSubject(appleOAuthClientProperties.clientId())
.setIssuer(appleOAuthClientProperties.teamId())
.setAudience(appleOAuthClientProperties.clientSecretAudienceUrl())
.setExpiration(expiration)
.signWith(SignatureAlgorithm.ES256, privateKey)
.compact();
}

private PrivateKey readPrivateKey() {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(SECRET_KEY_PATH);
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {

String secretKey = reader.lines().collect(Collectors.joining("\n"));
byte[] encoded = Base64.decodeBase64(secretKey);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded);
KeyFactory keyFactory = KeyFactory.getInstance("EC");
return keyFactory.generatePrivate(keySpec);
} catch (Exception e) {
throw new CustomException(FAILED_TO_READ_APPLE_PRIVATE_KEY);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.example.solidconnection.auth.client;

import com.example.solidconnection.config.client.AppleOAuthClientProperties;
import com.example.solidconnection.custom.exception.CustomException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED;
import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND;
import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN;
import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe;

/*
* idToken 검증을 위해서 애플의 공개키를 가져온다.
* - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다.
* - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다.
* - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다.
* https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature
* */
@Component
@RequiredArgsConstructor
public class ApplePublicKeyProvider {

private final AppleOAuthClientProperties properties;
private final RestTemplate restTemplate;

private final Map<String, PublicKey> applePublicKeyCache = new ConcurrentHashMap<>();

public PublicKey getApplePublicKey(String idToken) {
try {
String kid = getKeyIdFromTokenHeader(idToken);
if (applePublicKeyCache.containsKey(kid)) {
return applePublicKeyCache.get(kid);
}

fetchApplePublicKeys();
if (applePublicKeyCache.containsKey(kid)) {
return applePublicKeyCache.get(kid);
} else {
throw new CustomException(APPLE_PUBLIC_KEY_NOT_FOUND);
}
} catch (ExpiredJwtException e) {
throw new CustomException(APPLE_ID_TOKEN_EXPIRED);
} catch (Exception e) {
throw new CustomException(INVALID_APPLE_ID_TOKEN);
}
}

/*
* idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다
* 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다.
* */
private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException {
String[] jwtParts = idToken.split("\\.");
if (jwtParts.length < 2) {
throw new CustomException(INVALID_APPLE_ID_TOKEN);
}
String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0]), StandardCharsets.UTF_8);
return new ObjectMapper().readTree(headerJson).get("kid").asText();
}

private void fetchApplePublicKeys() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResponseEntity<String> response = restTemplate.getForEntity(properties.publicKeyUrl(), String.class);
JsonNode jsonNode = objectMapper.readTree(response.getBody()).get("keys");

applePublicKeyCache.clear();
for (JsonNode key : jsonNode) {
applePublicKeyCache.put(key.get("kid").asText(), generatePublicKey(key));
}
}

private PublicKey generatePublicKey(JsonNode key) throws Exception {
BigInteger modulus = new BigInteger(1, decodeBase64URLSafe(key.get("n").asText()));
BigInteger exponent = new BigInteger(1, decodeBase64URLSafe(key.get("e").asText()));
RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent);
return KeyFactory.getInstance("RSA").generatePublic(spec);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.example.solidconnection.auth.client;

import com.example.solidconnection.auth.dto.kakao.KakaoTokenDto;
import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto;
import com.example.solidconnection.auth.dto.oauth.KakaoTokenDto;
import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto;
import com.example.solidconnection.config.client.KakaoOAuthClientProperties;
import com.example.solidconnection.custom.exception.CustomException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
Expand All @@ -20,38 +20,24 @@
import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH;
import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_USER_INFO_FAIL;

/*
* 카카오 인증을 위한 OAuth2 클라이언트
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
* */
@Component
@RequiredArgsConstructor
public class KakaoOAuthClient {

private final RestTemplate restTemplate;
private final KakaoOAuthClientProperties kakaoOAuthClientProperties;

@Value("${kakao.redirect_uri}")
public String redirectUri;

@Value("${kakao.client_id}")
private String clientId;

@Value("${kakao.token_url}")
private String tokenUrl;

@Value("${kakao.user_info_url}")
private String userInfoUrl;

/*
* 클라이언트에서 사용자가 카카오 로그인을 하면, 클라이언트는 '카카오 인가 코드'를 받아, 서버에 넘겨준다.
* - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code
* 서버는 카카오 인증 코드를 사용해 카카오 서버로부터 '카카오 토큰'을 받아온다.
* - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token
* 그리고 카카오 엑세스 토큰으로 카카오 서버에 요청해 '카카오 사용자 정보'를 받아온다.
* - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
* */
public KakaoUserInfoDto processOauth(String code) {
public KakaoUserInfoDto getUserInfo(String code) {
String kakaoAccessToken = getKakaoAccessToken(code);
return getKakaoUserInfo(kakaoAccessToken);
}

// 카카오 토큰 요청
private String getKakaoAccessToken(String code) {
try {
ResponseEntity<KakaoTokenDto> response = restTemplate.exchange(
Expand All @@ -72,30 +58,26 @@ private String getKakaoAccessToken(String code) {
}
}

// 카카오 엑세스 토큰 요청하는 URI 생성
private String buildTokenUri(String code) {
return UriComponentsBuilder.fromHttpUrl(tokenUrl)
return UriComponentsBuilder.fromHttpUrl(kakaoOAuthClientProperties.tokenUrl())
.queryParam("grant_type", "authorization_code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("client_id", kakaoOAuthClientProperties.clientId())
.queryParam("redirect_uri", kakaoOAuthClientProperties.redirectUrl())
.queryParam("code", code)
.toUriString();
}

// 카카오 사용자 정보 요청
private KakaoUserInfoDto getKakaoUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

// 사용자의 정보 요청
ResponseEntity<KakaoUserInfoDto> response = restTemplate.exchange(
userInfoUrl,
kakaoOAuthClientProperties.userInfoUrl(),
HttpMethod.GET,
new HttpEntity<>(headers),
KakaoUserInfoDto.class
);

// 응답 예외처리
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
return response.getBody();
} else {
Expand Down
Loading