diff --git a/.env.example b/.env.example index 5912fa3..bcb0e09 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 b680e33..b976291 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 8b2e554..ca3aea2 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 b38d065..062f310 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 diff --git a/routee-external/build.gradle b/routee-external/build.gradle index c998db4..31f873e 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' 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 0000000..cd8776d --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java @@ -0,0 +1,6 @@ +package org.sopt.routee.external.api.port; + +public interface OAuthRevokePort { + + void revoke(String authorizationCode); +} 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 0000000..7b32cd2 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java @@ -0,0 +1,76 @@ +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; + +@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 0000000..62f0ccc --- /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 0000000..a33fb1a --- /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 0000000..65e6891 --- /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 0000000..684d7f1 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java @@ -0,0 +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() { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); + } +} 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 0000000..b69d91a --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java @@ -0,0 +1,15 @@ +package org.sopt.routee.external.internal.oauth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@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 0000000..e2185b3 --- /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 0000000..9aced0b --- /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); + } +} 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 93defed..51bb70d 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 dd8870a..b05f80c 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,7 +84,11 @@ ResponseEntity> register( @RequestHeader("Time-Zone") ZoneId timeZone ); - @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다.") + @Operation(summary = "회원 탈퇴", + description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " + + "Apple 로그인 회원은 authorization_code를 함께 전달해야 소셜 로그인 연동도 해제됩니다. " + + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, Apple 외 소셜 로그인 회원은 전달하지 않아도 됩니다. " + + "연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @@ -108,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 673d537..45cf1d2 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,20 @@ 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( + @Schema(description = "탈퇴를 요청하는 회원의 리프레시 토큰. 모든 탈퇴 요청에 필수입니다.") @NotBlank(message = "refresh_token은 필수입니다.") - String refreshToken + String refreshToken, + + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다. " + + "그 외 소셜 로그인 회원은 전달하지 않아도 됩니다.") + 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 421e7dc..3c81d2a 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,33 @@ 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) { + if (oauthProvider != OAuthProvider.APPLE) { + return; + } + + try { + oAuthRevokePort.revoke(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 0000000..a2f4166 --- /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 +) { +} 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 30ea186..c416370 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()); } }