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 c114f9fd..fed3fd8d 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 738d8712..21772ebb 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,11 @@ @Tag(name = "Auth", description = "인증 API") public interface AuthControllerDocs { - @Operation(summary = "소셜 로그인", description = "OIDC ID 토큰으로 로그인하고 액세스/리프레시 토큰을 발급합니다.") + @Operation(summary = "소셜 로그인", + 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))), @@ -32,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", @@ -42,14 +48,30 @@ 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), 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(@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 d68d744f..1eed0705 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,14 @@ public record LoginRequest( OAuthProvider provider, @NotBlank(message = "id_token은 필수입니다.") - String idToken + String idToken, + + @Schema(description = "Apple 로그인 시 함께 전달받은 인가 코드. Apple refresh_token 발급에 사용됩니다. " + + "아직 저장된 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 4a34e4d6..23840685 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 9c59670d..f04a8245 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-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 00000000..7c52a78d --- /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 cd8776da..083efe27 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/AppleOAuthErrorResponse.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthErrorResponse.java new file mode 100644 index 00000000..54e4df4e --- /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/AppleOAuthFormClient.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthFormClient.java new file mode 100644 index 00000000..d07534cc --- /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 00000000..2a63f38d --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRefreshTokenExchangeAdapter.java @@ -0,0 +1,66 @@ +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.OAuthAuthorizationCodeExpiredException; +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.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) { + 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 (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/adapter/AppleOAuthRevokeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java index 62f0ccc4..54c9af1d 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,81 +1,58 @@ 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.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.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 RestClient restClient; - private final OAuthRevokeProperty property; - private final AppleClientSecretGenerator clientSecretGenerator; + private final AppleOAuthFormClient client; + private final ObjectMapper objectMapper; @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; - } + public void revoke(String refreshToken) { + MultiValueMap form = client.credentialForm(); - 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); - } + form.add("token", refreshToken); + form.add("token_type_hint", REFRESH_TOKEN_HINT); - 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 (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 MultiValueMap credentialForm() { - MultiValueMap form = new LinkedMultiValueMap<>(); - - form.add("client_id", property.clientId()); - form.add("client_secret", clientSecretGenerator.generate()); - - return form; + 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-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 65e68914..02dfb544 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,8 @@ 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-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthAuthorizationCodeExpiredException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthAuthorizationCodeExpiredException.java new file mode 100644 index 00000000..99e94039 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthAuthorizationCodeExpiredException.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 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/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 00000000..2ab93cdf --- /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); + } +} 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 c563e141..5cd226c7 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 03215062..9e430f7d 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 b1831731..2c57a11d 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,9 @@ 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, "소셜 로그인 자격 증명 암복호화에 실패했습니다."), + 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/config/MemberOAuthCredentialProperty.java b/routee-member/src/main/java/org/sopt/routee/member/internal/config/MemberOAuthCredentialProperty.java new file mode 100644 index 00000000..9388cba4 --- /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/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index b05f80ce..f55843fe 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,9 @@ ResponseEntity> register( @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " - + "Apple 로그인 회원은 authorization_code를 함께 전달해야 소셜 로그인 연동도 해제됩니다. " - + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, Apple 외 소셜 로그인 회원은 전달하지 않아도 됩니다. " - + "연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") + + "Apple 로그인 회원은 로그인 시점에 저장해둔 Apple refresh_token으로 소셜 로그인 연동도 함께 해제되며, " + + "탈퇴 요청 자체에는 별도의 인가 정보를 전달할 필요가 없습니다. 연동 해제에 실패하면 탈퇴 자체도 실패하지만, " + + "이미 연동이 해제된 상태(Apple 계정 설정에서 직접 연동을 끊은 경우 등)라면 실패로 보지 않고 탈퇴를 계속 진행합니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @@ -107,19 +107,18 @@ 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, @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 45cf1d2d..745bea12 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/converter/RefreshTokenAttributeConverter.java b/routee-member/src/main/java/org/sopt/routee/member/internal/converter/RefreshTokenAttributeConverter.java new file mode 100644 index 00000000..32d33438 --- /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 new file mode 100644 index 00000000..881a7e0d --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberOAuthCredential.java @@ -0,0 +1,45 @@ +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; +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; + + @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/AuthorizationCodeRequiredException.java b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/AuthorizationCodeRequiredException.java new file mode 100644 index 00000000..cd815868 --- /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/exception/OAuthCredentialEncryptionException.java b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/OAuthCredentialEncryptionException.java new file mode 100644 index 00000000..4fb32f32 --- /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); + } +} 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 50661e8f..72621716 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/repository/MemberOAuthCredentialRepository.java b/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberOAuthCredentialRepository.java new file mode 100644 index 00000000..27d1c45b --- /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); + +} 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 3c81d2aa..75f91134 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 @@ -20,6 +20,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 +37,15 @@ 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.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; 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) { + return; + } + + if (memberOAuthCredentialRepository.existsByMember_Id(member.getId())) { + return; + } + + if (!StringUtils.hasText(authorizationCode)) { + throw new AuthorizationCodeRequiredException(); + } + + String refreshToken = oAuthRefreshTokenExchangePort.exchangeForRefreshToken( + member.getOauthProvider(), authorizationCode); + memberOAuthCredentialRepository.save(MemberMapper.toOAuthCredentialEntity(member, refreshToken)); + } + @Transactional(readOnly = true) public boolean existsById(long memberId) { return memberRepository.existsById(memberId); @@ -115,41 +142,45 @@ private void validateRequiredAgreements(AgreementCommand agreement) { public void withdraw(WithdrawCommand command) { long memberId = command.memberId(); - OAuthProvider oauthProvider; - try { - oauthProvider = transactionTemplate.execute(status -> { - Member member = memberRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + revokeOAuthConnection(memberId, member.getOauthProvider()); + try { + transactionTemplate.execute(status -> { + memberOAuthCredentialRepository.deleteByMember_Id(memberId); memberAgreementRepository.deleteByMember_Id(memberId); memberRepository.delete(member); activityUseCase.deleteForMemberWithdrawal(memberId); - return member.getOauthProvider(); + return null; }); } catch (ObjectOptimisticLockingFailureException e) { throw new MemberNotFoundException(); } - 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) { + private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider) { if (oauthProvider != OAuthProvider.APPLE) { return; } - try { - oAuthRevokePort.revoke(authorizationCode); - } 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) { 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 a2f4166b..041fdac2 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 ) { }