diff --git a/build.gradle b/build.gradle index 018fa723..d08c61ca 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,10 @@ tasks.named('test') { useJUnitPlatform() } +tasks.named('bootRun') { + environment dotenv() +} + tasks.register('placeGeocodingBatch', JavaExec) { group = 'application' description = '수집 장소 데이터를 카카오 API로 보강하는 1회성 배치 (KAKAO_REST_API_KEY, PLACE_SHEET_CSV_URL, PLACE_SPREADSHEET_ID 필요)' diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 72703608..b8ec4792 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -40,6 +40,12 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} APPLE_OAUTH_ALLOWED_AUDIENCES: ${APPLE_OAUTH_ALLOWED_AUDIENCES} + APPLE_OAUTH_TEAM_ID: ${APPLE_OAUTH_TEAM_ID} + APPLE_OAUTH_KEY_ID: ${APPLE_OAUTH_KEY_ID} + APPLE_OAUTH_PRIVATE_KEY: ${APPLE_OAUTH_PRIVATE_KEY} + + OAUTH_CREDENTIAL_SECRET: ${OAUTH_CREDENTIAL_SECRET} + OAUTH_CREDENTIAL_SALT: ${OAUTH_CREDENTIAL_SALT} DISCORD_ERROR_WEBHOOK_URL: ${DISCORD_ERROR_WEBHOOK_URL} DISCORD_REPORT_WEBHOOK_URL: ${DISCORD_REPORT_WEBHOOK_URL} diff --git a/scripts/add-social-oauth-credential.sql b/scripts/add-social-oauth-credential.sql new file mode 100644 index 00000000..75598c21 --- /dev/null +++ b/scripts/add-social-oauth-credential.sql @@ -0,0 +1,16 @@ +-- Apple/카카오 revoke용 social_oauth_credential 테이블 추가 +-- +-- 운영 프로파일은 ddl-auto: validate라 이 테이블이 없으면 기동 자체가 실패한다. +-- 이 스크립트는 코드 배포 "전"에 실행되어야 한다. +-- + +CREATE TABLE social_oauth_credential ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_social_account_id BIGINT NOT NULL, + provider VARCHAR(20) NOT NULL, + refresh_token VARCHAR(1000) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + + CONSTRAINT uk_social_oauth_credential_member_social_account_id UNIQUE (member_social_account_id) +); diff --git a/src/main/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGenerator.java b/src/main/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGenerator.java new file mode 100644 index 00000000..0ce08390 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGenerator.java @@ -0,0 +1,86 @@ +package com.cotato.nextstation.domain.auth.client; + +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +// Apple REST API(토큰 교환·revoke)에 필요한 client_secret은 Apple이 발급해주는 고정값이 아니라, +// 우리가 매번 ES256으로 서명해서 만드는 JWT다. Team ID/Key ID/.p8 프라이빗 키는 Apple Developer +// 콘솔에서 "Sign In with Apple" capability로 발급받은 Key 하나로 얻는다(allowed-audiences와 별개 크레덴셜). +@Component +public class AppleClientSecretGenerator { + + private static final String AUDIENCE = "https://appleid.apple.com"; + + // Apple 문서상 exp는 최대 6개월까지 허용하지만, 매 요청 직전에 새로 만들어 쓰므로 짧게 잡아 유출 시 악용 창을 최소화한다. + private static final Duration CLIENT_SECRET_EXPIRATION = Duration.ofMinutes(5); + + private final String teamId; + private final String keyId; + private final String rawPrivateKey; + private final String clientId; + + public AppleClientSecretGenerator(@Value("${apple.oauth.team-id:}") String teamId, + @Value("${apple.oauth.key-id:}") String keyId, + @Value("${apple.oauth.private-key:}") String rawPrivateKey, + @Value("${apple.oauth.allowed-audiences}") List allowedAudiences) { + this.teamId = teamId; + this.keyId = keyId; + this.rawPrivateKey = rawPrivateKey; + // client_secret의 sub 클레임은 identity token의 aud와 동일해야 한다 -> 네이티브 Bundle ID를 그대로 쓴다. + // 웹 Services ID를 추가로 지원하게 되면 어떤 클라이언트로 교환하는지에 따라 sub를 구분해야 한다. + this.clientId = allowedAudiences.isEmpty() ? "" : allowedAudiences.get(0); + } + + // team-id/key-id/private-key는 Account Holder가 Apple Developer 콘솔에서 발급하는 값이라, 발급 전에는 + // 비어 있을 수 있다. AppleOAuthClient(allowed-audiences)와 달리 로그인/가입의 핵심 경로가 아니라서 + // 부팅 시점에 막지 않고, 실제로 revoke/토큰 교환을 시도하는 시점에만 지연 검증한다. + public String generate() { + if (teamId.isBlank() || keyId.isBlank() || rawPrivateKey.isBlank()) { + throw new IllegalStateException( + "apple.oauth.team-id/key-id/private-key가 설정되지 않았습니다. Apple Sign In Key 발급 후 채워주세요."); + } + + PrivateKey privateKey = parsePrivateKey(rawPrivateKey); + Instant now = Instant.now(); + + return Jwts.builder() + .header().add("kid", keyId).and() + .issuer(teamId) + .audience().add(AUDIENCE).and() + .subject(clientId) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(CLIENT_SECRET_EXPIRATION))) + .signWith(privateKey, Jwts.SIG.ES256) + .compact(); + } + + // .p8 파일은 PEM(PKCS#8) 형식이다. 환경변수에는 줄바꿈이 리터럴 "\n"으로 이스케이프돼 들어올 수 있어 둘 다 처리한다. + private PrivateKey parsePrivateKey(String rawPrivateKey) { + try { + String base64Body = rawPrivateKey + .replace("\\n", "\n") + .replaceAll("-----BEGIN (.*)-----", "") + .replaceAll("-----END (.*)-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(base64Body); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + + } catch (Exception e) { + throw new IllegalStateException( + "apple.oauth.private-key 파싱에 실패했습니다. .p8 파일 내용이 올바른지 확인하세요.", e); + } + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/auth/client/AppleTokenClient.java b/src/main/java/com/cotato/nextstation/domain/auth/client/AppleTokenClient.java new file mode 100644 index 00000000..aa682f7d --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/auth/client/AppleTokenClient.java @@ -0,0 +1,99 @@ +package com.cotato.nextstation.domain.auth.client; + +import com.cotato.nextstation.domain.auth.client.dto.AppleTokenResponse; +import com.cotato.nextstation.global.exception.CustomException; +import com.cotato.nextstation.global.exception.error.GlobalErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; + +// Apple REST API 중 identity token 검증 이외의 것(authorizationCode 교환, revoke)을 다루는 클라이언트. +// AppleOAuthClient(JWKS 서명 검증)와 책임이 달라 분리했다 - 이쪽은 client_secret(JWT)로 Apple과 직접 통신한다. +@Slf4j +@Component +public class AppleTokenClient { + + private static final String TOKEN_URI = "https://appleid.apple.com/auth/token"; + private static final String REVOKE_URI = "https://appleid.apple.com/auth/revoke"; + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(5); + + private final AppleClientSecretGenerator clientSecretGenerator; + private final RestClient restClient; + private final String clientId; + + public AppleTokenClient(AppleClientSecretGenerator clientSecretGenerator, + @Value("${apple.oauth.allowed-audiences}") List allowedAudiences) { + this.clientSecretGenerator = clientSecretGenerator; + + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(READ_TIMEOUT); + + this.restClient = RestClient.builder() + .requestFactory(requestFactory) + .build(); + this.clientId = allowedAudiences.isEmpty() ? "" : allowedAudiences.get(0); + } + + // authorizationCode는 1회용이라 재시도 시 이미 소모된 코드로는 실패한다. 신규 가입(최초 Apple 인증) 시점에만 호출한다. + public AppleTokenResponse exchangeAuthorizationCode(String authorizationCode) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("client_id", clientId); + form.add("client_secret", clientSecretGenerator.generate()); + form.add("code", authorizationCode); + form.add("grant_type", "authorization_code"); + + try { + return restClient.post() + .uri(TOKEN_URI) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(AppleTokenResponse.class); + + } catch (RestClientException e) { + log.warn("Apple authorizationCode 교환 실패", e); + throw new CustomException(GlobalErrorCode.EXTERNAL_API_ERROR); + } + } + + // 파기 배치에서 저장해둔 refresh_token을 폐기한다. 이미 폐기된 토큰을 다시 revoke해도 Apple은 보통 200을 반환한다(멱등). + // 호출자(WithdrawnMemberCleaner)가 삭제보다 먼저 결과를 보고 재시도 대상을 가려야 하므로 예외 대신 boolean으로 알린다. + public boolean revoke(String refreshToken) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("client_id", clientId); + form.add("client_secret", clientSecretGenerator.generate()); + form.add("token", refreshToken); + form.add("token_type_hint", "refresh_token"); + + try { + restClient.post() + .uri(REVOKE_URI) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .toBodilessEntity(); + + log.info("Apple refresh_token revoke 완료"); + return true; + + } catch (RestClientException e) { + log.warn("Apple refresh_token revoke 실패", e); + return false; + } + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/auth/client/dto/AppleTokenResponse.java b/src/main/java/com/cotato/nextstation/domain/auth/client/dto/AppleTokenResponse.java new file mode 100644 index 00000000..70935c82 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/auth/client/dto/AppleTokenResponse.java @@ -0,0 +1,20 @@ +package com.cotato.nextstation.domain.auth.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +// POST https://appleid.apple.com/auth/token 응답 매핑용 DTO +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppleTokenResponse( + + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("expires_in") long expiresIn, + + // 이 값을 SocialOauthCredential에 암호화해서 저장해뒀다가, 탈퇴 시 revoke에 사용한다. + @JsonProperty("refresh_token") String refreshToken, + + // identityToken과 동일한 값이라 별도로 검증/저장하지 않는다. + @JsonProperty("id_token") String idToken +) { +} diff --git a/src/main/java/com/cotato/nextstation/domain/auth/controller/AppleAuthController.java b/src/main/java/com/cotato/nextstation/domain/auth/controller/AppleAuthController.java index 15f20002..884cb5d3 100644 --- a/src/main/java/com/cotato/nextstation/domain/auth/controller/AppleAuthController.java +++ b/src/main/java/com/cotato/nextstation/domain/auth/controller/AppleAuthController.java @@ -45,6 +45,9 @@ public class AppleAuthController { 카카오와 달리 인가코드 교환이나 별도의 사용자정보조회 API 호출이 없다 - identity token 자체에 서명·클레임 검증을 수행한다. - `nonce`는 클라이언트가 `ASAuthorizationAppleIDRequest.nonce`에 사용한 원문(raw) 값이다. 서버가 SHA-256으로 해싱해 identityToken의 `nonce` 클레임과 대조하므로, 탈취된 identity token을 재전송해도 로그인에 성공할 수 없다. + - `authorizationCode`는 identityToken과 함께 발급되는 값으로, `resultType=NEW_MEMBER`일 때만 의미가 있다. + 이 시점에 미리 refresh_token으로 교환해둬야 나중에 탈퇴 시 Apple 쪽 연동을 자동 해제(revoke)할 수 있다. + 생략해도 로그인 판별 자체는 정상 동작한다. - `resultType=LOGIN_SUCCESS`: 기존 ACTIVE 회원. accessToken은 응답 body로, refreshToken은 httpOnly 쿠키로 내려간다(로그인 API와 동일). - `resultType=PENDING_PROFILE`: 프로필 설정이 끝나지 않은 회원. `signupToken`이 발급되며, 이후 흐름은 회원가입의 `/profile` 호출과 동일하다. - `resultType=NEW_MEMBER`: 처음 보는 Apple 계정. `appleSignupToken`이 발급된다. 이 값을 들고 약관 동의 화면을 보여준 뒤 `/apple/signup`을 호출해야 한다. @@ -61,7 +64,7 @@ public class AppleAuthController { @PostMapping("/login") public CommonResponse appleLogin(@Valid @RequestBody AppleLoginRequest request, HttpServletResponse httpResponse) { - AppleLoginResult result = appleLoginQueryService.login(request.identityToken(), request.nonce()); + AppleLoginResult result = appleLoginQueryService.login(request.identityToken(), request.nonce(), request.authorizationCode()); if (result.resultType() == AppleLoginResultType.LOGIN_SUCCESS) { ResponseCookie refreshTokenCookie = refreshTokenCookieFactory.create(result.refreshToken()); diff --git a/src/main/java/com/cotato/nextstation/domain/auth/dto/request/AppleLoginRequest.java b/src/main/java/com/cotato/nextstation/domain/auth/dto/request/AppleLoginRequest.java index 7bc925c3..82876792 100644 --- a/src/main/java/com/cotato/nextstation/domain/auth/dto/request/AppleLoginRequest.java +++ b/src/main/java/com/cotato/nextstation/domain/auth/dto/request/AppleLoginRequest.java @@ -13,6 +13,12 @@ public record AppleLoginRequest( @Schema(description = "클라이언트가 ASAuthorizationAppleIDRequest.nonce에 사용한 원문(raw) 값. " + "SHA-256 해싱한 값이 identityToken의 nonce 클레임과 일치해야 한다 - 탈취된 identity token 재전송 방지용", example = "a1b2c3...") @NotBlank(message = "nonce는 필수입니다.") - String nonce + String nonce, + + @Schema(description = "iOS 네이티브 Sign In with Apple SDK가 identityToken과 함께 발급한 authorizationCode. " + + "resultType=NEW_MEMBER일 때만 의미가 있다 - 탈퇴 시 Apple 쪽 연동을 폐기(revoke)할 수 있도록 이 시점에 " + + "미리 refresh_token으로 교환해둔다. 생략해도 로그인 판별 자체는 정상 동작하며, 이 경우 나중에 " + + "탈퇴해도 Apple 쪽 연동이 자동 해제되지 않을 뿐이다.", example = "c1a2b3...") + String authorizationCode ) { } diff --git a/src/main/java/com/cotato/nextstation/domain/auth/repository/PendingAppleCredentialRepository.java b/src/main/java/com/cotato/nextstation/domain/auth/repository/PendingAppleCredentialRepository.java new file mode 100644 index 00000000..bd02d8bf --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/auth/repository/PendingAppleCredentialRepository.java @@ -0,0 +1,41 @@ +package com.cotato.nextstation.domain.auth.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Optional; + +// Apple authorizationCode를 로그인 판별 시점(NEW_MEMBER로 갈릴 때)에 미리 교환해둔 암호화된 refresh_token을 +// 회원가입이 실제로 완료될 때까지 잠깐 보관한다. +//

+// authorizationCode는 1회용이고 수명이 짧다(수 분). 예전엔 이 교환을 /apple/signup 시점(약관 동의 화면을 다 +// 보고 난 뒤)까지 미뤄뒀는데, 그 사이 code가 만료되거나 - 교환에 성공해도 그 뒤 로컬 저장이 실패하면 이미 +// 소비된 code만 날리는 문제가 있었다. 그래서 code를 받는 가장 이른 시점(로그인)에 바로 교환해 여기 캐싱해두고, +// 가입 시점엔 네트워크 호출 없이 이 값을 그대로 SocialOauthCredential로 옮겨 붙이기만 한다. +@Repository +@RequiredArgsConstructor +public class PendingAppleCredentialRepository { + + private static final String KEY_FORMAT = "auth:pending-apple-credential:%s"; + + // appleSignupToken 수명(10분)과 맞춘다 - 그 안에 가입을 완료하지 않으면 어차피 처음부터 다시 로그인해야 한다. + private static final Duration EXPIRATION = Duration.ofMinutes(10); + + private final RedisTemplate redisTemplate; + + public void save(String providerUserId, String encryptedRefreshToken) { + redisTemplate.opsForValue().set(key(providerUserId), encryptedRefreshToken, EXPIRATION); + } + + // 가입 완료 시 한 번만 쓰이므로 조회와 동시에 지운다(재사용 방지, 메모리 낭비 방지). + public Optional consume(String providerUserId) { + String value = redisTemplate.opsForValue().getAndDelete(key(providerUserId)); + return Optional.ofNullable(value); + } + + private String key(String providerUserId) { + return KEY_FORMAT.formatted(providerUserId); + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandService.java b/src/main/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandService.java index 9ae32742..f0d17c6f 100644 --- a/src/main/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandService.java +++ b/src/main/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandService.java @@ -4,6 +4,7 @@ import com.cotato.nextstation.domain.auth.entity.MemberTermsAgreement; import com.cotato.nextstation.domain.auth.exception.AuthErrorCode; import com.cotato.nextstation.domain.auth.repository.MemberTermsAgreementRepository; +import com.cotato.nextstation.domain.auth.repository.PendingAppleCredentialRepository; import com.cotato.nextstation.domain.auth.util.AppleSignupTokenClaims; import com.cotato.nextstation.domain.auth.util.SignupTokenClaims; import com.cotato.nextstation.domain.auth.util.TermsAgreementValidator; @@ -11,8 +12,10 @@ import com.cotato.nextstation.domain.member.entity.Member; import com.cotato.nextstation.domain.member.entity.MemberSocialAccount; import com.cotato.nextstation.domain.member.entity.MemberStatus; +import com.cotato.nextstation.domain.member.entity.SocialOauthCredential; import com.cotato.nextstation.domain.member.repository.MemberRepository; import com.cotato.nextstation.domain.member.repository.MemberSocialAccountRepository; +import com.cotato.nextstation.domain.member.repository.SocialOauthCredentialRepository; import com.cotato.nextstation.global.exception.CustomException; import com.cotato.nextstation.global.jwt.JwtProvider; import io.jsonwebtoken.Claims; @@ -39,6 +42,8 @@ public class AppleSignupCommandService { private final MemberRepository memberRepository; private final MemberSocialAccountRepository memberSocialAccountRepository; private final MemberTermsAgreementRepository memberTermsAgreementRepository; + private final SocialOauthCredentialRepository socialOauthCredentialRepository; + private final PendingAppleCredentialRepository pendingAppleCredentialRepository; private final JwtProvider jwtProvider; private final TermsAgreementValidator termsAgreementValidator; @@ -68,9 +73,10 @@ public SignupResponse signup(String appleSignupToken, List agreedTermsIds, } Member member; + MemberSocialAccount socialAccount; try { member = memberRepository.save(Member.builder().email(email).build()); - memberSocialAccountRepository.save( + socialAccount = memberSocialAccountRepository.save( MemberSocialAccount.builder() .memberId(member.getId()) .provider(AuthProvider.APPLE) @@ -96,6 +102,10 @@ public SignupResponse signup(String appleSignupToken, List agreedTermsIds, .toList(); memberTermsAgreementRepository.saveAll(agreements); + // authorizationCode 교환은 이미 로그인 판별 시점(AppleLoginQueryService)에 끝나 있다 - + // 여기서는 Apple API를 호출하지 않고, 그때 캐싱해둔 결과를 로컬 저장으로 옮겨 붙이기만 한다. + attachPendingCredential(socialAccount.getId(), appleClaims.providerUserId()); + String signupToken = issueSignupToken(member.getId()); log.info("Apple 회원가입 완료: memberId={}", member.getId()); return new SignupResponse(member.getId(), signupToken); @@ -124,6 +134,22 @@ private String issueSignupToken(Long memberId) { ); } + // pending 캐시에 없으면(로그인 시점에 authorizationCode를 안 보냈거나, 교환/캐싱이 실패했거나, TTL이 지났거나) + // 조용히 건너뛴다 - 이 회원은 탈퇴해도 Apple 쪽 연동이 자동 해제되지 않을 뿐, 가입 자체를 막을 이유가 아니다. + private void attachPendingCredential(Long memberSocialAccountId, String providerUserId) { + pendingAppleCredentialRepository.consume(providerUserId) + .ifPresentOrElse( + encryptedRefreshToken -> socialOauthCredentialRepository.save( + SocialOauthCredential.builder() + .memberSocialAccountId(memberSocialAccountId) + .provider(AuthProvider.APPLE) + .refreshToken(encryptedRefreshToken) + .build() + ), + () -> log.info("캐싱된 Apple refresh_token이 없어 연동 저장을 건너뜀: memberSocialAccountId={}", memberSocialAccountId) + ); + } + // subject는 memberId가 아니라 providerUserId(Apple 회원번호) private AppleSignupClaims resolveAppleClaims(String appleSignupToken) { diff --git a/src/main/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryService.java b/src/main/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryService.java index e049b8ef..0cf0d18e 100644 --- a/src/main/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryService.java +++ b/src/main/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryService.java @@ -1,13 +1,17 @@ package com.cotato.nextstation.domain.auth.service.query; import com.cotato.nextstation.domain.auth.client.AppleOAuthClient; +import com.cotato.nextstation.domain.auth.client.AppleTokenClient; import com.cotato.nextstation.domain.auth.client.dto.AppleIdentityToken; +import com.cotato.nextstation.domain.auth.client.dto.AppleTokenResponse; import com.cotato.nextstation.domain.auth.exception.AuthErrorCode; +import com.cotato.nextstation.domain.auth.repository.PendingAppleCredentialRepository; import com.cotato.nextstation.domain.auth.service.AuthTokenIssuer; import com.cotato.nextstation.domain.auth.service.IssuedTokens; import com.cotato.nextstation.domain.auth.service.result.AppleLoginResult; import com.cotato.nextstation.domain.auth.service.result.AppleLoginResultType; import com.cotato.nextstation.domain.auth.util.AppleSignupTokenClaims; +import com.cotato.nextstation.domain.auth.util.JwtSubjectReader; import com.cotato.nextstation.domain.auth.util.SignupTokenClaims; import com.cotato.nextstation.domain.member.entity.AuthProvider; import com.cotato.nextstation.domain.member.entity.Member; @@ -18,6 +22,7 @@ import com.cotato.nextstation.domain.member.service.command.MemberCommandService; import com.cotato.nextstation.global.exception.CustomException; import com.cotato.nextstation.global.jwt.JwtProvider; +import com.cotato.nextstation.global.security.OAuthRefreshTokenEncryptor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -26,8 +31,10 @@ import java.util.Map; import java.util.Optional; -// login()이 외부 API 호출(JWKS 조회)을 포함할 수 있는데, 트랜잭션으로 감싸면 그 호출이 끝날 때까지 DB 커넥션을 붙잡고 있게 되므로 붙이지 않는다. -// 카카오와 달리 토큰교환/사용자정보조회가 없다 - 클라이언트(iOS 네이티브)가 이미 들고 있는 identity token을 검증만 한다. +// login()이 외부 API 호출(JWKS 조회, NEW_MEMBER일 때 Apple 토큰교환)을 포함할 수 있는데, 트랜잭션으로 감싸면 +// 그 호출이 끝날 때까지 DB 커넥션을 붙잡고 있게 되므로 붙이지 않는다. +// 카카오와 달리 로그인 판별 자체엔 토큰교환/사용자정보조회가 없다 - 클라이언트(iOS 네이티브)가 이미 들고 있는 +// identity token을 검증만 한다. authorizationCode 교환은 신규 회원의 revoke 준비를 위한 부가 작업이다. @Slf4j @Service @RequiredArgsConstructor @@ -37,6 +44,9 @@ public class AppleLoginQueryService { private static final Duration SIGNUP_TOKEN_EXPIRATION = Duration.ofMinutes(30); private final AppleOAuthClient appleOAuthClient; + private final AppleTokenClient appleTokenClient; + private final OAuthRefreshTokenEncryptor oAuthRefreshTokenEncryptor; + private final PendingAppleCredentialRepository pendingAppleCredentialRepository; private final MemberRepository memberRepository; private final MemberSocialAccountRepository memberSocialAccountRepository; private final JwtProvider jwtProvider; @@ -44,7 +54,7 @@ public class AppleLoginQueryService { private final MemberCommandService memberCommandService; // identity token 검증 후 신규/PENDING/기존 회원 3분기 판별, Member 생성은 여기서 하지 않는다(AppleSignupCommandService 담당) - public AppleLoginResult login(String identityToken, String nonce) { + public AppleLoginResult login(String identityToken, String nonce, String authorizationCode) { AppleIdentityToken appleIdentityToken = appleOAuthClient.verify(identityToken, nonce); String providerUserId = appleIdentityToken.providerUserId(); @@ -54,6 +64,7 @@ public AppleLoginResult login(String identityToken, String nonce) { if (socialAccount.isEmpty()) { log.info("신규 Apple 회원 로그인 시도: providerUserId={}", providerUserId); + cachePendingCredentialIfPresent(providerUserId, authorizationCode); return issueAppleSignupToken(providerUserId, appleIdentityToken); } @@ -94,6 +105,34 @@ public AppleLoginResult login(String identityToken, String nonce) { null, null, restored, member.getRole()); } + // authorizationCode는 1회용에 수명도 짧아서, 받은 즉시(여기, 로그인 판별 시점) 교환해 pending에 캐싱해둔다. + // 나중에 /apple/signup에서 이 값을 그대로 가져다 쓰면, 그 시점엔 Apple API 호출 없이 로컬 저장만으로 끝나서 + // "약관 동의 화면을 오래 보다 code가 만료되는" 문제와 "교환 성공 후 로컬 저장만 실패하는" 문제를 둘 다 피한다. + // 실패해도(예: Apple Key 발급 전) 로그인 판별 자체는 막지 않는다 - 부가 기능 손실일 뿐이다. + private void cachePendingCredentialIfPresent(String providerUserId, String authorizationCode) { + if (authorizationCode == null || authorizationCode.isBlank()) { + return; + } + try { + AppleTokenResponse tokenResponse = appleTokenClient.exchangeAuthorizationCode(authorizationCode); + + // 서명 검증까지는 필요 없다 - Apple 토큰 엔드포인트에서 TLS로 직접 받은 응답이라 위조 경로가 없다. + // 다만 이 refresh_token을 providerUserId에 잘못 연결하는 실수(교차 오염)를 막기 위해 sub만 대조한다. + String tokenSubject = JwtSubjectReader.readSubject(tokenResponse.idToken()); + if (!providerUserId.equals(tokenSubject)) { + log.warn("authorizationCode 교환 응답의 sub가 예상과 다름(교차 오염 의심) - 캐싱하지 않음: " + + "providerUserId={}, tokenSubject={}", providerUserId, tokenSubject); + return; + } + + String encryptedRefreshToken = oAuthRefreshTokenEncryptor.encrypt(tokenResponse.refreshToken()); + pendingAppleCredentialRepository.save(providerUserId, encryptedRefreshToken); + } catch (Exception e) { + log.warn("Apple authorizationCode 교환/캐싱 실패(로그인 판별은 정상 처리) - 가입해도 이 회원의 " + + "Apple 연동은 탈퇴 시 자동 해제되지 않는다: providerUserId={}", providerUserId, e); + } + } + private AppleLoginResult issueAppleSignupToken(String providerUserId, AppleIdentityToken appleIdentityToken) { // Map.of()는 value가 null이면 NPE를 던지므로, Hide My Email 등으로 null일 수 있는 값은 빈 문자열로 치환 diff --git a/src/main/java/com/cotato/nextstation/domain/auth/util/JwtSubjectReader.java b/src/main/java/com/cotato/nextstation/domain/auth/util/JwtSubjectReader.java new file mode 100644 index 00000000..c5c7cac0 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/auth/util/JwtSubjectReader.java @@ -0,0 +1,35 @@ +package com.cotato.nextstation.domain.auth.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +// JWT의 서명을 검증하지 않고 payload의 sub 클레임만 가볍게 꺼낼 때 쓴다. +// Apple 토큰 엔드포인트가 TLS로 직접 내려준 응답(재전송/위조 경로가 없음)의 id_token처럼, +// 이미 신뢰할 수 있는 채널로 받은 값에서 "혹시 다른 사용자 것이 섞이지 않았는지"만 대조하고 싶을 때 쓰는 용도라 - +// 여기서 서명까지 검증해야 하는 경우(클라이언트가 보낸 identityToken 등)에는 절대 쓰면 안 되고 AppleOAuthClient.verify()를 써야 한다. +public final class JwtSubjectReader { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private JwtSubjectReader() { + } + + // 형식이 깨졌거나 sub가 없으면 null을 반환한다 - 호출자가 "확인 불가"로 보고 안전하게 처리하도록 예외 대신 null을 쓴다. + public static String readSubject(String jwt) { + try { + String[] parts = jwt.split("\\."); + if (parts.length < 2) { + return null; + } + byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]); + JsonNode payload = OBJECT_MAPPER.readTree(new String(payloadBytes, StandardCharsets.UTF_8)); + JsonNode sub = payload.get("sub"); + return sub == null ? null : sub.asText(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/member/entity/SocialOauthCredential.java b/src/main/java/com/cotato/nextstation/domain/member/entity/SocialOauthCredential.java new file mode 100644 index 00000000..9986ab4c --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/member/entity/SocialOauthCredential.java @@ -0,0 +1,54 @@ +package com.cotato.nextstation.domain.member.entity; + +import com.cotato.nextstation.global.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +// 소셜 로그인 연동(MemberSocialAccount)에 딸린, provider 서버 쪽에서 그 연동 자체를 폐기(revoke)할 때 쓰는 자격증명. +// member_social_account는 로그인 판별마다 조회되는 테이블이라 그 안에 얹지 않고 분리했다 -> 이 테이블은 +// 탈퇴(revoke) 시점에만 조회되어, 평소 로그인 흐름 코드/로그에 민감값이 섞여 들어갈 일이 없다. +@Entity +@Table( + name = "social_oauth_credential", + uniqueConstraints = @UniqueConstraint( + name = "uk_social_oauth_credential_member_social_account_id", + columnNames = {"member_social_account_id"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SocialOauthCredential extends BaseTimeEntity { + + // MemberSocialAccount와 연관관계 매핑 대신 FK 식별자(Long)만 보관 - 다른 엔티티와의 컨벤션과 동일 + @Column(name = "member_social_account_id", nullable = false) + private Long memberSocialAccountId; + + // 조회 편의/배치 필터링용으로 비정규화 - member_social_account 조인 없이 provider별 일괄 처리가 가능하다 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AuthProvider provider; + + // OAuthRefreshTokenEncryptor로 암호화한 값만 저장한다. 평문을 여기 저장하지 않는다. + @Column(name = "refresh_token", nullable = false, length = 1000) + private String refreshToken; + + @Builder + private SocialOauthCredential(Long memberSocialAccountId, AuthProvider provider, String refreshToken) { + this.memberSocialAccountId = memberSocialAccountId; + this.provider = provider; + this.refreshToken = refreshToken; + } + + // 재로그인/재동의로 새 refresh_token이 발급될 수 있어 이전 값을 덮어쓴다. + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/member/repository/MemberSocialAccountRepository.java b/src/main/java/com/cotato/nextstation/domain/member/repository/MemberSocialAccountRepository.java index baa0ff38..6a1943c3 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/repository/MemberSocialAccountRepository.java +++ b/src/main/java/com/cotato/nextstation/domain/member/repository/MemberSocialAccountRepository.java @@ -16,6 +16,7 @@ public interface MemberSocialAccountRepository extends JpaRepository findFirstByMemberIdOrderByIdAsc(Long memberId); + // 파기 배치가 대상 회원들 중 특정 provider(Apple/카카오) 연동만 골라 revoke 대상을 추리는 데 쓴다. List findByMemberIdInAndProvider(Collection memberIds, AuthProvider provider); } diff --git a/src/main/java/com/cotato/nextstation/domain/member/repository/SocialOauthCredentialRepository.java b/src/main/java/com/cotato/nextstation/domain/member/repository/SocialOauthCredentialRepository.java new file mode 100644 index 00000000..5e22a954 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/member/repository/SocialOauthCredentialRepository.java @@ -0,0 +1,18 @@ +package com.cotato.nextstation.domain.member.repository; + +import com.cotato.nextstation.domain.member.entity.SocialOauthCredential; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface SocialOauthCredentialRepository extends JpaRepository { + + Optional findByMemberSocialAccountId(Long memberSocialAccountId); + + // 파기 배치가 revoke 대상 credential을 한 번에 가져오는 용도. + List findByMemberSocialAccountIdIn(Collection memberSocialAccountIds); +} diff --git a/src/main/java/com/cotato/nextstation/domain/member/service/MemberWithdrawService.java b/src/main/java/com/cotato/nextstation/domain/member/service/MemberWithdrawService.java index 9a715f4e..5cd2b7fd 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/service/MemberWithdrawService.java +++ b/src/main/java/com/cotato/nextstation/domain/member/service/MemberWithdrawService.java @@ -11,6 +11,10 @@ *

* 트랜잭션을 열지 않는다. * 세션 삭제가 트랜잭션 안에서 일어나면 DB가 롤백돼도 Redis는 되돌아오지 않아 "로그아웃은 됐는데 탈퇴는 안 된" 상태가 남는다. + *

+ * Apple/카카오 등 소셜 연동 해제(revoke)는 여기서 하지 않는다 - 탈퇴 유예 기간(7일) 동안은 재로그인으로 + * 계정이 복구될 수 있는데, 여기서 즉시 끊어버리면 복구된 계정의 연동 상태가 DB와 어긋난다. + * 그래서 유예 기간이 끝나 실제로 파기될 때 {@link WithdrawnMemberCleaner}에서 함께 처리한다. */ @Slf4j @Service @@ -30,4 +34,4 @@ public void withdraw(Long memberId) { log.error("탈퇴 회원 세션 정리 실패 - 탈퇴 자체는 완료됨: memberId={}", memberId, e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java index a4ea6509..7da381aa 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java +++ b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleaner.java @@ -1,12 +1,16 @@ package com.cotato.nextstation.domain.member.service; +import com.cotato.nextstation.domain.auth.client.AppleTokenClient; import com.cotato.nextstation.domain.auth.client.KakaoOAuthClient; import com.cotato.nextstation.domain.member.entity.AuthProvider; import com.cotato.nextstation.domain.member.entity.Member; import com.cotato.nextstation.domain.member.entity.MemberSocialAccount; import com.cotato.nextstation.domain.member.entity.MemberStatus; +import com.cotato.nextstation.domain.member.entity.SocialOauthCredential; import com.cotato.nextstation.domain.member.repository.MemberRepository; import com.cotato.nextstation.domain.member.repository.MemberSocialAccountRepository; +import com.cotato.nextstation.domain.member.repository.SocialOauthCredentialRepository; +import com.cotato.nextstation.global.security.OAuthRefreshTokenEncryptor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -15,12 +19,18 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** - * 유예 기간이 끝난 탈퇴 회원의 카카오 연결을 해제하고 파기시킨다. 삭제는 WithdrawnMemberPurger가 한다. + * 유예 기간이 끝난 탈퇴 회원의 소셜 연동(Apple/카카오)을 해제하고 파기시킨다. 삭제는 WithdrawnMemberPurger가 한다. *

- * 트랜잭션을 열지 않는다. 카카오 응답을 기다리는 동안 DB 커넥션을 잡지 않기 위해서다. + * 트랜잭션을 열지 않는다. Apple/카카오 응답을 기다리는 동안 DB 커넥션을 잡지 않기 위해서다. + *

+ * revoke/unlink를 탈퇴 요청 즉시가 아니라 이 배치(유예 종료) 시점에 하는 이유: 유예 기간 중에는 재로그인으로 + * 계정이 복구될 수 있는데, 탈퇴 즉시 끊어버리면 복구된 계정의 소셜 연동 상태가 DB와 어긋난다. + * 실제로 파기되어 되돌릴 수 없는 시점에만 해제하면 이 문제가 생기지 않는다. */ @Slf4j @Component @@ -29,6 +39,9 @@ public class WithdrawnMemberCleaner { private final MemberRepository memberRepository; private final MemberSocialAccountRepository memberSocialAccountRepository; + private final SocialOauthCredentialRepository socialOauthCredentialRepository; + private final OAuthRefreshTokenEncryptor oAuthRefreshTokenEncryptor; + private final AppleTokenClient appleTokenClient; private final KakaoOAuthClient kakaoOAuthClient; private final WithdrawnMemberPurger withdrawnMemberPurger; @@ -46,42 +59,99 @@ public void purgeExpiredWithdrawals() { log.info("파기 대상 탈퇴 회원 조회: memberIds={}, threshold={}", targetIds, threshold); - // 삭제보다 먼저 해제한다, 순서가 반대면 실패했을 때 회원번호를 잃어 카카오 연결이 영구히 남는다 - List purgeTargets = unlinkKakaoAccounts(targetIds); + // 삭제보다 먼저 해제한다, 순서가 반대면 실패했을 때 회원번호(providerUserId)를 잃어 연동이 영구히 남는다. + // Apple/카카오 각각 실패한 회원 집합을 구해서 합집합만큼 이번 파기 대상에서 제외한다. + Set appleFailedMemberIds = revokeAppleTokens(targetIds); + Set kakaoFailedMemberIds = unlinkKakaoAccounts(targetIds); + + List purgeTargets = targetIds.stream() + .filter(id -> !appleFailedMemberIds.contains(id) && !kakaoFailedMemberIds.contains(id)) + .toList(); if (purgeTargets.isEmpty()) { - log.warn("카카오 연결 해제에 모두 실패해 이번 파기를 건너뛴다: memberIds={}", targetIds); + log.warn("소셜 연동 해제에 모두 실패해 이번 파기를 건너뛴다: memberIds={}", targetIds); return; } withdrawnMemberPurger.purge(purgeTargets); } + // 해제에 실패한 회원은 제외해 social_oauth_credential 행을 남긴다, 다음 배치가 같은 refresh_token으로 다시 시도한다 + private Set revokeAppleTokens(List targetIds) { + + List appleAccounts = + memberSocialAccountRepository.findByMemberIdInAndProvider(targetIds, AuthProvider.APPLE); + + if (appleAccounts.isEmpty()) { + return Set.of(); + } + + Map memberIdByAccountId = appleAccounts.stream() + .collect(Collectors.toMap(MemberSocialAccount::getId, MemberSocialAccount::getMemberId)); + + List credentials = socialOauthCredentialRepository + .findByMemberSocialAccountIdIn(memberIdByAccountId.keySet()); + + // 가입 시 저장이 실패했거나 Apple Key 발급 전에 가입한 회원 - revoke할 게 없으니 실패로 치지 않는다. + Set attemptedMemberIds = new HashSet<>(); + Set failedMemberIds = new HashSet<>(); + for (SocialOauthCredential credential : credentials) { + Long memberId = memberIdByAccountId.get(credential.getMemberSocialAccountId()); + attemptedMemberIds.add(memberId); + if (!revokeOne(credential)) { + failedMemberIds.add(memberId); + } + } + + logFailures("Apple", failedMemberIds, attemptedMemberIds.size()); + return failedMemberIds; + } + + private boolean revokeOne(SocialOauthCredential credential) { + try { + String refreshToken = oAuthRefreshTokenEncryptor.decrypt(credential.getRefreshToken()); + return appleTokenClient.revoke(refreshToken); + } catch (Exception e) { + log.warn("Apple refresh_token 복호화/revoke 중 오류: memberSocialAccountId={}", credential.getMemberSocialAccountId(), e); + return false; + } + } + // 해제에 실패한 회원은 제외해 member_social_account 행을 남긴다, 다음 배치가 같은 회원번호로 다시 시도한다 - private List unlinkKakaoAccounts(List targetIds) { + private Set unlinkKakaoAccounts(List targetIds) { List kakaoAccounts = memberSocialAccountRepository.findByMemberIdInAndProvider(targetIds, AuthProvider.KAKAO); if (kakaoAccounts.isEmpty()) { - return targetIds; + return Set.of(); } // member_id에 유니크 제약이 없어 행이 여러 개일 수 있다, 하나라도 실패하면 그 회원은 제외 + Set attemptedMemberIds = new HashSet<>(); Set failedMemberIds = new HashSet<>(); for (MemberSocialAccount account : kakaoAccounts) { + attemptedMemberIds.add(account.getMemberId()); if (!kakaoOAuthClient.unlink(account.getProviderUserId())) { failedMemberIds.add(account.getMemberId()); } } + logFailures("카카오", failedMemberIds, attemptedMemberIds.size()); + return failedMemberIds; + } + + // 일부만 실패하면 그 회원들 refresh_token/연동 정보가 아직 남아있어 다음 배치가 알아서 재시도한다(WARN으로 충분). + // 시도한 회원 전원이 실패하면 개별 계정 문제가 아니라 어드민 키 만료·인증서 문제 같은 설정/연동 자체의 + // 장애일 가능성이 높고, 그 상태로는 파기가 계속 밀리므로 놓치지 않도록 ERROR로 올린다. + private void logFailures(String provider, Set failedMemberIds, int attemptedCount) { if (failedMemberIds.isEmpty()) { - return targetIds; + return; + } + if (failedMemberIds.size() == attemptedCount) { + log.error("{} 연동 해제가 전원 실패했다 - 설정/연동 자체의 문제일 수 있다: memberIds={}", provider, failedMemberIds); + } else { + log.warn("{} 연동 해제 실패로 이번 파기에서 제외: memberIds={}", provider, failedMemberIds); } - - log.warn("카카오 연결 해제 실패로 이번 파기에서 제외: memberIds={}", failedMemberIds); - return targetIds.stream() - .filter(id -> !failedMemberIds.contains(id)) - .toList(); } } diff --git a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurger.java b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurger.java index f15e3252..fd131d1e 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurger.java +++ b/src/main/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurger.java @@ -12,6 +12,10 @@ * 파기 대상으로 확정된 탈퇴 회원을 회원 행까지 통째로 지운다(hard delete). *

* 회원이 남긴 콘텐츠(일지·코스·리뷰·사진)와 다른 회원이 그 콘텐츠에 남긴 좋아요까지 함께 사라진다. + * FK 제약 때문에 자식 → 부모 순서로 지워야 하므로 SQL 순서를 바꾸지 말 것. + *

+ * 하루치 대상이 많아야 수십 명이고 실패하면 롤백 후 다음 날 다시 집으면 되므로 + * Spring Batch(청크·재시작·메타데이터 테이블) 없이 이 클래스 하나로 끝낸다. */ @Slf4j @Component @@ -21,9 +25,11 @@ public class WithdrawnMemberPurger { private static final String JOURNAL_IDS = "SELECT id FROM journal WHERE member_id IN (:ids)"; private static final String COURSE_IDS = "SELECT id FROM course WHERE member_id IN (:ids)"; private static final String REVIEW_IDS = "SELECT id FROM place_review WHERE journal_id IN (" + JOURNAL_IDS + ")"; + private static final String SOCIAL_ACCOUNT_IDS = "SELECT id FROM member_social_account WHERE member_id IN (:ids)"; // 자식 → 부모 순서, 각 문장의 :ids는 파기 대상 회원 ID 목록 // TODO: 테이블 목록이 수동 관리 - member_id를 갖는 엔티티가 늘면 추가 필요 + // 빠뜨리면 마지막 DELETE FROM member가 FK 위반으로 터지고 전체 롤백되므로 조용히 깨지진 않는다. private static final List DELETE_STATEMENTS = List.of( "DELETE FROM place_image WHERE place_review_id IN (" + REVIEW_IDS + ")", "DELETE FROM place_review_image WHERE place_review_id IN (" + REVIEW_IDS + ")", @@ -37,6 +43,9 @@ public class WithdrawnMemberPurger { "DELETE FROM course WHERE member_id IN (:ids)", "DELETE FROM journal WHERE member_id IN (:ids)", "DELETE FROM member_terms_agreement WHERE member_id IN (:ids)", + // WithdrawnMemberCleaner가 이미 revoke를 끝낸 뒤라 credential이 가리키는 member_social_account를 + // 지우기 전에 먼저 지워야 한다(FK 잔존 방지) + "DELETE FROM social_oauth_credential WHERE member_social_account_id IN (" + SOCIAL_ACCOUNT_IDS + ")", "DELETE FROM member_social_account WHERE member_id IN (:ids)", "DELETE FROM email_verification WHERE member_id IN (:ids)", "DELETE FROM recommendation_log WHERE member_id IN (:ids)", diff --git a/src/main/java/com/cotato/nextstation/global/security/OAuthRefreshTokenEncryptor.java b/src/main/java/com/cotato/nextstation/global/security/OAuthRefreshTokenEncryptor.java new file mode 100644 index 00000000..1ece5a28 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/global/security/OAuthRefreshTokenEncryptor.java @@ -0,0 +1,37 @@ +package com.cotato.nextstation.global.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.encrypt.Encryptors; +import org.springframework.security.crypto.encrypt.TextEncryptor; +import org.springframework.stereotype.Component; + +// SocialOauthCredential.refreshToken을 저장 전/조회 후 암복호화하는 전담 컴포넌트. +// Encryptors.delux()는 AES-256-GCM(인증된 암호화, Encryptors.stronger()의 TextEncryptor 버전) 기반이라 +// 암호문 무결성까지 검증한다 - Encryptors.text()(AES-CBC)와 달리 암호문이 변조되면 복호화 시점에 예외로 걸러진다. +// 매 encrypt() 호출마다 랜덤 IV를 섞어 넣어 같은 평문이어도 암호문이 매번 달라지므로, 저장된 암호문끼리 +// 비교해서 평문을 추측하는 공격에도 안전하다. 결과는 hex 문자열로 나와 DB 컬럼에 바로 저장 가능하다. +// +// secret/salt는 Apple/카카오가 발급하는 값이 아니라 우리가 직접 만드는 애플리케이션 비밀이다. +// 유출되면 저장된 모든 refresh_token이 한꺼번에 복호화되므로, JWT_SECRET과 동급으로 취급해 관리한다. +@Component +public class OAuthRefreshTokenEncryptor { + + private final TextEncryptor textEncryptor; + + public OAuthRefreshTokenEncryptor(@Value("${security.oauth-credential.secret}") String secret, + @Value("${security.oauth-credential.salt}") String salt) { + + if (secret.isBlank() || salt.isBlank()) { + throw new IllegalStateException("security.oauth-credential.secret/salt가 비어 있습니다."); + } + this.textEncryptor = Encryptors.delux(secret, salt); + } + + public String encrypt(String plainText) { + return textEncryptor.encrypt(plainText); + } + + public String decrypt(String cipherText) { + return textEncryptor.decrypt(cipherText); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 21b99c76..2d794b72 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -56,6 +56,15 @@ kakao: apple: oauth: allowed-audiences: ${APPLE_OAUTH_ALLOWED_AUDIENCES:} + # revoke/refresh_token 교환용 client_secret(ES256 JWT) 서명에 쓴다 - Apple Account Holder가 발급 + team-id: ${APPLE_OAUTH_TEAM_ID:} + key-id: ${APPLE_OAUTH_KEY_ID:} + private-key: ${APPLE_OAUTH_PRIVATE_KEY:} + +security: + oauth-credential: + secret: ${OAUTH_CREDENTIAL_SECRET:} + salt: ${OAUTH_CREDENTIAL_SALT:} aws: s3: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ec734d03..3c5887e3 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -61,4 +61,13 @@ kakao: apple: oauth: # 네이티브 앱의 Bundle ID(App ID). 나중에 웹 Services ID를 추가하면 여기에 콤마로 더한다. - allowed-audiences: ${APPLE_OAUTH_ALLOWED_AUDIENCES} \ No newline at end of file + allowed-audiences: ${APPLE_OAUTH_ALLOWED_AUDIENCES} + # revoke/refresh_token 교환용 client_secret(ES256 JWT) 서명에 쓴다 - Apple Account Holder가 발급 + team-id: ${APPLE_OAUTH_TEAM_ID} + key-id: ${APPLE_OAUTH_KEY_ID} + private-key: ${APPLE_OAUTH_PRIVATE_KEY} + +security: + oauth-credential: + secret: ${OAUTH_CREDENTIAL_SECRET} + salt: ${OAUTH_CREDENTIAL_SALT} \ No newline at end of file diff --git a/src/test/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGeneratorTest.java b/src/test/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGeneratorTest.java new file mode 100644 index 00000000..4b00171a --- /dev/null +++ b/src/test/java/com/cotato/nextstation/domain/auth/client/AppleClientSecretGeneratorTest.java @@ -0,0 +1,113 @@ +package com.cotato.nextstation.domain.auth.client; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.interfaces.ECPublicKey; +import java.util.Base64; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +// Apple의 실제 .p8 키가 없어도, 같은 곡선(P-256/ES256)의 테스트용 EC 키 쌍으로 서명·파싱 로직 자체를 검증할 수 있다. +class AppleClientSecretGeneratorTest { + + private static final String PEM_HEADER = "-----BEGIN PRIVATE KEY-----"; + private static final String PEM_FOOTER = "-----END PRIVATE KEY-----"; + + private KeyPair generateEcKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(256); // P-256 곡선 -> ES256과 매칭 + return generator.generateKeyPair(); + } + + private String toPem(KeyPair keyPair) { + String base64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + return PEM_HEADER + "\n" + base64 + "\n" + PEM_FOOTER; + } + + @Test + @DisplayName("team-id/key-id/private-key가 모두 설정되어 있으면 ES256 서명 JWT를 생성한다") + void generate_success() throws Exception { + // given + KeyPair keyPair = generateEcKeyPair(); + AppleClientSecretGenerator generator = new AppleClientSecretGenerator( + "TEAM1234ID", "KEY1234ID", toPem(keyPair), List.of("com.cotato.nextstation")); + + // when + String clientSecret = generator.generate(); + + // then + Claims claims = Jwts.parser() + .verifyWith((PublicKey) keyPair.getPublic()) + .build() + .parseSignedClaims(clientSecret) + .getPayload(); + + assertThat(claims.getIssuer()).isEqualTo("TEAM1234ID"); + assertThat(claims.getSubject()).isEqualTo("com.cotato.nextstation"); + assertThat(claims.getAudience()).containsExactly("https://appleid.apple.com"); + + JwsHeader header = Jwts.parser() + .verifyWith((PublicKey) keyPair.getPublic()) + .build() + .parseSignedClaims(clientSecret) + .getHeader(); + assertThat(header.getKeyId()).isEqualTo("KEY1234ID"); + } + + @Test + @DisplayName("리터럴 개행(\\n)으로 이스케이프된 PEM도 정상 파싱한다 - 환경변수로 넘어올 때 흔한 형태") + void generate_escapedNewlinePem() throws Exception { + // given + KeyPair keyPair = generateEcKeyPair(); + String escapedPem = toPem(keyPair).replace("\n", "\\n"); + AppleClientSecretGenerator generator = new AppleClientSecretGenerator( + "TEAM1234ID", "KEY1234ID", escapedPem, List.of("com.cotato.nextstation")); + + // when & then - 파싱 실패 없이 서명까지 성공해야 한다 + assertThat(generator.generate()).isNotBlank(); + } + + @Test + @DisplayName("team-id가 비어 있으면 예외가 발생한다") + void generate_missingTeamId() throws Exception { + // given + KeyPair keyPair = generateEcKeyPair(); + AppleClientSecretGenerator generator = new AppleClientSecretGenerator( + "", "KEY1234ID", toPem(keyPair), List.of("com.cotato.nextstation")); + + // when & then + assertThatThrownBy(generator::generate).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("private-key가 비어 있으면 예외가 발생한다") + void generate_missingPrivateKey() { + // given + AppleClientSecretGenerator generator = new AppleClientSecretGenerator( + "TEAM1234ID", "KEY1234ID", "", List.of("com.cotato.nextstation")); + + // when & then + assertThatThrownBy(generator::generate).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("private-key 형식이 깨져 있으면 예외가 발생한다") + void generate_malformedPrivateKey() { + // given + AppleClientSecretGenerator generator = new AppleClientSecretGenerator( + "TEAM1234ID", "KEY1234ID", PEM_HEADER + "\nnot-a-valid-base64-key\n" + PEM_FOOTER, + List.of("com.cotato.nextstation")); + + // when & then + assertThatThrownBy(generator::generate).isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandServiceTest.java b/src/test/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandServiceTest.java index 481684e2..5e426949 100644 --- a/src/test/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandServiceTest.java +++ b/src/test/java/com/cotato/nextstation/domain/auth/service/command/AppleSignupCommandServiceTest.java @@ -3,6 +3,7 @@ import com.cotato.nextstation.domain.auth.exception.AuthErrorCode; import com.cotato.nextstation.domain.auth.exception.TermsErrorCode; import com.cotato.nextstation.domain.auth.repository.MemberTermsAgreementRepository; +import com.cotato.nextstation.domain.auth.repository.PendingAppleCredentialRepository; import com.cotato.nextstation.domain.auth.util.AppleSignupTokenClaims; import com.cotato.nextstation.domain.auth.util.TermsAgreementValidator; import com.cotato.nextstation.domain.member.entity.AuthProvider; @@ -11,6 +12,7 @@ import com.cotato.nextstation.domain.member.entity.MemberSocialAccount; import com.cotato.nextstation.domain.member.repository.MemberRepository; import com.cotato.nextstation.domain.member.repository.MemberSocialAccountRepository; +import com.cotato.nextstation.domain.member.repository.SocialOauthCredentialRepository; import com.cotato.nextstation.global.exception.CustomException; import com.cotato.nextstation.global.jwt.JwtProvider; import io.jsonwebtoken.Claims; @@ -63,6 +65,12 @@ class AppleSignupCommandServiceTest { @Mock private TermsAgreementValidator termsAgreementValidator; + @Mock + private SocialOauthCredentialRepository socialOauthCredentialRepository; + + @Mock + private PendingAppleCredentialRepository pendingAppleCredentialRepository; + private static final String APPLE_SIGNUP_TOKEN = "apple-signup-token"; private static final String PROVIDER_USER_ID = "000555.abcdef1234567890.0555"; @@ -102,6 +110,12 @@ private MemberSocialAccount socialAccount(Long memberId) { .build(); } + private MemberSocialAccount savedSocialAccount() { + MemberSocialAccount socialAccount = socialAccount(1L); + ReflectionTestUtils.setField(socialAccount, "id", 10L); + return socialAccount; + } + @Test @DisplayName("정상 요청이면 Member와 MemberSocialAccount가 생성되고 약관 동의가 저장되고 signupToken이 발급된다") void signup_success() { @@ -111,6 +125,7 @@ void signup_success() { given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) .willReturn(Optional.empty()); given(memberRepository.save(any(Member.class))).willReturn(savedMember()); + given(memberSocialAccountRepository.save(any(MemberSocialAccount.class))).willReturn(savedSocialAccount()); given(jwtProvider.generateToken(eq("1"), any(Map.class), any(Duration.class))).willReturn("signup-token"); // when @@ -123,6 +138,53 @@ void signup_success() { verify(memberTermsAgreementRepository, times(1)).saveAll(any()); } + @Test + @DisplayName("로그인 시점에 캐싱된 refresh_token이 있으면 SocialOauthCredential로 옮겨 저장한다") + void signup_success_attachesPendingCredential() { + // given + Claims claims = validClaims("user@privaterelay.appleid.com"); + given(jwtProvider.parseClaims(APPLE_SIGNUP_TOKEN)).willReturn(claims); + given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) + .willReturn(Optional.empty()); + given(memberRepository.save(any(Member.class))).willReturn(savedMember()); + given(memberSocialAccountRepository.save(any(MemberSocialAccount.class))).willReturn(savedSocialAccount()); + given(jwtProvider.generateToken(eq("1"), any(Map.class), any(Duration.class))).willReturn("signup-token"); + given(pendingAppleCredentialRepository.consume(PROVIDER_USER_ID)).willReturn(Optional.of("encrypted-refresh-token")); + + // when + appleSignupCommandService.signup(APPLE_SIGNUP_TOKEN, List.of(1L), "127.0.0.1"); + + // then + ArgumentCaptor captor = + ArgumentCaptor.forClass(com.cotato.nextstation.domain.member.entity.SocialOauthCredential.class); + verify(socialOauthCredentialRepository).save(captor.capture()); + assertThat(captor.getValue().getMemberSocialAccountId()).isEqualTo(10L); + assertThat(captor.getValue().getProvider()).isEqualTo(AuthProvider.APPLE); + assertThat(captor.getValue().getRefreshToken()).isEqualTo("encrypted-refresh-token"); + } + + @Test + @DisplayName("캐싱된 refresh_token이 없어도(로그인 시점에 code를 안 보냈거나 만료됨) 가입 자체는 정상 처리된다") + void signup_success_noPendingCredential() { + // given + Claims claims = validClaims("user@privaterelay.appleid.com"); + given(jwtProvider.parseClaims(APPLE_SIGNUP_TOKEN)).willReturn(claims); + given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) + .willReturn(Optional.empty()); + given(memberRepository.save(any(Member.class))).willReturn(savedMember()); + given(memberSocialAccountRepository.save(any(MemberSocialAccount.class))).willReturn(savedSocialAccount()); + given(jwtProvider.generateToken(eq("1"), any(Map.class), any(Duration.class))).willReturn("signup-token"); + given(pendingAppleCredentialRepository.consume(PROVIDER_USER_ID)).willReturn(Optional.empty()); + + // when + var response = appleSignupCommandService.signup(APPLE_SIGNUP_TOKEN, List.of(1L), "127.0.0.1"); + + // then + assertThat(response.memberId()).isEqualTo(1L); + assertThat(response.signupToken()).isEqualTo("signup-token"); + verify(socialOauthCredentialRepository, never()).save(any()); + } + @Test @DisplayName("Apple 이메일이 빈 문자열(미제공)이면 Member.email은 null로 저장된다") void signup_blankEmail_savedAsNull() { @@ -132,6 +194,7 @@ void signup_blankEmail_savedAsNull() { given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) .willReturn(Optional.empty()); given(memberRepository.save(any(Member.class))).willReturn(savedMember()); + given(memberSocialAccountRepository.save(any(MemberSocialAccount.class))).willReturn(savedSocialAccount()); given(jwtProvider.generateToken(eq("1"), any(Map.class), any(Duration.class))).willReturn("signup-token"); // when diff --git a/src/test/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryServiceTest.java b/src/test/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryServiceTest.java index f1e08b11..c9f517e8 100644 --- a/src/test/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryServiceTest.java +++ b/src/test/java/com/cotato/nextstation/domain/auth/service/query/AppleLoginQueryServiceTest.java @@ -1,8 +1,11 @@ package com.cotato.nextstation.domain.auth.service.query; import com.cotato.nextstation.domain.auth.client.AppleOAuthClient; +import com.cotato.nextstation.domain.auth.client.AppleTokenClient; import com.cotato.nextstation.domain.auth.client.dto.AppleIdentityToken; +import com.cotato.nextstation.domain.auth.client.dto.AppleTokenResponse; import com.cotato.nextstation.domain.auth.exception.AuthErrorCode; +import com.cotato.nextstation.domain.auth.repository.PendingAppleCredentialRepository; import com.cotato.nextstation.domain.auth.service.AuthTokenIssuer; import com.cotato.nextstation.domain.auth.service.IssuedTokens; import com.cotato.nextstation.domain.auth.service.result.AppleLoginResult; @@ -19,6 +22,7 @@ import com.cotato.nextstation.domain.member.service.command.MemberCommandService; import com.cotato.nextstation.global.exception.CustomException; import com.cotato.nextstation.global.jwt.JwtProvider; +import com.cotato.nextstation.global.security.OAuthRefreshTokenEncryptor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,6 +45,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.never; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; @ExtendWith(MockitoExtension.class) class AppleLoginQueryServiceTest { @@ -51,6 +56,15 @@ class AppleLoginQueryServiceTest { @Mock private AppleOAuthClient appleOAuthClient; + @Mock + private AppleTokenClient appleTokenClient; + + @Mock + private OAuthRefreshTokenEncryptor oAuthRefreshTokenEncryptor; + + @Mock + private PendingAppleCredentialRepository pendingAppleCredentialRepository; + @Mock private MemberRepository memberRepository; @@ -69,6 +83,7 @@ class AppleLoginQueryServiceTest { private static final String IDENTITY_TOKEN = "identity-token"; private static final String NONCE = "raw-nonce"; private static final String PROVIDER_USER_ID = "000555.abcdef1234567890.0555"; + private static final String AUTHORIZATION_CODE = "authorization-code"; private AppleIdentityToken identityTokenWithEmail() { return new AppleIdentityToken(PROVIDER_USER_ID, "user@privaterelay.appleid.com"); @@ -108,6 +123,15 @@ private MemberSocialAccount socialAccount(Long memberId) { .build(); } + // 서명 없이 payload만 있는 JWT 형태 - JwtSubjectReader는 서명을 검증하지 않으므로 테스트 목적엔 이걸로 충분하다. + private String fakeIdToken(String subject) { + String header = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"RS256\"}".getBytes()); + String payload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(("{\"sub\":\"" + subject + "\"}").getBytes()); + return header + "." + payload + ".signature"; + } + @Test @DisplayName("처음 보는 Apple 계정이면 Member를 만들지 않고 appleSignupToken을 발급한다") void login_newMember() { @@ -119,7 +143,7 @@ void login_newMember() { .willReturn("apple-signup-token"); // when - AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE); + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null); // then assertThat(result.resultType()).isEqualTo(AppleLoginResultType.NEW_MEMBER); @@ -139,7 +163,7 @@ void login_newMember_noEmail() { .willReturn("apple-signup-token"); // when - AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE); + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null); // then assertThat(result.resultType()).isEqualTo(AppleLoginResultType.NEW_MEMBER); @@ -150,6 +174,86 @@ void login_newMember_noEmail() { assertThat(claimsCaptor.getValue().get(AppleSignupTokenClaims.EMAIL_KEY)).isEqualTo(""); } + @Test + @DisplayName("신규 회원이고 authorizationCode가 있으면 즉시 교환해 pending으로 캐싱한다") + void login_newMember_cachesPendingCredential() { + // given + given(appleOAuthClient.verify(IDENTITY_TOKEN, NONCE)).willReturn(identityTokenWithEmail()); + given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) + .willReturn(Optional.empty()); + given(jwtProvider.generateToken(eq(PROVIDER_USER_ID), any(Map.class), any(Duration.class))) + .willReturn("apple-signup-token"); + given(appleTokenClient.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .willReturn(new AppleTokenResponse("access-token", "bearer", 3600, "raw-refresh-token", fakeIdToken(PROVIDER_USER_ID))); + given(oAuthRefreshTokenEncryptor.encrypt("raw-refresh-token")).willReturn("encrypted-refresh-token"); + + // when + appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, AUTHORIZATION_CODE); + + // then + then(pendingAppleCredentialRepository).should().save(PROVIDER_USER_ID, "encrypted-refresh-token"); + } + + @Test + @DisplayName("교환 응답의 id_token sub가 예상 providerUserId와 다르면 캐싱하지 않는다(교차 오염 방지)") + void login_newMember_subjectMismatch_skipsCaching() { + // given + given(appleOAuthClient.verify(IDENTITY_TOKEN, NONCE)).willReturn(identityTokenWithEmail()); + given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) + .willReturn(Optional.empty()); + given(jwtProvider.generateToken(eq(PROVIDER_USER_ID), any(Map.class), any(Duration.class))) + .willReturn("apple-signup-token"); + given(appleTokenClient.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .willReturn(new AppleTokenResponse("access-token", "bearer", 3600, "raw-refresh-token", + fakeIdToken("000999.other-provider-user-id.0999"))); + + // when + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, AUTHORIZATION_CODE); + + // then + assertThat(result.resultType()).isEqualTo(AppleLoginResultType.NEW_MEMBER); + then(pendingAppleCredentialRepository).should(never()).save(any(), any()); + then(oAuthRefreshTokenEncryptor).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("authorizationCode가 없으면 교환을 시도하지 않는다") + void login_newMember_withoutAuthorizationCode_skipsCaching() { + // given + given(appleOAuthClient.verify(IDENTITY_TOKEN, NONCE)).willReturn(identityTokenWithEmail()); + given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) + .willReturn(Optional.empty()); + given(jwtProvider.generateToken(eq(PROVIDER_USER_ID), any(Map.class), any(Duration.class))) + .willReturn("apple-signup-token"); + + // when + appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null); + + // then + then(appleTokenClient).shouldHaveNoInteractions(); + then(pendingAppleCredentialRepository).should(never()).save(any(), any()); + } + + @Test + @DisplayName("authorizationCode 교환이 실패해도 로그인 판별 자체는 정상 처리된다") + void login_newMember_exchangeFailure_stillIssuesSignupToken() { + // given + given(appleOAuthClient.verify(IDENTITY_TOKEN, NONCE)).willReturn(identityTokenWithEmail()); + given(memberSocialAccountRepository.findByProviderAndProviderUserId(AuthProvider.APPLE, PROVIDER_USER_ID)) + .willReturn(Optional.empty()); + given(jwtProvider.generateToken(eq(PROVIDER_USER_ID), any(Map.class), any(Duration.class))) + .willReturn("apple-signup-token"); + willThrow(new RuntimeException("Apple 통신 실패")) + .given(appleTokenClient).exchangeAuthorizationCode(AUTHORIZATION_CODE); + + // when + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, AUTHORIZATION_CODE); + + // then + assertThat(result.resultType()).isEqualTo(AppleLoginResultType.NEW_MEMBER); + then(pendingAppleCredentialRepository).should(never()).save(any(), any()); + } + @Test @DisplayName("프로필 설정이 끝나지 않은(PENDING) Apple 회원이 재로그인하면 signupToken을 재발급한다") void login_pendingMember() { @@ -162,12 +266,13 @@ void login_pendingMember() { .willReturn("reissued-signup-token"); // when - AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE); + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null); // then assertThat(result.resultType()).isEqualTo(AppleLoginResultType.PENDING_PROFILE); assertThat(result.memberId()).isEqualTo(1L); assertThat(result.signupToken()).isEqualTo("reissued-signup-token"); + then(appleTokenClient).shouldHaveNoInteractions(); } @Test @@ -181,7 +286,7 @@ void login_activeMember_loginSuccess() { given(authTokenIssuer.issue(1L)).willReturn(new IssuedTokens("access-token", "refresh-token")); // when - AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE); + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null); // then assertThat(result.resultType()).isEqualTo(AppleLoginResultType.LOGIN_SUCCESS); @@ -201,7 +306,7 @@ void login_memberNotActive() { given(memberRepository.findById(1L)).willReturn(Optional.of(withdrawnMember(LocalDateTime.now().minusDays(8)))); // when & then - assertThatThrownBy(() -> appleLoginQueryService.login(IDENTITY_TOKEN, NONCE)) + assertThatThrownBy(() -> appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null)) .isInstanceOf(CustomException.class) .hasMessageContaining(AuthErrorCode.APPLE_MEMBER_NOT_ACTIVE.getMessage()); @@ -220,7 +325,7 @@ void login_restoresWithdrawnMemberWithinGracePeriod() { given(authTokenIssuer.issue(1L)).willReturn(new IssuedTokens("access-token", "refresh-token")); // when - AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE); + AppleLoginResult result = appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null); // then assertThat(result.resultType()).isEqualTo(AppleLoginResultType.LOGIN_SUCCESS); @@ -238,7 +343,7 @@ void login_memberNotFound_dataIntegrityError() { given(memberRepository.findById(999L)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> appleLoginQueryService.login(IDENTITY_TOKEN, NONCE)) + assertThatThrownBy(() -> appleLoginQueryService.login(IDENTITY_TOKEN, NONCE, null)) .isInstanceOf(CustomException.class) .hasMessageContaining(AuthErrorCode.MEMBER_NOT_FOUND.getMessage()); } diff --git a/src/test/java/com/cotato/nextstation/domain/auth/util/JwtSubjectReaderTest.java b/src/test/java/com/cotato/nextstation/domain/auth/util/JwtSubjectReaderTest.java new file mode 100644 index 00000000..ae9b1bc1 --- /dev/null +++ b/src/test/java/com/cotato/nextstation/domain/auth/util/JwtSubjectReaderTest.java @@ -0,0 +1,45 @@ +package com.cotato.nextstation.domain.auth.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtSubjectReaderTest { + + private String jwt(String payloadJson) { + String header = Base64.getUrlEncoder().withoutPadding().encodeToString("{\"alg\":\"RS256\"}".getBytes()); + String payload = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.getBytes()); + return header + "." + payload + ".signature"; + } + + @Test + @DisplayName("payload의 sub 클레임을 꺼낸다") + void readSubject_returnsSubject() { + String token = jwt("{\"sub\":\"000555.abcdef.0555\",\"aud\":\"com.cotato.nextstation\"}"); + + assertThat(JwtSubjectReader.readSubject(token)).isEqualTo("000555.abcdef.0555"); + } + + @Test + @DisplayName("sub 클레임이 없으면 null을 반환한다") + void readSubject_noSubClaim_returnsNull() { + String token = jwt("{\"aud\":\"com.cotato.nextstation\"}"); + + assertThat(JwtSubjectReader.readSubject(token)).isNull(); + } + + @Test + @DisplayName("JWT 형식이 아니면 예외 대신 null을 반환한다") + void readSubject_malformedToken_returnsNull() { + assertThat(JwtSubjectReader.readSubject("not-a-jwt")).isNull(); + } + + @Test + @DisplayName("null 입력이면 예외 대신 null을 반환한다") + void readSubject_nullInput_returnsNull() { + assertThat(JwtSubjectReader.readSubject(null)).isNull(); + } +} diff --git a/src/test/java/com/cotato/nextstation/domain/member/service/MemberWithdrawServiceTest.java b/src/test/java/com/cotato/nextstation/domain/member/service/MemberWithdrawServiceTest.java index 7e83c316..d0ec5dad 100644 --- a/src/test/java/com/cotato/nextstation/domain/member/service/MemberWithdrawServiceTest.java +++ b/src/test/java/com/cotato/nextstation/domain/member/service/MemberWithdrawServiceTest.java @@ -71,4 +71,4 @@ void withdraw_keepsSessionsWhenDbFails() { then(refreshSessionRepository).should(never()).deleteAllOf(1L); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java b/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java index d6332e7c..6c87aa38 100644 --- a/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java +++ b/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberCleanerTest.java @@ -1,21 +1,35 @@ package com.cotato.nextstation.domain.member.service; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.cotato.nextstation.domain.auth.client.AppleTokenClient; import com.cotato.nextstation.domain.auth.client.KakaoOAuthClient; import com.cotato.nextstation.domain.member.entity.AuthProvider; import com.cotato.nextstation.domain.member.entity.MemberSocialAccount; import com.cotato.nextstation.domain.member.entity.MemberStatus; +import com.cotato.nextstation.domain.member.entity.SocialOauthCredential; import com.cotato.nextstation.domain.member.repository.MemberRepository; import com.cotato.nextstation.domain.member.repository.MemberSocialAccountRepository; +import com.cotato.nextstation.domain.member.repository.SocialOauthCredentialRepository; +import com.cotato.nextstation.global.security.OAuthRefreshTokenEncryptor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.never; @@ -33,14 +47,80 @@ class WithdrawnMemberCleanerTest { @Mock private MemberSocialAccountRepository memberSocialAccountRepository; + @Mock + private SocialOauthCredentialRepository socialOauthCredentialRepository; + + @Mock + private OAuthRefreshTokenEncryptor oAuthRefreshTokenEncryptor; + + @Mock + private AppleTokenClient appleTokenClient; + @Mock private KakaoOAuthClient kakaoOAuthClient; @Mock private WithdrawnMemberPurger withdrawnMemberPurger; + private Logger logger; + private ListAppender logCapture; + + @BeforeEach + void setUpLogCapture() { + logCapture = new ListAppender<>(); + logCapture.start(); + logger = (Logger) LoggerFactory.getLogger(WithdrawnMemberCleaner.class); + logger.addAppender(logCapture); + } + + @AfterEach + void tearDownLogCapture() { + logger.detachAppender(logCapture); + } + + private MemberSocialAccount appleAccount(Long memberId, Long accountId) { + MemberSocialAccount account = MemberSocialAccount.builder() + .memberId(memberId) + .provider(AuthProvider.APPLE) + .providerUserId("provider-user-" + memberId) + .build(); + ReflectionTestUtils.setField(account, "id", accountId); + return account; + } + + private SocialOauthCredential credential(Long accountId, String encryptedRefreshToken) { + return SocialOauthCredential.builder() + .memberSocialAccountId(accountId) + .provider(AuthProvider.APPLE) + .refreshToken(encryptedRefreshToken) + .build(); + } + + private MemberSocialAccount kakaoAccount(Long memberId, String providerUserId) { + return MemberSocialAccount.builder() + .memberId(memberId) + .provider(AuthProvider.KAKAO) + .providerUserId(providerUserId) + .build(); + } + + private void givenTargets(List memberIds) { + given(memberRepository.findIdsByStatusAndDeletedAtBefore(eq(MemberStatus.WITHDRAWN), any())) + .willReturn(memberIds); + } + + private void givenAppleAccounts(List accounts) { + given(memberSocialAccountRepository.findByMemberIdInAndProvider(anyCollection(), eq(AuthProvider.APPLE))) + .willReturn(accounts); + } + + private void givenKakaoAccounts(List accounts) { + given(memberSocialAccountRepository.findByMemberIdInAndProvider(anyCollection(), eq(AuthProvider.KAKAO))) + .willReturn(accounts); + } + @Test - @DisplayName("대상이 없으면 조회만 하고 끝낸다") + @DisplayName("대상이 없으면 아무것도 하지 않는다") void purge_noTargets() { // given givenTargets(List.of()); @@ -50,29 +130,114 @@ void purge_noTargets() { // then then(withdrawnMemberPurger).should(never()).purge(any()); + then(appleTokenClient).shouldHaveNoInteractions(); then(kakaoOAuthClient).shouldHaveNoInteractions(); } @Test - @DisplayName("소셜 연동이 없는 회원은 연결 해제 없이 파기한다") - void purge_localMembers() { + @DisplayName("소셜 연동이 없는 회원은 해제 없이 그대로 파기 대상에 넘긴다") + void purge_localMembers_skipsRevoke() { + // given + givenTargets(List.of(1L)); + givenAppleAccounts(List.of()); + givenKakaoAccounts(List.of()); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + then(appleTokenClient).should(never()).revoke(any()); + then(kakaoOAuthClient).should(never()).unlink(any()); + then(withdrawnMemberPurger).should().purge(List.of(1L)); + } + + @Test + @DisplayName("Apple refresh_token을 복호화해서 revoke를 호출하고, 성공하면 파기 대상에 그대로 남긴다") + void purge_appleMemberWithCredential_revokesAndKeepsInPurgeTargets() { + // given + givenTargets(List.of(1L)); + givenAppleAccounts(List.of(appleAccount(1L, 10L))); + givenKakaoAccounts(List.of()); + given(socialOauthCredentialRepository.findByMemberSocialAccountIdIn(anyCollection())) + .willReturn(List.of(credential(10L, "encrypted"))); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted")).willReturn("plain"); + given(appleTokenClient.revoke("plain")).willReturn(true); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + then(appleTokenClient).should().revoke("plain"); + then(withdrawnMemberPurger).should().purge(List.of(1L)); + } + + @Test + @DisplayName("Apple revoke에 실패한 회원은 이번 파기 대상에서 제외한다 - 다음 배치가 같은 회원번호로 재시도한다") + void purge_appleRevokeFailure_excludesFromPurgeTargets() { // given givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of(appleAccount(1L, 10L))); givenKakaoAccounts(List.of()); + given(socialOauthCredentialRepository.findByMemberSocialAccountIdIn(anyCollection())) + .willReturn(List.of(credential(10L, "encrypted"))); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted")).willReturn("plain"); + given(appleTokenClient.revoke("plain")).willReturn(false); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then - memberId=1은 제외되고, Apple 연동이 없던 memberId=2만 파기된다 + then(withdrawnMemberPurger).should().purge(List.of(2L)); + } + + @Test + @DisplayName("Apple 시도 대상 중 일부만 실패하면 WARN으로 남긴다") + void purge_applePartialFailure_logsWarn() { + // given + givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of(appleAccount(1L, 10L), appleAccount(2L, 20L))); + givenKakaoAccounts(List.of()); + given(socialOauthCredentialRepository.findByMemberSocialAccountIdIn(anyCollection())) + .willReturn(List.of(credential(10L, "encrypted-1"), credential(20L, "encrypted-2"))); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted-1")).willReturn("plain-1"); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted-2")).willReturn("plain-2"); + given(appleTokenClient.revoke("plain-1")).willReturn(false); + given(appleTokenClient.revoke("plain-2")).willReturn(true); // when withdrawnMemberCleaner.purgeExpiredWithdrawals(); // then - then(kakaoOAuthClient).shouldHaveNoInteractions(); - then(withdrawnMemberPurger).should().purge(List.of(1L, 2L)); + assertThat(logCapture.list).extracting(ILoggingEvent::getLevel).contains(Level.WARN).doesNotContain(Level.ERROR); + } + + @Test + @DisplayName("Apple 시도 대상 전원이 실패하면 설정 문제로 보고 ERROR로 남긴다") + void purge_appleAllFailure_logsError() { + // given + givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of(appleAccount(1L, 10L), appleAccount(2L, 20L))); + givenKakaoAccounts(List.of()); + given(socialOauthCredentialRepository.findByMemberSocialAccountIdIn(anyCollection())) + .willReturn(List.of(credential(10L, "encrypted-1"), credential(20L, "encrypted-2"))); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted-1")).willReturn("plain-1"); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted-2")).willReturn("plain-2"); + given(appleTokenClient.revoke("plain-1")).willReturn(false); + given(appleTokenClient.revoke("plain-2")).willReturn(false); + + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); + + // then + assertThat(logCapture.list).extracting(ILoggingEvent::getLevel).contains(Level.ERROR); } @Test @DisplayName("카카오 연결을 해제한 뒤 파기한다") - void purge_unlinksBeforeDelete() { + void purge_kakaoUnlinksBeforeDelete() { // given givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of()); givenKakaoAccounts(List.of(kakaoAccount(1L, "kakao-1"), kakaoAccount(2L, "kakao-2"))); given(kakaoOAuthClient.unlink("kakao-1")).willReturn(true); given(kakaoOAuthClient.unlink("kakao-2")).willReturn(true); @@ -87,50 +252,58 @@ void purge_unlinksBeforeDelete() { } @Test - @DisplayName("연결 해제에 실패한 회원만 파기 대상에서 빠진다") - void purge_excludesUnlinkFailure() { + @DisplayName("카카오 연결 해제에 실패한 회원만 파기 대상에서 빠진다") + void purge_kakaoUnlinkFailure_excludesFromPurgeTargets() { // given givenTargets(List.of(1L, 2L, 3L)); + givenAppleAccounts(List.of()); givenKakaoAccounts(List.of(kakaoAccount(1L, "kakao-1"), kakaoAccount(2L, "kakao-2"))); given(kakaoOAuthClient.unlink("kakao-1")).willReturn(true); given(kakaoOAuthClient.unlink("kakao-2")).willReturn(false); - // then - 실패한 2번만 빠지고, 소셜 연동이 없는 3번은 그대로 파기된다 + // when withdrawnMemberCleaner.purgeExpiredWithdrawals(); + // then - 실패한 2번만 빠지고, 소셜 연동이 없는 3번은 그대로 파기된다 then(withdrawnMemberPurger).should().purge(List.of(1L, 3L)); } @Test - @DisplayName("연결 해제에 모두 실패하면 파기하지 않는다") - void purge_skipsWhenAllUnlinkFail() { + @DisplayName("Apple/카카오 중 하나라도 해제 실패한 회원이 있으면 그 회원만 빠지고 나머지는 함께 파기된다") + void purge_mixedProviders_excludesOnlyFailedOnes() { // given - givenTargets(List.of(1L)); - givenKakaoAccounts(List.of(kakaoAccount(1L, "kakao-1"))); - given(kakaoOAuthClient.unlink("kakao-1")).willReturn(false); + givenTargets(List.of(1L, 2L)); + givenAppleAccounts(List.of(appleAccount(1L, 10L))); + givenKakaoAccounts(List.of(kakaoAccount(2L, "kakao-2"))); + given(socialOauthCredentialRepository.findByMemberSocialAccountIdIn(anyCollection())) + .willReturn(List.of(credential(10L, "encrypted"))); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted")).willReturn("plain"); + given(appleTokenClient.revoke("plain")).willReturn(true); + given(kakaoOAuthClient.unlink("kakao-2")).willReturn(true); // when withdrawnMemberCleaner.purgeExpiredWithdrawals(); - // then - 빈 목록으로 파기를 호출하면 IN () 이 되어 SQL이 깨진다 - then(withdrawnMemberPurger).should(never()).purge(any()); + // then + then(withdrawnMemberPurger).should().purge(List.of(1L, 2L)); } - private void givenTargets(List memberIds) { - given(memberRepository.findIdsByStatusAndDeletedAtBefore(eq(MemberStatus.WITHDRAWN), any())) - .willReturn(memberIds); - } + @Test + @DisplayName("모두 해제에 실패하면 이번 파기를 건너뛴다") + void purge_allFailed_skipsPurge() { + // given + givenTargets(List.of(1L)); + givenAppleAccounts(List.of(appleAccount(1L, 10L))); + givenKakaoAccounts(List.of()); + given(socialOauthCredentialRepository.findByMemberSocialAccountIdIn(anyCollection())) + .willReturn(List.of(credential(10L, "encrypted"))); + given(oAuthRefreshTokenEncryptor.decrypt("encrypted")).willReturn("plain"); + given(appleTokenClient.revoke("plain")).willReturn(false); - private void givenKakaoAccounts(List accounts) { - given(memberSocialAccountRepository.findByMemberIdInAndProvider(any(), eq(AuthProvider.KAKAO))) - .willReturn(accounts); - } + // when + withdrawnMemberCleaner.purgeExpiredWithdrawals(); - private MemberSocialAccount kakaoAccount(Long memberId, String providerUserId) { - return MemberSocialAccount.builder() - .memberId(memberId) - .provider(AuthProvider.KAKAO) - .providerUserId(providerUserId) - .build(); + // then - 빈 목록으로 파기를 호출하면 IN () 이 되어 SQL이 깨진다 + then(withdrawnMemberPurger).should(never()).purge(any()); } } diff --git a/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurgerTest.java b/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurgerTest.java index ee63688a..ed21f7d6 100644 --- a/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurgerTest.java +++ b/src/test/java/com/cotato/nextstation/domain/member/service/WithdrawnMemberPurgerTest.java @@ -45,7 +45,7 @@ private List executedSql() { } @Test - @DisplayName("회원 행과 관련 데이터를 자식 → 부모 순서로 삭제한다") + @DisplayName("파기 대상 회원의 행과 관련 데이터를 자식 → 부모 순서로 삭제한다") void purge_hardDeletesMemberAndRelatedRows() { // when withdrawnMemberPurger.purge(List.of(1L, 2L)); @@ -58,11 +58,13 @@ void purge_hardDeletesMemberAndRelatedRows() { assertThat(indexOfTable(sqls, "place_review_image")).isLessThan(indexOfTable(sqls, "place_review")); assertThat(indexOfTable(sqls, "place_review")).isLessThan(indexOfTable(sqls, "journal")); assertThat(indexOfTable(sqls, "course_places")).isLessThan(indexOfTable(sqls, "course")); + // Apple/카카오 revoke 재료(social_oauth_credential)도 member_social_account보다 먼저 지운다 + assertThat(indexOfTable(sqls, "social_oauth_credential")).isLessThan(indexOfTable(sqls, "member_social_account")); // 회원이 남긴 흔적이 어느 테이블에도 남지 않는다 assertThat(sqls).allMatch(sql -> sql.startsWith("DELETE FROM")); assertThat(tables(sqls)).contains("journal", "journal_image", "course", "course_like", "place_review", - "place_review_like", "member_place_stamps", "member_terms_agreement", "member_social_account", - "email_verification", "recommendation_log", "member"); + "place_review_like", "member_place_stamps", "member_terms_agreement", "social_oauth_credential", + "member_social_account", "email_verification", "recommendation_log", "member"); then(query).should(org.mockito.Mockito.atLeastOnce()).setParameter("ids", List.of(1L, 2L)); } diff --git a/src/test/java/com/cotato/nextstation/global/security/OAuthRefreshTokenEncryptorTest.java b/src/test/java/com/cotato/nextstation/global/security/OAuthRefreshTokenEncryptorTest.java new file mode 100644 index 00000000..2e1612d0 --- /dev/null +++ b/src/test/java/com/cotato/nextstation/global/security/OAuthRefreshTokenEncryptorTest.java @@ -0,0 +1,75 @@ +package com.cotato.nextstation.global.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OAuthRefreshTokenEncryptorTest { + + private static final String SECRET = "test-secret-not-real"; + private static final String SALT = "e5ccd50dd8fa49fba661b88ed4400b2f"; // hex 문자열이어야 한다 + + private OAuthRefreshTokenEncryptor encryptor() { + return new OAuthRefreshTokenEncryptor(SECRET, SALT); + } + + @Test + @DisplayName("암호화한 값을 복호화하면 원문과 같다") + void encryptThenDecrypt_roundTrip() { + // given + String plainText = "apple-refresh-token-abc123"; + + // when + String encrypted = encryptor().encrypt(plainText); + String decrypted = encryptor().decrypt(encrypted); + + // then + assertThat(decrypted).isEqualTo(plainText); + } + + @Test + @DisplayName("같은 평문을 암호화해도 매번 다른 암호문이 나온다 - AES-GCM의 랜덤 IV 덕분") + void encrypt_producesDifferentCipherTextEachTime() { + // given + String plainText = "apple-refresh-token-abc123"; + OAuthRefreshTokenEncryptor encryptor = encryptor(); + + // when + String first = encryptor.encrypt(plainText); + String second = encryptor.encrypt(plainText); + + // then + assertThat(first).isNotEqualTo(second); + } + + @Test + @DisplayName("암호문이 변조되면 복호화가 실패한다 - AES-GCM(stronger)은 인증된 암호화라 무결성이 깨지면 예외를 던진다") + void decrypt_tamperedCipherText_throws() { + // given + String encrypted = encryptor().encrypt("apple-refresh-token-abc123"); + // hex 문자열의 마지막 한 글자를 바꿔 암호문(태그 포함)을 변조한다 + char lastChar = encrypted.charAt(encrypted.length() - 1); + char replacement = lastChar == '0' ? '1' : '0'; + String tampered = encrypted.substring(0, encrypted.length() - 1) + replacement; + + // when & then + assertThatThrownBy(() -> encryptor().decrypt(tampered)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("secret이 비어 있으면 생성 시점에 실패한다") + void constructor_emptySecret() { + assertThatThrownBy(() -> new OAuthRefreshTokenEncryptor("", SALT)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("salt가 비어 있으면 생성 시점에 실패한다") + void constructor_emptySalt() { + assertThatThrownBy(() -> new OAuthRefreshTokenEncryptor(SECRET, "")) + .isInstanceOf(IllegalStateException.class); + } +}