From 71d7b8fc5b51cc72e2c14ba25a2daabbcdc9e1c5 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 15 Sep 2026 21:13:35 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20revoke=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=ED=98=B8=EC=B6=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/api/port/OAuthRevokePort.java | 8 ++++++ .../internal/controller/MemberController.java | 2 +- .../controller/MemberControllerDocs.java | 7 ++++-- .../dto/request/WithdrawRequest.java | 12 ++++++++- .../internal/service/MemberService.java | 25 ++++++++++++++++--- .../service/dto/command/WithdrawCommand.java | 9 +++++++ 6 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java new file mode 100644 index 00000000..a4de2ee0 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.api.port; + +import org.sopt.routee.external.api.type.OAuthProvider; + +public interface OAuthRevokePort { + + void revoke(OAuthProvider provider, String authorizationCode); +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index 93defed0..51bb70d1 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -71,7 +71,7 @@ public ResponseEntity> withdraw( String accessTokenHash = TokenHasher.hash(TokenExtractor.extract(accessTokenWithBearer)); String refreshTokenHash = TokenHasher.hash(request.refreshToken()); - memberService.withdraw(memberId, accessTokenHash, refreshTokenHash); + memberService.withdraw(request.toCommand(memberId, accessTokenHash, refreshTokenHash)); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.MEMBER_WITHDRAW)); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index dd8870a3..76878e4a 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -84,14 +84,17 @@ ResponseEntity> register( @RequestHeader("Time-Zone") ZoneId timeZone ); - @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다.") + @Operation(summary = "회원 탈퇴", + description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. " + + "함께 전달한 authorization_code로 소셜 로그인(Apple/Google) 연동도 해제합니다. " + + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = @ExampleObject(name = "INVALID_INPUT_VALUE", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"refresh_token은 필수입니다.\"}"))), + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"authorization_code는 필수입니다.\"}"))), @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 673d537a..23f10f05 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -1,9 +1,19 @@ package org.sopt.routee.member.internal.controller.dto.request; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; + +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; public record WithdrawRequest( @NotBlank(message = "refresh_token은 필수입니다.") - String refreshToken + String refreshToken, + + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 소셜 로그인 인가 코드. provider와의 연동 해제에 사용됩니다.") + @NotBlank(message = "authorization_code는 필수입니다.") + String authorizationCode ) { + public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) { + return new WithdrawCommand(memberId, accessTokenHash, refreshTokenHash, authorizationCode); + } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index 421e7dce..cc779376 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -19,6 +19,7 @@ import org.sopt.routee.external.api.result.FileUploadPresignResult; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.OAuthProvider; +import org.sopt.routee.external.api.port.OAuthRevokePort; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; import org.sopt.routee.member.internal.service.dto.command.AgreementCommand; @@ -26,6 +27,7 @@ import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand; import org.sopt.routee.member.internal.service.dto.command.UpdateProfileImageCommand; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; @@ -58,6 +60,7 @@ public class MemberService { private final OidcVerifyPort oidcVerifyPort; + private final OAuthRevokePort oAuthRevokePort; private final ActivityUseCase activityUseCase; private final MemberRepository memberRepository; private final MemberAgreementRepository memberAgreementRepository; @@ -109,9 +112,12 @@ private void validateRequiredAgreements(AgreementCommand agreement) { } } - public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { + public void withdraw(WithdrawCommand command) { + long memberId = command.memberId(); + + OAuthProvider oauthProvider; try { - transactionTemplate.executeWithoutResult(status -> { + oauthProvider = transactionTemplate.execute(status -> { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); @@ -119,16 +125,29 @@ public void withdraw(long memberId, String accessTokenHash, String refreshTokenH memberRepository.delete(member); activityUseCase.deleteForMemberWithdrawal(memberId); + + return member.getOauthProvider(); }); } catch (ObjectOptimisticLockingFailureException e) { throw new MemberNotFoundException(); } - applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); + revokeOAuthConnection(memberId, oauthProvider, command.authorizationCode()); + + applicationEventPublisher.publishEvent( + new MemberWithdrawnEvent(memberId, command.accessTokenHash(), command.refreshTokenHash())); Thread.startVirtualThread(() -> deleteMemberImages(memberId)); } + private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String authorizationCode) { + try { + oAuthRevokePort.revoke(oauthProvider, authorizationCode); + } catch (BaseException e) { + log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); + } + } + private void deleteMemberImages(long memberId) { try { fileDeletePort.deleteDirectory(new FileDeleteDirectoryCommand(Long.toString(memberId))); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java new file mode 100644 index 00000000..a2f4166b --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java @@ -0,0 +1,9 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record WithdrawCommand( + Long memberId, + String accessTokenHash, + String refreshTokenHash, + String authorizationCode +) { +} From b16b6e783d4f114259bd90e58bf5678c44bce324 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:14:55 +0900 Subject: [PATCH 02/11] =?UTF-8?q?chore:=20=EC=95=A0=ED=94=8C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=EC=9A=A9=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routee-external/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routee-external/build.gradle b/routee-external/build.gradle index c998db47..31f873e8 100644 --- a/routee-external/build.gradle +++ b/routee-external/build.gradle @@ -6,6 +6,9 @@ dependencies { // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // Apple client secret nimbus + implementation 'com.nimbusds:nimbus-jose-jwt' + // AWS implementation platform("software.amazon.awssdk:bom:${rootProject.ext['awsSdkVersion']}") implementation 'software.amazon.awssdk:s3' From 0d66a33ef65646cefbffd89b29fb02e8ee357431 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:15:59 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20revoke=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B0=8F=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/routee/external/api/port/OAuthRevokePort.java | 4 +--- .../sopt/routee/member/internal/service/MemberService.java | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java index a4de2ee0..cd8776da 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java @@ -1,8 +1,6 @@ package org.sopt.routee.external.api.port; -import org.sopt.routee.external.api.type.OAuthProvider; - public interface OAuthRevokePort { - void revoke(OAuthProvider provider, String authorizationCode); + void revoke(String authorizationCode); } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index cc779376..3c81d2aa 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -141,8 +141,12 @@ public void withdraw(WithdrawCommand command) { } private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String authorizationCode) { + if (oauthProvider != OAuthProvider.APPLE) { + return; + } + try { - oAuthRevokePort.revoke(oauthProvider, authorizationCode); + oAuthRevokePort.revoke(authorizationCode); } catch (BaseException e) { log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); } From b15aebd5828281177447c72e82ebac0a9af1f418 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:17:07 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20revoke=EC=9A=A9=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/dto/request/WithdrawRequest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 23f10f05..51c4cd88 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -9,8 +9,7 @@ public record WithdrawRequest( @NotBlank(message = "refresh_token은 필수입니다.") String refreshToken, - @Schema(description = "탈퇴 시점에 재인증하여 발급받은 소셜 로그인 인가 코드. provider와의 연동 해제에 사용됩니다.") - @NotBlank(message = "authorization_code는 필수입니다.") + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다.") String authorizationCode ) { public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) { From 45e4bb3a614de6019af568b8906e774498bcb98c Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:51:21 +0900 Subject: [PATCH 05/11] =?UTF-8?q?chore:=20revoke=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ .github/workflows/deploy-common.yml | 3 +++ docker-compose.yml | 6 ++++++ routee-app/src/main/resources/application.yml | 11 +++++++++++ 4 files changed, 26 insertions(+) diff --git a/.env.example b/.env.example index 5912fa3f..bcb0e090 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ JWT_SECRET= # OIDC APPLE_CLIENT_ID=com.Routee-iOS +# OAuth revoke +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +GOOGLE_CLIENT_ID= + # Redis REDIS_HOST= REDIS_PORT= diff --git a/.github/workflows/deploy-common.yml b/.github/workflows/deploy-common.yml index b680e33a..b9762914 100644 --- a/.github/workflows/deploy-common.yml +++ b/.github/workflows/deploy-common.yml @@ -79,6 +79,9 @@ jobs: export S3_BUCKET="${{ secrets[format('{0}S3_BUCKET', inputs.secret_prefix)] }}" export S3_ENDPOINT="${{ secrets[format('{0}S3_ENDPOINT', inputs.secret_prefix)] }}" export APPLE_CLIENT_ID="${{ secrets[format('{0}APPLE_CLIENT_ID', inputs.secret_prefix)] }}" + export APPLE_TEAM_ID="${{ secrets[format('{0}APPLE_TEAM_ID', inputs.secret_prefix)] }}" + export APPLE_KEY_ID="${{ secrets[format('{0}APPLE_KEY_ID', inputs.secret_prefix)] }}" + export APPLE_PRIVATE_KEY="${{ secrets[format('{0}APPLE_PRIVATE_KEY', inputs.secret_prefix)] }}" export GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} diff --git a/docker-compose.yml b/docker-compose.yml index 8b2e5549..ca3aea27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,9 @@ services: S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + APPLE_TEAM_ID: ${APPLE_TEAM_ID} + APPLE_KEY_ID: ${APPLE_KEY_ID} + APPLE_PRIVATE_KEY: ${APPLE_PRIVATE_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: @@ -60,6 +63,9 @@ services: S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + APPLE_TEAM_ID: ${APPLE_TEAM_ID} + APPLE_KEY_ID: ${APPLE_KEY_ID} + APPLE_PRIVATE_KEY: ${APPLE_PRIVATE_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index b38d0659..062f310f 100644 --- a/routee-app/src/main/resources/application.yml +++ b/routee-app/src/main/resources/application.yml @@ -39,6 +39,17 @@ oidc: issuer: https://accounts.google.com client-id: ${GOOGLE_CLIENT_ID} +oauth: + providers: + apple: + token-uri: https://appleid.apple.com/auth/token + revoke-uri: https://appleid.apple.com/auth/revoke + audience: https://appleid.apple.com + client-id: ${APPLE_CLIENT_ID} + team-id: ${APPLE_TEAM_ID} + key-id: ${APPLE_KEY_ID} + private-key: ${APPLE_PRIVATE_KEY} + jwt: secret: ${JWT_SECRET} issuer: org.routee From 3d99bca1347eaa167f804c58eb4afce35de04805 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:58:10 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20revoke=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/AppleClientSecretGenerator.java | 81 +++++++++++++++++++ .../adapter/AppleOAuthRevokeAdapter.java | 81 +++++++++++++++++++ .../oauth/adapter/OAuthTokenResponse.java | 8 ++ .../internal/oauth/code/ErrorCode.java | 18 +++++ .../oauth/config/OAuthRevokeClientConfig.java | 14 ++++ .../oauth/config/OAuthRevokeProperty.java | 21 +++++ .../exception/AppleClientSecretException.java | 11 +++ .../oauth/exception/OAuthRevokeException.java | 15 ++++ 8 files changed, 249 insertions(+) create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java new file mode 100644 index 00000000..78474f84 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java @@ -0,0 +1,81 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.atomic.AtomicReference; + +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.sopt.routee.external.internal.oauth.exception.AppleClientSecretException; +import org.springframework.stereotype.Component; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +/** + * Apple client secret(ES256로 서명한 JWT)을 생성한다. + *

+ * 서명 비용을 줄이기 위해 만료 전까지 캐시한다. Apple은 최대 6개월까지 허용하지만 보수적으로 짧게 잡는다. + */ +@Component +class AppleClientSecretGenerator { + + private static final Duration TOKEN_EXPIRY = Duration.ofMinutes(30); + private static final Duration CACHE_TTL = Duration.ofMinutes(25); + + private final OAuthRevokeProperty property; + private final AtomicReference cache = new AtomicReference<>(); + + AppleClientSecretGenerator(OAuthRevokeProperty property) { + this.property = property; + } + + String generate() { + CachedSecret cached = cache.get(); + if (cached != null && cached.expiresAt().isAfter(Instant.now())) { + return cached.value(); + } + + String secret = sign(); + cache.set(new CachedSecret(secret, Instant.now().plus(CACHE_TTL))); + return secret; + } + + private String sign() { + try { + Instant now = Instant.now(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(property.keyId()).build(), + new JWTClaimsSet.Builder() + .issuer(property.teamId()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(TOKEN_EXPIRY))) + .audience(property.audience()) + .subject(property.clientId()) + .build() + ); + jwt.sign(new ECDSASigner(parsePrivateKey(property.privateKey()))); + return jwt.serialize(); + } catch (JOSEException | GeneralSecurityException | IllegalArgumentException e) { + throw new AppleClientSecretException(e); + } + } + + private ECPrivateKey parsePrivateKey(String privateKey) throws GeneralSecurityException { + byte[] der = Base64.getDecoder().decode(privateKey); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return (ECPrivateKey)keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der)); + } + + private record CachedSecret(String value, Instant expiresAt) { + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java new file mode 100644 index 00000000..62f0ccc4 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java @@ -0,0 +1,81 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import org.sopt.routee.external.api.port.OAuthRevokePort; +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.sopt.routee.external.internal.oauth.exception.OAuthRevokeException; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class AppleOAuthRevokeAdapter implements OAuthRevokePort { + + private static final String REFRESH_TOKEN_HINT = "refresh_token"; + + private final RestClient restClient; + private final OAuthRevokeProperty property; + private final AppleClientSecretGenerator clientSecretGenerator; + + @Override + public void revoke(String authorizationCode) { + OAuthTokenResponse token = exchangeAuthorizationCode(authorizationCode); + + if (!StringUtils.hasText(token.refreshToken())) { + throw new OAuthRevokeException(); + } + + requestRevoke(token.refreshToken(), REFRESH_TOKEN_HINT); + } + + private OAuthTokenResponse exchangeAuthorizationCode(String authorizationCode) { + MultiValueMap form = credentialForm(); + + form.add("grant_type", "authorization_code"); + form.add("code", authorizationCode); + + OAuthTokenResponse response = post(property.tokenUri(), form, OAuthTokenResponse.class); + + if (response == null) { + throw new OAuthRevokeException(); + } + + return response; + } + + private void requestRevoke(String token, String tokenTypeHint) { + MultiValueMap form = credentialForm(); + form.add("token", token); + form.add("token_type_hint", tokenTypeHint); + + post(property.revokeUri(), form, Void.class); + } + + private T post(String uri, MultiValueMap form, Class responseType) { + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(responseType); + } catch (RestClientException e) { + throw new OAuthRevokeException(e); + } + } + + private MultiValueMap credentialForm() { + MultiValueMap form = new LinkedMultiValueMap<>(); + + form.add("client_id", property.clientId()); + form.add("client_secret", clientSecretGenerator.generate()); + + return form; + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java new file mode 100644 index 00000000..a33fb1a1 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import com.fasterxml.jackson.annotation.JsonProperty; + +record OAuthTokenResponse( + @JsonProperty("refresh_token") String refreshToken +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java new file mode 100644 index 00000000..65e68914 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java @@ -0,0 +1,18 @@ +package org.sopt.routee.external.internal.oauth.code; + +import org.sopt.routee.code.ErrorResultCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements ErrorResultCode { + + OAUTH_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 연동 해제에 실패했습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple client secret 생성에 실패했습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java new file mode 100644 index 00000000..ee1c6b32 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java @@ -0,0 +1,14 @@ +package org.sopt.routee.external.internal.oauth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +class OAuthRevokeClientConfig { + + @Bean + RestClient oauthRevokeRestClient() { + return RestClient.create(); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java new file mode 100644 index 00000000..f5ac9840 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java @@ -0,0 +1,21 @@ +package org.sopt.routee.external.internal.oauth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Apple OAuth 연동 해제(revoke) 설정. + *

+ * client secret은 정적 문자열이 아니라 {@code teamId}/{@code keyId}/{@code privateKey}로 + * client secret JWT(ES256)를 직접 서명해서 사용한다. + */ +@ConfigurationProperties(prefix = "oauth.providers.apple") +public record OAuthRevokeProperty( + String tokenUri, + String revokeUri, + String audience, + String clientId, + String teamId, + String keyId, + String privateKey +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java new file mode 100644 index 00000000..e2185b34 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.external.internal.oauth.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class AppleClientSecretException extends BaseException { + + public AppleClientSecretException(Throwable cause) { + super(ErrorCode.APPLE_CLIENT_SECRET_GENERATION_FAILED, cause); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java new file mode 100644 index 00000000..9aced0bf --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java @@ -0,0 +1,15 @@ +package org.sopt.routee.external.internal.oauth.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class OAuthRevokeException extends BaseException { + + public OAuthRevokeException() { + super(ErrorCode.OAUTH_REVOKE_FAILED); + } + + public OAuthRevokeException(Throwable cause) { + super(ErrorCode.OAUTH_REVOKE_FAILED, cause); + } +} From 93d8adfa0dee33ab34dd8b2c5374ac535e2a41ed Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:58:44 +0900 Subject: [PATCH 07/11] =?UTF-8?q?test:=20=EA=B8=B0=EC=A1=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/MemberServiceTest.java | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java index 30ea1862..c4163706 100644 --- a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java +++ b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java @@ -5,7 +5,6 @@ import static org.mockito.Mockito.*; import java.util.Optional; -import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,16 +17,20 @@ import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.port.OAuthRevokePort; import org.sopt.routee.external.api.port.OidcVerifyPort; +import org.sopt.routee.external.api.type.OAuthProvider; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.exception.MemberNotFoundException; import org.sopt.routee.member.internal.repository.MemberAgreementRepository; import org.sopt.routee.member.internal.repository.MemberRepository; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; import org.springframework.context.ApplicationEventPublisher; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; @ExtendWith(MockitoExtension.class) @@ -36,10 +39,16 @@ class MemberServiceTest { private static final long MEMBER_ID = 1L; private static final String ACCESS_TOKEN_HASH = "access-hash"; private static final String REFRESH_TOKEN_HASH = "refresh-hash"; + private static final String AUTHORIZATION_CODE = "auth-code"; + private static final WithdrawCommand WITHDRAW_COMMAND = + new WithdrawCommand(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH, AUTHORIZATION_CODE); @Mock private OidcVerifyPort oidcVerifyPort; + @Mock + private OAuthRevokePort oAuthRevokePort; + @Mock private ActivityUseCase activityUseCase; @@ -73,6 +82,7 @@ class MemberServiceTest { void setUp() { memberService = new MemberService( oidcVerifyPort, + oAuthRevokePort, activityUseCase, memberRepository, memberAgreementRepository, @@ -87,28 +97,29 @@ void setUp() { @SuppressWarnings("unchecked") private void stubTransactionTemplateToRunCallback() { - doAnswer(invocation -> { - Consumer callback = invocation.getArgument(0); - callback.accept(mock(TransactionStatus.class)); - return null; - }).when(transactionTemplate).executeWithoutResult(any(Consumer.class)); + when(transactionTemplate.execute(any(TransactionCallback.class))).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); } @Test - @DisplayName("withdraw: 정상적으로 회원을 탈퇴시키고 토큰 무효화 이벤트를 발행한다") - void withdraw_success_publishesMemberWithdrawnEvent() { + @DisplayName("withdraw: 회원을 탈퇴시키고 소셜 연동을 해제한 뒤 토큰 무효화 이벤트를 발행한다") + void withdraw_success_revokesOAuthAndPublishesEvent() { // given stubTransactionTemplateToRunCallback(); Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.APPLE); when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); // when - memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + memberService.withdraw(WITHDRAW_COMMAND); // then verify(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); verify(memberRepository).delete(member); verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(oAuthRevokePort).revoke(AUTHORIZATION_CODE); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(MemberWithdrawnEvent.class); verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); @@ -120,6 +131,45 @@ void withdraw_success_publishesMemberWithdrawnEvent() { .containsExactly(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); } + @Test + @DisplayName("withdraw: 소셜 연동 해제가 실패해도 회원 탈퇴와 이벤트 발행은 정상 처리된다") + void withdraw_oauthRevokeFails_stillCompletesWithdrawal() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.APPLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + // 임의의 BaseException — revoke 어댑터가 던지는 예외를 흉내낸다 + doThrow(new MemberNotFoundException()).when(oAuthRevokePort).revoke(AUTHORIZATION_CODE); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(applicationEventPublisher).publishEvent(any(MemberWithdrawnEvent.class)); + } + + @Test + @DisplayName("withdraw: Apple이 아닌 provider는 OAuth revoke를 호출하지 않는다") + void withdraw_nonAppleProvider_doesNotCallOAuthRevoke() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.GOOGLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(oAuthRevokePort, never()).revoke(any()); + verify(applicationEventPublisher).publishEvent(any(MemberWithdrawnEvent.class)); + } + @Test @DisplayName("withdraw: 동시 탈퇴 요청으로 낙관적 락 예외가 발생하면 회원 없음 예외로 변환한다") void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { @@ -132,11 +182,12 @@ void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { // when Throwable thrown = catchThrowable( - () -> memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH)); + () -> memberService.withdraw(WITHDRAW_COMMAND)); // then assertThat(thrown).isInstanceOf(MemberNotFoundException.class); verify(activityUseCase, never()).deleteForMemberWithdrawal(anyLong()); + verify(oAuthRevokePort, never()).revoke(any()); verify(applicationEventPublisher, never()).publishEvent(any()); } } From 9d81c789cbebe603e0820b2f098030364b9ba5e6 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 01:39:11 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EB=AF=B8=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=84=A4=EB=AA=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routee/member/internal/controller/MemberControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 76878e4a..d2d411da 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -86,7 +86,7 @@ ResponseEntity> register( @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. " - + "함께 전달한 authorization_code로 소셜 로그인(Apple/Google) 연동도 해제합니다. " + + "함께 전달한 authorization_code로 소셜 로그인(Apple) 연동도 해제합니다. " + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ From 17a2ce62b10dc8ff175e1b09dc2604e213c29c65 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 01:47:58 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20timeout=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/config/OAuthRevokeClientConfig.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java index ee1c6b32..684d7f13 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java @@ -1,14 +1,30 @@ package org.sopt.routee.external.internal.oauth.config; +import java.net.http.HttpClient; +import java.time.Duration; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; @Configuration class OAuthRevokeClientConfig { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + @Bean RestClient oauthRevokeRestClient() { - return RestClient.create(); + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); } } From f08c8b5abf71f63d3571ae3deddb209f722157af Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 12:13:04 +0900 Subject: [PATCH 10/11] =?UTF-8?q?style:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/oauth/adapter/AppleClientSecretGenerator.java | 5 ----- .../external/internal/oauth/config/OAuthRevokeProperty.java | 6 ------ 2 files changed, 11 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java index 78474f84..7b32cd2d 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java @@ -21,11 +21,6 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; -/** - * Apple client secret(ES256로 서명한 JWT)을 생성한다. - *

- * 서명 비용을 줄이기 위해 만료 전까지 캐시한다. Apple은 최대 6개월까지 허용하지만 보수적으로 짧게 잡는다. - */ @Component class AppleClientSecretGenerator { diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java index f5ac9840..b69d91a1 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java @@ -2,12 +2,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -/** - * Apple OAuth 연동 해제(revoke) 설정. - *

- * client secret은 정적 문자열이 아니라 {@code teamId}/{@code keyId}/{@code privateKey}로 - * client secret JWT(ES256)를 직접 서명해서 사용한다. - */ @ConfigurationProperties(prefix = "oauth.providers.apple") public record OAuthRevokeProperty( String tokenUri, From 2d0b33159ab9f26da1c0bc252f3c7c63b7991191 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 13:04:46 +0900 Subject: [PATCH 11/11] =?UTF-8?q?style:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MemberControllerDocs.java | 17 +++++++++++++---- .../controller/dto/request/WithdrawRequest.java | 4 +++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index d2d411da..b05f80ce 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -85,16 +85,17 @@ ResponseEntity> register( ); @Operation(summary = "회원 탈퇴", - description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. " - + "함께 전달한 authorization_code로 소셜 로그인(Apple) 연동도 해제합니다. " - + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") + description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " + + "Apple 로그인 회원은 authorization_code를 함께 전달해야 소셜 로그인 연동도 해제됩니다. " + + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, Apple 외 소셜 로그인 회원은 전달하지 않아도 됩니다. " + + "연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = @ExampleObject(name = "INVALID_INPUT_VALUE", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"authorization_code는 필수입니다.\"}"))), + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"refresh_token은 필수입니다.\"}"))), @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @@ -111,6 +112,14 @@ ResponseEntity> register( ResponseEntity> withdraw( Long memberId, @RequestHeader(name = "Authorization") String accessTokenWithBearer, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = WithdrawRequest.class), + examples = { + @ExampleObject(name = "APPLE_MEMBER", summary = "Apple 로그인 회원", + value = "{\"refreshToken\":\"eyJ...\",\"authorizationCode\":\"c1234...\"}"), + @ExampleObject(name = "OTHER_MEMBER", summary = "그 외 소셜 로그인 회원", + value = "{\"refreshToken\":\"eyJ...\"}") + })) @Valid @RequestBody WithdrawRequest request ); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 51c4cd88..45cf1d2d 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -6,10 +6,12 @@ import jakarta.validation.constraints.NotBlank; public record WithdrawRequest( + @Schema(description = "탈퇴를 요청하는 회원의 리프레시 토큰. 모든 탈퇴 요청에 필수입니다.") @NotBlank(message = "refresh_token은 필수입니다.") String refreshToken, - @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다.") + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다. " + + "그 외 소셜 로그인 회원은 전달하지 않아도 됩니다.") String authorizationCode ) { public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) {