Repository navigation
[feat] Apple 회원 탈퇴 시 refresh_token revoke 처리 추가 #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
de95642
feat: Apple 회원 탈퇴 시 refresh_token revoke 처리 추가
ch0iii 899adca
refactor: Apple revoke를 탈퇴 즉시 처리에서 파기 배치 시점으로 이동
ch0iii ab05487
chore: social_oauth_credential 마이그레이션 스크립트 불필요한 주석 정리
ch0iii f334584
fix: authorizationCode 교환을 트랜잭션 마지막으로 재배치
ch0iii bd4db77
fix: refresh_token 암호화를 인증되지 않은 AES-CBC에서 AES-GCM으로 교체
ch0iii ccd10c6
Merge branch 'develop' into feat/19-apple-token-revoke
ch0iii dca6a88
fix: Apple/카카오 연동 해제 전원 실패 시 ERROR로 구분해서 로그
ch0iii 5c6ffdf
feat: Apple authorizationCode pending 캐시 저장소 추가
ch0iii f7a6258
refactor: Apple authorizationCode 교환을 로그인 시점으로 앞당김
ch0iii 999001e
fix: authorizationCode 교환 응답의 sub를 대조해 교차 오염 방지
ch0iii bed3e03
Merge branch 'develop' into feat/19-apple-token-revoke
ch0iii File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| ); |
86 changes: 86 additions & 0 deletions
86
src/main/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGenerator.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } |
99 changes: 99 additions & 0 deletions
99
src/main/java/com/cotato/nextstation/domain/auth/client/AppleTokenClient.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
src/main/java/com/cotato/nextstation/domain/auth/client/dto/AppleTokenResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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과 동일한 값이라 별도로 검증/저장하지 않는다. | ||
| @JsonProperty("id_token") String idToken | ||
| ) { | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
41 changes: 41 additions & 0 deletions
41
.../java/com/cotato/nextstation/domain/auth/repository/PendingAppleCredentialRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
id_token에 있는 사용자 식별값이 Apple 사용자와 같은지 한 번 비교한 뒤 refreshToken을 저장하면 더 안전할 것 같아요! 가입 토큰이 섞이는게 일반적인 사용자 흐름에서는 일어나지 않을 일이라 문제될 가능성은 낮아보여서,, 수정이 꼭 필요해 보이진 않지만 보완하면 좋을 것 같아서 의견 남깁니다!!
There was a problem hiding this comment.
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와 대조한 뒤에만 캐싱하도록 반영해두겠습니다!
의견 감사합니다 😊