Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ services:
KAKAO_OAUTH_REDIRECT_URIS: ${KAKAO_OAUTH_REDIRECT_URIS}

APPLE_OAUTH_ALLOWED_AUDIENCES: ${APPLE_OAUTH_ALLOWED_AUDIENCES}
APPLE_OAUTH_TEAM_ID: ${APPLE_OAUTH_TEAM_ID}
APPLE_OAUTH_KEY_ID: ${APPLE_OAUTH_KEY_ID}
APPLE_OAUTH_PRIVATE_KEY: ${APPLE_OAUTH_PRIVATE_KEY}

OAUTH_CREDENTIAL_SECRET: ${OAUTH_CREDENTIAL_SECRET}
OAUTH_CREDENTIAL_SALT: ${OAUTH_CREDENTIAL_SALT}

DISCORD_ERROR_WEBHOOK_URL: ${DISCORD_ERROR_WEBHOOK_URL}
DISCORD_REPORT_WEBHOOK_URL: ${DISCORD_REPORT_WEBHOOK_URL}
Expand Down
19 changes: 19 additions & 0 deletions scripts/add-social-oauth-credential.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Apple/카카오 revoke(#19)용 social_oauth_credential 테이블 추가
--
-- 운영 프로파일은 ddl-auto: validate라 이 테이블이 없으면 기동 자체가 실패한다.
-- 이 스크립트는 코드 배포 "전"에 실행되어야 한다.
--
-- 실행 전 필수 확인사항:
-- 1. APPLE_OAUTH_TEAM_ID / APPLE_OAUTH_KEY_ID / APPLE_OAUTH_PRIVATE_KEY 환경변수 준비 완료
-- 2. OAUTH_CREDENTIAL_SECRET / OAUTH_CREDENTIAL_SALT 환경변수 준비 완료 (한 번 정하면 이후 바꾸면 기존 암호문을 복호화할 수 없다)

CREATE TABLE social_oauth_credential (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
member_social_account_id BIGINT NOT NULL,
provider VARCHAR(20) NOT NULL,
refresh_token VARCHAR(1000) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,

CONSTRAINT uk_social_oauth_credential_member_social_account_id UNIQUE (member_social_account_id)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.cotato.nextstation.domain.auth.client;

import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.List;

// Apple REST API(토큰 교환·revoke)에 필요한 client_secret은 Apple이 발급해주는 고정값이 아니라,
// 우리가 매번 ES256으로 서명해서 만드는 JWT다. Team ID/Key ID/.p8 프라이빗 키는 Apple Developer
// 콘솔에서 "Sign In with Apple" capability로 발급받은 Key 하나로 얻는다(allowed-audiences와 별개 크레덴셜).
@Component
public class AppleClientSecretGenerator {

private static final String AUDIENCE = "https://appleid.apple.com";

// Apple 문서상 exp는 최대 6개월까지 허용하지만, 매 요청 직전에 새로 만들어 쓰므로 짧게 잡아 유출 시 악용 창을 최소화한다.
private static final Duration CLIENT_SECRET_EXPIRATION = Duration.ofMinutes(5);

private final String teamId;
private final String keyId;
private final String rawPrivateKey;
private final String clientId;

public AppleClientSecretGenerator(@Value("${apple.oauth.team-id:}") String teamId,
@Value("${apple.oauth.key-id:}") String keyId,
@Value("${apple.oauth.private-key:}") String rawPrivateKey,
@Value("${apple.oauth.allowed-audiences}") List<String> allowedAudiences) {
this.teamId = teamId;
this.keyId = keyId;
this.rawPrivateKey = rawPrivateKey;
// client_secret의 sub 클레임은 identity token의 aud와 동일해야 한다 -> 네이티브 Bundle ID를 그대로 쓴다.
// 웹 Services ID를 추가로 지원하게 되면 어떤 클라이언트로 교환하는지에 따라 sub를 구분해야 한다.
this.clientId = allowedAudiences.isEmpty() ? "" : allowedAudiences.get(0);
}

// team-id/key-id/private-key는 Account Holder가 Apple Developer 콘솔에서 발급하는 값이라, 발급 전에는
// 비어 있을 수 있다. AppleOAuthClient(allowed-audiences)와 달리 로그인/가입의 핵심 경로가 아니라서
// 부팅 시점에 막지 않고, 실제로 revoke/토큰 교환을 시도하는 시점에만 지연 검증한다.
public String generate() {
if (teamId.isBlank() || keyId.isBlank() || rawPrivateKey.isBlank()) {
throw new IllegalStateException(
"apple.oauth.team-id/key-id/private-key가 설정되지 않았습니다. Apple Sign In Key 발급 후 채워주세요.");
}

PrivateKey privateKey = parsePrivateKey(rawPrivateKey);
Instant now = Instant.now();

return Jwts.builder()
.header().add("kid", keyId).and()
.issuer(teamId)
.audience().add(AUDIENCE).and()
.subject(clientId)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(CLIENT_SECRET_EXPIRATION)))
.signWith(privateKey, Jwts.SIG.ES256)
.compact();
}

// .p8 파일은 PEM(PKCS#8) 형식이다. 환경변수에는 줄바꿈이 리터럴 "\n"으로 이스케이프돼 들어올 수 있어 둘 다 처리한다.
private PrivateKey parsePrivateKey(String rawPrivateKey) {
try {
String base64Body = rawPrivateKey
.replace("\\n", "\n")
.replaceAll("-----BEGIN (.*)-----", "")
.replaceAll("-----END (.*)-----", "")
.replaceAll("\\s", "");

byte[] decoded = Base64.getDecoder().decode(base64Body);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded);
KeyFactory keyFactory = KeyFactory.getInstance("EC");
return keyFactory.generatePrivate(keySpec);

} catch (Exception e) {
throw new IllegalStateException(
"apple.oauth.private-key 파싱에 실패했습니다. .p8 파일 내용이 올바른지 확인하세요.", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.cotato.nextstation.domain.auth.client;

import com.cotato.nextstation.domain.auth.client.dto.AppleTokenResponse;
import com.cotato.nextstation.global.exception.CustomException;
import com.cotato.nextstation.global.exception.error.GlobalErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import java.net.http.HttpClient;
import java.time.Duration;
import java.util.List;

// Apple REST API 중 identity token 검증 이외의 것(authorizationCode 교환, revoke)을 다루는 클라이언트.
// AppleOAuthClient(JWKS 서명 검증)와 책임이 달라 분리했다 - 이쪽은 client_secret(JWT)로 Apple과 직접 통신한다.
@Slf4j
@Component
public class AppleTokenClient {

private static final String TOKEN_URI = "https://appleid.apple.com/auth/token";
private static final String REVOKE_URI = "https://appleid.apple.com/auth/revoke";

private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(5);

private final AppleClientSecretGenerator clientSecretGenerator;
private final RestClient restClient;
private final String clientId;

public AppleTokenClient(AppleClientSecretGenerator clientSecretGenerator,
@Value("${apple.oauth.allowed-audiences}") List<String> allowedAudiences) {
this.clientSecretGenerator = clientSecretGenerator;

HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(READ_TIMEOUT);

this.restClient = RestClient.builder()
.requestFactory(requestFactory)
.build();
this.clientId = allowedAudiences.isEmpty() ? "" : allowedAudiences.get(0);
}

// authorizationCode는 1회용이라 재시도 시 이미 소모된 코드로는 실패한다. 신규 가입(최초 Apple 인증) 시점에만 호출한다.
public AppleTokenResponse exchangeAuthorizationCode(String authorizationCode) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("client_id", clientId);
form.add("client_secret", clientSecretGenerator.generate());
form.add("code", authorizationCode);
form.add("grant_type", "authorization_code");

try {
return restClient.post()
.uri(TOKEN_URI)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(AppleTokenResponse.class);

} catch (RestClientException e) {
log.warn("Apple authorizationCode 교환 실패", e);
throw new CustomException(GlobalErrorCode.EXTERNAL_API_ERROR);
}
}

// 탈퇴 시 저장해둔 refresh_token을 폐기한다. 이미 폐기된 토큰을 다시 revoke해도 Apple은 보통 200을 반환한다(멱등).
public void revoke(String refreshToken) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("client_id", clientId);
form.add("client_secret", clientSecretGenerator.generate());
form.add("token", refreshToken);
form.add("token_type_hint", "refresh_token");

try {
restClient.post()
.uri(REVOKE_URI)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.toBodilessEntity();

} catch (RestClientException e) {
log.warn("Apple refresh_token revoke 실패", e);
throw new CustomException(GlobalErrorCode.EXTERNAL_API_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.cotato.nextstation.domain.auth.client.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

// POST https://appleid.apple.com/auth/token 응답 매핑용 DTO
@JsonIgnoreProperties(ignoreUnknown = true)
public record AppleTokenResponse(

@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") long expiresIn,

// 이 값을 SocialOauthCredential에 암호화해서 저장해뒀다가, 탈퇴 시 revoke에 사용한다.
@JsonProperty("refresh_token") String refreshToken,

// identityToken과 동일한 값이라 별도로 검증/저장하지 않는다.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id_token에 있는 사용자 식별값이 Apple 사용자와 같은지 한 번 비교한 뒤 refreshToken을 저장하면 더 안전할 것 같아요! 가입 토큰이 섞이는게 일반적인 사용자 흐름에서는 일어나지 않을 일이라 문제될 가능성은 낮아보여서,, 수정이 꼭 필요해 보이진 않지만 보완하면 좋을 것 같아서 의견 남깁니다!!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확실히 한 번 더 검증이 거치면 안정성 측면에서 좋을 것 같네요!!
응답의 id_token의 sub 클레임과 Apple 식별 번호 providerUserId와 대조한 뒤에만 캐싱하도록 반영해두겠습니다!
의견 감사합니다 😊

@JsonProperty("id_token") String idToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ public CommonResponse<SignupResponse> appleSignup(@Valid @RequestBody AppleSignu
SignupResponse response = appleSignupCommandService.signup(
request.appleSignupToken(),
request.agreedTermsIds(),
ClientIpResolver.resolve(httpRequest)
ClientIpResolver.resolve(httpRequest),
request.authorizationCode()
);
return CommonResponse.success(HttpStatus.CREATED, response);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public record AppleSignupRequest(

@Schema(description = "동의한 약관 ID 목록", example = "[1, 2]")
@NotEmpty(message = "동의한 약관 목록은 필수입니다.")
List<Long> agreedTermsIds
List<Long> agreedTermsIds,

@Schema(description = "iOS 네이티브 Sign In with Apple SDK가 identityToken과 함께 발급한 authorizationCode. " +
"탈퇴 시 Apple 쪽 연동을 폐기(revoke)할 수 있도록 refresh_token 교환에 사용한다.", example = "c1a2b3...")
@NotBlank(message = "authorizationCode는 필수입니다.")
String authorizationCode
) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.cotato.nextstation.domain.auth.service.command;

import com.cotato.nextstation.domain.auth.client.AppleTokenClient;
import com.cotato.nextstation.domain.auth.client.dto.AppleTokenResponse;
import com.cotato.nextstation.domain.auth.dto.response.SignupResponse;
import com.cotato.nextstation.domain.auth.entity.MemberTermsAgreement;
import com.cotato.nextstation.domain.auth.exception.AuthErrorCode;
Expand All @@ -11,10 +13,13 @@
import com.cotato.nextstation.domain.member.entity.Member;
import com.cotato.nextstation.domain.member.entity.MemberSocialAccount;
import com.cotato.nextstation.domain.member.entity.MemberStatus;
import com.cotato.nextstation.domain.member.entity.SocialOauthCredential;
import com.cotato.nextstation.domain.member.repository.MemberRepository;
import com.cotato.nextstation.domain.member.repository.MemberSocialAccountRepository;
import com.cotato.nextstation.domain.member.repository.SocialOauthCredentialRepository;
import com.cotato.nextstation.global.exception.CustomException;
import com.cotato.nextstation.global.jwt.JwtProvider;
import com.cotato.nextstation.global.security.OAuthRefreshTokenEncryptor;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
Expand All @@ -39,11 +44,14 @@ public class AppleSignupCommandService {
private final MemberRepository memberRepository;
private final MemberSocialAccountRepository memberSocialAccountRepository;
private final MemberTermsAgreementRepository memberTermsAgreementRepository;
private final SocialOauthCredentialRepository socialOauthCredentialRepository;
private final JwtProvider jwtProvider;
private final TermsAgreementValidator termsAgreementValidator;
private final AppleTokenClient appleTokenClient;
private final OAuthRefreshTokenEncryptor oAuthRefreshTokenEncryptor;

@Transactional
public SignupResponse signup(String appleSignupToken, List<Long> agreedTermsIds, String ipAddress) {
public SignupResponse signup(String appleSignupToken, List<Long> agreedTermsIds, String ipAddress, String authorizationCode) {

AppleSignupClaims appleClaims = resolveAppleClaims(appleSignupToken);
log.info("Apple 회원가입 요청: providerUserId={}", appleClaims.providerUserId());
Expand All @@ -68,9 +76,10 @@ public SignupResponse signup(String appleSignupToken, List<Long> agreedTermsIds,
}

Member member;
MemberSocialAccount socialAccount;
try {
member = memberRepository.save(Member.builder().email(email).build());
memberSocialAccountRepository.save(
socialAccount = memberSocialAccountRepository.save(
MemberSocialAccount.builder()
.memberId(member.getId())
.provider(AuthProvider.APPLE)
Expand All @@ -85,6 +94,8 @@ public SignupResponse signup(String appleSignupToken, List<Long> agreedTermsIds,
throw new CustomException(AuthErrorCode.APPLE_ACCOUNT_ALREADY_REGISTERED);
}

saveOauthCredential(socialAccount.getId(), authorizationCode);

List<MemberTermsAgreement> agreements = agreedTermsIds.stream()
.distinct()
.map(termsConsentId -> MemberTermsAgreement.builder()
Expand Down Expand Up @@ -124,6 +135,27 @@ private String issueSignupToken(Long memberId) {
);
}

// authorizationCode를 refresh_token으로 교환해 암호화 저장한다 - 탈퇴 시 Apple 쪽 연동을 revoke하기 위한 준비.
// 실패해도(예: Apple Key 발급 전, authorizationCode 만료 등) 가입 자체는 그대로 진행한다 - 이건
// "나중에 탈퇴할 때 자동으로 못 끊는다"는 부가 기능 손실일 뿐, 핵심 가입 흐름을 막을 이유가 아니다.
private void saveOauthCredential(Long memberSocialAccountId, String authorizationCode) {
try {
AppleTokenResponse tokenResponse = appleTokenClient.exchangeAuthorizationCode(authorizationCode);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
String encryptedRefreshToken = oAuthRefreshTokenEncryptor.encrypt(tokenResponse.refreshToken());

socialOauthCredentialRepository.save(
SocialOauthCredential.builder()
.memberSocialAccountId(memberSocialAccountId)
.provider(AuthProvider.APPLE)
.refreshToken(encryptedRefreshToken)
.build()
);
} catch (Exception e) {
log.warn("Apple refresh_token 저장 실패(가입은 정상 처리) - 이 회원은 탈퇴해도 Apple 쪽 연동이 자동 해제되지 않는다: memberSocialAccountId={}",
memberSocialAccountId, e);
}
}

// subject는 memberId가 아니라 providerUserId(Apple 회원번호)
private AppleSignupClaims resolveAppleClaims(String appleSignupToken) {

Expand Down
Loading
Loading