From 91bfe3c92996ba463eddfd0392a1de7a183d1ad0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 21 Oct 2026 00:21:26 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[fix]=20WebClient.Builder=20=EB=B9=88?= =?UTF-8?q?=EC=9D=84=20prototype=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BF=94=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B0=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B3=B5=EC=9C=A0=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유 빌더에 DataPipelineClient가 추가한 내부 시크릿 헤더가 다른 외부 클라이언트 요청에 섞일 수 있었음 Co-Authored-By: Claude Opus 5.5 --- .../com/Coming/Backend/common/config/WebClientConfig.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java b/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java index 9906da9d..3f10a861 100644 --- a/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java @@ -3,6 +3,7 @@ import io.netty.channel.ChannelOption; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; @@ -15,7 +16,12 @@ public class WebClientConfig { private static final int CONNECT_TIMEOUT_MILLIS = 5000; private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(120); + /** + * WebClient.Builder는 변경 가능한 객체라, 주입받는 곳마다 새 인스턴스를 주어 한 클라이언트의 + * baseUrl·defaultHeader(예: 내부 시크릿)가 다른 클라이언트에 섞이지 않게 한다. + */ @Bean + @Scope("prototype") public WebClient.Builder webClientBuilder() { HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MILLIS) From 84b786e1651bc66ea957ab990a14c04f8787069e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 21 Oct 2026 00:21:26 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[docs]=20=EC=95=B1=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=9A=A9=20Refresh=20Token=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=C2=B7Apple=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EC=B1=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .claude/agents/security-reviewer.md | 12 ++++++++---- CLAUDE.md | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index 0489af5a..96ad1f73 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -26,9 +26,12 @@ tools: Read, Bash, Grep ## Coming 인증 정책 (기준) - Access Token: 30분, `Authorization: Bearer {token}` 헤더로만 전달 -- Refresh Token: 7일, HttpOnly Cookie로만 전달 (JS에서 접근 불가해야 함) +- Refresh Token: 7일 + - 웹: HttpOnly Cookie로만 전달 (JS에서 접근 불가해야 함) + - 앱(네이티브): 앱 전용 엔드포인트(`/api/auth/login/{provider}/token`, `/api/auth/app/**`)에서만 요청·응답 바디로 주고받는다. 앱은 Keychain/Keystore에 보관한다. 웹 엔드포인트는 바디로 내려보내지 않는다 - 로그아웃 시 Access Token을 Redis 블랙리스트에 등록 -- OAuth2 Provider: Google, Kakao만 지원 +- OAuth2 Provider: 웹은 Google, Kakao. 앱은 Google, Kakao, Apple(앱 전용) +- 앱 소셜 로그인: 제공자 토큰을 서버가 검증한다(Google·Apple은 JWKS 서명, iss, aud, 서버 발급 1회용 nonce. Kakao는 토큰 정보 API의 app_id) ## 심각도 기준 @@ -43,8 +46,9 @@ tools: Read, Bash, Grep ### 🔴 critical - 신규/변경 엔드포인트에 `@PreAuthorize` 또는 SecurityConfig 경로 설정 누락 (인증 필요 API가 허용 목록에 포함) - `ROLE_ADMIN` 필요 엔드포인트에 권한 체크 누락 (일반 사용자가 403 없이 접근 가능) -- Refresh Token을 응답 바디·헤더·로그에 노출 (HttpOnly Cookie 외 경로로 전달) -- Access Token을 쿠키에 저장하거나 Refresh Token을 로컬 스토리지·바디로 내려보내는 등 Bearer/Cookie 역할 뒤바뀜 +- Refresh Token을 로그에 노출하거나, 앱 전용 엔드포인트가 아닌 웹 엔드포인트에서 응답 바디·헤더로 내려보냄 (웹은 HttpOnly Cookie 외 경로 금지) +- Access Token을 쿠키에 저장하거나 웹에서 Refresh Token을 로컬 스토리지·바디로 내려보내는 등 Bearer/Cookie 역할 뒤바뀜 +- 앱 소셜 로그인에서 제공자 토큰 서명·aud·nonce(Google·Apple) 또는 app_id(Kakao) 검증 누락 - 로그아웃/토큰 폐기 로직에서 Redis 블랙리스트 등록 누락 - JWT 서명 검증 없이 payload를 신뢰 (예: 파싱만 하고 `verify` 생략) - 사용자 입력(닉네임, 문의 내용 등)을 파라미터 바인딩 없이 쿼리 문자열에 직접 결합 (SQL Injection) — JPA 파라미터 바인딩·엔티티 필드 대입은 해당 없음 diff --git a/CLAUDE.md b/CLAUDE.md index 26c72380..2348b0aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ com.Coming.Backend **인증 정책** - Access Token: 30분, `Authorization: Bearer {token}` 헤더 -- Refresh Token: 7일, HttpOnly Cookie +- Refresh Token: 7일. 웹은 HttpOnly Cookie, 앱(네이티브)은 앱 전용 엔드포인트(`/api/auth/login/{provider}/token`, `/api/auth/app/**`)에서 요청·응답 바디로 주고받는다 +- 소셜 로그인: 웹은 Google·Kakao(OAuth2 리다이렉트). 앱은 Google·Kakao·Apple SDK 토큰을 서버가 검증한다(Google·Apple은 서버 발급 nonce 필수) - 로그아웃: Redis 블랙리스트 등록 **에러 응답 형식** From 338a6730f83492eb92da9c1ff9dd70c387c6e37e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 21 Oct 2026 00:21:26 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[feat]=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20r?= =?UTF-8?q?evoke=EC=9A=A9=20Apple=20refresh=5Ftoken=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/main/java/com/Coming/Backend/auth/entity/User.java | 7 +++++++ .../db/migration/V41__add_apple_refresh_token_to_user.sql | 2 ++ 2 files changed, 9 insertions(+) create mode 100644 src/main/resources/db/migration/V41__add_apple_refresh_token_to_user.sql diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index 7d9421e5..736081f1 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -53,6 +53,9 @@ public class User extends BaseTimeEntity { @Column(name = "agreed_marketing") private Boolean agreedMarketing; + @Column(name = "apple_refresh_token", length = 512) + private String appleRefreshToken; + public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) { this.nickname = nickname; this.birthYear = birthYear; @@ -77,6 +80,10 @@ public void updateEmail(String email) { this.email = email; } + public void updateAppleRefreshToken(String appleRefreshToken) { + this.appleRefreshToken = appleRefreshToken; + } + public boolean hasNoEmail() { return email == null || email.isBlank(); } diff --git a/src/main/resources/db/migration/V41__add_apple_refresh_token_to_user.sql b/src/main/resources/db/migration/V41__add_apple_refresh_token_to_user.sql new file mode 100644 index 00000000..d03e2ebf --- /dev/null +++ b/src/main/resources/db/migration/V41__add_apple_refresh_token_to_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" + ADD COLUMN apple_refresh_token VARCHAR(512) NULL; From 3d323e33e5096fd14b1fefed683269b07e6ca89d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 21 Oct 2026 00:21:26 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[feat]=20Google=C2=B7Kakao=C2=B7Apple=20?= =?UTF-8?q?=EC=95=B1=20=EC=86=8C=EC=85=9C=20=ED=86=A0=ED=81=B0=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EA=B8=B0=EC=99=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20non?= =?UTF-8?q?ce=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google·Apple: JWKS 서명·iss·aud 검증, 서버 발급 1회용 nonce 소비 - Kakao: access_token_info app_id 확인 후 사용자 정보 조회 - Apple: authorizationCode 교환·revoke 클라이언트(.p8 ES256 client_secret) - 제공자 장애는 SOCIAL_PROVIDER_UNAVAILABLE(502)로 분리 Co-Authored-By: Claude Opus 5.5 --- .../SocialProviderUnavailableException.java | 11 + .../SocialTokenInvalidException.java | 11 + .../auth/repository/LoginNonceRepository.java | 32 ++ .../Backend/auth/social/AppleAuthClient.java | 166 +++++++++ .../social/AppleIdentityTokenVerifier.java | 56 +++ .../Backend/auth/social/AppleUserInfo.java | 24 ++ .../auth/social/GoogleIdTokenVerifier.java | 64 ++++ .../Backend/auth/social/IdTokenDecoders.java | 82 +++++ .../auth/social/KakaoTokenVerifier.java | 63 ++++ .../auth/social/SocialProviderCalls.java | 50 +++ .../auth/social/SocialTokenVerifier.java | 16 + .../Backend/common/exception/ErrorCode.java | 2 + src/main/resources/application.yaml | 7 + .../repository/LoginNonceRepositoryTest.java | 66 ++++ .../auth/social/AppleAuthClientTest.java | 330 ++++++++++++++++++ .../AppleIdentityTokenVerifierTest.java | 127 +++++++ .../social/GoogleIdTokenVerifierTest.java | 124 +++++++ .../auth/social/IdTokenDecodersTest.java | 215 ++++++++++++ .../auth/social/KakaoTokenVerifierTest.java | 176 ++++++++++ .../auth/social/SocialProviderCallsTest.java | 75 ++++ 20 files changed, 1697 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/auth/exception/SocialProviderUnavailableException.java create mode 100644 src/main/java/com/Coming/Backend/auth/exception/SocialTokenInvalidException.java create mode 100644 src/main/java/com/Coming/Backend/auth/repository/LoginNonceRepository.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/AppleAuthClient.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifier.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/AppleUserInfo.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifier.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/IdTokenDecoders.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/KakaoTokenVerifier.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/SocialProviderCalls.java create mode 100644 src/main/java/com/Coming/Backend/auth/social/SocialTokenVerifier.java create mode 100644 src/test/java/com/Coming/Backend/auth/repository/LoginNonceRepositoryTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/social/AppleAuthClientTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifierTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifierTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/social/IdTokenDecodersTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/social/KakaoTokenVerifierTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/social/SocialProviderCallsTest.java diff --git a/src/main/java/com/Coming/Backend/auth/exception/SocialProviderUnavailableException.java b/src/main/java/com/Coming/Backend/auth/exception/SocialProviderUnavailableException.java new file mode 100644 index 00000000..85c2aebb --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/exception/SocialProviderUnavailableException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.auth.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class SocialProviderUnavailableException extends BusinessException { + + public SocialProviderUnavailableException() { + super(ErrorCode.SOCIAL_PROVIDER_UNAVAILABLE); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/exception/SocialTokenInvalidException.java b/src/main/java/com/Coming/Backend/auth/exception/SocialTokenInvalidException.java new file mode 100644 index 00000000..6d6455a8 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/exception/SocialTokenInvalidException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.auth.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class SocialTokenInvalidException extends BusinessException { + + public SocialTokenInvalidException() { + super(ErrorCode.SOCIAL_TOKEN_INVALID); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/repository/LoginNonceRepository.java b/src/main/java/com/Coming/Backend/auth/repository/LoginNonceRepository.java new file mode 100644 index 00000000..c94178c6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/repository/LoginNonceRepository.java @@ -0,0 +1,32 @@ +package com.Coming.Backend.auth.repository; + +import java.time.Duration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +/** + * 앱 소셜 로그인용 1회용 nonce 저장소. 서버가 발급한 nonce만, 한 번만 쓸 수 있게 해 ID Token 재전송을 막는다. + */ +@Repository +public class LoginNonceRepository { + + private static final String KEY_PREFIX = "NONCE:"; + private static final String ISSUED = "1"; + + private final RedisTemplate redisTemplate; + + public LoginNonceRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void save(String nonce, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX + nonce, ISSUED, ttl); + } + + /** + * nonce가 발급된 상태면 지우고 true를 반환한다. 원자적으로 지우므로 같은 nonce는 한 번만 true다. + */ + public boolean consume(String nonce) { + return Boolean.TRUE.equals(redisTemplate.delete(KEY_PREFIX + nonce)); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/AppleAuthClient.java b/src/main/java/com/Coming/Backend/auth/social/AppleAuthClient.java new file mode 100644 index 00000000..296f0393 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/AppleAuthClient.java @@ -0,0 +1,166 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import io.jsonwebtoken.Jwts; +import java.security.GeneralSecurityException; +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.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * Apple 인증 서버 호출(authorizationCode 교환, 토큰 revoke). 요청마다 .p8 키로 서명한 client_secret JWT를 쓴다. + */ +@Slf4j +@Component +public class AppleAuthClient { + + static final String BASE_URL = "https://appleid.apple.com"; + private static final String TOKEN_PATH = "/auth/token"; + private static final String REVOKE_PATH = "/auth/revoke"; + private static final String INVALID_GRANT = "invalid_grant"; + private static final Duration CLIENT_SECRET_TTL = Duration.ofMinutes(5); + private static final ParameterizedTypeReference> MAP_TYPE = + new ParameterizedTypeReference<>() {}; + + private final WebClient webClient; + private final AppleProperties properties; + private final PrivateKey privateKey; + + @Autowired + public AppleAuthClient( + WebClient.Builder builder, + @Value("${app.oauth2.apple.bundle-id}") String bundleId, + @Value("${app.oauth2.apple.team-id}") String teamId, + @Value("${app.oauth2.apple.key-id}") String keyId, + @Value("${app.oauth2.apple.private-key}") String privateKey) { + this(builder.baseUrl(BASE_URL).build(), new AppleProperties(bundleId, teamId, keyId, privateKey)); + } + + AppleAuthClient(WebClient webClient, AppleProperties properties) { + this.webClient = webClient; + this.properties = properties; + this.privateKey = properties.privateKey().isBlank() ? null : parsePrivateKey(properties.privateKey()); + } + + record AppleProperties(String bundleId, String teamId, String keyId, String privateKey) {} + + /** + * authorizationCode 교환 결과. idToken은 코드를 발급받은 Apple 사용자를 확인하는 데 쓴다. + */ + public record AppleTokens(String refreshToken, String idToken) {} + + /** + * 로그인 시 받은 1회용 authorizationCode를 Apple refresh_token·id_token으로 교환한다. + * + * @throws SocialTokenInvalidException 코드가 만료됐거나 이미 사용된 경우 + */ + public AppleTokens exchangeAuthorizationCode(String authorizationCode) { + MultiValueMap form = baseForm(); + form.add("code", authorizationCode); + form.add("grant_type", "authorization_code"); + Map response = SocialProviderCalls.block(post(TOKEN_PATH, form).bodyToMono(MAP_TYPE), TOKEN_PATH); + Object refreshToken = response.get("refresh_token"); + Object idToken = response.get("id_token"); + if (refreshToken == null || idToken == null) { + log.warn("Apple 코드 교환 응답에 refresh_token 또는 id_token 없음"); + throw new SocialTokenInvalidException(); + } + return new AppleTokens(refreshToken.toString(), idToken.toString()); + } + + /** + * 회원 탈퇴 시 Apple refresh_token을 revoke해 앱과 Apple ID의 연결을 끊는다. + */ + public void revoke(String appleRefreshToken) { + MultiValueMap form = baseForm(); + form.add("token", appleRefreshToken); + form.add("token_type_hint", "refresh_token"); + ResponseEntity response = SocialProviderCalls.block(post(REVOKE_PATH, form).toBodilessEntity(), REVOKE_PATH); + log.info("Apple 토큰 revoke 완료 — status: {}", response.getStatusCode()); + } + + /** + * Apple 4xx 중 invalid_grant(코드·토큰 만료·재사용)만 사용자 쪽 문제로 보고, 그 밖의 4xx + * (invalid_client 등)는 우리 client_secret·키 설정 문제이므로 서버 오류로 올린다. 5xx는 제공자 장애다. + */ + private WebClient.ResponseSpec post(String path, MultiValueMap form) { + return webClient.post() + .uri(path) + .body(BodyInserters.fromFormData(form)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> response.bodyToMono(MAP_TYPE) + .defaultIfEmpty(Map.of()) + .flatMap(body -> Mono.error(toClientError(path, response.statusCode(), body.get("error"))))) + .onStatus(HttpStatusCode::is5xxServerError, response -> { + log.warn("Apple 인증 서버 오류 — path: {}, status: {}", path, response.statusCode()); + return Mono.error(new SocialProviderUnavailableException()); + }); + } + + private static RuntimeException toClientError(String path, HttpStatusCode status, Object error) { + if (INVALID_GRANT.equals(error)) { + log.warn("Apple 요청 거절(invalid_grant) — path: {}", path); + return new SocialTokenInvalidException(); + } + log.error("Apple 요청 설정 오류 — path: {}, status: {}, error: {}", path, status, error); + return new IllegalStateException("Apple 인증 서버 요청 설정 오류: " + error); + } + + private MultiValueMap baseForm() { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("client_id", properties.bundleId()); + form.add("client_secret", createClientSecret()); + return form; + } + + String createClientSecret() { + Instant now = Instant.now(); + return Jwts.builder() + .header().keyId(properties.keyId()).and() + .issuer(properties.teamId()) + .subject(properties.bundleId()) + .audience().add(BASE_URL).and() + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(CLIENT_SECRET_TTL))) + .signWith(requirePrivateKey(), Jwts.SIG.ES256) + .compact(); + } + + private PrivateKey requirePrivateKey() { + if (privateKey == null) { + throw new IllegalStateException("Apple 개인키(app.oauth2.apple.private-key)가 설정되지 않았습니다."); + } + return privateKey; + } + + /** + * .p8 PEM 문자열(환경변수에서 줄바꿈이 "\n" 문자로 들어온 경우 포함)을 EC 개인키로 변환한다. + */ + private static PrivateKey parsePrivateKey(String pem) { + String base64 = pem.replaceAll("-----[A-Z ]+-----", "").replace("\\n", "").replaceAll("\\s", ""); + try { + return KeyFactory.getInstance("EC") + .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64))); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("Apple 개인키(app.oauth2.apple.private-key) 형식이 올바르지 않습니다.", e); + } + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifier.java b/src/main/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifier.java new file mode 100644 index 00000000..0185df23 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifier.java @@ -0,0 +1,56 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.stereotype.Component; + +/** + * Sign in with Apple identityToken을 검증한다. aud는 iOS 앱 번들 ID이고, + * 앱은 서버가 발급한 nonce로 로그인을 요청해야 한다. + */ +@Component +public class AppleIdentityTokenVerifier implements SocialTokenVerifier { + + static final String JWK_SET_URI = "https://appleid.apple.com/auth/keys"; + static final String ISSUER = "https://appleid.apple.com"; + private static final String PROVIDER = "apple"; + + private final JwtDecoder jwtDecoder; + private final LoginNonceRepository loginNonceRepository; + + @Autowired + public AppleIdentityTokenVerifier( + @Value("${app.oauth2.apple.bundle-id}") String bundleId, + LoginNonceRepository loginNonceRepository) { + this(IdTokenDecoders.create(JWK_SET_URI, Set.of(ISSUER), bundleId), loginNonceRepository); + } + + AppleIdentityTokenVerifier(JwtDecoder jwtDecoder, LoginNonceRepository loginNonceRepository) { + this.jwtDecoder = jwtDecoder; + this.loginNonceRepository = loginNonceRepository; + } + + @Override + public String provider() { + return PROVIDER; + } + + @Override + public OAuth2UserInfo verify(String token) { + Jwt jwt = IdTokenDecoders.decode(jwtDecoder, token, PROVIDER); + IdTokenDecoders.consumeNonce(jwt, loginNonceRepository, PROVIDER); + return new AppleUserInfo(jwt.getClaims()); + } + + /** + * authorizationCode 교환 응답의 id_token을 검증하고 sub를 꺼낸다. 이 토큰에는 nonce를 요구하지 않는다. + */ + public String extractSubject(String idToken) { + return IdTokenDecoders.decode(jwtDecoder, idToken, PROVIDER).getSubject(); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/AppleUserInfo.java b/src/main/java/com/Coming/Backend/auth/social/AppleUserInfo.java new file mode 100644 index 00000000..27d070db --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/AppleUserInfo.java @@ -0,0 +1,24 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import java.util.Map; +import lombok.RequiredArgsConstructor; + +/** + * Apple identityToken 클레임. email은 최초 로그인에만 들어오고 비공개 릴레이 주소일 수 있다. + */ +@RequiredArgsConstructor +public class AppleUserInfo implements OAuth2UserInfo { + + private final Map claims; + + @Override + public String getProviderId() { + return (String) claims.get("sub"); + } + + @Override + public String getEmail() { + return (String) claims.get("email"); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifier.java b/src/main/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifier.java new file mode 100644 index 00000000..73d9ca04 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifier.java @@ -0,0 +1,64 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.oauth2.GoogleOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.stereotype.Component; + +/** + * Google ID Token을 검증한다. 앱(Android·iOS)은 serverClientId로 웹 클라이언트 ID를 지정하므로 + * aud는 웹 로그인과 같은 클라이언트 ID다. 앱은 서버가 발급한 nonce로 토큰을 요청해야 한다. + */ +@Component +public class GoogleIdTokenVerifier implements SocialTokenVerifier { + + static final String JWK_SET_URI = "https://www.googleapis.com/oauth2/v3/certs"; + static final Set ISSUERS = Set.of("accounts.google.com", "https://accounts.google.com"); + private static final String PROVIDER = "google"; + + private final JwtDecoder jwtDecoder; + private final LoginNonceRepository loginNonceRepository; + + @Autowired + public GoogleIdTokenVerifier( + @Value("${spring.security.oauth2.client.registration.google.client-id}") String clientId, + LoginNonceRepository loginNonceRepository) { + this(IdTokenDecoders.create(JWK_SET_URI, ISSUERS, clientId), loginNonceRepository); + } + + GoogleIdTokenVerifier(JwtDecoder jwtDecoder, LoginNonceRepository loginNonceRepository) { + this.jwtDecoder = jwtDecoder; + this.loginNonceRepository = loginNonceRepository; + } + + @Override + public String provider() { + return PROVIDER; + } + + @Override + public OAuth2UserInfo verify(String token) { + Jwt jwt = IdTokenDecoders.decode(jwtDecoder, token, PROVIDER); + IdTokenDecoders.consumeNonce(jwt, loginNonceRepository, PROVIDER); + return new GoogleOAuth2UserInfo(withoutUnverifiedEmail(jwt.getClaims())); + } + + /** + * email_verified가 true가 아닌 이메일은 메일 발송에 쓰이지 않도록 저장하지 않는다. + */ + private static Map withoutUnverifiedEmail(Map claims) { + if (Boolean.TRUE.equals(claims.get("email_verified"))) { + return claims; + } + Map filtered = new HashMap<>(claims); + filtered.remove("email"); + return filtered; + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/IdTokenDecoders.java b/src/main/java/com/Coming/Backend/auth/social/IdTokenDecoders.java new file mode 100644 index 00000000..e8ccab1b --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/IdTokenDecoders.java @@ -0,0 +1,82 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.web.client.RestTemplate; + +/** + * 제공자 JWKS로 서명을 검증하고 만료·발급자·대상(aud)을 확인하는 ID Token 디코더를 만들고 쓴다. + */ +@Slf4j +final class IdTokenDecoders { + + private static final String NONCE_CLAIM = "nonce"; + private static final Duration JWKS_TIMEOUT = Duration.ofSeconds(5); + + private IdTokenDecoders() { + } + + static JwtDecoder create(String jwkSetUri, Set issuers, String audience) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(JWKS_TIMEOUT); + requestFactory.setReadTimeout(JWKS_TIMEOUT); + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri) + .restOperations(new RestTemplate(requestFactory)) + .build(); + decoder.setJwtValidator(validator(issuers, audience)); + return decoder; + } + + static OAuth2TokenValidator validator(Set issuers, String audience) { + return new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + new JwtClaimValidator(JwtClaimNames.ISS, + issuer -> issuer != null && issuers.contains(issuer.toString())), + new JwtClaimValidator>(JwtClaimNames.AUD, + audiences -> audiences != null && !audience.isBlank() && audiences.contains(audience)) + ); + } + + /** + * 토큰 자체가 잘못됐으면(서명·클레임 검증 실패) SOCIAL_TOKEN_INVALID, JWKS 조회 실패 등 + * 제공자 쪽 문제면 SOCIAL_PROVIDER_UNAVAILABLE로 바꾼다. + */ + static Jwt decode(JwtDecoder decoder, String token, String provider) { + try { + return decoder.decode(token); + } catch (BadJwtException e) { + log.warn("{} ID Token 검증 실패 — {}", provider, e.getMessage()); + throw new SocialTokenInvalidException(); + } catch (JwtException e) { + log.warn("{} ID Token 검증 중 제공자 키 조회 실패 — {}", provider, e.getMessage()); + throw new SocialProviderUnavailableException(); + } + } + + /** + * 토큰의 nonce가 서버가 발급한 미사용 nonce인지 확인하고 소비한다. + */ + static void consumeNonce(Jwt jwt, LoginNonceRepository loginNonceRepository, String provider) { + String nonce = jwt.getClaimAsString(NONCE_CLAIM); + if (nonce == null || !loginNonceRepository.consume(nonce)) { + log.warn("{} ID Token nonce 불일치 또는 재사용", provider); + throw new SocialTokenInvalidException(); + } + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/KakaoTokenVerifier.java b/src/main/java/com/Coming/Backend/auth/social/KakaoTokenVerifier.java new file mode 100644 index 00000000..4ef5fb84 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/KakaoTokenVerifier.java @@ -0,0 +1,63 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.oauth2.KakaoOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Kakao Access Token을 검증한다. 토큰 정보 API로 우리 앱(app_id)에서 발급된 토큰인지 확인한 뒤 + * 사용자 정보 API로 회원 식별 정보를 조회한다. + */ +@Slf4j +@Component +public class KakaoTokenVerifier implements SocialTokenVerifier { + + static final String BASE_URL = "https://kapi.kakao.com"; + private static final String PROVIDER = "kakao"; + private static final ParameterizedTypeReference> MAP_TYPE = + new ParameterizedTypeReference<>() {}; + + private final WebClient webClient; + private final String appId; + + @Autowired + public KakaoTokenVerifier(WebClient.Builder builder, @Value("${app.oauth2.kakao.app-id}") String appId) { + this(builder.baseUrl(BASE_URL).build(), appId); + } + + KakaoTokenVerifier(WebClient webClient, String appId) { + this.webClient = webClient; + this.appId = appId; + } + + @Override + public String provider() { + return PROVIDER; + } + + @Override + public OAuth2UserInfo verify(String token) { + Map tokenInfo = get("/v1/user/access_token_info", token); + if (appId.isBlank() || !appId.equals(String.valueOf(tokenInfo.get("app_id")))) { + log.warn("Kakao Access Token app_id 불일치 — app_id: {}", tokenInfo.get("app_id")); + throw new SocialTokenInvalidException(); + } + return new KakaoOAuth2UserInfo(get("/v2/user/me", token)); + } + + private Map get(String path, String token) { + WebClient.ResponseSpec response = webClient.get() + .uri(path) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .retrieve(); + return SocialProviderCalls.block(SocialProviderCalls.mapStatus(response, path).bodyToMono(MAP_TYPE), path); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/SocialProviderCalls.java b/src/main/java/com/Coming/Backend/auth/social/SocialProviderCalls.java new file mode 100644 index 00000000..264e68ab --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/SocialProviderCalls.java @@ -0,0 +1,50 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import reactor.core.publisher.Mono; + +/** + * 소셜 제공자 HTTP 호출의 공통 오류 변환. 4xx는 토큰·코드가 잘못된 것으로(SOCIAL_TOKEN_INVALID), + * 5xx·연결 실패·타임아웃·빈 응답은 제공자 장애로(SOCIAL_PROVIDER_UNAVAILABLE) 본다. + */ +@Slf4j +final class SocialProviderCalls { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private SocialProviderCalls() { + } + + static WebClient.ResponseSpec mapStatus(WebClient.ResponseSpec spec, String path) { + return spec + .onStatus(HttpStatusCode::is4xxClientError, response -> { + log.warn("소셜 제공자 요청 거절 — path: {}, status: {}", path, response.statusCode()); + return Mono.error(new SocialTokenInvalidException()); + }) + .onStatus(HttpStatusCode::is5xxServerError, response -> { + log.warn("소셜 제공자 서버 오류 — path: {}, status: {}", path, response.statusCode()); + return Mono.error(new SocialProviderUnavailableException()); + }); + } + + static T block(Mono call, String path) { + T result = call.timeout(TIMEOUT) + .onErrorMap(e -> e instanceof TimeoutException || e instanceof WebClientRequestException, e -> { + log.warn("소셜 제공자 연결 실패 — path: {}, error: {}", path, e.getMessage()); + return new SocialProviderUnavailableException(); + }) + .block(); + if (result == null) { + log.warn("소셜 제공자 빈 응답 — path: {}", path); + throw new SocialProviderUnavailableException(); + } + return result; + } +} diff --git a/src/main/java/com/Coming/Backend/auth/social/SocialTokenVerifier.java b/src/main/java/com/Coming/Backend/auth/social/SocialTokenVerifier.java new file mode 100644 index 00000000..1f8288e3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/social/SocialTokenVerifier.java @@ -0,0 +1,16 @@ +package com.Coming.Backend.auth.social; + +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; + +/** + * 앱이 기기의 소셜 SDK로 받은 토큰을 제공자 기준으로 검증하고 사용자 식별 정보를 꺼낸다. + */ +public interface SocialTokenVerifier { + + String provider(); + + /** + * @throws com.Coming.Backend.auth.exception.SocialTokenInvalidException 토큰이 유효하지 않거나 우리 앱에서 발급된 것이 아닌 경우 + */ + OAuth2UserInfo verify(String token); +} diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 13255f5e..ff2c7944 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -11,6 +11,8 @@ public enum ErrorCode { EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), + SOCIAL_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 소셜 로그인 토큰입니다."), + SOCIAL_PROVIDER_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "소셜 로그인 제공자에 일시적으로 연결할 수 없습니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f74eccd1..e185ef21 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -94,6 +94,13 @@ app: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} oauth2: redirect-base-uri: ${OAUTH2_REDIRECT_BASE_URI:http://localhost:3000/auth/callback} + kakao: + app-id: ${KAKAO_APP_ID:} + apple: + bundle-id: ${APPLE_BUNDLE_ID:} + team-id: ${APPLE_TEAM_ID:} + key-id: ${APPLE_KEY_ID:} + private-key: ${APPLE_PRIVATE_KEY:} cookie: secure: true diff --git a/src/test/java/com/Coming/Backend/auth/repository/LoginNonceRepositoryTest.java b/src/test/java/com/Coming/Backend/auth/repository/LoginNonceRepositoryTest.java new file mode 100644 index 00000000..8a103445 --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/repository/LoginNonceRepositoryTest.java @@ -0,0 +1,66 @@ +package com.Coming.Backend.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.time.Duration; +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.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class LoginNonceRepositoryTest { + + @InjectMocks + private LoginNonceRepository loginNonceRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private static final String NONCE = "q1w2e3r4t5y6u7i8o9p0a1s2d3f4g5h6j7k8l9z0x1c"; + + @Test + void should_store_nonce_with_prefix_and_ttl_when_save() { + // given + Duration ttl = Duration.ofMinutes(5); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // when + loginNonceRepository.save(NONCE, ttl); + + // then + verify(valueOperations).set("NONCE:" + NONCE, "1", ttl); + } + + @Test + void should_return_true_when_issued_nonce_is_deleted() { + // given + given(redisTemplate.delete("NONCE:" + NONCE)).willReturn(Boolean.TRUE); + + // when + boolean result = loginNonceRepository.consume(NONCE); + + // then + assertThat(result).isTrue(); + } + + @Test + void should_return_false_when_nonce_does_not_exist_or_was_already_consumed() { + // given + given(redisTemplate.delete("NONCE:" + NONCE)).willReturn(Boolean.FALSE); + + // when + boolean result = loginNonceRepository.consume(NONCE); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/social/AppleAuthClientTest.java b/src/test/java/com/Coming/Backend/auth/social/AppleAuthClientTest.java new file mode 100644 index 00000000..2ff18cd7 --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/social/AppleAuthClientTest.java @@ -0,0 +1,330 @@ +package com.Coming.Backend.auth.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.social.AppleAuthClient.AppleProperties; +import com.Coming.Backend.auth.social.AppleAuthClient.AppleTokens; +import com.Coming.Backend.common.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +class AppleAuthClientTest { + + private static final String BUNDLE_ID = "com.coming.app"; + private static final String TEAM_ID = "TEAM123456"; + private static final String KEY_ID = "KEY1234567"; + private static final String AUTHORIZATION_CODE = "apple-authorization-code"; + private static final String APPLE_REFRESH_TOKEN = "apple-refresh-token"; + private static final String APPLE_ID_TOKEN = "apple-code-id-token"; + + private static KeyPair keyPair; + private static String pem; + + private final List requests = new ArrayList<>(); + + @BeforeAll + static void generateKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + keyPair = generator.generateKeyPair(); + String base64 = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)) + .encodeToString(keyPair.getPrivate().getEncoded()); + pem = "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----\n"; + } + + private AppleAuthClient client(String privateKey, Function responder) { + WebClient webClient = WebClient.builder() + .baseUrl(AppleAuthClient.BASE_URL) + .exchangeFunction(request -> { + requests.add(request); + return Mono.just(responder.apply(request)); + }) + .build(); + return new AppleAuthClient(webClient, new AppleProperties(BUNDLE_ID, TEAM_ID, KEY_ID, privateKey)); + } + + private AppleAuthClient client(Function responder) { + return client(pem, responder); + } + + private static ClientResponse json(HttpStatus status, String body) { + return ClientResponse.create(status) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build(); + } + + private static Jws parse(String jwt) { + return Jwts.parser().verifyWith(keyPair.getPublic()).build().parseSignedClaims(jwt); + } + + private static MultiValueMap formOf(ClientRequest request) { + MockClientHttpRequest out = new MockClientHttpRequest(request.method(), request.url()); + request.body().insert(out, new BodyInserter.Context() { + @Override + public List> messageWriters() { + return ExchangeStrategies.withDefaults().messageWriters(); + } + + @Override + public Optional serverRequest() { + return Optional.empty(); + } + + @Override + public Map hints() { + return Map.of(); + } + }).block(); + MultiValueMap form = new LinkedMultiValueMap<>(); + for (String pair : out.getBodyAsString().block().split("&")) { + String[] keyValue = pair.split("=", 2); + form.add(URLDecoder.decode(keyValue[0], StandardCharsets.UTF_8), + URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8)); + } + return form; + } + + // ------------------------------------------------------------------------- + // createClientSecret + // ------------------------------------------------------------------------- + + @Test + void should_create_es256_signed_client_secret_with_apple_claims() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.OK, "{}")); + + // when + Jws jws = parse(client.createClientSecret()); + + // then + assertThat(jws.getHeader().getAlgorithm()).isEqualTo("ES256"); + assertThat(jws.getHeader().getKeyId()).isEqualTo(KEY_ID); + assertThat(jws.getPayload().getIssuer()).isEqualTo(TEAM_ID); + assertThat(jws.getPayload().getSubject()).isEqualTo(BUNDLE_ID); + assertThat(jws.getPayload().getAudience()).containsExactly("https://appleid.apple.com"); + assertThat(jws.getPayload().getExpiration()).isAfter(jws.getPayload().getIssuedAt()); + } + + @Test + void should_parse_private_key_when_pem_newlines_are_escaped_as_backslash_n() { + // given — 환경변수로 들어온 PEM은 줄바꿈이 "\n" 문자열로 들어올 수 있다 + String escapedPem = pem.replace("\n", "\\n"); + AppleAuthClient client = client(escapedPem, request -> json(HttpStatus.OK, "{}")); + + // when + Jws jws = parse(client.createClientSecret()); + + // then + assertThat(jws.getPayload().getSubject()).isEqualTo(BUNDLE_ID); + } + + @Test + void should_throw_IllegalStateException_when_creating_client_secret_without_private_key() { + // given — 키 설정이 비어 있어도 빈 생성은 성공한다 + AppleAuthClient client = client("", request -> json(HttpStatus.OK, "{}")); + + // when & then + assertThatThrownBy(client::createClientSecret).isInstanceOf(IllegalStateException.class); + assertThat(requests).isEmpty(); + } + + @Test + void should_throw_IllegalStateException_on_construction_when_private_key_is_malformed() { + // given + String malformedPem = "-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----"; + + // when & then + assertThatThrownBy(() -> client(malformedPem, request -> json(HttpStatus.OK, "{}"))) + .isInstanceOf(IllegalStateException.class); + } + + // ------------------------------------------------------------------------- + // exchangeAuthorizationCode + // ------------------------------------------------------------------------- + + @Test + void should_post_authorization_code_form_and_return_apple_tokens_when_exchange_succeeds() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.OK, + "{\"access_token\": \"apple-access-token\", \"refresh_token\": \"" + APPLE_REFRESH_TOKEN + + "\", \"id_token\": \"" + APPLE_ID_TOKEN + "\", \"token_type\": \"bearer\", \"expires_in\": 3600}")); + + // when + AppleTokens tokens = client.exchangeAuthorizationCode(AUTHORIZATION_CODE); + + // then + assertThat(tokens.refreshToken()).isEqualTo(APPLE_REFRESH_TOKEN); + assertThat(tokens.idToken()).isEqualTo(APPLE_ID_TOKEN); + assertThat(requests).hasSize(1); + ClientRequest request = requests.get(0); + assertThat(request.method().name()).isEqualTo("POST"); + assertThat(request.url().toString()).isEqualTo("https://appleid.apple.com/auth/token"); + MultiValueMap form = formOf(request); + assertThat(form.getFirst("client_id")).isEqualTo(BUNDLE_ID); + assertThat(form.getFirst("code")).isEqualTo(AUTHORIZATION_CODE); + assertThat(form.getFirst("grant_type")).isEqualTo("authorization_code"); + assertThat(parse(form.getFirst("client_secret")).getPayload().getSubject()).isEqualTo(BUNDLE_ID); + } + + @Test + void should_throw_SocialTokenInvalidException_when_token_endpoint_returns_invalid_grant() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.BAD_REQUEST, "{\"error\": \"invalid_grant\"}")); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(SocialTokenInvalidException.class) + .hasMessage(ErrorCode.SOCIAL_TOKEN_INVALID.getMessage()); + } + + @Test + void should_throw_IllegalStateException_when_token_endpoint_returns_invalid_client() { + // given — client_secret·키 설정 문제는 사용자 오류가 아니라 서버 오류다 + AppleAuthClient client = client(request -> json(HttpStatus.BAD_REQUEST, "{\"error\": \"invalid_client\"}")); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void should_throw_IllegalStateException_when_token_endpoint_returns_4xx_without_body() { + // given + AppleAuthClient client = client(request -> ClientResponse.create(HttpStatus.UNAUTHORIZED).build()); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void should_throw_SocialTokenInvalidException_when_response_has_no_refresh_token() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.OK, + "{\"access_token\": \"apple-access-token\", \"id_token\": \"" + APPLE_ID_TOKEN + "\"}")); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(SocialTokenInvalidException.class); + } + + @Test + void should_throw_SocialTokenInvalidException_when_response_has_no_id_token() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.OK, + "{\"access_token\": \"apple-access-token\", \"refresh_token\": \"" + APPLE_REFRESH_TOKEN + "\"}")); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(SocialTokenInvalidException.class); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_token_endpoint_returns_5xx() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.INTERNAL_SERVER_ERROR, "{}")); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(SocialProviderUnavailableException.class) + .hasMessage(ErrorCode.SOCIAL_PROVIDER_UNAVAILABLE.getMessage()); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_token_response_body_is_empty() { + // given + AppleAuthClient client = client(request -> ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build()); + + // when & then + assertThatThrownBy(() -> client.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .isInstanceOf(SocialProviderUnavailableException.class); + } + + // ------------------------------------------------------------------------- + // revoke + // ------------------------------------------------------------------------- + + @Test + void should_post_refresh_token_with_type_hint_to_revoke_endpoint_when_revoke() { + // given + AppleAuthClient client = client(request -> ClientResponse.create(HttpStatus.OK).build()); + + // when + client.revoke(APPLE_REFRESH_TOKEN); + + // then + assertThat(requests).hasSize(1); + ClientRequest request = requests.get(0); + assertThat(request.method().name()).isEqualTo("POST"); + assertThat(request.url().toString()).isEqualTo("https://appleid.apple.com/auth/revoke"); + MultiValueMap form = formOf(request); + assertThat(form.getFirst("client_id")).isEqualTo(BUNDLE_ID); + assertThat(form.getFirst("token")).isEqualTo(APPLE_REFRESH_TOKEN); + assertThat(form.getFirst("token_type_hint")).isEqualTo("refresh_token"); + assertThat(form.getFirst("client_secret")).isNotBlank(); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_revoke_endpoint_returns_5xx() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.SERVICE_UNAVAILABLE, "{}")); + + // when & then + assertThatThrownBy(() -> client.revoke(APPLE_REFRESH_TOKEN)) + .isInstanceOf(SocialProviderUnavailableException.class); + } + + @Test + void should_throw_SocialTokenInvalidException_when_revoke_endpoint_returns_invalid_grant() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.BAD_REQUEST, "{\"error\": \"invalid_grant\"}")); + + // when & then + assertThatThrownBy(() -> client.revoke(APPLE_REFRESH_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class); + } + + @Test + void should_throw_IllegalStateException_when_revoke_endpoint_returns_invalid_client() { + // given + AppleAuthClient client = client(request -> json(HttpStatus.BAD_REQUEST, "{\"error\": \"invalid_client\"}")); + + // when & then + assertThatThrownBy(() -> client.revoke(APPLE_REFRESH_TOKEN)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifierTest.java b/src/test/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifierTest.java new file mode 100644 index 00000000..705f7d2f --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/social/AppleIdentityTokenVerifierTest.java @@ -0,0 +1,127 @@ +package com.Coming.Backend.auth.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +@ExtendWith(MockitoExtension.class) +class AppleIdentityTokenVerifierTest { + + @Mock + private JwtDecoder jwtDecoder; + + @Mock + private LoginNonceRepository loginNonceRepository; + + private AppleIdentityTokenVerifier verifier; + + private static final String IDENTITY_TOKEN = "apple-identity-token"; + private static final String CODE_ID_TOKEN = "apple-code-id-token"; + private static final String PROVIDER_ID = "001234.abcdef0123456789.0123"; + private static final String EMAIL = "abc123@privaterelay.appleid.com"; + private static final String NONCE = "server-issued-nonce"; + + @BeforeEach + void setUp() { + verifier = new AppleIdentityTokenVerifier(jwtDecoder, loginNonceRepository); + } + + private Jwt.Builder appleJwt(String tokenValue) { + return Jwt.withTokenValue(tokenValue) + .header("alg", "RS256") + .subject(PROVIDER_ID); + } + + // ------------------------------------------------------------------------- + // verify + // ------------------------------------------------------------------------- + + @Test + void should_return_apple_as_provider() { + // when & then + assertThat(verifier.provider()).isEqualTo("apple"); + } + + @Test + void should_return_user_info_and_consume_nonce_when_token_is_valid() { + // given + given(jwtDecoder.decode(IDENTITY_TOKEN)) + .willReturn(appleJwt(IDENTITY_TOKEN).claim("email", EMAIL).claim("nonce", NONCE).build()); + given(loginNonceRepository.consume(NONCE)).willReturn(true); + + // when + OAuth2UserInfo userInfo = verifier.verify(IDENTITY_TOKEN); + + // then + assertThat(userInfo).isInstanceOf(AppleUserInfo.class); + assertThat(userInfo.getProviderId()).isEqualTo(PROVIDER_ID); + assertThat(userInfo.getEmail()).isEqualTo(EMAIL); + verify(loginNonceRepository).consume(NONCE); + } + + @Test + void should_throw_SocialTokenInvalidException_when_nonce_claim_is_missing() { + // given + given(jwtDecoder.decode(IDENTITY_TOKEN)).willReturn(appleJwt(IDENTITY_TOKEN).build()); + + // when & then + assertThatThrownBy(() -> verifier.verify(IDENTITY_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class) + .hasMessage(ErrorCode.SOCIAL_TOKEN_INVALID.getMessage()); + verify(loginNonceRepository, never()).consume(anyString()); + } + + @Test + void should_throw_SocialTokenInvalidException_when_identity_token_is_invalid() { + // given + given(jwtDecoder.decode(IDENTITY_TOKEN)).willThrow(new BadJwtException("Signed JWT rejected")); + + // when & then + assertThatThrownBy(() -> verifier.verify(IDENTITY_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class); + } + + // ------------------------------------------------------------------------- + // extractSubject + // ------------------------------------------------------------------------- + + @Test + void should_return_subject_without_checking_nonce_when_extract_subject() { + // given + given(jwtDecoder.decode(CODE_ID_TOKEN)).willReturn(appleJwt(CODE_ID_TOKEN).build()); + + // when + String subject = verifier.extractSubject(CODE_ID_TOKEN); + + // then + assertThat(subject).isEqualTo(PROVIDER_ID); + verifyNoInteractions(loginNonceRepository); + } + + @Test + void should_throw_SocialTokenInvalidException_when_extract_subject_from_invalid_token() { + // given + given(jwtDecoder.decode(CODE_ID_TOKEN)).willThrow(new BadJwtException("Signed JWT rejected")); + + // when & then + assertThatThrownBy(() -> verifier.extractSubject(CODE_ID_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifierTest.java b/src/test/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifierTest.java new file mode 100644 index 00000000..03b2e6b3 --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/social/GoogleIdTokenVerifierTest.java @@ -0,0 +1,124 @@ +package com.Coming.Backend.auth.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.oauth2.GoogleOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +@ExtendWith(MockitoExtension.class) +class GoogleIdTokenVerifierTest { + + @Mock + private JwtDecoder jwtDecoder; + + @Mock + private LoginNonceRepository loginNonceRepository; + + private GoogleIdTokenVerifier verifier; + + private static final String ID_TOKEN = "google-id-token"; + private static final String PROVIDER_ID = "google-provider-id-001"; + private static final String EMAIL = "iu@gmail.com"; + private static final String NONCE = "server-issued-nonce"; + + @BeforeEach + void setUp() { + verifier = new GoogleIdTokenVerifier(jwtDecoder, loginNonceRepository); + } + + private Jwt.Builder googleJwt() { + return Jwt.withTokenValue(ID_TOKEN) + .header("alg", "RS256") + .subject(PROVIDER_ID) + .claim("email", EMAIL) + .claim("nonce", NONCE); + } + + @Test + void should_return_google_as_provider() { + // when & then + assertThat(verifier.provider()).isEqualTo("google"); + } + + @Test + void should_return_user_info_with_email_and_consume_nonce_when_email_is_verified() { + // given + given(jwtDecoder.decode(ID_TOKEN)).willReturn(googleJwt().claim("email_verified", true).build()); + given(loginNonceRepository.consume(NONCE)).willReturn(true); + + // when + OAuth2UserInfo userInfo = verifier.verify(ID_TOKEN); + + // then + assertThat(userInfo).isInstanceOf(GoogleOAuth2UserInfo.class); + assertThat(userInfo.getProviderId()).isEqualTo(PROVIDER_ID); + assertThat(userInfo.getEmail()).isEqualTo(EMAIL); + verify(loginNonceRepository).consume(NONCE); + } + + @Test + void should_drop_email_when_email_verified_is_false() { + // given + given(jwtDecoder.decode(ID_TOKEN)).willReturn(googleJwt().claim("email_verified", false).build()); + given(loginNonceRepository.consume(NONCE)).willReturn(true); + + // when + OAuth2UserInfo userInfo = verifier.verify(ID_TOKEN); + + // then + assertThat(userInfo.getProviderId()).isEqualTo(PROVIDER_ID); + assertThat(userInfo.getEmail()).isNull(); + } + + @Test + void should_drop_email_when_email_verified_claim_is_missing() { + // given + given(jwtDecoder.decode(ID_TOKEN)).willReturn(googleJwt().build()); + given(loginNonceRepository.consume(NONCE)).willReturn(true); + + // when + OAuth2UserInfo userInfo = verifier.verify(ID_TOKEN); + + // then + assertThat(userInfo.getEmail()).isNull(); + } + + @Test + void should_throw_SocialTokenInvalidException_when_nonce_is_already_used() { + // given + given(jwtDecoder.decode(ID_TOKEN)).willReturn(googleJwt().claim("email_verified", true).build()); + given(loginNonceRepository.consume(NONCE)).willReturn(false); + + // when & then + assertThatThrownBy(() -> verifier.verify(ID_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class) + .hasMessage(ErrorCode.SOCIAL_TOKEN_INVALID.getMessage()); + } + + @Test + void should_throw_SocialTokenInvalidException_without_consuming_nonce_when_token_is_invalid() { + // given + given(jwtDecoder.decode(ID_TOKEN)).willThrow(new BadJwtException("Signed JWT rejected")); + + // when & then + assertThatThrownBy(() -> verifier.verify(ID_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class); + verify(loginNonceRepository, never()).consume(anyString()); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/social/IdTokenDecodersTest.java b/src/test/java/com/Coming/Backend/auth/social/IdTokenDecodersTest.java new file mode 100644 index 00000000..6fbe6562 --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/social/IdTokenDecodersTest.java @@ -0,0 +1,215 @@ +package com.Coming.Backend.auth.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import com.Coming.Backend.common.exception.ErrorCode; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; + +@ExtendWith(MockitoExtension.class) +class IdTokenDecodersTest { + + @Mock + private JwtDecoder jwtDecoder; + + @Mock + private LoginNonceRepository loginNonceRepository; + + private static final Set ISSUERS = Set.of("accounts.google.com", "https://accounts.google.com"); + private static final String CLIENT_ID = "coming-web-client-id.apps.googleusercontent.com"; + private static final String ID_TOKEN = "id-token"; + private static final String NONCE = "server-issued-nonce"; + + private Jwt jwt(String issuer, List audience, Instant issuedAt, Instant expiresAt) { + return Jwt.withTokenValue(ID_TOKEN) + .header("alg", "RS256") + .issuer(issuer) + .audience(audience) + .subject("google-provider-id-001") + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .build(); + } + + private Jwt validJwt(String issuer, List audience) { + Instant now = Instant.now(); + return jwt(issuer, audience, now, now.plusSeconds(3600)); + } + + private Jwt jwtWithNonce(String nonce) { + Jwt.Builder builder = Jwt.withTokenValue(ID_TOKEN) + .header("alg", "RS256") + .subject("google-provider-id-001"); + if (nonce != null) { + builder.claim("nonce", nonce); + } + return builder.build(); + } + + // ------------------------------------------------------------------------- + // validator + // ------------------------------------------------------------------------- + + @Test + void should_pass_when_issuer_audience_and_expiry_are_valid() { + // given + OAuth2TokenValidator validator = IdTokenDecoders.validator(ISSUERS, CLIENT_ID); + Jwt token = validJwt("https://accounts.google.com", List.of(CLIENT_ID)); + + // when + OAuth2TokenValidatorResult result = validator.validate(token); + + // then + assertThat(result.hasErrors()).isFalse(); + } + + @Test + void should_fail_when_issuer_does_not_match() { + // given + OAuth2TokenValidator validator = IdTokenDecoders.validator(ISSUERS, CLIENT_ID); + Jwt token = validJwt("https://evil.example.com", List.of(CLIENT_ID)); + + // when + OAuth2TokenValidatorResult result = validator.validate(token); + + // then + assertThat(result.hasErrors()).isTrue(); + } + + @Test + void should_fail_when_audience_does_not_match() { + // given + OAuth2TokenValidator validator = IdTokenDecoders.validator(ISSUERS, CLIENT_ID); + Jwt token = validJwt("https://accounts.google.com", List.of("other-app-client-id")); + + // when + OAuth2TokenValidatorResult result = validator.validate(token); + + // then + assertThat(result.hasErrors()).isTrue(); + } + + @Test + void should_fail_when_configured_audience_is_blank() { + // given — 설정값이 비어 있으면 aud에 빈 문자열이 들어 있어도 통과시키지 않는다 + OAuth2TokenValidator validator = IdTokenDecoders.validator(ISSUERS, ""); + Jwt token = validJwt("https://accounts.google.com", List.of("")); + + // when + OAuth2TokenValidatorResult result = validator.validate(token); + + // then + assertThat(result.hasErrors()).isTrue(); + } + + @Test + void should_fail_when_token_is_expired() { + // given + OAuth2TokenValidator validator = IdTokenDecoders.validator(ISSUERS, CLIENT_ID); + Instant now = Instant.now(); + Jwt token = jwt("https://accounts.google.com", List.of(CLIENT_ID), + now.minusSeconds(7200), now.minusSeconds(3600)); + + // when + OAuth2TokenValidatorResult result = validator.validate(token); + + // then + assertThat(result.hasErrors()).isTrue(); + } + + // ------------------------------------------------------------------------- + // decode + // ------------------------------------------------------------------------- + + @Test + void should_return_decoded_jwt_when_decode_succeeds() { + // given + Jwt decoded = jwtWithNonce(NONCE); + given(jwtDecoder.decode(ID_TOKEN)).willReturn(decoded); + + // when + Jwt result = IdTokenDecoders.decode(jwtDecoder, ID_TOKEN, "google"); + + // then + assertThat(result).isSameAs(decoded); + } + + @Test + void should_throw_SocialTokenInvalidException_when_decoder_throws_BadJwtException() { + // given + given(jwtDecoder.decode(ID_TOKEN)).willThrow(new BadJwtException("Signed JWT rejected")); + + // when & then + assertThatThrownBy(() -> IdTokenDecoders.decode(jwtDecoder, ID_TOKEN, "google")) + .isInstanceOf(SocialTokenInvalidException.class) + .hasMessage(ErrorCode.SOCIAL_TOKEN_INVALID.getMessage()); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_decoder_fails_to_fetch_jwk_set() { + // given + given(jwtDecoder.decode(ID_TOKEN)) + .willThrow(new JwtException("Couldn't retrieve remote JWK set: Read timed out")); + + // when & then + assertThatThrownBy(() -> IdTokenDecoders.decode(jwtDecoder, ID_TOKEN, "google")) + .isInstanceOf(SocialProviderUnavailableException.class) + .hasMessage(ErrorCode.SOCIAL_PROVIDER_UNAVAILABLE.getMessage()); + } + + // ------------------------------------------------------------------------- + // consumeNonce + // ------------------------------------------------------------------------- + + @Test + void should_consume_nonce_when_nonce_was_issued_by_server() { + // given + given(loginNonceRepository.consume(NONCE)).willReturn(true); + + // when + IdTokenDecoders.consumeNonce(jwtWithNonce(NONCE), loginNonceRepository, "google"); + + // then + verify(loginNonceRepository).consume(NONCE); + } + + @Test + void should_throw_SocialTokenInvalidException_when_nonce_claim_is_missing() { + // given + Jwt token = jwtWithNonce(null); + + // when & then + assertThatThrownBy(() -> IdTokenDecoders.consumeNonce(token, loginNonceRepository, "google")) + .isInstanceOf(SocialTokenInvalidException.class); + verify(loginNonceRepository, never()).consume(anyString()); + } + + @Test + void should_throw_SocialTokenInvalidException_when_nonce_is_unknown_or_already_used() { + // given + given(loginNonceRepository.consume(NONCE)).willReturn(false); + + // when & then + assertThatThrownBy(() -> IdTokenDecoders.consumeNonce(jwtWithNonce(NONCE), loginNonceRepository, "google")) + .isInstanceOf(SocialTokenInvalidException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/social/KakaoTokenVerifierTest.java b/src/test/java/com/Coming/Backend/auth/social/KakaoTokenVerifierTest.java new file mode 100644 index 00000000..380b978e --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/social/KakaoTokenVerifierTest.java @@ -0,0 +1,176 @@ +package com.Coming.Backend.auth.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.oauth2.KakaoOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.common.exception.ErrorCode; +import java.net.ConnectException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import reactor.core.publisher.Mono; + +class KakaoTokenVerifierTest { + + private static final String APP_ID = "123456"; + private static final String ACCESS_TOKEN = "kakao-access-token"; + private static final String TOKEN_INFO_PATH = "/v1/user/access_token_info"; + private static final String USER_ME_PATH = "/v2/user/me"; + + private final List requests = new ArrayList<>(); + + private KakaoTokenVerifier verifier(String appId, Function responder) { + return exchangingVerifier(appId, request -> Mono.just(responder.apply(request))); + } + + private KakaoTokenVerifier exchangingVerifier(String appId, Function> exchange) { + WebClient webClient = WebClient.builder() + .baseUrl(KakaoTokenVerifier.BASE_URL) + .exchangeFunction(request -> { + requests.add(request); + return exchange.apply(request); + }) + .build(); + return new KakaoTokenVerifier(webClient, appId); + } + + private static ClientResponse json(HttpStatus status, String body) { + return ClientResponse.create(status) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build(); + } + + private static Function kakaoApi(String tokenInfoAppId) { + return request -> switch (request.url().getPath()) { + case TOKEN_INFO_PATH -> json(HttpStatus.OK, "{\"id\": 99999, \"app_id\": " + tokenInfoAppId + "}"); + case USER_ME_PATH -> json(HttpStatus.OK, + "{\"id\": 99999, \"kakao_account\": {\"email\": \"iu@kakao.com\"}}"); + default -> json(HttpStatus.NOT_FOUND, "{}"); + }; + } + + @Test + void should_return_kakao_as_provider() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, kakaoApi(APP_ID)); + + // when & then + assertThat(verifier.provider()).isEqualTo("kakao"); + } + + @Test + void should_return_kakao_user_info_from_user_me_when_app_id_matches() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, kakaoApi(APP_ID)); + + // when + OAuth2UserInfo userInfo = verifier.verify(ACCESS_TOKEN); + + // then + assertThat(userInfo).isInstanceOf(KakaoOAuth2UserInfo.class); + assertThat(userInfo.getProviderId()).isEqualTo("99999"); + assertThat(userInfo.getEmail()).isEqualTo("iu@kakao.com"); + } + + @Test + void should_send_bearer_authorization_header_to_both_apis_when_verify() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, kakaoApi(APP_ID)); + + // when + verifier.verify(ACCESS_TOKEN); + + // then + assertThat(requests) + .extracting(request -> request.url().getPath()) + .containsExactly(TOKEN_INFO_PATH, USER_ME_PATH); + assertThat(requests) + .extracting(request -> request.headers().getFirst(HttpHeaders.AUTHORIZATION)) + .containsOnly("Bearer " + ACCESS_TOKEN); + } + + @Test + void should_throw_SocialTokenInvalidException_when_app_id_does_not_match() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, kakaoApi("654321")); + + // when & then + assertThatThrownBy(() -> verifier.verify(ACCESS_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class) + .hasMessage(ErrorCode.SOCIAL_TOKEN_INVALID.getMessage()); + assertThat(requests).extracting(request -> request.url().getPath()).containsExactly(TOKEN_INFO_PATH); + } + + @Test + void should_throw_SocialTokenInvalidException_when_app_id_setting_is_blank() { + // given + KakaoTokenVerifier verifier = verifier("", kakaoApi(APP_ID)); + + // when & then + assertThatThrownBy(() -> verifier.verify(ACCESS_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class); + assertThat(requests).extracting(request -> request.url().getPath()).containsExactly(TOKEN_INFO_PATH); + } + + @Test + void should_throw_SocialTokenInvalidException_when_token_info_api_returns_401() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, + request -> json(HttpStatus.UNAUTHORIZED, "{\"msg\": \"this access token does not exist\", \"code\": -401}")); + + // when & then + assertThatThrownBy(() -> verifier.verify(ACCESS_TOKEN)) + .isInstanceOf(SocialTokenInvalidException.class) + .hasMessage(ErrorCode.SOCIAL_TOKEN_INVALID.getMessage()); + assertThat(requests).extracting(request -> request.url().getPath()).containsExactly(TOKEN_INFO_PATH); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_kakao_api_returns_5xx() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, request -> json(HttpStatus.SERVICE_UNAVAILABLE, "{}")); + + // when & then + assertThatThrownBy(() -> verifier.verify(ACCESS_TOKEN)) + .isInstanceOf(SocialProviderUnavailableException.class) + .hasMessage(ErrorCode.SOCIAL_PROVIDER_UNAVAILABLE.getMessage()); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_connection_to_kakao_fails() { + // given + KakaoTokenVerifier verifier = exchangingVerifier(APP_ID, request -> Mono.error( + new WebClientRequestException(new ConnectException("Connection refused"), + HttpMethod.GET, request.url(), new HttpHeaders()))); + + // when & then + assertThatThrownBy(() -> verifier.verify(ACCESS_TOKEN)) + .isInstanceOf(SocialProviderUnavailableException.class); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_kakao_response_body_is_empty() { + // given + KakaoTokenVerifier verifier = verifier(APP_ID, request -> ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build()); + + // when & then + assertThatThrownBy(() -> verifier.verify(ACCESS_TOKEN)) + .isInstanceOf(SocialProviderUnavailableException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/social/SocialProviderCallsTest.java b/src/test/java/com/Coming/Backend/auth/social/SocialProviderCallsTest.java new file mode 100644 index 00000000..3734eab7 --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/social/SocialProviderCallsTest.java @@ -0,0 +1,75 @@ +package com.Coming.Backend.auth.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.common.exception.ErrorCode; +import java.net.URI; +import java.net.ConnectException; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import reactor.core.publisher.Mono; + +class SocialProviderCallsTest { + + private static final String PATH = "/v2/user/me"; + + @Test + void should_return_value_when_call_emits_value() { + // given + Mono call = Mono.just("response"); + + // when + String result = SocialProviderCalls.block(call, PATH); + + // then + assertThat(result).isEqualTo("response"); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_call_times_out() { + // given + Mono call = Mono.error(new TimeoutException("Did not observe any item within 5000ms")); + + // when & then + assertThatThrownBy(() -> SocialProviderCalls.block(call, PATH)) + .isInstanceOf(SocialProviderUnavailableException.class) + .hasMessage(ErrorCode.SOCIAL_PROVIDER_UNAVAILABLE.getMessage()); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_connection_fails() { + // given + Mono call = Mono.error(new WebClientRequestException(new ConnectException("Connection refused"), + HttpMethod.GET, URI.create("https://kapi.kakao.com" + PATH), new HttpHeaders())); + + // when & then + assertThatThrownBy(() -> SocialProviderCalls.block(call, PATH)) + .isInstanceOf(SocialProviderUnavailableException.class); + } + + @Test + void should_throw_SocialProviderUnavailableException_when_response_is_empty() { + // given + Mono call = Mono.empty(); + + // when & then + assertThatThrownBy(() -> SocialProviderCalls.block(call, PATH)) + .isInstanceOf(SocialProviderUnavailableException.class); + } + + @Test + void should_rethrow_SocialTokenInvalidException_as_is_when_status_mapping_rejected_request() { + // given + Mono call = Mono.error(new SocialTokenInvalidException()); + + // when & then + assertThatThrownBy(() -> SocialProviderCalls.block(call, PATH)) + .isInstanceOf(SocialTokenInvalidException.class); + } +} From e0ce6953d7ed6b2bcaa2ac8afcfcbfcdf9bbba45 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 21 Oct 2026 00:21:26 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[feat]=20=EC=95=B1=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=B1=20=EC=A0=84=EC=9A=A9=20refresh=C2=B7logout=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/auth/login/nonce, POST /api/auth/login/{provider}/token - POST /api/auth/app/refresh, /api/auth/app/logout (요청·응답 바디로 Refresh Token 전달) - 회원 조회·생성(SocialUserService)과 토큰 발급(AuthService.issueTokens) 공용화 - Apple 코드 교환 id_token sub 대조, 탈퇴 커밋 후 Apple 토큰 revoke - dev 로그인 응답에 refreshToken 추가 Co-Authored-By: Claude Opus 5.5 --- .../auth/controller/AuthController.java | 20 ++ .../controller/SocialLoginController.java | 44 +++ .../Backend/auth/dev/DevAuthController.java | 2 +- .../Backend/auth/dev/DevAuthService.java | 18 +- .../Backend/auth/dto/AppLogoutRequest.java | 7 + .../Backend/auth/dto/AppRefreshRequest.java | 9 + .../Backend/auth/dto/LoginNonceResponse.java | 4 + .../Backend/auth/dto/SocialLoginResponse.java | 4 + .../auth/dto/SocialTokenLoginRequest.java | 15 + .../auth/event/AppleTokenRevokeEvent.java | 4 + .../auth/event/AppleTokenRevokeListener.java | 33 ++ .../exception/UserSuspendedException.java | 11 + .../auth/oauth2/CustomOAuth2UserService.java | 71 +--- .../auth/oauth2/OAuth2SuccessHandler.java | 15 +- .../Backend/auth/service/AuthService.java | 30 ++ .../auth/service/SocialLoginService.java | 103 ++++++ .../auth/service/SocialUserService.java | 100 ++++++ .../Backend/common/config/SecurityConfig.java | 3 +- .../auth/controller/AuthControllerTest.java | 68 ++++ .../controller/SocialLoginControllerTest.java | 99 ++++++ .../event/AppleTokenRevokeListenerTest.java | 56 +++ .../oauth2/CustomOAuth2UserServiceTest.java | 295 ++++------------ .../auth/oauth2/OAuth2SuccessHandlerTest.java | 33 +- .../Backend/auth/service/AuthServiceTest.java | 111 ++++++ .../auth/service/SocialLoginServiceTest.java | 308 +++++++++++++++++ .../auth/service/SocialUserServiceTest.java | 321 ++++++++++++++++++ 26 files changed, 1455 insertions(+), 329 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/auth/controller/SocialLoginController.java create mode 100644 src/main/java/com/Coming/Backend/auth/dto/AppLogoutRequest.java create mode 100644 src/main/java/com/Coming/Backend/auth/dto/AppRefreshRequest.java create mode 100644 src/main/java/com/Coming/Backend/auth/dto/LoginNonceResponse.java create mode 100644 src/main/java/com/Coming/Backend/auth/dto/SocialLoginResponse.java create mode 100644 src/main/java/com/Coming/Backend/auth/dto/SocialTokenLoginRequest.java create mode 100644 src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeEvent.java create mode 100644 src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeListener.java create mode 100644 src/main/java/com/Coming/Backend/auth/exception/UserSuspendedException.java create mode 100644 src/main/java/com/Coming/Backend/auth/service/SocialLoginService.java create mode 100644 src/main/java/com/Coming/Backend/auth/service/SocialUserService.java create mode 100644 src/test/java/com/Coming/Backend/auth/controller/SocialLoginControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/event/AppleTokenRevokeListenerTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/service/SocialLoginServiceTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java diff --git a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java index 5098b225..abaccf53 100644 --- a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java +++ b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java @@ -1,5 +1,7 @@ package com.Coming.Backend.auth.controller; +import com.Coming.Backend.auth.dto.AppLogoutRequest; +import com.Coming.Backend.auth.dto.AppRefreshRequest; import com.Coming.Backend.auth.dto.MarketingUpdateRequest; import com.Coming.Backend.auth.dto.MeResponse; import com.Coming.Backend.auth.dto.NicknameCheckResponse; @@ -64,6 +66,24 @@ public ResponseEntity login(@PathVariable String provider) { .build(); } + @Operation(summary = "앱 Access Token 재발급") + @ApiResponse(responseCode = "401", description = "Refresh Token 만료 또는 유효하지 않음") + @PostMapping("/app/refresh") + public ResponseEntity refreshForApp(@Valid @RequestBody AppRefreshRequest request) { + return ResponseEntity.ok(authService.refreshToken(request.refreshToken())); + } + + @Operation(summary = "앱 로그아웃") + @PostMapping("/app/logout") + public ResponseEntity logoutForApp( + @RequestHeader("Authorization") String authorizationHeader, + @AuthenticationPrincipal Long userId, + @RequestBody(required = false) AppLogoutRequest request) { + String refreshToken = request == null ? null : request.refreshToken(); + authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId, refreshToken); + return ResponseEntity.ok().build(); + } + @Operation(summary = "Access Token 재발급") @ApiResponse(responseCode = "401", description = "Refresh Token 만료 또는 유효하지 않음") @PostMapping("/refresh") diff --git a/src/main/java/com/Coming/Backend/auth/controller/SocialLoginController.java b/src/main/java/com/Coming/Backend/auth/controller/SocialLoginController.java new file mode 100644 index 00000000..b13e01da --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/controller/SocialLoginController.java @@ -0,0 +1,44 @@ +package com.Coming.Backend.auth.controller; + +import com.Coming.Backend.auth.dto.LoginNonceResponse; +import com.Coming.Backend.auth.dto.SocialLoginResponse; +import com.Coming.Backend.auth.dto.SocialTokenLoginRequest; +import com.Coming.Backend.auth.service.SocialLoginService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Auth") +@RestController +@RequestMapping("/api/auth/login") +@RequiredArgsConstructor +public class SocialLoginController { + + private final SocialLoginService socialLoginService; + + @Operation(summary = "앱 소셜 로그인용 nonce 발급") + @PostMapping("/nonce") + public ResponseEntity issueLoginNonce() { + return ResponseEntity.ok(socialLoginService.issueNonce()); + } + + @Operation(summary = "앱 소셜 토큰 로그인") + @ApiResponse(responseCode = "400", description = "지원하지 않는 provider 또는 apple인데 authorizationCode 누락") + @ApiResponse(responseCode = "401", description = "유효하지 않은 소셜 로그인 토큰, nonce 불일치·재사용") + @ApiResponse(responseCode = "403", description = "정지된 계정") + @ApiResponse(responseCode = "502", description = "소셜 로그인 제공자 장애") + @PostMapping("/{provider}/token") + public ResponseEntity loginWithSocialToken( + @PathVariable String provider, + @Valid @RequestBody SocialTokenLoginRequest request) { + return ResponseEntity.ok(socialLoginService.login(provider, request)); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/dev/DevAuthController.java b/src/main/java/com/Coming/Backend/auth/dev/DevAuthController.java index 85151e3d..b3c64dd9 100644 --- a/src/main/java/com/Coming/Backend/auth/dev/DevAuthController.java +++ b/src/main/java/com/Coming/Backend/auth/dev/DevAuthController.java @@ -33,7 +33,7 @@ public Map devLogin( ) { DevTokenResult result = devAuthService.login(request.nickname(), request.role()); addRefreshTokenCookie(response, result.refreshToken()); - return Map.of("accessToken", result.accessToken()); + return Map.of("accessToken", result.accessToken(), "refreshToken", result.refreshToken()); } private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { diff --git a/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java b/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java index 9696800f..457da729 100644 --- a/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java +++ b/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java @@ -3,12 +3,11 @@ import com.Coming.Backend.auth.entity.User; import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; -import com.Coming.Backend.auth.jwt.JwtProvider; -import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.dto.TokenPair; import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.service.AuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,11 +21,7 @@ public class DevAuthService { private static final String DEV_PROVIDER = "local"; private final UserRepository userRepository; - private final JwtProvider jwtProvider; - private final TokenRepository tokenRepository; - - @Value("${jwt.refresh-token-expiry}") - private long refreshTokenExpiry; + private final AuthService authService; /** * nickname 기준으로 로컬 유저를 upsert하고 토큰을 발급한다. @@ -46,13 +41,10 @@ public DevTokenResult login(String nickname, UserRole role) { return userRepository.save(created); }); - String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name()); - String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(), - refreshToken, refreshTokenExpiry); + TokenPair tokens = authService.issueTokens(user); log.info("Dev 로그인 — userId: {}, nickname: {}", user.getId(), user.getNickname()); - return new DevTokenResult(accessToken, refreshToken); + return new DevTokenResult(tokens.accessToken(), tokens.refreshToken()); } record DevTokenResult(String accessToken, String refreshToken) { diff --git a/src/main/java/com/Coming/Backend/auth/dto/AppLogoutRequest.java b/src/main/java/com/Coming/Backend/auth/dto/AppLogoutRequest.java new file mode 100644 index 00000000..73dff162 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/dto/AppLogoutRequest.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.auth.dto; + +/** + * 앱 로그아웃 요청. refreshToken이 없으면 세션 삭제는 건너뛴다(세션은 TTL 만료로 정리). + */ +public record AppLogoutRequest(String refreshToken) { +} diff --git a/src/main/java/com/Coming/Backend/auth/dto/AppRefreshRequest.java b/src/main/java/com/Coming/Backend/auth/dto/AppRefreshRequest.java new file mode 100644 index 00000000..142d868b --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/dto/AppRefreshRequest.java @@ -0,0 +1,9 @@ +package com.Coming.Backend.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AppRefreshRequest( + @NotBlank + String refreshToken +) { +} diff --git a/src/main/java/com/Coming/Backend/auth/dto/LoginNonceResponse.java b/src/main/java/com/Coming/Backend/auth/dto/LoginNonceResponse.java new file mode 100644 index 00000000..f4fd8226 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/dto/LoginNonceResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.auth.dto; + +public record LoginNonceResponse(String nonce) { +} diff --git a/src/main/java/com/Coming/Backend/auth/dto/SocialLoginResponse.java b/src/main/java/com/Coming/Backend/auth/dto/SocialLoginResponse.java new file mode 100644 index 00000000..6d03481c --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/dto/SocialLoginResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.auth.dto; + +public record SocialLoginResponse(String accessToken, String refreshToken, boolean isNewUser) { +} diff --git a/src/main/java/com/Coming/Backend/auth/dto/SocialTokenLoginRequest.java b/src/main/java/com/Coming/Backend/auth/dto/SocialTokenLoginRequest.java new file mode 100644 index 00000000..5e7bdf04 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/dto/SocialTokenLoginRequest.java @@ -0,0 +1,15 @@ +package com.Coming.Backend.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 앱 소셜 토큰 로그인 요청. token은 Google ID Token·Kakao Access Token·Apple identityToken이고, + * authorizationCode는 apple일 때만 필수다. + */ +public record SocialTokenLoginRequest( + @NotBlank + String token, + + String authorizationCode +) { +} diff --git a/src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeEvent.java b/src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeEvent.java new file mode 100644 index 00000000..b8f2f787 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeEvent.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.auth.event; + +public record AppleTokenRevokeEvent(Long userId, String appleRefreshToken) { +} diff --git a/src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeListener.java b/src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeListener.java new file mode 100644 index 00000000..e654681c --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/event/AppleTokenRevokeListener.java @@ -0,0 +1,33 @@ +package com.Coming.Backend.auth.event; + +import com.Coming.Backend.auth.service.SocialUserService; +import com.Coming.Backend.auth.social.AppleAuthClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 탈퇴 트랜잭션이 커밋된 뒤 Apple 토큰을 revoke한다. 성공했을 때만 저장된 토큰을 지운다. + * 실패하면 토큰을 남겨 두지만 자동 재시도는 하지 않는다(WARN 로그로 확인 후 수동 처리). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleTokenRevokeListener { + + private final AppleAuthClient appleAuthClient; + private final SocialUserService socialUserService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onAppleTokenRevoke(AppleTokenRevokeEvent event) { + try { + appleAuthClient.revoke(event.appleRefreshToken()); + } catch (RuntimeException e) { + log.warn("Apple 토큰 revoke 실패 — 토큰 유지(수동 재시도 필요), userId: {}, error: {}", event.userId(), e.getMessage()); + return; + } + socialUserService.clearAppleRefreshToken(event.userId()); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/exception/UserSuspendedException.java b/src/main/java/com/Coming/Backend/auth/exception/UserSuspendedException.java new file mode 100644 index 00000000..9c0a9f13 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/exception/UserSuspendedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.auth.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class UserSuspendedException extends BusinessException { + + public UserSuspendedException() { + super(ErrorCode.USER_SUSPENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserService.java b/src/main/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserService.java index fdce7416..b81c7361 100644 --- a/src/main/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserService.java +++ b/src/main/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserService.java @@ -1,42 +1,44 @@ package com.Coming.Backend.auth.oauth2; -import com.Coming.Backend.auth.entity.User; -import com.Coming.Backend.auth.entity.UserRole; -import com.Coming.Backend.auth.entity.UserStatus; -import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.exception.UserSuspendedException; +import com.Coming.Backend.auth.service.SocialUserService; +import com.Coming.Backend.auth.service.SocialUserService.SocialUser; import com.Coming.Backend.common.exception.ErrorCode; import java.util.Map; -import java.util.Optional; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -@Slf4j @Service @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { - private final UserRepository userRepository; - - private record UserResult(User user, boolean isNewUser) {} + private final SocialUserService socialUserService; @Override - @Transactional public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); String provider = userRequest.getClientRegistration().getRegistrationId(); - Map attributes = oAuth2User.getAttributes(); + return toCustomOAuth2User(provider, oAuth2User.getAttributes()); + } + /** + * 제공자 응답 attributes로 회원을 조회·생성한다. 정지 계정은 OAuth2FailureHandler가 처리하도록 + * OAuth2AuthenticationException(USER_SUSPENDED)으로 변환한다. + */ + CustomOAuth2User toCustomOAuth2User(String provider, Map attributes) { OAuth2UserInfo userInfo = resolveUserInfo(provider, attributes); - UserResult result = findOrCreateUser(provider, userInfo); - return new CustomOAuth2User(result.user(), attributes, result.isNewUser()); + try { + SocialUser result = socialUserService.findOrCreate(provider, userInfo); + return new CustomOAuth2User(result.user(), attributes, result.isNewUser()); + } catch (UserSuspendedException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error(ErrorCode.USER_SUSPENDED.name()), ErrorCode.USER_SUSPENDED.getMessage()); + } } private OAuth2UserInfo resolveUserInfo(String provider, Map attributes) { @@ -46,41 +48,4 @@ private OAuth2UserInfo resolveUserInfo(String provider, Map attr default -> throw new OAuth2AuthenticationException("Unsupported provider: " + provider); }; } - - private UserResult findOrCreateUser(String provider, OAuth2UserInfo userInfo) { - Optional existing = userRepository.findByProviderAndProviderId(provider, userInfo.getProviderId()); - if (existing.isPresent()) { - User user = existing.get(); - if (user.getStatus() == UserStatus.SUSPENDED) { - log.warn("정지된 계정 로그인 시도 — userId: {}", user.getId()); - throw new OAuth2AuthenticationException( - new OAuth2Error(ErrorCode.USER_SUSPENDED.name()), "정지된 계정입니다."); - } - if (user.getStatus() == UserStatus.INACTIVE) { - log.info("탈퇴 후 재가입 처리 — userId: {}", user.getId()); - user.reactivate(); - user.updateEmail(userInfo.getEmail()); - return new UserResult(user, true); - } - user.updateEmail(userInfo.getEmail()); - return new UserResult(user, false); - } - log.info("신규 OAuth2 사용자 생성 — provider: {}, providerId: {}", provider, userInfo.getProviderId()); - try { - return new UserResult(userRepository.save( - User.builder() - .email(userInfo.getEmail()) - .provider(provider) - .providerId(userInfo.getProviderId()) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .build() - ), true); - } catch (DataIntegrityViolationException e) { - log.info("동시 최초 로그인 경합 — 기존 사용자 재조회. provider: {}, providerId: {}", provider, userInfo.getProviderId()); - User user = userRepository.findByProviderAndProviderId(provider, userInfo.getProviderId()) - .orElseThrow(() -> e); - return new UserResult(user, false); - } - } } diff --git a/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java index b62256f3..3811ca0e 100644 --- a/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java +++ b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java @@ -2,8 +2,7 @@ import com.Coming.Backend.auth.entity.User; import com.Coming.Backend.auth.entity.UserRole; -import com.Coming.Backend.auth.jwt.JwtProvider; -import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.service.AuthService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -19,8 +18,7 @@ @Component public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final JwtProvider jwtProvider; - private final TokenRepository tokenRepository; + private final AuthService authService; @Value("${app.oauth2.redirect-base-uri}") private String redirectBaseUri; @@ -31,9 +29,8 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler @Value("${app.cookie.secure:true}") private boolean cookieSecure; - public OAuth2SuccessHandler(JwtProvider jwtProvider, TokenRepository tokenRepository) { - this.jwtProvider = jwtProvider; - this.tokenRepository = tokenRepository; + public OAuth2SuccessHandler(AuthService authService) { + this.authService = authService; } @Override @@ -44,9 +41,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.info("OAuth2 로그인 성공 — userId: {}, isNewUser: {}", user.getId(), oAuth2User.isNewUser()); - String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(), - refreshToken, refreshTokenExpiry); + String refreshToken = authService.createSession(user.getId()); addRefreshTokenCookie(response, refreshToken); String redirectUrl = redirectBaseUri + "?isNewUser=" + (user.getRole() == UserRole.PENDING); diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 026bcb39..f11a1ba3 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -25,6 +25,7 @@ import com.Coming.Backend.auth.repository.TokenRepository; import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.event.AppleTokenRevokeEvent; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; import com.Coming.Backend.policy.entity.PolicyDocument; @@ -36,8 +37,10 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Optional; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -56,6 +59,25 @@ public class AuthService { private final InquiryRepository inquiryRepository; private final PolicyDocumentRepository policyDocumentRepository; private final UserPolicyAgreementRepository userPolicyAgreementRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 새 로그인 세션(기기)을 만들고 그 세션의 Refresh Token을 발급한다. + */ + public String createSession(Long userId) { + String sessionId = UUID.randomUUID().toString(); + String refreshToken = jwtProvider.generateRefreshToken(userId, sessionId); + tokenRepository.saveSession(userId, sessionId, refreshToken, jwtProvider.getRefreshTokenExpiry()); + return refreshToken; + } + + /** + * 로그인한 사용자에게 Access Token과 새 세션의 Refresh Token을 발급한다. + */ + public TokenPair issueTokens(User user) { + String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name()); + return new TokenPair(accessToken, createSession(user.getId())); + } /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. @@ -102,6 +124,7 @@ public void logout(String accessToken, Long userId, String refreshToken) { /** * 회원 탈퇴 처리 후 Access Token을 블랙리스트에 등록하고 모든 기기의 세션을 삭제한다. + * Apple 회원이면 커밋 후 Apple 토큰 revoke를 요청한다(외부 호출을 트랜잭션 밖으로 분리, 실패해도 탈퇴는 유지). * * @param accessToken Authorization 헤더에서 추출한 Access Token * @param userId 인증된 사용자 ID @@ -113,6 +136,7 @@ public void withdraw(String accessToken, Long userId) { inquiryRepository.deleteByUserId(userId); User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + requestAppleTokenRevoke(user); user.withdraw(); userRepository.flush(); blacklistRepository.save(accessToken, jwtProvider.getRemainingExpiry(accessToken)); @@ -120,6 +144,12 @@ public void withdraw(String accessToken, Long userId) { log.info("회원 탈퇴"); } + private void requestAppleTokenRevoke(User user) { + if (user.getAppleRefreshToken() != null) { + eventPublisher.publishEvent(new AppleTokenRevokeEvent(user.getId(), user.getAppleRefreshToken())); + } + } + /** * 로그인한 사용자의 프로필 정보를 조회한다. */ diff --git a/src/main/java/com/Coming/Backend/auth/service/SocialLoginService.java b/src/main/java/com/Coming/Backend/auth/service/SocialLoginService.java new file mode 100644 index 00000000..bc51c2d9 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/service/SocialLoginService.java @@ -0,0 +1,103 @@ +package com.Coming.Backend.auth.service; + +import com.Coming.Backend.auth.dto.LoginNonceResponse; +import com.Coming.Backend.auth.dto.SocialLoginResponse; +import com.Coming.Backend.auth.dto.SocialTokenLoginRequest; +import com.Coming.Backend.auth.dto.TokenPair; +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import com.Coming.Backend.auth.service.SocialUserService.SocialUser; +import com.Coming.Backend.auth.social.AppleAuthClient; +import com.Coming.Backend.auth.social.AppleAuthClient.AppleTokens; +import com.Coming.Backend.auth.social.AppleIdentityTokenVerifier; +import com.Coming.Backend.auth.social.SocialTokenVerifier; +import com.Coming.Backend.common.exception.InvalidInputException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Slf4j +@Service +public class SocialLoginService { + + private static final Duration NONCE_TTL = Duration.ofMinutes(5); + private static final int NONCE_BYTES = 32; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final Map verifiers; + private final SocialUserService socialUserService; + private final AuthService authService; + private final AppleAuthClient appleAuthClient; + private final AppleIdentityTokenVerifier appleIdentityTokenVerifier; + private final LoginNonceRepository loginNonceRepository; + + public SocialLoginService(List verifiers, SocialUserService socialUserService, + AuthService authService, AppleAuthClient appleAuthClient, + AppleIdentityTokenVerifier appleIdentityTokenVerifier, LoginNonceRepository loginNonceRepository) { + this.verifiers = verifiers.stream() + .collect(Collectors.toMap(SocialTokenVerifier::provider, Function.identity())); + this.socialUserService = socialUserService; + this.authService = authService; + this.appleAuthClient = appleAuthClient; + this.appleIdentityTokenVerifier = appleIdentityTokenVerifier; + this.loginNonceRepository = loginNonceRepository; + } + + /** + * Google·Apple SDK 로그인 요청에 넣을 1회용 nonce를 발급한다. 5분 안에 한 번만 쓸 수 있다. + */ + public LoginNonceResponse issueNonce() { + byte[] bytes = new byte[NONCE_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + loginNonceRepository.save(nonce, NONCE_TTL); + return new LoginNonceResponse(nonce); + } + + /** + * 앱이 소셜 SDK로 받은 토큰을 검증하고 회원을 조회·생성한 뒤 새 세션의 Access/Refresh Token을 발급한다. + * 제공자 호출(검증·코드 교환)은 DB 트랜잭션 밖에서 먼저 끝낸다. + * + * @throws InvalidInputException 지원하지 않는 provider이거나 apple인데 authorizationCode가 없는 경우 + */ + public SocialLoginResponse login(String provider, SocialTokenLoginRequest request) { + SocialTokenVerifier verifier = verifiers.get(provider); + boolean isApple = appleIdentityTokenVerifier.provider().equals(provider); + if (verifier == null || (isApple && !StringUtils.hasText(request.authorizationCode()))) { + throw new InvalidInputException(); + } + OAuth2UserInfo userInfo = verifier.verify(request.token()); + String appleRefreshToken = isApple ? exchangeAppleCode(request.authorizationCode(), userInfo) : null; + + SocialUser socialUser = isApple + ? socialUserService.findOrCreateAppleUser(userInfo, appleRefreshToken) + : socialUserService.findOrCreate(provider, userInfo); + User user = socialUser.user(); + TokenPair tokens = authService.issueTokens(user); + log.info("앱 소셜 로그인 성공 — provider: {}, userId: {}, isNewUser: {}", provider, user.getId(), socialUser.isNewUser()); + return new SocialLoginResponse(tokens.accessToken(), tokens.refreshToken(), user.getRole() == UserRole.PENDING); + } + + /** + * authorizationCode를 교환하고, 코드의 주인(id_token sub)이 로그인한 identityToken의 사용자와 같은지 확인한다. + */ + private String exchangeAppleCode(String authorizationCode, OAuth2UserInfo userInfo) { + AppleTokens appleTokens = appleAuthClient.exchangeAuthorizationCode(authorizationCode); + String codeSubject = appleIdentityTokenVerifier.extractSubject(appleTokens.idToken()); + if (codeSubject == null || !codeSubject.equals(userInfo.getProviderId())) { + log.warn("Apple authorizationCode와 identityToken의 사용자 불일치"); + throw new SocialTokenInvalidException(); + } + return appleTokens.refreshToken(); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java b/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java new file mode 100644 index 00000000..2d2776b3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java @@ -0,0 +1,100 @@ +package com.Coming.Backend.auth.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.exception.UserSuspendedException; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.UserRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SocialUserService { + + private static final String APPLE = "apple"; + + private final UserRepository userRepository; + + public record SocialUser(User user, boolean isNewUser) {} + + /** + * 소셜 제공자 식별자로 회원을 조회하고, 없으면 PENDING 회원으로 생성한다. + * 탈퇴(INACTIVE) 회원은 재가입 처리하고, 정지(SUSPENDED) 회원은 로그인을 막는다. + * + * @throws UserSuspendedException 정지된 계정인 경우 + */ + @Transactional + public SocialUser findOrCreate(String provider, OAuth2UserInfo userInfo) { + Optional existing = userRepository.findByProviderAndProviderId(provider, userInfo.getProviderId()); + if (existing.isPresent()) { + User user = existing.get(); + if (user.getStatus() == UserStatus.SUSPENDED) { + log.warn("정지된 계정 로그인 시도 — userId: {}", user.getId()); + throw new UserSuspendedException(); + } + if (user.getStatus() == UserStatus.INACTIVE) { + log.info("탈퇴 후 재가입 처리 — userId: {}", user.getId()); + user.reactivate(); + updateEmailIfPresent(user, userInfo); + return new SocialUser(user, true); + } + updateEmailIfPresent(user, userInfo); + return new SocialUser(user, false); + } + log.info("신규 소셜 로그인 사용자 생성 — provider: {}, providerId: {}", provider, userInfo.getProviderId()); + try { + return new SocialUser(userRepository.save( + User.builder() + .email(userInfo.getEmail()) + .provider(provider) + .providerId(userInfo.getProviderId()) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .build() + ), true); + } catch (DataIntegrityViolationException e) { + log.info("동시 최초 로그인 경합 — 기존 사용자 재조회. provider: {}, providerId: {}", provider, userInfo.getProviderId()); + User user = userRepository.findByProviderAndProviderId(provider, userInfo.getProviderId()) + .orElseThrow(() -> e); + return new SocialUser(user, false); + } + } + + /** + * Apple 회원을 조회·생성하고, 탈퇴 시 revoke에 쓸 Apple refresh_token을 같은 트랜잭션에서 저장한다. + * 로그인할 때마다 최신 값으로 덮어쓴다. + * + * @throws UserSuspendedException 정지된 계정인 경우 + */ + @Transactional + public SocialUser findOrCreateAppleUser(OAuth2UserInfo userInfo, String appleRefreshToken) { + SocialUser socialUser = findOrCreate(APPLE, userInfo); + socialUser.user().updateAppleRefreshToken(appleRefreshToken); + return socialUser; + } + + /** + * revoke가 끝난 Apple refresh_token을 지운다. 탈퇴 트랜잭션 커밋 뒤에 호출되므로 새 트랜잭션에서 실행한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void clearAppleRefreshToken(Long userId) { + userRepository.findById(userId).ifPresent(user -> user.updateAppleRefreshToken(null)); + } + + /** + * 제공자가 email을 주지 않으면(Apple 재로그인, Kakao 이메일 동의 철회, Google 미인증 이메일) 기존 값을 유지한다. + */ + private void updateEmailIfPresent(User user, OAuth2UserInfo userInfo) { + if (userInfo.getEmail() != null) { + user.updateEmail(userInfo.getEmail()); + } + } +} diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index b707ea3a..3fc7eb08 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -110,6 +110,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/auth/login/**", "/api/auth/callback/**", "/api/auth/refresh", + "/api/auth/app/refresh", "/api/auth/check-nickname", "/actuator/health" ).permitAll() @@ -132,7 +133,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") .requestMatchers(HttpMethod.GET, "/api/auth/me").hasAnyRole("USER", "ADMIN", "PENDING") - .requestMatchers("/api/auth/logout", "/api/auth/withdraw").hasAnyRole("USER", "ADMIN", "PENDING") + .requestMatchers("/api/auth/logout", "/api/auth/app/logout", "/api/auth/withdraw").hasAnyRole("USER", "ADMIN", "PENDING") .anyRequest().hasAnyRole("USER", "ADMIN"); }) .oauth2Login(oauth2 -> oauth2 diff --git a/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java index 75856ab6..1ba34f51 100644 --- a/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java @@ -7,6 +7,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -14,6 +15,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.Coming.Backend.auth.dto.AppLogoutRequest; +import com.Coming.Backend.auth.dto.AppRefreshRequest; import com.Coming.Backend.auth.dto.MarketingUpdateRequest; import com.Coming.Backend.auth.dto.MeResponse; import com.Coming.Backend.auth.dto.NicknameCheckResponse; @@ -249,4 +252,69 @@ void should_issueSecureCookie_when_cookieSecureEnabled() throws Exception { .andExpect(status().isOk()) .andExpect(header().string("Set-Cookie", containsString("Secure"))); } + + // ------------------------------------------------------------------------- + // POST /api/auth/app/refresh + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_token_pair_in_body_and_no_cookie_when_app_refresh() throws Exception { + // given + given(authService.refreshToken("refresh-token-value")) + .willReturn(new TokenPair(ACCESS_TOKEN, "new-refresh-token")); + + // when & then + mockMvc.perform(post("/api/auth/app/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AppRefreshRequest("refresh-token-value")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value(ACCESS_TOKEN)) + .andExpect(jsonPath("$.refreshToken").value("new-refresh-token")) + .andExpect(header().doesNotExist("Set-Cookie")); + } + + @Test + void should_return_400_when_app_refresh_token_is_blank() throws Exception { + // given + AppRefreshRequest request = new AppRefreshRequest(""); + + // when & then + mockMvc.perform(post("/api/auth/app/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + verifyNoInteractions(authService); + } + + // ------------------------------------------------------------------------- + // POST /api/auth/app/logout + // ------------------------------------------------------------------------- + + @Test + void should_logout_with_body_refresh_token_and_no_cookie_when_app_logout() throws Exception { + // given + String refreshToken = "refresh-token-value"; + + // when & then + mockMvc.perform(post("/api/auth/app/logout") + .header("Authorization", "Bearer " + ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AppLogoutRequest(refreshToken)))) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist("Set-Cookie")); + verify(authService).logout(ACCESS_TOKEN, USER_ID, refreshToken); + } + + @Test + void should_logout_with_null_refresh_token_when_app_logout_without_body() throws Exception { + // given — 요청 본문 없음 + + // when & then + mockMvc.perform(post("/api/auth/app/logout") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist("Set-Cookie")); + verify(authService).logout(ACCESS_TOKEN, USER_ID, null); + } } diff --git a/src/test/java/com/Coming/Backend/auth/controller/SocialLoginControllerTest.java b/src/test/java/com/Coming/Backend/auth/controller/SocialLoginControllerTest.java new file mode 100644 index 00000000..48d9ea5c --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/controller/SocialLoginControllerTest.java @@ -0,0 +1,99 @@ +package com.Coming.Backend.auth.controller; + +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.auth.dto.LoginNonceResponse; +import com.Coming.Backend.auth.dto.SocialLoginResponse; +import com.Coming.Backend.auth.dto.SocialTokenLoginRequest; +import com.Coming.Backend.auth.service.SocialLoginService; +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +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.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class SocialLoginControllerTest { + + private MockMvc mockMvc; + + @Mock + private SocialLoginService socialLoginService; + + @InjectMocks + private SocialLoginController socialLoginController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String ACCESS_TOKEN = "new-access-token"; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(socialLoginController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .build(); + } + + // ------------------------------------------------------------------------- + // POST /api/auth/login/nonce + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_nonce_when_issue_login_nonce() throws Exception { + // given + String nonce = "q1w2e3r4t5y6u7i8o9p0a1s2d3f4g5h6j7k8l9z0x1c"; + given(socialLoginService.issueNonce()).willReturn(new LoginNonceResponse(nonce)); + + // when & then + mockMvc.perform(post("/api/auth/login/nonce")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nonce").value(nonce)); + } + + // ------------------------------------------------------------------------- + // POST /api/auth/login/{provider}/token + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_tokens_and_isNewUser_when_social_token_login_succeeds() throws Exception { + // given + SocialTokenLoginRequest request = new SocialTokenLoginRequest("google-id-token", null); + given(socialLoginService.login("google", request)) + .willReturn(new SocialLoginResponse(ACCESS_TOKEN, "new-refresh-token", true)); + + // when & then + mockMvc.perform(post("/api/auth/login/google/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value(ACCESS_TOKEN)) + .andExpect(jsonPath("$.refreshToken").value("new-refresh-token")) + .andExpect(jsonPath("$.isNewUser").value(true)); + } + + @Test + void should_return_400_when_social_token_is_blank() throws Exception { + // given + SocialTokenLoginRequest request = new SocialTokenLoginRequest("", null); + + // when & then + mockMvc.perform(post("/api/auth/login/google/token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + verifyNoInteractions(socialLoginService); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/event/AppleTokenRevokeListenerTest.java b/src/test/java/com/Coming/Backend/auth/event/AppleTokenRevokeListenerTest.java new file mode 100644 index 00000000..f0fc65bd --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/event/AppleTokenRevokeListenerTest.java @@ -0,0 +1,56 @@ +package com.Coming.Backend.auth.event; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.exception.SocialProviderUnavailableException; +import com.Coming.Backend.auth.service.SocialUserService; +import com.Coming.Backend.auth.social.AppleAuthClient; +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; + +@ExtendWith(MockitoExtension.class) +class AppleTokenRevokeListenerTest { + + @InjectMocks + private AppleTokenRevokeListener appleTokenRevokeListener; + + @Mock + private AppleAuthClient appleAuthClient; + + @Mock + private SocialUserService socialUserService; + + private static final Long USER_ID = 1L; + private static final String APPLE_REFRESH_TOKEN = "apple-refresh-token"; + + @Test + void should_clear_apple_refresh_token_when_revoke_succeeds() { + // given + AppleTokenRevokeEvent event = new AppleTokenRevokeEvent(USER_ID, APPLE_REFRESH_TOKEN); + + // when + appleTokenRevokeListener.onAppleTokenRevoke(event); + + // then + verify(appleAuthClient).revoke(APPLE_REFRESH_TOKEN); + verify(socialUserService).clearAppleRefreshToken(USER_ID); + } + + @Test + void should_keep_token_and_swallow_exception_when_revoke_fails() { + // given + AppleTokenRevokeEvent event = new AppleTokenRevokeEvent(USER_ID, APPLE_REFRESH_TOKEN); + willThrow(new SocialProviderUnavailableException()).given(appleAuthClient).revoke(APPLE_REFRESH_TOKEN); + + // when & then + assertThatCode(() -> appleTokenRevokeListener.onAppleTokenRevoke(event)).doesNotThrowAnyException(); + verify(socialUserService, never()).clearAppleRefreshToken(anyLong()); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserServiceTest.java b/src/test/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserServiceTest.java index d72fec19..a37994b0 100644 --- a/src/test/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/oauth2/CustomOAuth2UserServiceTest.java @@ -3,303 +3,144 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import com.Coming.Backend.auth.entity.User; import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; -import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.exception.UserSuspendedException; +import com.Coming.Backend.auth.service.SocialUserService; +import com.Coming.Backend.auth.service.SocialUserService.SocialUser; import com.Coming.Backend.common.exception.ErrorCode; -import java.util.HashMap; import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.user.OAuth2User; +/** + * loadUser는 super.loadUser가 제공자 서버에 HTTP 요청을 보내므로, 그 이후 로직인 toCustomOAuth2User를 직접 검증한다. + */ @ExtendWith(MockitoExtension.class) class CustomOAuth2UserServiceTest { - @Mock - private UserRepository userRepository; - - /** - * super.loadUser(userRequest)가 실제 OAuth2 Provider 서버에 HTTP 요청을 보내므로, - * 테스트용 서브클래스로 해당 호출을 가로채고 미리 준비된 attributes를 반환한다. - */ - private static class TestableCustomOAuth2UserService extends CustomOAuth2UserService { - - private final UserRepository repo; - private final Map stubbedAttributes; - - TestableCustomOAuth2UserService(UserRepository userRepository, - Map stubbedAttributes) { - super(userRepository); - this.repo = userRepository; - this.stubbedAttributes = stubbedAttributes; - } - - private record UserHolder(User user, boolean isNewUser) {} - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) { - String provider = userRequest.getClientRegistration().getRegistrationId(); - OAuth2UserInfo userInfo = resolveUserInfoForTest(provider, stubbedAttributes); - UserHolder holder = findOrCreateUserForTest(provider, userInfo); - return new CustomOAuth2User(holder.user(), stubbedAttributes, holder.isNewUser()); - } - - private OAuth2UserInfo resolveUserInfoForTest(String provider, - Map attributes) { - return switch (provider) { - case "google" -> new GoogleOAuth2UserInfo(attributes); - case "kakao" -> new KakaoOAuth2UserInfo(attributes); - default -> throw new OAuth2AuthenticationException("Unsupported provider: " + provider); - }; - } - - private UserHolder findOrCreateUserForTest(String provider, OAuth2UserInfo userInfo) { - Optional existing = repo.findByProviderAndProviderId(provider, userInfo.getProviderId()); - if (existing.isPresent()) { - User user = existing.get(); - if (user.getStatus() == UserStatus.SUSPENDED) { - throw new OAuth2AuthenticationException( - new OAuth2Error(ErrorCode.USER_SUSPENDED.name()), "정지된 계정입니다."); - } - if (user.getStatus() == UserStatus.INACTIVE) { - user.reactivate(); - return new UserHolder(user, true); - } - return new UserHolder(user, false); - } - try { - return new UserHolder(repo.save( - User.builder() - .provider(provider) - .providerId(userInfo.getProviderId()) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .build() - ), true); - } catch (DataIntegrityViolationException e) { - User user = repo.findByProviderAndProviderId(provider, userInfo.getProviderId()) - .orElseThrow(() -> e); - return new UserHolder(user, false); - } - } - } + @InjectMocks + private CustomOAuth2UserService customOAuth2UserService; - private static class UnsupportedProviderService extends CustomOAuth2UserService { + @Mock + private SocialUserService socialUserService; - UnsupportedProviderService(UserRepository userRepository) { - super(userRepository); - } + private static final Map GOOGLE_ATTRIBUTES = Map.of( + "sub", "google-provider-id-001", + "email", "iu@gmail.com"); - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) { - String provider = userRequest.getClientRegistration().getRegistrationId(); - throw new OAuth2AuthenticationException("Unsupported provider: " + provider); - } - } + private static final Map KAKAO_ATTRIBUTES = Map.of( + "id", 99999L, + "kakao_account", Map.of("email", "iu@kakao.com")); - private OAuth2UserRequest buildUserRequest(String registrationId) { - ClientRegistration clientRegistration = ClientRegistration - .withRegistrationId(registrationId) - .clientId("client-id") - .clientSecret("client-secret") - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .redirectUri("http://localhost/login/oauth2/code/" + registrationId) - .authorizationUri("https://example.com/oauth/authorize") - .tokenUri("https://example.com/oauth/token") - .userInfoUri("https://example.com/userinfo") - .userNameAttributeName("sub") + private User buildUser(String provider, String providerId, UserRole role) { + return User.builder() + .provider(provider) + .providerId(providerId) + .role(role) + .status(UserStatus.ACTIVE) .build(); - OAuth2AccessToken accessToken = new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, - "dummy-access-token", - null, - null - ); - return new OAuth2UserRequest(clientRegistration, accessToken); - } - - private Map buildGoogleAttributes() { - Map attributes = new HashMap<>(); - attributes.put("sub", "google-provider-id-001"); - attributes.put("name", "IU"); - return attributes; - } - - private Map buildKakaoAttributes() { - Map profileDetail = new HashMap<>(); - profileDetail.put("nickname", "IU"); - - Map kakaoAccount = new HashMap<>(); - kakaoAccount.put("profile", profileDetail); - - Map attributes = new HashMap<>(); - attributes.put("id", 99999L); - attributes.put("kakao_account", kakaoAccount); - return attributes; - } - - private TestableCustomOAuth2UserService googleService; - private TestableCustomOAuth2UserService kakaoService; - - @BeforeEach - void setUp() { - googleService = new TestableCustomOAuth2UserService(userRepository, buildGoogleAttributes()); - kakaoService = new TestableCustomOAuth2UserService(userRepository, buildKakaoAttributes()); } @Test - void should_return_existing_user_without_save_when_google_user_already_exists() { + void should_return_custom_oauth2_user_with_result_of_social_user_service_when_existing_user_logs_in() { // given - User existingUser = User.builder() - .provider("google") - .providerId("google-provider-id-001") - .role(UserRole.USER) - .status(UserStatus.ACTIVE) - .build(); - - given(userRepository.findByProviderAndProviderId("google", "google-provider-id-001")) - .willReturn(Optional.of(existingUser)); + User user = buildUser("google", "google-provider-id-001", UserRole.USER); + given(socialUserService.findOrCreate(eq("google"), any(OAuth2UserInfo.class))) + .willReturn(new SocialUser(user, false)); // when - OAuth2User result = googleService.loadUser(buildUserRequest("google")); + CustomOAuth2User result = customOAuth2UserService.toCustomOAuth2User("google", GOOGLE_ATTRIBUTES); // then - assertThat(result).isInstanceOf(CustomOAuth2User.class); - CustomOAuth2User customUser = (CustomOAuth2User) result; - assertThat(customUser.getUser()).isEqualTo(existingUser); - assertThat(customUser.isNewUser()).isFalse(); - verify(userRepository, never()).save(existingUser); + assertThat(result.getUser()).isSameAs(user); + assertThat(result.isNewUser()).isFalse(); + assertThat(result.getAttributes()).isEqualTo(GOOGLE_ATTRIBUTES); } @Test - void should_save_pending_user_and_return_isNewUser_true_when_kakao_user_not_found() { + void should_return_isNewUser_true_when_social_user_service_returns_new_user() { // given - User savedUser = User.builder() - .provider("kakao") - .providerId("99999") - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .build(); - - given(userRepository.findByProviderAndProviderId("kakao", "99999")) - .willReturn(Optional.empty()); - given(userRepository.save(org.mockito.ArgumentMatchers.any(User.class))) - .willReturn(savedUser); + User user = buildUser("kakao", "99999", UserRole.PENDING); + given(socialUserService.findOrCreate(eq("kakao"), any(OAuth2UserInfo.class))) + .willReturn(new SocialUser(user, true)); // when - OAuth2User result = kakaoService.loadUser(buildUserRequest("kakao")); + CustomOAuth2User result = customOAuth2UserService.toCustomOAuth2User("kakao", KAKAO_ATTRIBUTES); // then - assertThat(result).isInstanceOf(CustomOAuth2User.class); - CustomOAuth2User customUser = (CustomOAuth2User) result; - assertThat(customUser.getUser().getRole()).isEqualTo(UserRole.PENDING); - assertThat(customUser.isNewUser()).isTrue(); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); - verify(userRepository).save(userCaptor.capture()); - assertThat(userCaptor.getValue().getProvider()).isEqualTo("kakao"); - assertThat(userCaptor.getValue().getProviderId()).isEqualTo("99999"); - assertThat(userCaptor.getValue().getNickname()).isNull(); + assertThat(result.getUser()).isSameAs(user); + assertThat(result.isNewUser()).isTrue(); } @Test - void should_return_existing_user_when_save_fails_with_concurrent_duplicate_key() { + void should_pass_google_provider_id_and_email_to_social_user_service_when_google_attributes_given() { // given - User existingUser = User.builder() - .provider("kakao") - .providerId("99999") - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .build(); - - given(userRepository.findByProviderAndProviderId("kakao", "99999")) - .willReturn(Optional.empty(), Optional.of(existingUser)); - given(userRepository.save(any(User.class))) - .willThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint")); + User user = buildUser("google", "google-provider-id-001", UserRole.USER); + given(socialUserService.findOrCreate(eq("google"), any(OAuth2UserInfo.class))) + .willReturn(new SocialUser(user, false)); // when - OAuth2User result = kakaoService.loadUser(buildUserRequest("kakao")); + customOAuth2UserService.toCustomOAuth2User("google", GOOGLE_ATTRIBUTES); // then - assertThat(result).isInstanceOf(CustomOAuth2User.class); - CustomOAuth2User customUser = (CustomOAuth2User) result; - assertThat(customUser.getUser()).isEqualTo(existingUser); - assertThat(customUser.isNewUser()).isFalse(); + ArgumentCaptor captor = ArgumentCaptor.forClass(OAuth2UserInfo.class); + verify(socialUserService).findOrCreate(eq("google"), captor.capture()); + assertThat(captor.getValue()).isInstanceOf(GoogleOAuth2UserInfo.class); + assertThat(captor.getValue().getProviderId()).isEqualTo("google-provider-id-001"); + assertThat(captor.getValue().getEmail()).isEqualTo("iu@gmail.com"); } @Test - void should_reactivate_and_return_isNewUser_true_when_inactive_user_logs_in() { + void should_pass_kakao_provider_id_and_email_to_social_user_service_when_kakao_attributes_given() { // given - User inactiveUser = User.builder() - .provider("google") - .providerId("google-provider-id-001") - .role(UserRole.USER) - .status(UserStatus.INACTIVE) - .nickname("IU") - .birthYear(1993) - .build(); - - given(userRepository.findByProviderAndProviderId("google", "google-provider-id-001")) - .willReturn(Optional.of(inactiveUser)); + User user = buildUser("kakao", "99999", UserRole.PENDING); + given(socialUserService.findOrCreate(eq("kakao"), any(OAuth2UserInfo.class))) + .willReturn(new SocialUser(user, true)); // when - OAuth2User result = googleService.loadUser(buildUserRequest("google")); + customOAuth2UserService.toCustomOAuth2User("kakao", KAKAO_ATTRIBUTES); // then - CustomOAuth2User customUser = (CustomOAuth2User) result; - assertThat(customUser.isNewUser()).isTrue(); - assertThat(customUser.getUser().getStatus()).isEqualTo(UserStatus.ACTIVE); - assertThat(customUser.getUser().getRole()).isEqualTo(UserRole.PENDING); - assertThat(customUser.getUser().getNickname()).isNull(); - assertThat(customUser.getUser().getBirthYear()).isNull(); - verify(userRepository, never()).save(any(User.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(OAuth2UserInfo.class); + verify(socialUserService).findOrCreate(eq("kakao"), captor.capture()); + assertThat(captor.getValue()).isInstanceOf(KakaoOAuth2UserInfo.class); + assertThat(captor.getValue().getProviderId()).isEqualTo("99999"); + assertThat(captor.getValue().getEmail()).isEqualTo("iu@kakao.com"); } @Test - void should_throw_OAuth2AuthenticationException_when_suspended_user_logs_in() { + void should_throw_OAuth2AuthenticationException_with_USER_SUSPENDED_when_suspended_user_logs_in() { // given - User suspendedUser = User.builder() - .provider("google") - .providerId("google-provider-id-001") - .role(UserRole.USER) - .status(UserStatus.SUSPENDED) - .build(); - - given(userRepository.findByProviderAndProviderId("google", "google-provider-id-001")) - .willReturn(Optional.of(suspendedUser)); + given(socialUserService.findOrCreate(eq("google"), any(OAuth2UserInfo.class))) + .willThrow(new UserSuspendedException()); // when & then - assertThatThrownBy(() -> googleService.loadUser(buildUserRequest("google"))) + assertThatThrownBy(() -> customOAuth2UserService.toCustomOAuth2User("google", GOOGLE_ATTRIBUTES)) .isInstanceOf(OAuth2AuthenticationException.class) .extracting(e -> ((OAuth2AuthenticationException) e).getError().getErrorCode()) .isEqualTo(ErrorCode.USER_SUSPENDED.name()); } @Test - void should_throw_OAuth2AuthenticationException_when_unsupported_provider_given() { + void should_throw_OAuth2AuthenticationException_without_calling_social_user_service_when_unsupported_provider_given() { // given - UnsupportedProviderService service = new UnsupportedProviderService(userRepository); + Map naverAttributes = Map.of("id", "naver-1"); // when & then - assertThatThrownBy(() -> service.loadUser(buildUserRequest("naver"))) + assertThatThrownBy(() -> customOAuth2UserService.toCustomOAuth2User("naver", naverAttributes)) .isInstanceOf(OAuth2AuthenticationException.class); + verifyNoInteractions(socialUserService); } } diff --git a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java index b6bf90fa..6b6da8d3 100644 --- a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java +++ b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java @@ -7,9 +7,7 @@ import com.Coming.Backend.auth.entity.User; import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; -import com.Coming.Backend.auth.jwt.JwtProvider; -import com.Coming.Backend.auth.jwt.RefreshTokenClaims; -import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.service.AuthService; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,10 +27,7 @@ class OAuth2SuccessHandlerTest { private OAuth2SuccessHandler oAuth2SuccessHandler; @Mock - private JwtProvider jwtProvider; - - @Mock - private TokenRepository tokenRepository; + private AuthService authService; @Mock private Authentication authentication; @@ -62,9 +57,7 @@ void should_redirect_with_isNewUser_false_when_existing_user_logs_in() throws Ex // given CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); - given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); - given(jwtProvider.parseRefreshToken("refresh-token-value")) - .willReturn(new RefreshTokenClaims(1L, "session-1")); + given(authService.createSession(1L)).willReturn("refresh-token-value"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -81,9 +74,7 @@ void should_redirect_with_isNewUser_true_when_new_user_logs_in() throws Exceptio // given CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.PENDING), Map.of(), true); given(authentication.getPrincipal()).willReturn(oAuth2User); - given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); - given(jwtProvider.parseRefreshToken("refresh-token-value")) - .willReturn(new RefreshTokenClaims(1L, "session-1")); + given(authService.createSession(1L)).willReturn("refresh-token-value"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -100,9 +91,7 @@ void should_redirect_with_isNewUser_true_when_returning_pending_user_logs_in() t // given — isNewUser 플래그가 false여도 role이 PENDING이면 isNewUser=true CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.PENDING), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); - given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); - given(jwtProvider.parseRefreshToken("refresh-token-value")) - .willReturn(new RefreshTokenClaims(1L, "session-1")); + given(authService.createSession(1L)).willReturn("refresh-token-value"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -119,9 +108,7 @@ void should_set_httpOnly_refresh_token_cookie_when_authentication_success() thro // given CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); - given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); - given(jwtProvider.parseRefreshToken("refresh-token-value")) - .willReturn(new RefreshTokenClaims(1L, "session-1")); + given(authService.createSession(1L)).willReturn("refresh-token-value"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -137,13 +124,11 @@ void should_set_httpOnly_refresh_token_cookie_when_authentication_success() thro } @Test - void should_saveNewSession_when_authentication_success() throws Exception { + void should_create_session_when_authentication_success() throws Exception { // given CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); - given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); - given(jwtProvider.parseRefreshToken("refresh-token-value")) - .willReturn(new RefreshTokenClaims(1L, "session-1")); + given(authService.createSession(1L)).willReturn("refresh-token-value"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -152,6 +137,6 @@ void should_saveNewSession_when_authentication_success() throws Exception { oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); // then - verify(tokenRepository).saveSession(1L, "session-1", "refresh-token-value", REFRESH_TOKEN_EXPIRY); + verify(authService).createSession(1L); } } diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 708b277b..63f02f71 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -34,6 +35,7 @@ import com.Coming.Backend.auth.repository.TokenRepository; import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.event.AppleTokenRevokeEvent; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; @@ -45,12 +47,14 @@ import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; import java.time.LocalDate; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class AuthServiceTest { @@ -85,6 +89,9 @@ class AuthServiceTest { @Mock private UserPolicyAgreementRepository userPolicyAgreementRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + private static final Long USER_ID = 1L; private static final String REFRESH_TOKEN = "valid-refresh-token"; private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; @@ -126,6 +133,66 @@ private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { .build(); } + // ------------------------------------------------------------------------- + // createSession + // ------------------------------------------------------------------------- + + @Test + void should_save_session_with_same_session_id_as_refresh_token_when_create_session() { + // given + given(jwtProvider.generateRefreshToken(eq(USER_ID), anyString())).willReturn(REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + + // when + String result = authService.createSession(USER_ID); + + // then + assertThat(result).isEqualTo(REFRESH_TOKEN); + ArgumentCaptor tokenSessionId = ArgumentCaptor.forClass(String.class); + ArgumentCaptor savedSessionId = ArgumentCaptor.forClass(String.class); + verify(jwtProvider).generateRefreshToken(eq(USER_ID), tokenSessionId.capture()); + verify(tokenRepository).saveSession(eq(USER_ID), savedSessionId.capture(), eq(REFRESH_TOKEN), eq(REFRESH_EXPIRY)); + assertThat(savedSessionId.getValue()).isEqualTo(tokenSessionId.getValue()); + assertThat(UUID.fromString(savedSessionId.getValue())).isNotNull(); + } + + @Test + void should_issue_new_session_id_for_each_create_session() { + // given + given(jwtProvider.generateRefreshToken(eq(USER_ID), anyString())).willReturn(REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + + // when + authService.createSession(USER_ID); + authService.createSession(USER_ID); + + // then + ArgumentCaptor sessionIds = ArgumentCaptor.forClass(String.class); + verify(jwtProvider, times(2)).generateRefreshToken(eq(USER_ID), sessionIds.capture()); + assertThat(sessionIds.getAllValues()).doesNotHaveDuplicates(); + } + + // ------------------------------------------------------------------------- + // issueTokens + // ------------------------------------------------------------------------- + + @Test + void should_return_access_token_with_role_and_new_session_refresh_token_when_issue_tokens() { + // given + User user = buildPendingUser(); + given(jwtProvider.generateAccessToken(USER_ID, "PENDING")).willReturn(NEW_ACCESS_TOKEN); + given(jwtProvider.generateRefreshToken(eq(USER_ID), anyString())).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + + // when + TokenPair result = authService.issueTokens(user); + + // then + assertThat(result.accessToken()).isEqualTo(NEW_ACCESS_TOKEN); + assertThat(result.refreshToken()).isEqualTo(NEW_REFRESH_TOKEN); + verify(tokenRepository).saveSession(eq(USER_ID), anyString(), eq(NEW_REFRESH_TOKEN), eq(REFRESH_EXPIRY)); + } + // ------------------------------------------------------------------------- // refreshToken // ------------------------------------------------------------------------- @@ -333,6 +400,50 @@ void should_call_withdraw_and_invalidate_all_sessions_when_withdraw() { verify(tokenRepository).deleteAllSessions(USER_ID); } + private User buildAppleUser() { + return User.builder() + .id(USER_ID) + .nickname("테스터") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .provider("apple") + .providerId("001234.abcdef0123456789.0123") + .appleRefreshToken("apple-refresh-token") + .build(); + } + + @Test + void should_publish_apple_token_revoke_event_and_keep_token_when_apple_user_withdraws() { + // given + User user = buildAppleUser(); + long remainingExpiry = 900_000L; + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); + + // when + authService.withdraw(ACCESS_TOKEN, USER_ID); + + // then — revoke는 커밋 후 리스너가 처리하므로 토큰은 아직 지우지 않는다 + verify(eventPublisher).publishEvent(new AppleTokenRevokeEvent(USER_ID, "apple-refresh-token")); + assertThat(user.getAppleRefreshToken()).isEqualTo("apple-refresh-token"); + assertThat(user.getStatus()).isEqualTo(UserStatus.INACTIVE); + verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); + verify(tokenRepository).deleteAllSessions(USER_ID); + } + + @Test + void should_not_publish_apple_token_revoke_event_when_user_has_no_apple_refresh_token() { + // given + User user = buildUser(); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + // when + authService.withdraw(ACCESS_TOKEN, USER_ID); + + // then + verify(eventPublisher, never()).publishEvent(any(AppleTokenRevokeEvent.class)); + } + // ------------------------------------------------------------------------- // getMe // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/auth/service/SocialLoginServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/SocialLoginServiceTest.java new file mode 100644 index 00000000..b7e3313d --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/service/SocialLoginServiceTest.java @@ -0,0 +1,308 @@ +package com.Coming.Backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.Coming.Backend.auth.dto.LoginNonceResponse; +import com.Coming.Backend.auth.dto.SocialLoginResponse; +import com.Coming.Backend.auth.dto.SocialTokenLoginRequest; +import com.Coming.Backend.auth.dto.TokenPair; +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.exception.SocialTokenInvalidException; +import com.Coming.Backend.auth.exception.UserSuspendedException; +import com.Coming.Backend.auth.oauth2.GoogleOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.LoginNonceRepository; +import com.Coming.Backend.auth.service.SocialUserService.SocialUser; +import com.Coming.Backend.auth.social.AppleAuthClient; +import com.Coming.Backend.auth.social.AppleAuthClient.AppleTokens; +import com.Coming.Backend.auth.social.AppleIdentityTokenVerifier; +import com.Coming.Backend.auth.social.AppleUserInfo; +import com.Coming.Backend.auth.social.SocialTokenVerifier; +import com.Coming.Backend.common.exception.InvalidInputException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SocialLoginServiceTest { + + @Mock + private SocialTokenVerifier googleVerifier; + + @Mock + private AppleIdentityTokenVerifier appleVerifier; + + @Mock + private SocialUserService socialUserService; + + @Mock + private AuthService authService; + + @Mock + private AppleAuthClient appleAuthClient; + + @Mock + private LoginNonceRepository loginNonceRepository; + + private SocialLoginService socialLoginService; + + private static final Long USER_ID = 1L; + private static final String GOOGLE_ID_TOKEN = "google-id-token"; + private static final String APPLE_IDENTITY_TOKEN = "apple-identity-token"; + private static final String AUTHORIZATION_CODE = "apple-authorization-code"; + private static final String APPLE_REFRESH_TOKEN = "apple-refresh-token"; + private static final String APPLE_CODE_ID_TOKEN = "apple-code-id-token"; + private static final String APPLE_SUB = "001234.abcdef0123456789.0123"; + private static final String ACCESS_TOKEN = "access-token"; + private static final String REFRESH_TOKEN = "refresh-token"; + + private final OAuth2UserInfo googleUserInfo = + new GoogleOAuth2UserInfo(Map.of("sub", "google-provider-id-001", "email", "iu@gmail.com")); + private final OAuth2UserInfo appleUserInfo = new AppleUserInfo(Map.of("sub", APPLE_SUB)); + + @BeforeEach + void setUp() { + given(googleVerifier.provider()).willReturn("google"); + given(appleVerifier.provider()).willReturn("apple"); + socialLoginService = new SocialLoginService(List.of(googleVerifier, appleVerifier), + socialUserService, authService, appleAuthClient, appleVerifier, loginNonceRepository); + } + + private User buildUser(String provider, UserRole role) { + return User.builder() + .id(USER_ID) + .provider(provider) + .providerId(provider + "-provider-id") + .role(role) + .status(UserStatus.ACTIVE) + .build(); + } + + private void givenTokensIssued(User user) { + given(authService.issueTokens(user)).willReturn(new TokenPair(ACCESS_TOKEN, REFRESH_TOKEN)); + } + + // ------------------------------------------------------------------------- + // issueNonce + // ------------------------------------------------------------------------- + + @Test + void should_save_43_char_base64url_nonce_for_5_minutes_when_issue_nonce() { + // when + LoginNonceResponse response = socialLoginService.issueNonce(); + + // then + assertThat(response.nonce()).hasSize(43).matches("[A-Za-z0-9_-]+"); + verify(loginNonceRepository).save(response.nonce(), Duration.ofMinutes(5)); + } + + @Test + void should_issue_different_nonce_each_time() { + // when + String first = socialLoginService.issueNonce().nonce(); + String second = socialLoginService.issueNonce().nonce(); + + // then + assertThat(first).isNotEqualTo(second); + } + + // ------------------------------------------------------------------------- + // google + // ------------------------------------------------------------------------- + + @Test + void should_issue_tokens_with_isNewUser_true_when_google_login_for_pending_user() { + // given + given(googleVerifier.verify(GOOGLE_ID_TOKEN)).willReturn(googleUserInfo); + User user = buildUser("google", UserRole.PENDING); + given(socialUserService.findOrCreate("google", googleUserInfo)).willReturn(new SocialUser(user, true)); + givenTokensIssued(user); + + // when + SocialLoginResponse response = + socialLoginService.login("google", new SocialTokenLoginRequest(GOOGLE_ID_TOKEN, null)); + + // then + assertThat(response.accessToken()).isEqualTo(ACCESS_TOKEN); + assertThat(response.refreshToken()).isEqualTo(REFRESH_TOKEN); + assertThat(response.isNewUser()).isTrue(); + verify(authService).issueTokens(user); + } + + @Test + void should_return_isNewUser_false_when_google_login_for_registered_user() { + // given + given(googleVerifier.verify(GOOGLE_ID_TOKEN)).willReturn(googleUserInfo); + User user = buildUser("google", UserRole.USER); + given(socialUserService.findOrCreate("google", googleUserInfo)).willReturn(new SocialUser(user, false)); + givenTokensIssued(user); + + // when + SocialLoginResponse response = + socialLoginService.login("google", new SocialTokenLoginRequest(GOOGLE_ID_TOKEN, null)); + + // then + assertThat(response.isNewUser()).isFalse(); + } + + @Test + void should_not_interact_with_apple_auth_client_when_google_login() { + // given + given(googleVerifier.verify(GOOGLE_ID_TOKEN)).willReturn(googleUserInfo); + User user = buildUser("google", UserRole.USER); + given(socialUserService.findOrCreate("google", googleUserInfo)).willReturn(new SocialUser(user, false)); + givenTokensIssued(user); + + // when + socialLoginService.login("google", new SocialTokenLoginRequest(GOOGLE_ID_TOKEN, AUTHORIZATION_CODE)); + + // then + verifyNoInteractions(appleAuthClient); + verify(appleVerifier, never()).extractSubject(anyString()); + verify(socialUserService, never()).findOrCreateAppleUser(any(), any()); + } + + // ------------------------------------------------------------------------- + // apple + // ------------------------------------------------------------------------- + + @Test + void should_find_or_create_apple_user_with_refresh_token_when_code_subject_matches() { + // given + given(appleVerifier.verify(APPLE_IDENTITY_TOKEN)).willReturn(appleUserInfo); + given(appleAuthClient.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .willReturn(new AppleTokens(APPLE_REFRESH_TOKEN, APPLE_CODE_ID_TOKEN)); + given(appleVerifier.extractSubject(APPLE_CODE_ID_TOKEN)).willReturn(APPLE_SUB); + User user = buildUser("apple", UserRole.PENDING); + given(socialUserService.findOrCreateAppleUser(appleUserInfo, APPLE_REFRESH_TOKEN)) + .willReturn(new SocialUser(user, true)); + givenTokensIssued(user); + + // when + SocialLoginResponse response = socialLoginService.login("apple", + new SocialTokenLoginRequest(APPLE_IDENTITY_TOKEN, AUTHORIZATION_CODE)); + + // then + assertThat(response.accessToken()).isEqualTo(ACCESS_TOKEN); + assertThat(response.refreshToken()).isEqualTo(REFRESH_TOKEN); + assertThat(response.isNewUser()).isTrue(); + verify(socialUserService).findOrCreateAppleUser(appleUserInfo, APPLE_REFRESH_TOKEN); + verify(socialUserService, never()).findOrCreate(anyString(), any()); + } + + @Test + void should_throw_SocialTokenInvalidException_and_not_create_session_when_code_subject_differs() { + // given — 다른 Apple 사용자의 authorizationCode를 끼워 넣은 경우 + given(appleVerifier.verify(APPLE_IDENTITY_TOKEN)).willReturn(appleUserInfo); + given(appleAuthClient.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .willReturn(new AppleTokens(APPLE_REFRESH_TOKEN, APPLE_CODE_ID_TOKEN)); + given(appleVerifier.extractSubject(APPLE_CODE_ID_TOKEN)).willReturn("009999.other-apple-user.0001"); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("apple", + new SocialTokenLoginRequest(APPLE_IDENTITY_TOKEN, AUTHORIZATION_CODE))) + .isInstanceOf(SocialTokenInvalidException.class); + verify(socialUserService, never()).findOrCreateAppleUser(any(), any()); + verify(authService, never()).issueTokens(any()); + } + + @Test + void should_throw_SocialTokenInvalidException_when_code_id_token_has_no_subject() { + // given + given(appleVerifier.verify(APPLE_IDENTITY_TOKEN)).willReturn(appleUserInfo); + given(appleAuthClient.exchangeAuthorizationCode(AUTHORIZATION_CODE)) + .willReturn(new AppleTokens(APPLE_REFRESH_TOKEN, APPLE_CODE_ID_TOKEN)); + given(appleVerifier.extractSubject(APPLE_CODE_ID_TOKEN)).willReturn(null); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("apple", + new SocialTokenLoginRequest(APPLE_IDENTITY_TOKEN, AUTHORIZATION_CODE))) + .isInstanceOf(SocialTokenInvalidException.class); + verify(socialUserService, never()).findOrCreateAppleUser(any(), any()); + verify(authService, never()).issueTokens(any()); + } + + // ------------------------------------------------------------------------- + // 입력 검증 + // ------------------------------------------------------------------------- + + @Test + void should_throw_InvalidInputException_when_provider_is_not_supported() { + // given + SocialTokenLoginRequest request = new SocialTokenLoginRequest("naver-token", null); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("naver", request)) + .isInstanceOf(InvalidInputException.class); + verify(googleVerifier, never()).verify(anyString()); + verify(appleVerifier, never()).verify(anyString()); + } + + @Test + void should_throw_InvalidInputException_when_apple_login_without_authorization_code() { + // given + SocialTokenLoginRequest request = new SocialTokenLoginRequest(APPLE_IDENTITY_TOKEN, null); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("apple", request)) + .isInstanceOf(InvalidInputException.class); + verify(appleVerifier, never()).verify(anyString()); + verifyNoInteractions(appleAuthClient); + } + + @Test + void should_throw_InvalidInputException_when_apple_login_with_blank_authorization_code() { + // given + SocialTokenLoginRequest request = new SocialTokenLoginRequest(APPLE_IDENTITY_TOKEN, ""); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("apple", request)) + .isInstanceOf(InvalidInputException.class); + verify(appleVerifier, never()).verify(anyString()); + verifyNoInteractions(appleAuthClient); + } + + // ------------------------------------------------------------------------- + // 예외 전파 + // ------------------------------------------------------------------------- + + @Test + void should_propagate_SocialTokenInvalidException_and_not_create_session_when_token_verification_fails() { + // given + given(googleVerifier.verify(GOOGLE_ID_TOKEN)).willThrow(new SocialTokenInvalidException()); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("google", + new SocialTokenLoginRequest(GOOGLE_ID_TOKEN, null))) + .isInstanceOf(SocialTokenInvalidException.class); + verify(socialUserService, never()).findOrCreate(anyString(), any()); + verify(authService, never()).issueTokens(any()); + } + + @Test + void should_propagate_UserSuspendedException_and_not_create_session_when_user_is_suspended() { + // given + given(googleVerifier.verify(GOOGLE_ID_TOKEN)).willReturn(googleUserInfo); + given(socialUserService.findOrCreate("google", googleUserInfo)).willThrow(new UserSuspendedException()); + + // when & then + assertThatThrownBy(() -> socialLoginService.login("google", + new SocialTokenLoginRequest(GOOGLE_ID_TOKEN, null))) + .isInstanceOf(UserSuspendedException.class); + verify(authService, never()).issueTokens(any()); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java new file mode 100644 index 00000000..87d241b7 --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java @@ -0,0 +1,321 @@ +package com.Coming.Backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.exception.UserSuspendedException; +import com.Coming.Backend.auth.oauth2.GoogleOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.KakaoOAuth2UserInfo; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.service.SocialUserService.SocialUser; +import com.Coming.Backend.auth.social.AppleUserInfo; +import com.Coming.Backend.common.exception.ErrorCode; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class SocialUserServiceTest { + + @InjectMocks + private SocialUserService socialUserService; + + @Mock + private UserRepository userRepository; + + private static final String GOOGLE = "google"; + private static final String GOOGLE_PROVIDER_ID = "google-provider-id-001"; + private static final String GOOGLE_EMAIL = "iu@gmail.com"; + private static final String KAKAO = "kakao"; + private static final String KAKAO_PROVIDER_ID = "99999"; + private static final String KAKAO_EMAIL = "iu@kakao.com"; + + private OAuth2UserInfo googleUserInfo() { + return new GoogleOAuth2UserInfo(Map.of("sub", GOOGLE_PROVIDER_ID, "email", GOOGLE_EMAIL)); + } + + private OAuth2UserInfo kakaoUserInfo() { + return new KakaoOAuth2UserInfo(Map.of( + "id", 99999L, + "kakao_account", Map.of("email", KAKAO_EMAIL))); + } + + private User buildGoogleUser(UserStatus status) { + return User.builder() + .email("old@gmail.com") + .provider(GOOGLE) + .providerId(GOOGLE_PROVIDER_ID) + .role(UserRole.USER) + .status(status) + .nickname("IU") + .birthYear(1993) + .build(); + } + + @Test + void should_return_existing_user_with_updated_email_and_isNewUser_false_when_active_user_exists() { + // given + User existingUser = buildGoogleUser(UserStatus.ACTIVE); + given(userRepository.findByProviderAndProviderId(GOOGLE, GOOGLE_PROVIDER_ID)) + .willReturn(Optional.of(existingUser)); + + // when + SocialUser result = socialUserService.findOrCreate(GOOGLE, googleUserInfo()); + + // then + assertThat(result.user()).isSameAs(existingUser); + assertThat(result.isNewUser()).isFalse(); + assertThat(existingUser.getEmail()).isEqualTo(GOOGLE_EMAIL); + assertThat(existingUser.getRole()).isEqualTo(UserRole.USER); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void should_reactivate_user_with_updated_email_and_isNewUser_true_when_inactive_user_exists() { + // given + User inactiveUser = buildGoogleUser(UserStatus.INACTIVE); + given(userRepository.findByProviderAndProviderId(GOOGLE, GOOGLE_PROVIDER_ID)) + .willReturn(Optional.of(inactiveUser)); + + // when + SocialUser result = socialUserService.findOrCreate(GOOGLE, googleUserInfo()); + + // then + assertThat(result.user()).isSameAs(inactiveUser); + assertThat(result.isNewUser()).isTrue(); + assertThat(inactiveUser.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(inactiveUser.getRole()).isEqualTo(UserRole.PENDING); + assertThat(inactiveUser.getNickname()).isNull(); + assertThat(inactiveUser.getBirthYear()).isNull(); + assertThat(inactiveUser.getEmail()).isEqualTo(GOOGLE_EMAIL); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void should_throw_UserSuspendedException_when_suspended_user_exists() { + // given + User suspendedUser = buildGoogleUser(UserStatus.SUSPENDED); + given(userRepository.findByProviderAndProviderId(GOOGLE, GOOGLE_PROVIDER_ID)) + .willReturn(Optional.of(suspendedUser)); + + // when & then + assertThatThrownBy(() -> socialUserService.findOrCreate(GOOGLE, googleUserInfo())) + .isInstanceOf(UserSuspendedException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } + + @Test + void should_save_pending_active_user_and_return_isNewUser_true_when_user_not_found() { + // given + User savedUser = User.builder() + .email(KAKAO_EMAIL) + .provider(KAKAO) + .providerId(KAKAO_PROVIDER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .build(); + given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willReturn(savedUser); + + // when + SocialUser result = socialUserService.findOrCreate(KAKAO, kakaoUserInfo()); + + // then + assertThat(result.user()).isSameAs(savedUser); + assertThat(result.isNewUser()).isTrue(); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + User toSave = userCaptor.getValue(); + assertThat(toSave.getProvider()).isEqualTo(KAKAO); + assertThat(toSave.getProviderId()).isEqualTo(KAKAO_PROVIDER_ID); + assertThat(toSave.getEmail()).isEqualTo(KAKAO_EMAIL); + assertThat(toSave.getRole()).isEqualTo(UserRole.PENDING); + assertThat(toSave.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(toSave.getNickname()).isNull(); + } + + @Test + void should_return_refetched_user_and_isNewUser_false_when_save_fails_with_concurrent_duplicate_key() { + // given + User existingUser = User.builder() + .provider(KAKAO) + .providerId(KAKAO_PROVIDER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .build(); + given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) + .willReturn(Optional.empty(), Optional.of(existingUser)); + given(userRepository.save(any(User.class))) + .willThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint")); + + // when + SocialUser result = socialUserService.findOrCreate(KAKAO, kakaoUserInfo()); + + // then + assertThat(result.user()).isSameAs(existingUser); + assertThat(result.isNewUser()).isFalse(); + } + + @Test + void should_rethrow_original_exception_when_save_fails_and_user_still_not_found() { + // given + DataIntegrityViolationException original = + new DataIntegrityViolationException("not-null constraint violation"); + given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) + .willReturn(Optional.empty(), Optional.empty()); + given(userRepository.save(any(User.class))).willThrow(original); + + // when & then + assertThatThrownBy(() -> socialUserService.findOrCreate(KAKAO, kakaoUserInfo())) + .isSameAs(original); + } + + @Test + void should_keep_existing_email_when_active_user_logs_in_without_email() { + // given — 제공자가 email을 주지 않는 경우(예: Apple 재로그인) + User existingUser = buildGoogleUser(UserStatus.ACTIVE); + given(userRepository.findByProviderAndProviderId(GOOGLE, GOOGLE_PROVIDER_ID)) + .willReturn(Optional.of(existingUser)); + OAuth2UserInfo noEmailUserInfo = new GoogleOAuth2UserInfo(Map.of("sub", GOOGLE_PROVIDER_ID)); + + // when + SocialUser result = socialUserService.findOrCreate(GOOGLE, noEmailUserInfo); + + // then + assertThat(result.isNewUser()).isFalse(); + assertThat(existingUser.getEmail()).isEqualTo("old@gmail.com"); + } + + @Test + void should_keep_existing_email_when_inactive_user_rejoins_without_email() { + // given + User inactiveUser = buildGoogleUser(UserStatus.INACTIVE); + given(userRepository.findByProviderAndProviderId(GOOGLE, GOOGLE_PROVIDER_ID)) + .willReturn(Optional.of(inactiveUser)); + OAuth2UserInfo noEmailUserInfo = new GoogleOAuth2UserInfo(Map.of("sub", GOOGLE_PROVIDER_ID)); + + // when + SocialUser result = socialUserService.findOrCreate(GOOGLE, noEmailUserInfo); + + // then + assertThat(result.isNewUser()).isTrue(); + assertThat(inactiveUser.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(inactiveUser.getEmail()).isEqualTo("old@gmail.com"); + } + + // ------------------------------------------------------------------------- + // findOrCreateAppleUser + // ------------------------------------------------------------------------- + + private static final String APPLE_PROVIDER_ID = "001234.abcdef0123456789.0123"; + + private User buildAppleUser(UserStatus status) { + return User.builder() + .id(1L) + .provider("apple") + .providerId(APPLE_PROVIDER_ID) + .role(UserRole.USER) + .status(status) + .appleRefreshToken("old-apple-refresh-token") + .build(); + } + + @Test + void should_set_apple_refresh_token_on_existing_user_when_find_or_create_apple_user() { + // given + User existingUser = buildAppleUser(UserStatus.ACTIVE); + given(userRepository.findByProviderAndProviderId("apple", APPLE_PROVIDER_ID)) + .willReturn(Optional.of(existingUser)); + + // when + SocialUser result = socialUserService.findOrCreateAppleUser( + new AppleUserInfo(Map.of("sub", APPLE_PROVIDER_ID)), "new-apple-refresh-token"); + + // then + assertThat(result.user()).isSameAs(existingUser); + assertThat(result.isNewUser()).isFalse(); + assertThat(existingUser.getAppleRefreshToken()).isEqualTo("new-apple-refresh-token"); + } + + @Test + void should_create_apple_user_and_set_refresh_token_when_apple_user_not_found() { + // given + given(userRepository.findByProviderAndProviderId("apple", APPLE_PROVIDER_ID)).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + SocialUser result = socialUserService.findOrCreateAppleUser( + new AppleUserInfo(Map.of("sub", APPLE_PROVIDER_ID, "email", "abc123@privaterelay.appleid.com")), + "new-apple-refresh-token"); + + // then + assertThat(result.isNewUser()).isTrue(); + assertThat(result.user().getProvider()).isEqualTo("apple"); + assertThat(result.user().getRole()).isEqualTo(UserRole.PENDING); + assertThat(result.user().getAppleRefreshToken()).isEqualTo("new-apple-refresh-token"); + } + + @Test + void should_propagate_UserSuspendedException_and_keep_token_when_apple_user_is_suspended() { + // given + User suspendedUser = buildAppleUser(UserStatus.SUSPENDED); + given(userRepository.findByProviderAndProviderId("apple", APPLE_PROVIDER_ID)) + .willReturn(Optional.of(suspendedUser)); + + // when & then + assertThatThrownBy(() -> socialUserService.findOrCreateAppleUser( + new AppleUserInfo(Map.of("sub", APPLE_PROVIDER_ID)), "new-apple-refresh-token")) + .isInstanceOf(UserSuspendedException.class); + assertThat(suspendedUser.getAppleRefreshToken()).isEqualTo("old-apple-refresh-token"); + } + + // ------------------------------------------------------------------------- + // clearAppleRefreshToken + // ------------------------------------------------------------------------- + + @Test + void should_clear_apple_refresh_token_when_user_exists() { + // given + User user = User.builder() + .id(1L) + .provider("apple") + .providerId("001234.abcdef0123456789.0123") + .role(UserRole.USER) + .status(UserStatus.INACTIVE) + .appleRefreshToken("revoked-apple-refresh-token") + .build(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + // when + socialUserService.clearAppleRefreshToken(1L); + + // then + assertThat(user.getAppleRefreshToken()).isNull(); + } + + @Test + void should_do_nothing_when_clearing_apple_refresh_token_for_missing_user() { + // given + given(userRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatCode(() -> socialUserService.clearAppleRefreshToken(999L)).doesNotThrowAnyException(); + } +} From d700488d8f18b88482e02b860bf9288efa4c1bf0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 21 Oct 2026 01:22:44 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[fix]=20=EB=8F=99=EC=8B=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=ED=9A=8C=EC=9B=90=20INSERT=EB=A5=BC=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유니크 제약 위반이 외부 트랜잭션을 rollback-only로 만들어 재조회가 실패하던 문제 수정 - SocialUserCreator(REQUIRES_NEW + saveAndFlush)로 INSERT 분리 - uq_user_provider_provider_id 위반만 재조회로 복구하고 그 외 제약 위반은 재던짐 - 실제 PostgreSQL로 경합을 재현하는 통합 테스트 추가 Co-Authored-By: Claude Opus 5.5 --- .../auth/service/SocialUserCreator.java | 38 ++++++ .../auth/service/SocialUserService.java | 24 ++-- .../auth/service/SocialUserCreatorTest.java | 52 ++++++++ .../SocialUserServiceConcurrencyTest.java | 111 ++++++++++++++++++ .../auth/service/SocialUserServiceTest.java | 75 ++++++++---- 5 files changed, 264 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/auth/service/SocialUserCreator.java create mode 100644 src/test/java/com/Coming/Backend/auth/service/SocialUserCreatorTest.java create mode 100644 src/test/java/com/Coming/Backend/auth/service/SocialUserServiceConcurrencyTest.java diff --git a/src/main/java/com/Coming/Backend/auth/service/SocialUserCreator.java b/src/main/java/com/Coming/Backend/auth/service/SocialUserCreator.java new file mode 100644 index 00000000..f08755b4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/service/SocialUserCreator.java @@ -0,0 +1,38 @@ +package com.Coming.Backend.auth.service; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; +import com.Coming.Backend.auth.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 신규 소셜 회원 INSERT를 별도 트랜잭션으로 분리한다. + * 동시 최초 로그인에서 유니크 제약 위반이 나도 이 트랜잭션만 롤백되어, 호출 측 트랜잭션에서 기존 회원을 재조회할 수 있다. + */ +@Component +@RequiredArgsConstructor +public class SocialUserCreator { + + private final UserRepository userRepository; + + /** + * PENDING·ACTIVE 상태의 신규 회원을 저장하고 즉시 커밋한다. + * + * @return 생성된 회원의 ID + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Long create(String provider, OAuth2UserInfo userInfo) { + return userRepository.saveAndFlush(User.builder() + .email(userInfo.getEmail()) + .provider(provider) + .providerId(userInfo.getProviderId()) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .build()).getId(); + } +} diff --git a/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java b/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java index 2d2776b3..f5054f9b 100644 --- a/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java +++ b/src/main/java/com/Coming/Backend/auth/service/SocialUserService.java @@ -1,7 +1,6 @@ package com.Coming.Backend.auth.service; import com.Coming.Backend.auth.entity.User; -import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; import com.Coming.Backend.auth.exception.UserSuspendedException; import com.Coming.Backend.auth.oauth2.OAuth2UserInfo; @@ -9,6 +8,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -21,7 +21,10 @@ public class SocialUserService { private static final String APPLE = "apple"; + private static final String PROVIDER_ID_UNIQUE_CONSTRAINT = "uq_user_provider_provider_id"; + private final UserRepository userRepository; + private final SocialUserCreator socialUserCreator; public record SocialUser(User user, boolean isNewUser) {} @@ -51,16 +54,12 @@ public SocialUser findOrCreate(String provider, OAuth2UserInfo userInfo) { } log.info("신규 소셜 로그인 사용자 생성 — provider: {}, providerId: {}", provider, userInfo.getProviderId()); try { - return new SocialUser(userRepository.save( - User.builder() - .email(userInfo.getEmail()) - .provider(provider) - .providerId(userInfo.getProviderId()) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .build() - ), true); + Long userId = socialUserCreator.create(provider, userInfo); + return new SocialUser(userRepository.findById(userId).orElseThrow(), true); } catch (DataIntegrityViolationException e) { + if (!isProviderIdDuplicate(e)) { + throw e; + } log.info("동시 최초 로그인 경합 — 기존 사용자 재조회. provider: {}, providerId: {}", provider, userInfo.getProviderId()); User user = userRepository.findByProviderAndProviderId(provider, userInfo.getProviderId()) .orElseThrow(() -> e); @@ -89,6 +88,11 @@ public void clearAppleRefreshToken(Long userId) { userRepository.findById(userId).ifPresent(user -> user.updateAppleRefreshToken(null)); } + private boolean isProviderIdDuplicate(DataIntegrityViolationException e) { + return e.getCause() instanceof ConstraintViolationException cve + && PROVIDER_ID_UNIQUE_CONSTRAINT.equals(cve.getConstraintName()); + } + /** * 제공자가 email을 주지 않으면(Apple 재로그인, Kakao 이메일 동의 철회, Google 미인증 이메일) 기존 값을 유지한다. */ diff --git a/src/test/java/com/Coming/Backend/auth/service/SocialUserCreatorTest.java b/src/test/java/com/Coming/Backend/auth/service/SocialUserCreatorTest.java new file mode 100644 index 00000000..a66989cc --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/service/SocialUserCreatorTest.java @@ -0,0 +1,52 @@ +package com.Coming.Backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.oauth2.KakaoOAuth2UserInfo; +import com.Coming.Backend.auth.repository.UserRepository; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SocialUserCreatorTest { + + @InjectMocks + private SocialUserCreator socialUserCreator; + + @Mock + private UserRepository userRepository; + + @Test + void should_save_and_flush_pending_active_user_and_return_id_when_creating() { + // given + given(userRepository.saveAndFlush(any(User.class))).willReturn(User.builder().id(10L).build()); + + // when + Long userId = socialUserCreator.create("kakao", new KakaoOAuth2UserInfo(Map.of( + "id", 99999L, + "kakao_account", Map.of("email", "iu@kakao.com")))); + + // then + assertThat(userId).isEqualTo(10L); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).saveAndFlush(userCaptor.capture()); + User toSave = userCaptor.getValue(); + assertThat(toSave.getProvider()).isEqualTo("kakao"); + assertThat(toSave.getProviderId()).isEqualTo("99999"); + assertThat(toSave.getEmail()).isEqualTo("iu@kakao.com"); + assertThat(toSave.getRole()).isEqualTo(UserRole.PENDING); + assertThat(toSave.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(toSave.getNickname()).isNull(); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceConcurrencyTest.java b/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceConcurrencyTest.java new file mode 100644 index 00000000..8bb3156e --- /dev/null +++ b/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceConcurrencyTest.java @@ -0,0 +1,111 @@ +package com.Coming.Backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.oauth2.GoogleOAuth2UserInfo; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.auth.service.SocialUserService.SocialUser; +import com.Coming.Backend.auth.social.AppleUserInfo; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +/** + * 동시 최초 로그인 경합을 실제 DB로 재현한다. 첫 조회만 비어 있게 만들어 + * 이미 커밋된 회원이 있는 상태에서 INSERT가 유니크 제약에 걸리도록 한다. + */ +@SpringBootTest +class SocialUserServiceConcurrencyTest { + + @Autowired + private SocialUserService socialUserService; + + @MockitoSpyBean + private UserRepository userRepository; + + private final String providerId = "race-" + UUID.randomUUID(); + + @AfterEach + void tearDown() { + userRepository.findByProviderAndProviderId("google", providerId).ifPresent(userRepository::delete); + userRepository.findByProviderAndProviderId("apple", providerId).ifPresent(userRepository::delete); + } + + private User saveExistingUser(String provider) { + return userRepository.save(User.builder() + .email("winner@example.com") + .provider(provider) + .providerId(providerId) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .build()); + } + + /** 첫 조회는 비어 있고, 재조회부터는 실제 DB를 조회한다. */ + private void stubFirstLookupEmpty(String provider, User winner) { + doReturn(Optional.empty()) + .doAnswer(invocation -> userRepository.findById(winner.getId())) + .when(userRepository).findByProviderAndProviderId(provider, providerId); + } + + @Test + void should_return_existing_user_when_concurrent_first_login_hits_unique_constraint() { + // given + User winner = saveExistingUser("google"); + stubFirstLookupEmpty("google", winner); + + // when + SocialUser result = socialUserService.findOrCreate("google", + new GoogleOAuth2UserInfo(Map.of("sub", providerId, "email", "loser@example.com"))); + + // then + assertThat(result.user().getId()).isEqualTo(winner.getId()); + assertThat(result.isNewUser()).isFalse(); + } + + @Test + void should_save_apple_refresh_token_on_existing_user_when_concurrent_first_apple_login() { + // given + User winner = saveExistingUser("apple"); + stubFirstLookupEmpty("apple", winner); + + // when + socialUserService.findOrCreateAppleUser(new AppleUserInfo(Map.of("sub", providerId)), "apple-refresh"); + + // then + assertThat(userRepository.findById(winner.getId()).orElseThrow().getAppleRefreshToken()) + .isEqualTo("apple-refresh"); + } + + @Test + void should_persist_new_user_with_apple_refresh_token_when_apple_user_not_found() { + // when + SocialUser result = socialUserService.findOrCreateAppleUser( + new AppleUserInfo(Map.of("sub", providerId)), "apple-refresh"); + + // then + assertThat(result.isNewUser()).isTrue(); + assertThat(userRepository.findById(result.user().getId()).orElseThrow().getAppleRefreshToken()) + .isEqualTo("apple-refresh"); + } + + @Test + void should_rethrow_when_insert_violates_other_constraint() { + // given: provider_id NOT NULL 위반 + // when & then + assertThatThrownBy(() -> socialUserService.findOrCreate("google", + new GoogleOAuth2UserInfo(Map.of("email", "no-sub@example.com")))) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java index 87d241b7..e973f7d1 100644 --- a/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/SocialUserServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.Coming.Backend.auth.entity.User; @@ -19,11 +20,12 @@ import com.Coming.Backend.auth.service.SocialUserService.SocialUser; import com.Coming.Backend.auth.social.AppleUserInfo; import com.Coming.Backend.common.exception.ErrorCode; +import java.sql.SQLException; import java.util.Map; import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -38,6 +40,9 @@ class SocialUserServiceTest { @Mock private UserRepository userRepository; + @Mock + private SocialUserCreator socialUserCreator; + private static final String GOOGLE = "google"; private static final String GOOGLE_PROVIDER_ID = "google-provider-id-001"; private static final String GOOGLE_EMAIL = "iu@gmail.com"; @@ -82,7 +87,7 @@ void should_return_existing_user_with_updated_email_and_isNewUser_false_when_act assertThat(result.isNewUser()).isFalse(); assertThat(existingUser.getEmail()).isEqualTo(GOOGLE_EMAIL); assertThat(existingUser.getRole()).isEqualTo(UserRole.USER); - verify(userRepository, never()).save(any(User.class)); + verify(socialUserCreator, never()).create(any(), any()); } @Test @@ -103,7 +108,7 @@ void should_reactivate_user_with_updated_email_and_isNewUser_true_when_inactive_ assertThat(inactiveUser.getNickname()).isNull(); assertThat(inactiveUser.getBirthYear()).isNull(); assertThat(inactiveUser.getEmail()).isEqualTo(GOOGLE_EMAIL); - verify(userRepository, never()).save(any(User.class)); + verify(socialUserCreator, never()).create(any(), any()); } @Test @@ -120,39 +125,32 @@ void should_throw_UserSuspendedException_when_suspended_user_exists() { } @Test - void should_save_pending_active_user_and_return_isNewUser_true_when_user_not_found() { + void should_create_user_in_new_transaction_and_return_isNewUser_true_when_user_not_found() { // given - User savedUser = User.builder() + User createdUser = User.builder() + .id(10L) .email(KAKAO_EMAIL) .provider(KAKAO) .providerId(KAKAO_PROVIDER_ID) .role(UserRole.PENDING) .status(UserStatus.ACTIVE) .build(); + OAuth2UserInfo userInfo = kakaoUserInfo(); given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) .willReturn(Optional.empty()); - given(userRepository.save(any(User.class))).willReturn(savedUser); + given(socialUserCreator.create(KAKAO, userInfo)).willReturn(10L); + given(userRepository.findById(10L)).willReturn(Optional.of(createdUser)); // when - SocialUser result = socialUserService.findOrCreate(KAKAO, kakaoUserInfo()); + SocialUser result = socialUserService.findOrCreate(KAKAO, userInfo); // then - assertThat(result.user()).isSameAs(savedUser); + assertThat(result.user()).isSameAs(createdUser); assertThat(result.isNewUser()).isTrue(); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); - verify(userRepository).save(userCaptor.capture()); - User toSave = userCaptor.getValue(); - assertThat(toSave.getProvider()).isEqualTo(KAKAO); - assertThat(toSave.getProviderId()).isEqualTo(KAKAO_PROVIDER_ID); - assertThat(toSave.getEmail()).isEqualTo(KAKAO_EMAIL); - assertThat(toSave.getRole()).isEqualTo(UserRole.PENDING); - assertThat(toSave.getStatus()).isEqualTo(UserStatus.ACTIVE); - assertThat(toSave.getNickname()).isNull(); } @Test - void should_return_refetched_user_and_isNewUser_false_when_save_fails_with_concurrent_duplicate_key() { + void should_return_refetched_user_and_isNewUser_false_when_create_fails_with_concurrent_duplicate_key() { // given User existingUser = User.builder() .provider(KAKAO) @@ -162,8 +160,8 @@ void should_return_refetched_user_and_isNewUser_false_when_save_fails_with_concu .build(); given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) .willReturn(Optional.empty(), Optional.of(existingUser)); - given(userRepository.save(any(User.class))) - .willThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint")); + given(socialUserCreator.create(any(), any())) + .willThrow(constraintViolation("uq_user_provider_provider_id")); // when SocialUser result = socialUserService.findOrCreate(KAKAO, kakaoUserInfo()); @@ -174,19 +172,37 @@ void should_return_refetched_user_and_isNewUser_false_when_save_fails_with_concu } @Test - void should_rethrow_original_exception_when_save_fails_and_user_still_not_found() { + void should_rethrow_original_exception_when_create_fails_with_duplicate_key_and_user_still_not_found() { // given - DataIntegrityViolationException original = - new DataIntegrityViolationException("not-null constraint violation"); + DataIntegrityViolationException original = constraintViolation("uq_user_provider_provider_id"); given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) .willReturn(Optional.empty(), Optional.empty()); - given(userRepository.save(any(User.class))).willThrow(original); + given(socialUserCreator.create(any(), any())).willThrow(original); // when & then assertThatThrownBy(() -> socialUserService.findOrCreate(KAKAO, kakaoUserInfo())) .isSameAs(original); } + @Test + void should_rethrow_without_refetch_when_create_fails_with_other_constraint() { + // given + DataIntegrityViolationException original = constraintViolation("user_provider_id_not_null"); + given(userRepository.findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID)) + .willReturn(Optional.empty()); + given(socialUserCreator.create(any(), any())).willThrow(original); + + // when & then + assertThatThrownBy(() -> socialUserService.findOrCreate(KAKAO, kakaoUserInfo())) + .isSameAs(original); + verify(userRepository, times(1)).findByProviderAndProviderId(KAKAO, KAKAO_PROVIDER_ID); + } + + private DataIntegrityViolationException constraintViolation(String constraintName) { + return new DataIntegrityViolationException("could not execute statement", + new ConstraintViolationException("constraint violation", new SQLException(), constraintName)); + } + @Test void should_keep_existing_email_when_active_user_logs_in_without_email() { // given — 제공자가 email을 주지 않는 경우(예: Apple 재로그인) @@ -258,7 +274,14 @@ void should_set_apple_refresh_token_on_existing_user_when_find_or_create_apple_u void should_create_apple_user_and_set_refresh_token_when_apple_user_not_found() { // given given(userRepository.findByProviderAndProviderId("apple", APPLE_PROVIDER_ID)).willReturn(Optional.empty()); - given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(socialUserCreator.create(any(), any())).willReturn(1L); + given(userRepository.findById(1L)).willReturn(Optional.of(User.builder() + .id(1L) + .provider("apple") + .providerId(APPLE_PROVIDER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .build())); // when SocialUser result = socialUserService.findOrCreateAppleUser(