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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ tasks.named('test') {
useJUnitPlatform()
}

tasks.named('bootRun') {
environment dotenv()
}

tasks.register('placeGeocodingBatch', JavaExec) {
group = 'application'
description = '수집 장소 데이터를 카카오 API로 보강하는 1회성 배치 (KAKAO_REST_API_KEY, PLACE_SHEET_CSV_URL, PLACE_SPREADSHEET_ID 필요)'
Expand Down
6 changes: 6 additions & 0 deletions docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ services:
KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY}

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
16 changes: 16 additions & 0 deletions scripts/add-social-oauth-credential.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Apple/카카오 revoke용 social_oauth_credential 테이블 추가
--
-- 운영 프로파일은 ddl-auto: validate라 이 테이블이 없으면 기동 자체가 실패한다.
-- 이 스크립트는 코드 배포 "전"에 실행되어야 한다.
--

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,99 @@
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을 반환한다(멱등).
// 호출자(WithdrawnMemberCleaner)가 삭제보다 먼저 결과를 보고 재시도 대상을 가려야 하므로 예외 대신 boolean으로 알린다.
public boolean 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();

log.info("Apple refresh_token revoke 완료");
return true;

} catch (RestClientException e) {
log.warn("Apple refresh_token revoke 실패", e);
return false;
}
}
}
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 @@ -45,6 +45,9 @@ public class AppleAuthController {
카카오와 달리 인가코드 교환이나 별도의 사용자정보조회 API 호출이 없다 - identity token 자체에 서명·클레임 검증을 수행한다.
- `nonce`는 클라이언트가 `ASAuthorizationAppleIDRequest.nonce`에 사용한 원문(raw) 값이다. 서버가 SHA-256으로 해싱해
identityToken의 `nonce` 클레임과 대조하므로, 탈취된 identity token을 재전송해도 로그인에 성공할 수 없다.
- `authorizationCode`는 identityToken과 함께 발급되는 값으로, `resultType=NEW_MEMBER`일 때만 의미가 있다.
이 시점에 미리 refresh_token으로 교환해둬야 나중에 탈퇴 시 Apple 쪽 연동을 자동 해제(revoke)할 수 있다.
생략해도 로그인 판별 자체는 정상 동작한다.
- `resultType=LOGIN_SUCCESS`: 기존 ACTIVE 회원. accessToken은 응답 body로, refreshToken은 httpOnly 쿠키로 내려간다(로그인 API와 동일).
- `resultType=PENDING_PROFILE`: 프로필 설정이 끝나지 않은 회원. `signupToken`이 발급되며, 이후 흐름은 회원가입의 `/profile` 호출과 동일하다.
- `resultType=NEW_MEMBER`: 처음 보는 Apple 계정. `appleSignupToken`이 발급된다. 이 값을 들고 약관 동의 화면을 보여준 뒤 `/apple/signup`을 호출해야 한다.
Expand All @@ -61,7 +64,7 @@ public class AppleAuthController {
@PostMapping("/login")
public CommonResponse<AppleLoginResponse> appleLogin(@Valid @RequestBody AppleLoginRequest request,
HttpServletResponse httpResponse) {
AppleLoginResult result = appleLoginQueryService.login(request.identityToken(), request.nonce());
AppleLoginResult result = appleLoginQueryService.login(request.identityToken(), request.nonce(), request.authorizationCode());

if (result.resultType() == AppleLoginResultType.LOGIN_SUCCESS) {
ResponseCookie refreshTokenCookie = refreshTokenCookieFactory.create(result.refreshToken());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ public record AppleLoginRequest(
@Schema(description = "클라이언트가 ASAuthorizationAppleIDRequest.nonce에 사용한 원문(raw) 값. " +
"SHA-256 해싱한 값이 identityToken의 nonce 클레임과 일치해야 한다 - 탈취된 identity token 재전송 방지용", example = "a1b2c3...")
@NotBlank(message = "nonce는 필수입니다.")
String nonce
String nonce,

@Schema(description = "iOS 네이티브 Sign In with Apple SDK가 identityToken과 함께 발급한 authorizationCode. " +
"resultType=NEW_MEMBER일 때만 의미가 있다 - 탈퇴 시 Apple 쪽 연동을 폐기(revoke)할 수 있도록 이 시점에 " +
"미리 refresh_token으로 교환해둔다. 생략해도 로그인 판별 자체는 정상 동작하며, 이 경우 나중에 " +
"탈퇴해도 Apple 쪽 연동이 자동 해제되지 않을 뿐이다.", example = "c1a2b3...")
String authorizationCode
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.cotato.nextstation.domain.auth.repository;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.time.Duration;
import java.util.Optional;

// Apple authorizationCode를 로그인 판별 시점(NEW_MEMBER로 갈릴 때)에 미리 교환해둔 암호화된 refresh_token을
// 회원가입이 실제로 완료될 때까지 잠깐 보관한다.
// <p>
// authorizationCode는 1회용이고 수명이 짧다(수 분). 예전엔 이 교환을 /apple/signup 시점(약관 동의 화면을 다
// 보고 난 뒤)까지 미뤄뒀는데, 그 사이 code가 만료되거나 - 교환에 성공해도 그 뒤 로컬 저장이 실패하면 이미
// 소비된 code만 날리는 문제가 있었다. 그래서 code를 받는 가장 이른 시점(로그인)에 바로 교환해 여기 캐싱해두고,
// 가입 시점엔 네트워크 호출 없이 이 값을 그대로 SocialOauthCredential로 옮겨 붙이기만 한다.
@Repository
@RequiredArgsConstructor
public class PendingAppleCredentialRepository {

private static final String KEY_FORMAT = "auth:pending-apple-credential:%s";

// appleSignupToken 수명(10분)과 맞춘다 - 그 안에 가입을 완료하지 않으면 어차피 처음부터 다시 로그인해야 한다.
private static final Duration EXPIRATION = Duration.ofMinutes(10);

private final RedisTemplate<String, String> redisTemplate;

public void save(String providerUserId, String encryptedRefreshToken) {
redisTemplate.opsForValue().set(key(providerUserId), encryptedRefreshToken, EXPIRATION);
}

// 가입 완료 시 한 번만 쓰이므로 조회와 동시에 지운다(재사용 방지, 메모리 낭비 방지).
public Optional<String> consume(String providerUserId) {
String value = redisTemplate.opsForValue().getAndDelete(key(providerUserId));
return Optional.ofNullable(value);
}

private String key(String providerUserId) {
return KEY_FORMAT.formatted(providerUserId);
}
}
Loading
Loading