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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ dependencies {
implementation 'com.google.firebase:firebase-admin:6.8.1'
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2'
implementation 'com.squareup.okio:okio:2.7.0'
// AuthO
implementation 'com.auth0:java-jwt:3.19.2'
implementation 'com.auth0:jwks-rsa:0.21.1'
}

// Querydsl 설정부
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.pawwithu.connectdog.domain.oauth.api;

import com.pawwithu.connectdog.domain.oauth.dto.response.OAuthInfoResponse;
import com.pawwithu.connectdog.domain.oauth.service.AppleIdTokenDecodeService;
import com.pawwithu.connectdog.domain.volunteer.entity.SocialType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
public class AppleApiClient implements OAuthApiClient {

private final AppleIdTokenDecodeService appleIdTokenDecodeService;

private String apiUrl = "https://kapi.kakao.com";
private final RestTemplate restTemplate;

@Override
public SocialType socialType() {
return SocialType.APPLE;
}

@Override
public OAuthInfoResponse requestOauthInfo(String socialToken) {
return appleIdTokenDecodeService.getPayloadFromIdToken(socialToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.pawwithu.connectdog.domain.oauth.api;

import com.pawwithu.connectdog.domain.oauth.dto.response.OidcPublicKeyListResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
public class AppleOidcKeyClient {

private final RestTemplate restTemplate;

public OidcPublicKeyListResponse getAppleOidcOpenKeys() {
String appleOidcPublicKeyUrl = "https://appleid.apple.com/auth/keys";
return restTemplate.getForObject(appleOidcPublicKeyUrl, OidcPublicKeyListResponse.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ public SocialType socialType() {
}

@Override
public OAuthInfoResponse requestOauthInfo(String accessToken) {
public OAuthInfoResponse requestOauthInfo(String socialToken) {
String url = apiUrl + "/v2/user/me";

HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
httpHeaders.set("Authorization", "Bearer " + accessToken);
httpHeaders.set("Authorization", "Bearer " + socialToken);

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("property_keys", "[\"id\"]"); // id 값만 받아옴
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ public SocialType socialType() {
}

@Override
public OAuthInfoResponse requestOauthInfo(String accessToken) {
public OAuthInfoResponse requestOauthInfo(String socialToken) {
String url = apiUrl + "/v1/nid/me";

HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
httpHeaders.set("Authorization", "Bearer " + accessToken);
httpHeaders.set("Authorization", "Bearer " + socialToken);

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@

public interface OAuthApiClient {
SocialType socialType();
OAuthInfoResponse requestOauthInfo(String accessToken);
OAuthInfoResponse requestOauthInfo(String socialToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public class OAuthController {
@Operation(summary = "이동봉사자 소셜 로그인", description = "이동봉사자 소셜 로그인을 합니다.",
responses = {@ApiResponse(responseCode = "204", description = "이동봉사자 소셜 로그인 성공")
, @ApiResponse(responseCode = "400"
, description = "V1, AccessToken은 필수 입력 값입니다. \t\n V1, provider는 필수 입력 값입니다. \t\n M1, 해당 이동봉사자를 찾을 수 없습니다. \t\n A5, provider 값이 KAKAO 또는 NAVER가 아닙니다."
, description = "V1, AccessToken/IdToken은 필수 입력 값입니다. \t\n V1, provider는 필수 입력 값입니다. \t\n M1, 해당 이동봉사자를 찾을 수 없습니다. \t\n A5, provider 값이 KAKAO/NAVER/APPLE 중에 없습니다." +
" \t\n A10, 애플 공개 키를 찾을 수 없습니다. \t\n A11, 애플 id_token 검증에 실패하였습니다. \t\n A12, 애플에서 발급된 토큰이 아닙니다. \t\n A13 코넥독 앱에서 발급된 토큰이 아닙니다."
, content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
@PostMapping("/volunteers/login/social")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import jakarta.validation.constraints.NotNull;

public record SocialLoginRequest(
@NotBlank(message = "AccessToken은 필수 입력 값입니다.")
String accessToken,
@NotBlank(message = "AccessToken/IdToken은 필수 입력 값입니다.")
String socialToken,
@NotNull(message = "provider는 필수 입력 값입니다.")
SocialType provider) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.pawwithu.connectdog.domain.oauth.dto.response;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.pawwithu.connectdog.domain.volunteer.entity.SocialType;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
@AllArgsConstructor
public class AppleInfoResponse implements OAuthInfoResponse {
@JsonProperty("id")
private String id; // 애플이 제공하는 사용자 고유 ID (sub)

@Override
public SocialType getSocialType() {
return SocialType.APPLE;
}
@Override
public String getId() {
return id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.pawwithu.connectdog.domain.oauth.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.List;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class OidcPublicKeyListResponse {
private List<OidcPublicKeyResponse> keys;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.pawwithu.connectdog.domain.oauth.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class OidcPublicKeyResponse {
private String kty;
private String kid;
private String use;
private String alg;
private String n;
private String e;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.pawwithu.connectdog.domain.oauth.service;

import com.pawwithu.connectdog.domain.oauth.api.AppleOidcKeyClient;
import com.pawwithu.connectdog.domain.oauth.dto.response.AppleInfoResponse;
import com.pawwithu.connectdog.error.exception.custom.BadRequestException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.pawwithu.connectdog.error.ErrorCode.APPLE_VALIDATED_ERROR;
import static com.pawwithu.connectdog.error.ErrorCode.NOT_FOUND_APPLE_PUBLIC_KEY;

@Service
@RequiredArgsConstructor
public class AppleIdTokenDecodeService {

private final AppleOidcKeyClient appleOidcKeyClient;

private final OidcJwtDecoder oidcJwtDecoder;
@Value("${oauth.apple.iss}")
private String iss;

@Value("${oauth.apple.client-id}")
private String clientId;

@Transactional
public AppleInfoResponse getPayloadFromIdToken(final String token) {
try {
// 1) JWT 헤더에서 kid 값 추출 (issuer 및 audience 검증 포함)
final String kid = oidcJwtDecoder.getKidFromUnsignedTokenHeader(token, clientId);

// 2) 애플의 공개 키 가져오기
final var applePublicKeyList = appleOidcKeyClient.getAppleOidcOpenKeys();
final var oidcPublicKey = applePublicKeyList.getKeys().stream()
.filter(o -> o.getKid().equals(kid))
.findFirst()
.orElseThrow(() -> new BadRequestException(NOT_FOUND_APPLE_PUBLIC_KEY));

// 3) id_token 검증 및 디코딩
return oidcJwtDecoder.getOidcTokenBody(token);
} catch (Exception e) {
throw new BadRequestException(APPLE_VALIDATED_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.pawwithu.connectdog.domain.oauth.service;

import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.JwkProviderBuilder;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.pawwithu.connectdog.domain.oauth.dto.response.AppleInfoResponse;
import com.pawwithu.connectdog.error.exception.custom.BadRequestException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.net.URL;
import java.security.interfaces.RSAPublicKey;
import java.util.concurrent.TimeUnit;

import static com.pawwithu.connectdog.error.ErrorCode.*;

@Slf4j
@Component
public class OidcJwtDecoder {

private static final String APPLE_OIDC_URL = "https://appleid.apple.com/auth/keys";
private static final String APPLE_ISSUER = "https://appleid.apple.com";
private final JwkProvider jwkProvider;

public OidcJwtDecoder() throws Exception {
this.jwkProvider = new JwkProviderBuilder(new URL(APPLE_OIDC_URL))
.cached(10, 24, TimeUnit.HOURS) // 최대 10개 키를 24시간 캐싱
.rateLimited(10, 1, TimeUnit.MINUTES) // 1분당 최대 10개 요청 제한
.build();
}

public String getKidFromUnsignedTokenHeader(String token, String clientId) { // id_token 헤더에서 kid 값 추출 및 issuer/audience 검증
try {
DecodedJWT jwt = JWT.decode(token);

// 1) Issuer 검증 (애플에서 발급된 토큰인지 확인)
if (!jwt.getIssuer().equals(APPLE_ISSUER)) {
log.info("Invalid id_token issuer: " + jwt.getIssuer());
throw new BadRequestException(INVALID_ID_TOKEN_ISSUER);
}

// 2) Audience 검증 (내 앱에서 발급된 토큰인지 확인)
if (!jwt.getAudience().contains(clientId)) {
log.info("Invalid id_token audience: " + jwt.getAudience());
throw new BadRequestException(INVALID_ID_TOKEN_AUDIENCE);
}

return jwt.getKeyId(); // kid 반환
} catch (Exception e) {
throw new BadRequestException(APPLE_VALIDATED_ERROR);
}
}

public AppleInfoResponse getOidcTokenBody(String token) { // 공개 키를 사용하여 id_token 서명 검증 및 디코딩
try {
DecodedJWT jwt = JWT.decode(token);

// 1) kid 값으로 공개 키 가져오기
Jwk jwk = jwkProvider.get(jwt.getKeyId());
RSAPublicKey publicKey = (RSAPublicKey) jwk.getPublicKey();

// 2) 알고리즘 검증 및 id_token 서명 검증
Algorithm algorithm = Algorithm.RSA256(publicKey, null);
jwt = JWT.require(algorithm)
.withIssuer(APPLE_ISSUER) // 애플에서 발급된 토큰인지 재검증
.build()
.verify(token);

// 3) 사용자 정보 추출
String sub = jwt.getSubject();

return new AppleInfoResponse(sub);
} catch (Exception e) {
throw new BadRequestException(APPLE_VALIDATED_ERROR);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public RequestOAuthInfoService(List<OAuthApiClient> clients) {

public OAuthInfoResponse request(SocialLoginRequest request) {
OAuthApiClient client = clients.get(request.provider());
String accessToken = request.accessToken();
return client.requestOauthInfo(accessToken);
String socialToken = request.socialToken();
return client.requestOauthInfo(socialToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import static com.pawwithu.connectdog.error.ErrorCode.UNKNOWN_PROVIDER;

public enum SocialType {
KAKAO, NAVER;
KAKAO, NAVER, APPLE;

@JsonCreator // 이 어노테이션은 Jackson이 JSON에서 객체로 변환할 때 사용
public static SocialType fromString(String value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class Volunteer extends BaseTimeEntity {
@Enumerated(EnumType.STRING)
private VolunteerRole role; // 권한
@Enumerated(EnumType.STRING)
private SocialType socialType; // KAKAO, NAVER
private SocialType socialType; // KAKAO, NAVER, APPLE
private String socialId; // 로그인한 소셜 타입 식별자 값 (일반 로그인의 경우 null)
private Boolean isOptionAgr; // 선택 이용약관 체크 여부
private Boolean notification; // 알림 true, false
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/com/pawwithu/connectdog/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ public enum ErrorCode {
ALREADY_EXIST_NICKNAME("A2", "이미 사용 중인 닉네임입니다."),
ALREADY_LOGOUT_MEMBER("A3", "이미 로그아웃한 회원입니다"),
EMAIL_SEND_ERROR("A4", "이메일 인증 코드 전송을 실패했습니다."),
UNKNOWN_PROVIDER("A5", "provider 값이 KAKAO 또는 NAVER가 아닙니다."),
UNKNOWN_PROVIDER("A5", "provider 값이 KAKAO/NAVER/APPLE 중에 없습니다."),
NOT_ALLOWED_MEMBER("A6", "해당 요청에 대한 권한이 없습니다."),
NOT_AUTHENTICATED_REQUEST("A7", "유효한 JWT 토큰이 없습니다."),
ALREADY_EXIST_PHONE("A8", "이미 등록된 전화번호입니다."),
ALREADY_EXIST_NAME("A9", "이미 등록된 모집자명입니다."),

NOT_FOUND_APPLE_PUBLIC_KEY("A10", "애플 공개 키를 찾을 수 없습니다."),
APPLE_VALIDATED_ERROR("A11", "애플 id_token 검증에 실패하였습니다."),
INVALID_ID_TOKEN_ISSUER("A12", "애플에서 발급된 토큰이 아닙니다."),
INVALID_ID_TOKEN_AUDIENCE("A13", "코넥독 앱에서 발급된 토큰이 아닙니다."),

VOLUNTEER_NOT_FOUND("M1", "해당 이동봉사자를 찾을 수 없습니다."), // Member -> M (이동봉사자, 이동봉사 중개 통일)
INTERMEDIARY_NOT_FOUND("M2", "해당 이동봉사 중개를 찾을 수 없습니다."),
Expand Down
Loading
Loading