From 904437704d59201c9f5ac75d55e6361b54e53d25 Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 01:17:47 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20refresh=20token=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/OAuthRefreshTokenExchangePort.java | 8 +++ .../external/api/port/OAuthRevokePort.java | 2 +- .../oauth/adapter/AppleOAuthFormClient.java | 45 ++++++++++++++ ...AppleOAuthRefreshTokenExchangeAdapter.java | 43 +++++++++++++ .../adapter/AppleOAuthRevokeAdapter.java | 61 ++----------------- .../internal/oauth/code/ErrorCode.java | 1 + .../OAuthRefreshTokenExchangeException.java | 15 +++++ 7 files changed, 119 insertions(+), 56 deletions(-) create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRefreshTokenExchangePort.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthFormClient.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRefreshTokenExchangeException.java diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRefreshTokenExchangePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRefreshTokenExchangePort.java new file mode 100644 index 0000000..7c52a78 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRefreshTokenExchangePort.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.api.port; + +import org.sopt.routee.external.api.type.OAuthProvider; + +public interface OAuthRefreshTokenExchangePort { + + String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode); +} 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 cd8776d..083efe2 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 @@ -2,5 +2,5 @@ public interface OAuthRevokePort { - void revoke(String authorizationCode); + void revoke(String refreshToken); } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthFormClient.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthFormClient.java new file mode 100644 index 0000000..d07534c --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthFormClient.java @@ -0,0 +1,45 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class AppleOAuthFormClient { + + private final RestClient restClient; + private final OAuthRevokeProperty property; + private final AppleClientSecretGenerator clientSecretGenerator; + + MultiValueMap credentialForm() { + MultiValueMap form = new LinkedMultiValueMap<>(); + + form.add("client_id", property.clientId()); + form.add("client_secret", clientSecretGenerator.generate()); + + return form; + } + + T post(String uri, MultiValueMap form, Class responseType) { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(responseType); + } + + String tokenUri() { + return property.tokenUri(); + } + + String revokeUri() { + return property.revokeUri(); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java new file mode 100644 index 0000000..80cb20b --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java @@ -0,0 +1,43 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort; +import org.sopt.routee.external.api.type.OAuthProvider; +import org.sopt.routee.external.internal.oauth.exception.OAuthRefreshTokenExchangeException; +import org.sopt.routee.external.internal.oidc.exception.UnsupportedOidcProviderException; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class AppleOAuthRefreshTokenExchangeAdapter implements OAuthRefreshTokenExchangePort { + + private final AppleOAuthFormClient client; + + @Override + public String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode) { + if (provider != OAuthProvider.APPLE) { + throw new UnsupportedOidcProviderException(); + } + + MultiValueMap form = client.credentialForm(); + + form.add("grant_type", "authorization_code"); + form.add("code", authorizationCode); + + try { + OAuthTokenResponse response = client.post(client.tokenUri(), form, OAuthTokenResponse.class); + + if (response == null || !StringUtils.hasText(response.refreshToken())) { + throw new OAuthRefreshTokenExchangeException(); + } + + return response.refreshToken(); + } catch (RestClientException e) { + throw new OAuthRefreshTokenExchangeException(e); + } + } +} 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 index 62f0ccc..abced57 100644 --- 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 @@ -1,14 +1,9 @@ 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; @@ -19,63 +14,19 @@ 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; + private final AppleOAuthFormClient client; @Override - public void revoke(String authorizationCode) { - OAuthTokenResponse token = exchangeAuthorizationCode(authorizationCode); + public void revoke(String refreshToken) { + MultiValueMap form = client.credentialForm(); - 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); + form.add("token", refreshToken); + form.add("token_type_hint", REFRESH_TOKEN_HINT); - 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); + client.post(client.revokeUri(), form, Void.class); } 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/code/ErrorCode.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java index 65e6891..ef8febe 100644 --- 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 @@ -11,6 +11,7 @@ public enum ErrorCode implements ErrorResultCode { OAUTH_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 연동 해제에 실패했습니다."), + OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 refresh_token 교환에 실패했습니다."), APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple client secret 생성에 실패했습니다."); private final HttpStatus status; diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRefreshTokenExchangeException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRefreshTokenExchangeException.java new file mode 100644 index 0000000..2ab93cd --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRefreshTokenExchangeException.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 OAuthRefreshTokenExchangeException extends BaseException { + + public OAuthRefreshTokenExchangeException() { + super(ErrorCode.OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED); + } + + public OAuthRefreshTokenExchangeException(Throwable cause) { + super(ErrorCode.OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED, cause); + } +} From 2828d8d9fbbab29f7406d6e8e3111c3bae85c4c3 Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 01:22:14 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20authorization=20code=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/MemberControllerDocs.java | 12 +++--------- .../controller/dto/request/WithdrawRequest.java | 8 ++------ .../service/dto/command/WithdrawCommand.java | 3 +-- 3 files changed, 6 insertions(+), 17 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 b05f80c..7370fc3 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,9 +86,8 @@ ResponseEntity> register( @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " - + "Apple 로그인 회원은 authorization_code를 함께 전달해야 소셜 로그인 연동도 해제됩니다. " - + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, Apple 외 소셜 로그인 회원은 전달하지 않아도 됩니다. " - + "연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") + + "Apple 로그인 회원은 로그인 시점에 저장해둔 Apple refresh_token으로 소셜 로그인 연동도 함께 해제되며, " + + "탈퇴 요청 자체에는 별도의 인가 정보를 전달할 필요가 없습니다. 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @@ -114,12 +113,7 @@ ResponseEntity> withdraw( @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...\"}") - })) + examples = @ExampleObject(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 45cf1d2..745bea1 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 @@ -8,13 +8,9 @@ public record WithdrawRequest( @Schema(description = "탈퇴를 요청하는 회원의 리프레시 토큰. 모든 탈퇴 요청에 필수입니다.") @NotBlank(message = "refresh_token은 필수입니다.") - String refreshToken, - - @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다. " - + "그 외 소셜 로그인 회원은 전달하지 않아도 됩니다.") - String authorizationCode + String refreshToken ) { public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) { - return new WithdrawCommand(memberId, accessTokenHash, refreshTokenHash, authorizationCode); + return new WithdrawCommand(memberId, accessTokenHash, refreshTokenHash); } } 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 index a2f4166..041fdac 100644 --- 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 @@ -3,7 +3,6 @@ public record WithdrawCommand( Long memberId, String accessTokenHash, - String refreshTokenHash, - String authorizationCode + String refreshTokenHash ) { } From 28f8c0d0074e95d28a2cda8482d1fa730ecfbc7c Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 01:25:37 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20refresh=20token=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B3=B5=EA=B0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/MemberOAuthCredential.java | 42 +++++++++++++++++++ .../MemberOAuthCredentialRepository.java | 16 +++++++ 2 files changed, 58 insertions(+) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberOAuthCredentialRepository.java diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java new file mode 100644 index 0000000..ac5f995 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java @@ -0,0 +1,42 @@ +package org.sopt.routee.member.internal.entity; + +import org.sopt.routee.entity.BaseEntity; + +import io.hypersistence.utils.hibernate.id.Tsid; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@Table(name = "member_oauth_credential", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_member_oauth_credential_member_id", + columnNames = {"member_id"}) + }) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberOAuthCredential extends BaseEntity { + @Id + @Tsid + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false, updatable = false) + private Member member; + + @Column(name = "refresh_token", nullable = false) + private String refreshToken; +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberOAuthCredentialRepository.java b/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberOAuthCredentialRepository.java new file mode 100644 index 0000000..27d1c45 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberOAuthCredentialRepository.java @@ -0,0 +1,16 @@ +package org.sopt.routee.member.internal.repository; + +import java.util.Optional; + +import org.sopt.routee.member.internal.entity.MemberOAuthCredential; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberOAuthCredentialRepository extends JpaRepository { + + Optional findByMember_Id(Long memberId); + + boolean existsByMember_Id(Long memberId); + + void deleteByMember_Id(Long memberId); + +} From e089f6dc5c01603c3d29923a9303b06bc24c2e7d Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 13:04:21 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=ED=95=84=EB=93=9C=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=95=94=ED=98=B8=ED=99=94?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/MemberOAuthCredentialProperty.java | 9 +++ .../RefreshTokenAttributeConverter.java | 80 +++++++++++++++++++ .../entity/MemberOAuthCredential.java | 5 +- .../OAuthCredentialEncryptionException.java | 11 +++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/config/MemberOAuthCredentialProperty.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/converter/RefreshTokenAttributeConverter.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/exception/OAuthCredentialEncryptionException.java diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/config/MemberOAuthCredentialProperty.java b/routee-member/src/main/java/org/sopt/routee/member/internal/config/MemberOAuthCredentialProperty.java new file mode 100644 index 0000000..9388cba --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/config/MemberOAuthCredentialProperty.java @@ -0,0 +1,9 @@ +package org.sopt.routee.member.internal.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "member.oauth-credential") +public record MemberOAuthCredentialProperty( + String encryptionKey +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/converter/RefreshTokenAttributeConverter.java b/routee-member/src/main/java/org/sopt/routee/member/internal/converter/RefreshTokenAttributeConverter.java new file mode 100644 index 0000000..32d3343 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/converter/RefreshTokenAttributeConverter.java @@ -0,0 +1,80 @@ +package org.sopt.routee.member.internal.converter; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.sopt.routee.member.internal.config.MemberOAuthCredentialProperty; +import org.sopt.routee.member.internal.exception.OAuthCredentialEncryptionException; +import org.springframework.stereotype.Component; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +@Component +public class RefreshTokenAttributeConverter implements AttributeConverter { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String KEY_ALGORITHM = "AES"; + private static final int IV_LENGTH_BYTES = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final SecretKeySpec secretKey; + private final SecureRandom secureRandom = new SecureRandom(); + + public RefreshTokenAttributeConverter(MemberOAuthCredentialProperty property) { + this.secretKey = new SecretKeySpec(Base64.getDecoder().decode(property.encryptionKey()), KEY_ALGORITHM); + } + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) { + return null; + } + + try { + byte[] iv = new byte[IV_LENGTH_BYTES]; + secureRandom.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] cipherText = cipher.doFinal(attribute.getBytes(StandardCharsets.UTF_8)); + + byte[] encrypted = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, encrypted, 0, iv.length); + System.arraycopy(cipherText, 0, encrypted, iv.length, cipherText.length); + + return Base64.getEncoder().encodeToString(encrypted); + } catch (GeneralSecurityException e) { + throw new OAuthCredentialEncryptionException(e); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + + try { + byte[] decoded = Base64.getDecoder().decode(dbData); + byte[] iv = Arrays.copyOfRange(decoded, 0, IV_LENGTH_BYTES); + byte[] cipherText = Arrays.copyOfRange(decoded, IV_LENGTH_BYTES, decoded.length); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] plainText = cipher.doFinal(cipherText); + + return new String(plainText, StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new OAuthCredentialEncryptionException(e); + } + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java index ac5f995..881a7e0 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java @@ -1,9 +1,11 @@ package org.sopt.routee.member.internal.entity; import org.sopt.routee.entity.BaseEntity; +import org.sopt.routee.member.internal.converter.RefreshTokenAttributeConverter; import io.hypersistence.utils.hibernate.id.Tsid; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; @@ -37,6 +39,7 @@ public class MemberOAuthCredential extends BaseEntity { @JoinColumn(name = "member_id", nullable = false, updatable = false) private Member member; - @Column(name = "refresh_token", nullable = false) + @Convert(converter = RefreshTokenAttributeConverter.class) + @Column(name = "refresh_token", nullable = false, length = 512) private String refreshToken; } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/exception/OAuthCredentialEncryptionException.java b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/OAuthCredentialEncryptionException.java new file mode 100644 index 0000000..4fb32f3 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/OAuthCredentialEncryptionException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.member.internal.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.member.internal.code.ErrorCode; + +public class OAuthCredentialEncryptionException extends BaseException { + + public OAuthCredentialEncryptionException(Throwable cause) { + super(ErrorCode.OAUTH_CREDENTIAL_ENCRYPTION_FAILED, cause); + } +} From bbad6688e5502fdb9bdca004d77a6b281c6e650d Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 14:44:37 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20refresh=20token=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/AuthController.java | 3 +- .../controller/AuthControllerDocs.java | 17 +++++- .../controller/dto/request/LoginRequest.java | 9 +++- .../auth/internal/service/AuthService.java | 3 +- .../service/dto/command/LoginCommand.java | 3 +- .../member/api/usecase/MemberFacade.java | 4 +- .../member/api/usecase/MemberUseCase.java | 2 +- .../member/internal/code/ErrorCode.java | 3 +- .../member/internal/mapper/MemberMapper.java | 8 +++ .../internal/service/MemberService.java | 52 +++++++++++++++---- 10 files changed, 83 insertions(+), 21 deletions(-) diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthController.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthController.java index c114f9f..fed3fd8 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthController.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthController.java @@ -1,7 +1,6 @@ package org.sopt.routee.auth.internal.controller; import org.sopt.routee.auth.internal.service.AuthService; -import org.sopt.routee.auth.internal.service.dto.command.LoginCommand; import org.sopt.routee.auth.internal.code.SuccessCode; import org.sopt.routee.auth.internal.controller.dto.response.TokenResponse; import org.sopt.routee.auth.internal.controller.dto.request.LoginRequest; @@ -36,7 +35,7 @@ public class AuthController implements AuthControllerDocs { public ResponseEntity> login( @Valid @RequestBody LoginRequest request ) { - TokenResult result = authService.login(new LoginCommand(request.provider(), request.idToken())); + TokenResult result = authService.login(request.toCommand()); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.LOGIN_SUCCESS, TokenResponse.of(result))); } diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java index 738d871..81eae6f 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java @@ -22,7 +22,10 @@ @Tag(name = "Auth", description = "인증 API") public interface AuthControllerDocs { - @Operation(summary = "소셜 로그인", description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다.") + @Operation(summary = "소셜 로그인", + description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다. Apple 로그인 회원이 authorization_code를 함께 전달하면, " + + "아직 저장된 Apple refresh_token이 없을 때에 한해 이를 교환하여 저장합니다. 이미 저장되어 있다면 전달값은 무시되며, " + + "저장된 refresh_token은 회원 탈퇴 시 소셜 로그인 연동 해제에 사용됩니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))), @@ -49,7 +52,17 @@ public interface AuthControllerDocs { examples = @ExampleObject(name = "MEMBER_NOT_FOUND", value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) }) - ResponseEntity> login(@Valid @RequestBody LoginRequest request); + ResponseEntity> login( + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = LoginRequest.class), + examples = { + @ExampleObject(name = "APPLE_MEMBER", summary = "Apple 로그인 회원", + value = "{\"provider\":\"APPLE\",\"idToken\":\"eyJ...\",\"authorizationCode\":\"c1234...\"}"), + @ExampleObject(name = "OTHER_MEMBER", summary = "그 외 소셜 로그인 회원", + value = "{\"provider\":\"GOOGLE\",\"idToken\":\"eyJ...\"}") + })) + @Valid @RequestBody LoginRequest request + ); @Operation(summary = "토큰 재발급", description = "리프레시 토큰으로 액세스/리프레시 토큰을 재발급합니다.") @ApiResponses({ diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java index d68d744..d1dd6e0 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java @@ -3,6 +3,7 @@ import org.sopt.routee.auth.internal.service.dto.command.LoginCommand; import org.sopt.routee.external.api.type.OAuthProvider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -11,9 +12,13 @@ public record LoginRequest( OAuthProvider provider, @NotBlank(message = "id_token은 필수입니다.") - String idToken + String idToken, + + @Schema(description = "Apple 로그인 시 함께 전달받은 인가 코드. Apple refresh_token 발급에 사용되며, " + + "이미 발급받아 저장된 회원이라면 전달하지 않아도 되고 전달되어도 무시됩니다. Apple 외 소셜 로그인 회원은 필요하지 않습니다.") + String authorizationCode ) { public LoginCommand toCommand() { - return new LoginCommand(provider, idToken); + return new LoginCommand(provider, idToken, authorizationCode); } } diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/AuthService.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/AuthService.java index 4a34e4d..2384068 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/AuthService.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/AuthService.java @@ -41,7 +41,8 @@ public class AuthService { public TokenResult login(LoginCommand command) { String oauthId = oidcVerifyPort.extractSubject(command.provider(), command.idToken()); - TokenClaimsResult tokenClaims = memberUseCase.getTokenResult(oauthId, command.provider()); + TokenClaimsResult tokenClaims = + memberUseCase.getTokenResult(oauthId, command.provider(), command.authorizationCode()); TokenResult tokenResult = issueTokenPair(tokenClaims.memberId(), tokenClaims.memberRole()); log.info("Login succeeded. memberId={}, provider={}", tokenClaims.memberId(), command.provider()); diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/dto/command/LoginCommand.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/dto/command/LoginCommand.java index 9c59670..f04a824 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/dto/command/LoginCommand.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/service/dto/command/LoginCommand.java @@ -4,6 +4,7 @@ public record LoginCommand( OAuthProvider provider, - String idToken + String idToken, + String authorizationCode ) { } diff --git a/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberFacade.java b/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberFacade.java index c563e14..5cd226c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberFacade.java +++ b/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberFacade.java @@ -12,8 +12,8 @@ public class MemberFacade implements MemberUseCase { private final MemberService memberService; - public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider) { - return memberService.getTokenResult(oauthId, oauthProvider); + public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider, String authorizationCode) { + return memberService.getTokenResult(oauthId, oauthProvider, authorizationCode); } public boolean existsById(long memberId) { diff --git a/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberUseCase.java b/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberUseCase.java index 0321506..9e430f7 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberUseCase.java +++ b/routee-member/src/main/java/org/sopt/routee/member/api/usecase/MemberUseCase.java @@ -5,7 +5,7 @@ public interface MemberUseCase { - TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider); + TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider, String authorizationCode); boolean existsById(long memberId); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java index b183173..45cdfa3 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java @@ -14,7 +14,8 @@ public enum ErrorCode implements ErrorResultCode { ALREADY_REGISTERED_MEMBER(HttpStatus.CONFLICT, "이미 가입된 회원입니다."), REQUIRED_AGREEMENT_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 모두 동의해야 합니다."), INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."), - UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다."); + UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다."), + OAUTH_CREDENTIAL_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 자격 증명 암복호화에 실패했습니다."); private final HttpStatus status; private final String message; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index 50661e8..7262171 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -19,6 +19,7 @@ import org.sopt.routee.member.api.type.MemberRole; import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.entity.MemberAgreement; +import org.sopt.routee.member.internal.entity.MemberOAuthCredential; import org.sopt.routee.util.TimeZoneUtils; import lombok.AccessLevel; @@ -55,6 +56,13 @@ public static MemberAgreement toAgreementEntity( .build(); } + public static MemberOAuthCredential toOAuthCredentialEntity(Member member, String refreshToken) { + return MemberOAuthCredential.builder() + .member(member) + .refreshToken(refreshToken) + .build(); + } + public static TokenClaimsResult toTokenClaimsResult(Member member) { return new TokenClaimsResult(member.getId(), member.getRole().name()); } 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 3c81d2a..2b2b17b 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 @@ -5,6 +5,7 @@ import java.time.YearMonth; import java.time.ZoneId; import java.util.List; +import java.util.Optional; import org.sopt.routee.activity.api.result.MonthlyActivityDailySummaryResult; import org.sopt.routee.activity.api.usecase.ActivityUseCase; @@ -20,6 +21,7 @@ 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.OAuthRefreshTokenExchangePort; 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; @@ -36,12 +38,14 @@ import org.sopt.routee.member.internal.service.dto.result.UpdateProfileImageResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.internal.entity.Member; +import org.sopt.routee.member.internal.entity.MemberOAuthCredential; import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException; import org.sopt.routee.member.internal.exception.MemberNotFoundException; import org.sopt.routee.member.internal.exception.RequiredAgreementNotAcceptedException; import org.sopt.routee.member.internal.exception.UnsupportedImageFileExtensionException; import org.sopt.routee.member.internal.mapper.MemberMapper; import org.sopt.routee.member.internal.repository.MemberAgreementRepository; +import org.sopt.routee.member.internal.repository.MemberOAuthCredentialRepository; import org.sopt.routee.member.internal.repository.MemberRepository; import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; import org.sopt.routee.util.TimeZoneUtils; @@ -50,6 +54,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.StringUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -61,9 +66,11 @@ public class MemberService { private final OidcVerifyPort oidcVerifyPort; private final OAuthRevokePort oAuthRevokePort; + private final OAuthRefreshTokenExchangePort oAuthRefreshTokenExchangePort; private final ActivityUseCase activityUseCase; private final MemberRepository memberRepository; private final MemberAgreementRepository memberAgreementRepository; + private final MemberOAuthCredentialRepository memberOAuthCredentialRepository; private final ApplicationEventPublisher applicationEventPublisher; private final FileUploadPresignPort fileUploadPresignPort; private final FileImageAccessUrlPort fileImageAccessUrlPort; @@ -71,14 +78,34 @@ public class MemberService { private final ProfileImageFileNameValidator profileImageFileNameValidator; private final TransactionTemplate transactionTemplate; - @Transactional(readOnly = true) - public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider) { + @Transactional + public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider, String authorizationCode) { Member member = memberRepository.findByOauthIdAndOauthProvider(oauthId, oauthProvider) .orElseThrow(MemberNotFoundException::new); + ensureOAuthCredential(member, authorizationCode); + return MemberMapper.toTokenClaimsResult(member); } + private void ensureOAuthCredential(Member member, String authorizationCode) { + if (member.getOauthProvider() != OAuthProvider.APPLE || !StringUtils.hasText(authorizationCode)) { + return; + } + + if (memberOAuthCredentialRepository.existsByMember_Id(member.getId())) { + return; + } + + try { + String refreshToken = oAuthRefreshTokenExchangePort.exchangeForRefreshToken( + member.getOauthProvider(), authorizationCode); + memberOAuthCredentialRepository.save(MemberMapper.toOAuthCredentialEntity(member, refreshToken)); + } catch (BaseException e) { + log.warn("OIDC token exchange failed. memberId={}, provider={}", member.getId(), member.getOauthProvider(), e); + } + } + @Transactional(readOnly = true) public boolean existsById(long memberId) { return memberRepository.existsById(memberId); @@ -115,24 +142,28 @@ private void validateRequiredAgreements(AgreementCommand agreement) { public void withdraw(WithdrawCommand command) { long memberId = command.memberId(); - OAuthProvider oauthProvider; + WithdrawalContext context; try { - oauthProvider = transactionTemplate.execute(status -> { + context = transactionTemplate.execute(status -> { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); + Optional credential = memberOAuthCredentialRepository.findByMember_Id(memberId); + credential.ifPresent(memberOAuthCredentialRepository::delete); + memberAgreementRepository.deleteByMember_Id(memberId); memberRepository.delete(member); activityUseCase.deleteForMemberWithdrawal(memberId); - return member.getOauthProvider(); + return new WithdrawalContext( + member.getOauthProvider(), credential.map(MemberOAuthCredential::getRefreshToken).orElse(null)); }); } catch (ObjectOptimisticLockingFailureException e) { throw new MemberNotFoundException(); } - revokeOAuthConnection(memberId, oauthProvider, command.authorizationCode()); + revokeOAuthConnection(memberId, context.oauthProvider(), context.refreshToken()); applicationEventPublisher.publishEvent( new MemberWithdrawnEvent(memberId, command.accessTokenHash(), command.refreshTokenHash())); @@ -140,13 +171,16 @@ public void withdraw(WithdrawCommand command) { Thread.startVirtualThread(() -> deleteMemberImages(memberId)); } - private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String authorizationCode) { - if (oauthProvider != OAuthProvider.APPLE) { + private record WithdrawalContext(OAuthProvider oauthProvider, String refreshToken) { + } + + private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String refreshToken) { + if (oauthProvider != OAuthProvider.APPLE || !StringUtils.hasText(refreshToken)) { return; } try { - oAuthRevokePort.revoke(authorizationCode); + oAuthRevokePort.revoke(refreshToken); } catch (BaseException e) { log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); } From c0aacc659c36b133c7b61b94d080db992a877ad5 Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 14:58:58 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20authorization=20code=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=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 --- .../controller/AuthControllerDocs.java | 7 ++++-- ...AuthAuthorizationCodeExpiredException.java | 11 +++++++++ .../external/api/exception/package-info.java | 2 ++ .../adapter/AppleOAuthErrorResponse.java | 8 +++++++ ...AppleOAuthRefreshTokenExchangeAdapter.java | 23 +++++++++++++++++++ .../internal/oauth/code/ErrorCode.java | 1 + .../internal/service/MemberService.java | 3 +++ 7 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthErrorResponse.java diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java index 81eae6f..0b0f2e5 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java @@ -25,7 +25,8 @@ public interface AuthControllerDocs { @Operation(summary = "소셜 로그인", description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다. Apple 로그인 회원이 authorization_code를 함께 전달하면, " + "아직 저장된 Apple refresh_token이 없을 때에 한해 이를 교환하여 저장합니다. 이미 저장되어 있다면 전달값은 무시되며, " - + "저장된 refresh_token은 회원 탈퇴 시 소셜 로그인 연동 해제에 사용됩니다.") + + "저장된 refresh_token은 회원 탈퇴 시 소셜 로그인 연동 해제에 사용됩니다. 단, 전달된 authorization_code가 만료되었거나 " + + "유효하지 않으면 로그인 자체가 실패합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))), @@ -45,7 +46,9 @@ public interface AuthControllerDocs { @ExampleObject(name = "ID_TOKEN_EXPIRED", value = "{\"status\":401,\"code\":\"ID_TOKEN_EXPIRED\",\"message\":\"만료된 id_token입니다.\"}"), @ExampleObject(name = "INVALID_TOKEN_CLAIMS", - value = "{\"status\":401,\"code\":\"INVALID_TOKEN_CLAIMS\",\"message\":\"id_token 클레임이 유효하지 않습니다.\"}") + value = "{\"status\":401,\"code\":\"INVALID_TOKEN_CLAIMS\",\"message\":\"id_token 클레임이 유효하지 않습니다.\"}"), + @ExampleObject(name = "AUTHORIZATION_CODE_EXPIRED", + value = "{\"status\":401,\"code\":\"AUTHORIZATION_CODE_EXPIRED\",\"message\":\"만료되었거나 유효하지 않은 authorization_code입니다.\"}") })), @ApiResponse(responseCode = "404", description = "가입된 회원 없음 - 회원가입 필요", content = @Content(schema = @Schema(implementation = FailureResponse.class), diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java b/routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java new file mode 100644 index 0000000..b04a180 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.external.api.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class OAuthAuthorizationCodeExpiredException extends BaseException { + + public OAuthAuthorizationCodeExpiredException(Throwable cause) { + super(ErrorCode.AUTHORIZATION_CODE_EXPIRED, cause); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java b/routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java new file mode 100644 index 0000000..726eff2 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("api") +package org.sopt.routee.external.api.exception; diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthErrorResponse.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthErrorResponse.java new file mode 100644 index 0000000..54e4df4 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthErrorResponse.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import com.fasterxml.jackson.annotation.JsonProperty; + +record AppleOAuthErrorResponse( + @JsonProperty("error") String error +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java index 80cb20b..8552125 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java @@ -1,5 +1,6 @@ package org.sopt.routee.external.internal.oauth.adapter; +import org.sopt.routee.external.api.exception.OAuthAuthorizationCodeExpiredException; import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort; import org.sopt.routee.external.api.type.OAuthProvider; import org.sopt.routee.external.internal.oauth.exception.OAuthRefreshTokenExchangeException; @@ -7,15 +8,22 @@ import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor class AppleOAuthRefreshTokenExchangeAdapter implements OAuthRefreshTokenExchangePort { + private static final String INVALID_GRANT = "invalid_grant"; + private final AppleOAuthFormClient client; + private final ObjectMapper objectMapper; @Override public String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode) { @@ -36,8 +44,23 @@ public String exchangeForRefreshToken(OAuthProvider provider, String authorizati } return response.refreshToken(); + } catch (HttpClientErrorException e) { + if (isInvalidGrant(e)) { + throw new OAuthAuthorizationCodeExpiredException(e); + } + throw new OAuthRefreshTokenExchangeException(e); } catch (RestClientException e) { throw new OAuthRefreshTokenExchangeException(e); } } + + private boolean isInvalidGrant(HttpClientErrorException e) { + try { + AppleOAuthErrorResponse errorResponse = + objectMapper.readValue(e.getResponseBodyAsString(), AppleOAuthErrorResponse.class); + return INVALID_GRANT.equals(errorResponse.error()); + } catch (JacksonException parseException) { + return false; + } + } } 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 index ef8febe..02dfb54 100644 --- 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 @@ -12,6 +12,7 @@ public enum ErrorCode implements ErrorResultCode { OAUTH_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 연동 해제에 실패했습니다."), OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 refresh_token 교환에 실패했습니다."), + AUTHORIZATION_CODE_EXPIRED(HttpStatus.UNAUTHORIZED, "만료되었거나 유효하지 않은 authorization_code입니다."), APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple client secret 생성에 실패했습니다."); private final HttpStatus status; 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 2b2b17b..b9b9c76 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 @@ -21,6 +21,7 @@ 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.exception.OAuthAuthorizationCodeExpiredException; import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; @@ -101,6 +102,8 @@ private void ensureOAuthCredential(Member member, String authorizationCode) { String refreshToken = oAuthRefreshTokenExchangePort.exchangeForRefreshToken( member.getOauthProvider(), authorizationCode); memberOAuthCredentialRepository.save(MemberMapper.toOAuthCredentialEntity(member, refreshToken)); + } catch (OAuthAuthorizationCodeExpiredException e) { + throw e; } catch (BaseException e) { log.warn("OIDC token exchange failed. memberId={}, provider={}", member.getId(), member.getOauthProvider(), e); } From ca1eadb8cf3ca01ec3743ea3c1cbb5549cf64cb6 Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 19 Sep 2026 16:11:30 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20=EC=A0=84?= =?UTF-8?q?=ED=8C=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AuthControllerDocs.java | 20 ++++++++++++------- .../controller/dto/request/LoginRequest.java | 3 ++- .../external/api/exception/package-info.java | 2 -- ...AppleOAuthRefreshTokenExchangeAdapter.java | 2 +- ...AuthAuthorizationCodeExpiredException.java | 2 +- .../member/internal/code/ErrorCode.java | 3 ++- .../AuthorizationCodeRequiredException.java | 11 ++++++++++ .../internal/service/MemberService.java | 18 ++++++++--------- 8 files changed, 38 insertions(+), 23 deletions(-) delete mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java rename routee-external/src/main/java/org/sopt/routee/external/{api => internal/oauth}/exception/OAuthAuthorizationCodeExpiredException.java (84%) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/exception/AuthorizationCodeRequiredException.java diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java index 0b0f2e5..21772eb 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/AuthControllerDocs.java @@ -23,10 +23,10 @@ public interface AuthControllerDocs { @Operation(summary = "소셜 로그인", - description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다. Apple 로그인 회원이 authorization_code를 함께 전달하면, " - + "아직 저장된 Apple refresh_token이 없을 때에 한해 이를 교환하여 저장합니다. 이미 저장되어 있다면 전달값은 무시되며, " - + "저장된 refresh_token은 회원 탈퇴 시 소셜 로그인 연동 해제에 사용됩니다. 단, 전달된 authorization_code가 만료되었거나 " - + "유효하지 않으면 로그인 자체가 실패합니다.") + description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다. Apple 로그인 회원 중 아직 저장된 Apple refresh_token이 없는 " + + "회원은 authorization_code가 필수이며, 이를 교환해 refresh_token을 저장합니다. 이미 저장되어 있는 회원은 authorization_code를 " + + "전달하지 않아도 되고 전달되어도 무시됩니다. 저장된 refresh_token은 회원 탈퇴 시 소셜 로그인 연동 해제에 사용됩니다. " + + "저장된 refresh_token이 없는데 authorization_code를 전달하지 않았거나, authorization_code 교환/저장에 실패하면 로그인 자체가 실패합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))), @@ -36,9 +36,11 @@ public interface AuthControllerDocs { @ExampleObject(name = "INVALID_INPUT_VALUE", value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"provider는 필수입니다.\"}"), @ExampleObject(name = "INVALID_REQUEST_BODY", - value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") + value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}"), + @ExampleObject(name = "AUTHORIZATION_CODE_REQUIRED", + value = "{\"status\":400,\"code\":\"AUTHORIZATION_CODE_REQUIRED\",\"message\":\"저장된 소셜 로그인 연동 정보가 없어 authorization_code가 필요합니다.\"}") })), - @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 id_token", + @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 id_token/authorization_code", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @ExampleObject(name = "INVALID_ID_TOKEN", @@ -53,7 +55,11 @@ public interface AuthControllerDocs { @ApiResponse(responseCode = "404", description = "가입된 회원 없음 - 회원가입 필요", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = @ExampleObject(name = "MEMBER_NOT_FOUND", - value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) + value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))), + @ApiResponse(responseCode = "502", description = "소셜 로그인 refresh_token 교환에 실패함 (저장된 연동 정보가 없는 회원만 해당)", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED", + value = "{\"status\":502,\"code\":\"OAUTH_REFRESH_TOKEN_EXCHANGE_FAILED\",\"message\":\"소셜 로그인 refresh_token 교환에 실패했습니다.\"}"))) }) ResponseEntity> login( @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java index d1dd6e0..1eed070 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/controller/dto/request/LoginRequest.java @@ -14,7 +14,8 @@ public record LoginRequest( @NotBlank(message = "id_token은 필수입니다.") String idToken, - @Schema(description = "Apple 로그인 시 함께 전달받은 인가 코드. Apple refresh_token 발급에 사용되며, " + @Schema(description = "Apple 로그인 시 함께 전달받은 인가 코드. Apple refresh_token 발급에 사용됩니다. " + + "아직 저장된 Apple refresh_token이 없는 회원은 필수이며, 없으면 로그인이 실패합니다. " + "이미 발급받아 저장된 회원이라면 전달하지 않아도 되고 전달되어도 무시됩니다. Apple 외 소셜 로그인 회원은 필요하지 않습니다.") String authorizationCode ) { diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java b/routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java deleted file mode 100644 index 726eff2..0000000 --- a/routee-external/src/main/java/org/sopt/routee/external/api/exception/package-info.java +++ /dev/null @@ -1,2 +0,0 @@ -@org.springframework.modulith.NamedInterface("api") -package org.sopt.routee.external.api.exception; diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java index 8552125..2a63f38 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java @@ -1,8 +1,8 @@ package org.sopt.routee.external.internal.oauth.adapter; -import org.sopt.routee.external.api.exception.OAuthAuthorizationCodeExpiredException; import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort; import org.sopt.routee.external.api.type.OAuthProvider; +import org.sopt.routee.external.internal.oauth.exception.OAuthAuthorizationCodeExpiredException; import org.sopt.routee.external.internal.oauth.exception.OAuthRefreshTokenExchangeException; import org.sopt.routee.external.internal.oidc.exception.UnsupportedOidcProviderException; import org.springframework.stereotype.Component; diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthAuthorizationCodeExpiredException.java similarity index 84% rename from routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java rename to routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthAuthorizationCodeExpiredException.java index b04a180..99e9403 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/exception/OAuthAuthorizationCodeExpiredException.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthAuthorizationCodeExpiredException.java @@ -1,4 +1,4 @@ -package org.sopt.routee.external.api.exception; +package org.sopt.routee.external.internal.oauth.exception; import org.sopt.routee.exception.BaseException; import org.sopt.routee.external.internal.oauth.code.ErrorCode; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java index 45cdfa3..2c57a11 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java @@ -15,7 +15,8 @@ public enum ErrorCode implements ErrorResultCode { REQUIRED_AGREEMENT_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 모두 동의해야 합니다."), INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."), UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다."), - OAUTH_CREDENTIAL_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 자격 증명 암복호화에 실패했습니다."); + OAUTH_CREDENTIAL_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 자격 증명 암복호화에 실패했습니다."), + AUTHORIZATION_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "저장된 소셜 로그인 연동 정보가 없어 authorization_code가 필요합니다."); private final HttpStatus status; private final String message; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/exception/AuthorizationCodeRequiredException.java b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/AuthorizationCodeRequiredException.java new file mode 100644 index 0000000..cd81586 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/AuthorizationCodeRequiredException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.member.internal.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.member.internal.code.ErrorCode; + +public class AuthorizationCodeRequiredException extends BaseException { + + public AuthorizationCodeRequiredException() { + super(ErrorCode.AUTHORIZATION_CODE_REQUIRED); + } +} 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 b9b9c76..1ed8e9f 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 @@ -21,7 +21,6 @@ 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.exception.OAuthAuthorizationCodeExpiredException; import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; @@ -41,6 +40,7 @@ import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.entity.MemberOAuthCredential; import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException; +import org.sopt.routee.member.internal.exception.AuthorizationCodeRequiredException; import org.sopt.routee.member.internal.exception.MemberNotFoundException; import org.sopt.routee.member.internal.exception.RequiredAgreementNotAcceptedException; import org.sopt.routee.member.internal.exception.UnsupportedImageFileExtensionException; @@ -90,7 +90,7 @@ public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvi } private void ensureOAuthCredential(Member member, String authorizationCode) { - if (member.getOauthProvider() != OAuthProvider.APPLE || !StringUtils.hasText(authorizationCode)) { + if (member.getOauthProvider() != OAuthProvider.APPLE) { return; } @@ -98,15 +98,13 @@ private void ensureOAuthCredential(Member member, String authorizationCode) { return; } - try { - String refreshToken = oAuthRefreshTokenExchangePort.exchangeForRefreshToken( - member.getOauthProvider(), authorizationCode); - memberOAuthCredentialRepository.save(MemberMapper.toOAuthCredentialEntity(member, refreshToken)); - } catch (OAuthAuthorizationCodeExpiredException e) { - throw e; - } catch (BaseException e) { - log.warn("OIDC token exchange failed. memberId={}, provider={}", member.getId(), member.getOauthProvider(), e); + if (!StringUtils.hasText(authorizationCode)) { + throw new AuthorizationCodeRequiredException(); } + + String refreshToken = oAuthRefreshTokenExchangePort.exchangeForRefreshToken( + member.getOauthProvider(), authorizationCode); + memberOAuthCredentialRepository.save(MemberMapper.toOAuthCredentialEntity(member, refreshToken)); } @Transactional(readOnly = true) From 8aefd7a5b880dd2434e7142c7dbed50bcb49629e Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 20 Sep 2026 20:23:27 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20revoke=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=83=88=ED=87=B4=20=EC=9A=94=EC=B2=AD=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/AppleOAuthRevokeAdapter.java | 26 +++++++++++++ .../controller/MemberControllerDocs.java | 9 ++++- .../internal/service/MemberService.java | 38 +++++++++---------- 3 files changed, 50 insertions(+), 23 deletions(-) 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 index abced57..54c9af1 100644 --- 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 @@ -1,20 +1,30 @@ package org.sopt.routee.external.internal.oauth.adapter; +import java.util.Set; + import org.sopt.routee.external.api.port.OAuthRevokePort; import org.sopt.routee.external.internal.oauth.exception.OAuthRevokeException; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Component @RequiredArgsConstructor class AppleOAuthRevokeAdapter implements OAuthRevokePort { private static final String REFRESH_TOKEN_HINT = "refresh_token"; + private static final Set ALREADY_INVALID_ERRORS = Set.of("invalid_token", "invalid_grant"); private final AppleOAuthFormClient client; + private final ObjectMapper objectMapper; @Override public void revoke(String refreshToken) { @@ -25,8 +35,24 @@ public void revoke(String refreshToken) { try { client.post(client.revokeUri(), form, Void.class); + } catch (HttpClientErrorException e) { + if (isAlreadyInvalid(e)) { + log.info("Apple OAuth token already invalid/revoked. Treating as success."); + return; + } + throw new OAuthRevokeException(e); } catch (RestClientException e) { throw new OAuthRevokeException(e); } } + + private boolean isAlreadyInvalid(HttpClientErrorException e) { + try { + AppleOAuthErrorResponse errorResponse = + objectMapper.readValue(e.getResponseBodyAsString(), AppleOAuthErrorResponse.class); + return ALREADY_INVALID_ERRORS.contains(errorResponse.error()); + } catch (JacksonException parseException) { + return false; + } + } } 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 7370fc3..f55843f 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 @@ -87,7 +87,8 @@ ResponseEntity> register( @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " + "Apple 로그인 회원은 로그인 시점에 저장해둔 Apple refresh_token으로 소셜 로그인 연동도 함께 해제되며, " - + "탈퇴 요청 자체에는 별도의 인가 정보를 전달할 필요가 없습니다. 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") + + "탈퇴 요청 자체에는 별도의 인가 정보를 전달할 필요가 없습니다. 연동 해제에 실패하면 탈퇴 자체도 실패하지만, " + + "이미 연동이 해제된 상태(Apple 계정 설정에서 직접 연동을 끊은 경우 등)라면 실패로 보지 않고 탈퇴를 계속 진행합니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @@ -106,7 +107,11 @@ ResponseEntity> register( @ApiResponse(responseCode = "404", description = "가입된 회원 없음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = @ExampleObject(name = "MEMBER_NOT_FOUND", - value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) + value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))), + @ApiResponse(responseCode = "502", description = "소셜 로그인 연동 해제에 실패함 (Apple refresh_token이 저장된 회원만 해당)", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "OAUTH_REVOKE_FAILED", + value = "{\"status\":502,\"code\":\"OAUTH_REVOKE_FAILED\",\"message\":\"소셜 로그인 연동 해제에 실패했습니다.\"}"))) }) ResponseEntity> withdraw( Long memberId, 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 1ed8e9f..75f9113 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 @@ -5,7 +5,6 @@ import java.time.YearMonth; import java.time.ZoneId; import java.util.List; -import java.util.Optional; import org.sopt.routee.activity.api.result.MonthlyActivityDailySummaryResult; import org.sopt.routee.activity.api.usecase.ActivityUseCase; @@ -143,48 +142,45 @@ private void validateRequiredAgreements(AgreementCommand agreement) { public void withdraw(WithdrawCommand command) { long memberId = command.memberId(); - WithdrawalContext context; - try { - context = transactionTemplate.execute(status -> { - Member member = memberRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); - Optional credential = memberOAuthCredentialRepository.findByMember_Id(memberId); - credential.ifPresent(memberOAuthCredentialRepository::delete); + revokeOAuthConnection(memberId, member.getOauthProvider()); + try { + transactionTemplate.execute(status -> { + memberOAuthCredentialRepository.deleteByMember_Id(memberId); memberAgreementRepository.deleteByMember_Id(memberId); memberRepository.delete(member); activityUseCase.deleteForMemberWithdrawal(memberId); - return new WithdrawalContext( - member.getOauthProvider(), credential.map(MemberOAuthCredential::getRefreshToken).orElse(null)); + return null; }); } catch (ObjectOptimisticLockingFailureException e) { throw new MemberNotFoundException(); } - revokeOAuthConnection(memberId, context.oauthProvider(), context.refreshToken()); - applicationEventPublisher.publishEvent( new MemberWithdrawnEvent(memberId, command.accessTokenHash(), command.refreshTokenHash())); Thread.startVirtualThread(() -> deleteMemberImages(memberId)); } - private record WithdrawalContext(OAuthProvider oauthProvider, String refreshToken) { - } - - private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String refreshToken) { - if (oauthProvider != OAuthProvider.APPLE || !StringUtils.hasText(refreshToken)) { + private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider) { + if (oauthProvider != OAuthProvider.APPLE) { return; } - try { - oAuthRevokePort.revoke(refreshToken); - } catch (BaseException e) { - log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); + String refreshToken = memberOAuthCredentialRepository.findByMember_Id(memberId) + .map(MemberOAuthCredential::getRefreshToken) + .orElse(null); + + if (!StringUtils.hasText(refreshToken)) { + return; } + + oAuthRevokePort.revoke(refreshToken); } private void deleteMemberImages(long memberId) {