Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Expand Up @@ -128,7 +128,7 @@ public void volunteersLogout(HttpServletRequest request, String email) {

redisUtil.delete(roleName, volunteer.getId());
volunteerFcmRepository.deleteByVolunteerId(volunteer.getId());
redisUtil.setBlackList(accessToken, "accessToken", jwtService.getAccessTokenExpirationPeriod());
redisUtil.setBlackList(accessToken, "socialToken", jwtService.getAccessTokenExpirationPeriod());
}

public void intermediariesLogout(HttpServletRequest request, String email) {
Expand All @@ -138,7 +138,7 @@ public void intermediariesLogout(HttpServletRequest request, String email) {

redisUtil.delete(roleName, intermediary.getId());
intermediaryFcmRepository.deleteByIntermediaryId(intermediary.getId());
redisUtil.setBlackList(accessToken, "accessToken", jwtService.getAccessTokenExpirationPeriod());
redisUtil.setBlackList(accessToken, "socialToken", jwtService.getAccessTokenExpirationPeriod());
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -179,7 +179,7 @@ public void volunteersWithdraw(HttpServletRequest request, String email) {
try {
redisUtil.delete(roleName, volunteer.getId());
volunteerFcmRepository.deleteByVolunteerId(volunteer.getId());
redisUtil.setBlackList(accessToken, "accessToken", jwtService.getAccessTokenExpirationPeriod());
redisUtil.setBlackList(accessToken, "socialToken", jwtService.getAccessTokenExpirationPeriod());

Volunteer deletedVolunteer = volunteerRepository.findByEmail("[email protected]").orElseThrow(() -> new BadRequestException(VOLUNTEER_NOT_FOUND));
List<Review> reviews = reviewRepository.findByVolunteer(volunteer);
Expand Down Expand Up @@ -213,7 +213,7 @@ public void intermediariesWithdraw(HttpServletRequest request, String email) {
try {
redisUtil.delete(roleName, intermediary.getId());
volunteerFcmRepository.deleteByVolunteerId(intermediary.getId());
redisUtil.setBlackList(accessToken, "accessToken", jwtService.getAccessTokenExpirationPeriod());
redisUtil.setBlackList(accessToken, "socialToken", jwtService.getAccessTokenExpirationPeriod());

Intermediary deletedIntermediary = intermediaryRepository.findByEmail("[email protected]").orElseThrow(() -> new BadRequestException(INTERMEDIARY_NOT_FOUND));

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();
String accessToken = request.socialToken();
return client.requestOauthInfo(accessToken);
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public String createRefreshToken(Long id, String roleName) {
public Map<String, String> sendAccessAndRefreshToken(String roleName, String accessToken, String refreshToken) {
Map<String, String> tokens = new HashMap<>();
tokens.put("roleName", roleName);
tokens.put("accessToken", accessToken);
tokens.put("socialToken", accessToken);
tokens.put("refreshToken", refreshToken);
return tokens;
}
Expand Down
Loading
Loading