diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 38af91a62..5150524b8 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -25,6 +25,7 @@ public enum ErrorCode implements ResponseCode { AUTH_INVALID_LOGIN_TOKEN_KEY(HttpStatus.UNAUTHORIZED, 40106, "유효하지 않은 로그인 토큰 키입니다."), AUTH_BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, 40107, "블랙리스트에 등록된 토큰입니다."), AUTH_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 40108, "인증 처리 중 서버 오류가 발생했습니다."), + AUTH_APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, 40109, "유효하지 않은 Apple identity token입니다."), JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50100, "JSON 직렬화/역직렬화에 실패했습니다."), AWS_BUCKET_BASE_URL_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, 50101, "aws s3 bucket base url 설정이 누락되었습니다."), diff --git a/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java b/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java index 70c51d441..742a6b207 100644 --- a/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java +++ b/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java @@ -8,6 +8,7 @@ public enum AuthParameters { JWT_PREFIX("Bearer "), KAKAO("kakao"), GOOGLE("google"), + APPLE("apple"), KAKAO_PROVIDER_ID_KEY("id"), GOOGLE_PROVIDER_ID_KEY("sub"), JWT_ACCESS_TOKEN_KEY("userId"), diff --git a/src/main/java/konkuk/thip/common/security/constant/SecurityWhitelist.java b/src/main/java/konkuk/thip/common/security/constant/SecurityWhitelist.java index 9a4d8247c..b7734d666 100644 --- a/src/main/java/konkuk/thip/common/security/constant/SecurityWhitelist.java +++ b/src/main/java/konkuk/thip/common/security/constant/SecurityWhitelist.java @@ -20,6 +20,7 @@ public enum SecurityWhitelist { ACTUATOR_PROMETHEUS("/actuator/prometheus"), AUTH_USERS("/auth/users"), AUTH_TOKEN("/auth/token"), + AUTH_APPLE("/auth/apple"), API_TEST("/api/test/**"), AUTH_EXCHANGE_TEMP_TOKEN("/auth/exchange-temp-token"), AUTH_SET_COOKIE("/auth/set-cookie"); diff --git a/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleClientSecretGenerator.java b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleClientSecretGenerator.java new file mode 100644 index 000000000..f28c9be59 --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleClientSecretGenerator.java @@ -0,0 +1,49 @@ +package konkuk.thip.common.security.oauth2.apple; + +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.security.KeyFactory; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class AppleClientSecretGenerator { + + private static final String APPLE_AUDIENCE = "https://appleid.apple.com"; + private static final long EXPIRATION_MS = 1000L * 60 * 60 * 24 * 180; // 180일 + + private final AppleProperties appleProperties; + + public String generate() { + try { + ECPrivateKey privateKey = loadPrivateKey(appleProperties.getPrivateKey()); + + return Jwts.builder() + .header().add("kid", appleProperties.getKeyId()).and() + .issuer(appleProperties.getTeamId()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS)) + .audience().add(APPLE_AUDIENCE).and() + .subject(appleProperties.getClientId()) + .signWith(privateKey, Jwts.SIG.ES256) + .compact(); + } catch (Exception e) { + throw new IllegalStateException("Apple client_secret 생성 실패", e); + } + } + + private ECPrivateKey loadPrivateKey(String privateKeyStr) throws Exception { + String stripped = privateKeyStr + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = Base64.getDecoder().decode(stripped); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + return (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(spec); + } +} diff --git a/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleIdentityTokenVerifier.java b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleIdentityTokenVerifier.java new file mode 100644 index 000000000..dcb255266 --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleIdentityTokenVerifier.java @@ -0,0 +1,48 @@ +package konkuk.thip.common.security.oauth2.apple; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import konkuk.thip.common.exception.AuthException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URL; + +import static konkuk.thip.common.exception.code.ErrorCode.AUTH_APPLE_IDENTITY_TOKEN_INVALID; + +@Slf4j +@Component +public class AppleIdentityTokenVerifier { + + private static final String APPLE_JWK_SET_URI = "https://appleid.apple.com/auth/keys"; + + private final ConfigurableJWTProcessor jwtProcessor; + + public AppleIdentityTokenVerifier() { + try { + JWKSource keySource = new RemoteJWKSet<>(new URL(APPLE_JWK_SET_URI)); + jwtProcessor = new DefaultJWTProcessor<>(); + jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource)); + } catch (Exception e) { + throw new IllegalStateException("Apple JWK 초기화 실패", e); + } + } + + public String verify(String identityToken) { + try { + JWTClaimsSet claims = jwtProcessor.process(identityToken, null); + String sub = claims.getSubject(); + log.info("[Apple Login] verified sub={}", sub); + return sub; + } catch (Exception e) { + log.warn("[Apple Login] identity token verification failed: {}", e.getMessage()); + throw new AuthException(AUTH_APPLE_IDENTITY_TOKEN_INVALID); + } + } +} diff --git a/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleLoginRequest.java b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleLoginRequest.java new file mode 100644 index 000000000..d9a5b84e8 --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleLoginRequest.java @@ -0,0 +1,9 @@ +package konkuk.thip.common.security.oauth2.apple; + +import jakarta.validation.constraints.NotBlank; + +public record AppleLoginRequest( + @NotBlank String identityToken, + String authorizationCode +) { +} diff --git a/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleProperties.java b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleProperties.java new file mode 100644 index 000000000..e9f3bba3d --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleProperties.java @@ -0,0 +1,17 @@ +package konkuk.thip.common.security.oauth2.apple; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "apple") +public class AppleProperties { + private String clientId; + private String teamId; + private String keyId; + private String privateKey; +} diff --git a/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleRefreshTokenStore.java b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleRefreshTokenStore.java new file mode 100644 index 000000000..386e22121 --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleRefreshTokenStore.java @@ -0,0 +1,30 @@ +package konkuk.thip.common.security.oauth2.apple; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AppleRefreshTokenStore { + + private static final String PREFIX = "apple:refresh:"; + private static final Duration TTL = Duration.ofMinutes(30); + + private final RedisTemplate redisTemplate; + + public void save(String oauth2Id, String refreshToken) { + redisTemplate.opsForValue().set(PREFIX + oauth2Id, refreshToken, TTL); + } + + public String pop(String oauth2Id) { + String key = PREFIX + oauth2Id; + String token = redisTemplate.opsForValue().get(key); + if (token != null) { + redisTemplate.delete(key); + } + return token; + } +} diff --git a/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleTokenClient.java b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleTokenClient.java new file mode 100644 index 000000000..d1d823d0e --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/oauth2/apple/AppleTokenClient.java @@ -0,0 +1,75 @@ +package konkuk.thip.common.security.oauth2.apple; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleTokenClient { + + private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"; + private static final String APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke"; + + private final RestTemplate restTemplate; + private final AppleProperties appleProperties; + private final AppleClientSecretGenerator clientSecretGenerator; + + public String exchangeAuthorizationCode(String authorizationCode) { + String clientSecret = clientSecretGenerator.generate(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", appleProperties.getClientId()); + params.add("client_secret", clientSecret); + params.add("code", authorizationCode); + params.add("grant_type", "authorization_code"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + try { + Map response = restTemplate.postForObject( + APPLE_TOKEN_URL, + new HttpEntity<>(params, headers), + Map.class + ); + if (response == null || !response.containsKey("refresh_token")) { + log.warn("[Apple] authorizationCode 교환 실패: refresh_token 없음"); + return null; + } + return (String) response.get("refresh_token"); + } catch (Exception e) { + log.warn("[Apple] authorizationCode 교환 중 오류: {}", e.getMessage()); + return null; + } + } + + public void revokeToken(String refreshToken) { + String clientSecret = clientSecretGenerator.generate(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", appleProperties.getClientId()); + params.add("client_secret", clientSecret); + params.add("token", refreshToken); + params.add("token_type_hint", "refresh_token"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + try { + restTemplate.postForObject(APPLE_REVOKE_URL, new HttpEntity<>(params, headers), Void.class); + log.info("[Apple] 토큰 철회 완료"); + } catch (Exception e) { + log.warn("[Apple] 토큰 철회 중 오류: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/konkuk/thip/common/security/oauth2/auth/AuthController.java b/src/main/java/konkuk/thip/common/security/oauth2/auth/AuthController.java index 3c0c43029..4a8a540fe 100644 --- a/src/main/java/konkuk/thip/common/security/oauth2/auth/AuthController.java +++ b/src/main/java/konkuk/thip/common/security/oauth2/auth/AuthController.java @@ -5,17 +5,23 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.exception.AuthException; import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.common.security.annotation.Oauth2Id; +import konkuk.thip.common.security.oauth2.apple.AppleIdentityTokenVerifier; +import konkuk.thip.common.security.oauth2.apple.AppleLoginRequest; +import konkuk.thip.common.security.oauth2.apple.AppleRefreshTokenStore; +import konkuk.thip.common.security.oauth2.apple.AppleTokenClient; import konkuk.thip.common.security.oauth2.tokenstorage.LoginTokenStorage; import konkuk.thip.common.security.oauth2.auth.dto.AuthSetCookieRequest; import konkuk.thip.common.security.oauth2.auth.dto.AuthSetCookieResponse; import konkuk.thip.common.security.oauth2.auth.dto.AuthTokenRequest; import konkuk.thip.common.security.oauth2.auth.dto.AuthTokenResponse; import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -29,6 +35,7 @@ import java.io.IOException; import java.time.Duration; import java.util.Map; +import java.util.Optional; import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; import static konkuk.thip.common.exception.code.ErrorCode.AUTH_INVALID_LOGIN_TOKEN_KEY; @@ -45,8 +52,10 @@ public class AuthController { private final UserJpaRepository userJpaRepository; private final JwtUtil jwtUtil; - private final LoginTokenStorage loginTokenStorage; + private final AppleIdentityTokenVerifier appleIdentityTokenVerifier; + private final AppleTokenClient appleTokenClient; + private final AppleRefreshTokenStore appleRefreshTokenStore; @Operation( summary = "소셜 로그인 유저 확인", @@ -70,6 +79,44 @@ public BaseResponse checkUserExists( }); } + @Operation( + summary = "Apple 소셜 로그인 (iOS 네이티브)", + description = "iOS에서 Apple Sign In SDK로 받은 identityToken을 검증하여 AccessToken 또는 SignupToken을 발급합니다." + ) + @PostMapping("/apple") + public BaseResponse appleLogin( + @Valid @RequestBody AppleLoginRequest request + ) { + String appleUserId = appleIdentityTokenVerifier.verify(request.identityToken()); + String oauth2Id = "apple_" + appleUserId; + + Optional existingUser = userJpaRepository.findByOauth2Id(oauth2Id); + + if (request.authorizationCode() != null) { + String refreshToken = appleTokenClient.exchangeAuthorizationCode(request.authorizationCode()); + if (refreshToken != null) { + if (existingUser.isPresent()) { + // 기존 유저: DB에 바로 저장 + existingUser.get().updateAppleRefreshToken(refreshToken); + userJpaRepository.save(existingUser.get()); + } else { + // 신규 유저: 회원가입 완료 시 옮겨 저장하도록 Redis에 임시 보관 (TTL 30분) + appleRefreshTokenStore.save(oauth2Id, refreshToken); + } + } + } + + return existingUser + .map(user -> { + String accessToken = jwtUtil.createAccessToken(user.getUserId()); + return BaseResponse.ok(AuthTokenResponse.of(accessToken, false)); + }) + .orElseGet(() -> { + String tempToken = jwtUtil.createSignupToken(oauth2Id); + return BaseResponse.ok(AuthTokenResponse.of(tempToken, true)); + }); + } + @Operation( summary = "로그인 토큰 키로 토큰 발급", description = "로그인 토큰 키를 사용하여 AccessToken 또는 SignupToken을 발급합니다." diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java index d2643f24f..c144c1742 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java @@ -39,6 +39,9 @@ public class UserJpaEntity extends BaseJpaEntity { @Column(name = "oauth2_id", length = 50, nullable = false) private String oauth2Id; + @Column(name = "apple_refresh_token", length = 1000) + private String appleRefreshToken; + /** * -- SETTER -- * 회원 탈퇴용 @@ -76,6 +79,10 @@ public void updateFrom(User user) { this.recordReviewCount = user.getRecordReviewCount(); } + public void updateAppleRefreshToken(String token) { + this.appleRefreshToken = token; + } + public void softDelete(User user) { if(this.status.equals(INACTIVE)){ throw new InvalidStateException(USER_ALREADY_DELETED); diff --git a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java index 842f91dd3..97fd361e8 100644 --- a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java +++ b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java @@ -4,6 +4,7 @@ import konkuk.thip.comment.application.port.out.CommentCommandPort; import konkuk.thip.comment.application.port.out.CommentLikeCommandPort; import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.security.oauth2.apple.AppleTokenClient; import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.post.application.port.out.PostLikeCommandPort; import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort; @@ -11,6 +12,7 @@ import konkuk.thip.roompost.application.port.out.AttendanceCheckCommandPort; import konkuk.thip.roompost.application.port.out.RecordCommandPort; import konkuk.thip.roompost.application.port.out.VoteCommandPort; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.application.port.UserTokenBlacklistCommandPort; import konkuk.thip.user.application.port.in.UserDeleteUseCase; import konkuk.thip.user.application.port.out.FollowingCommandPort; @@ -27,7 +29,9 @@ public class UserDeleteService implements UserDeleteUseCase { private final UserCommandPort userCommandPort; + private final UserJpaRepository userJpaRepository; private final FollowingCommandPort followingCommandPort; + private final AppleTokenClient appleTokenClient; private final FeedCommandPort feedCommandPort; private final BookCommandPort bookCommandPort; private final VoteCommandPort voteCommandPort; @@ -53,6 +57,14 @@ public void deleteUser(Long userId, String authToken) { // 2. 유저 조회 및 검증 User user = userCommandPort.findById(userId); + + // Apple 유저라면 markAsDeleted() 전에 oauth2Id 확인 후 refresh_token 철회 + if (user.getOauth2Id() != null && user.getOauth2Id().startsWith("apple_")) { + userJpaRepository.findByOauth2Id(user.getOauth2Id()) + .filter(entity -> entity.getAppleRefreshToken() != null) + .ifPresent(entity -> appleTokenClient.revokeToken(entity.getAppleRefreshToken())); + } + user.markAsDeleted(); // 3. 유저가 남긴 관련 정보들 삭제 diff --git a/src/main/java/konkuk/thip/user/application/service/UserSignupService.java b/src/main/java/konkuk/thip/user/application/service/UserSignupService.java index 716a8c297..98b30f8dd 100644 --- a/src/main/java/konkuk/thip/user/application/service/UserSignupService.java +++ b/src/main/java/konkuk/thip/user/application/service/UserSignupService.java @@ -2,7 +2,9 @@ import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.security.oauth2.apple.AppleRefreshTokenStore; import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.application.port.in.UserSignupUseCase; import konkuk.thip.user.application.port.in.dto.UserSignupCommand; import konkuk.thip.user.application.port.in.dto.UserSignupResult; @@ -23,7 +25,8 @@ public class UserSignupService implements UserSignupUseCase { private final UserCommandPort userCommandPort; private final UserQueryPort userQueryPort; - + private final UserJpaRepository userJpaRepository; + private final AppleRefreshTokenStore appleRefreshTokenStore; private final JwtUtil jwtUtil; @Override @@ -41,6 +44,19 @@ public UserSignupResult signup(UserSignupCommand command) { } Long userId = userCommandPort.save(user); + + // Apple 유저라면 Redis에 임시 보관된 refresh_token을 DB로 옮김 + if (command.oauth2Id().startsWith("apple_")) { + String refreshToken = appleRefreshTokenStore.pop(command.oauth2Id()); + if (refreshToken != null) { + userJpaRepository.findByOauth2Id(command.oauth2Id()) + .ifPresent(entity -> { + entity.updateAppleRefreshToken(refreshToken); + userJpaRepository.save(entity); + }); + } + } + String accessToken = jwtUtil.createAccessToken(userId); return UserSignupResult.of(userId, accessToken); } diff --git a/src/main/resources/db/migration/V260722__Add_apple_refresh_token.sql b/src/main/resources/db/migration/V260722__Add_apple_refresh_token.sql new file mode 100644 index 000000000..465d176dc --- /dev/null +++ b/src/main/resources/db/migration/V260722__Add_apple_refresh_token.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN apple_refresh_token VARCHAR(1000) NULL; diff --git a/src/test/java/konkuk/thip/common/security/oauth2/auth/AppleLoginApiTest.java b/src/test/java/konkuk/thip/common/security/oauth2/auth/AppleLoginApiTest.java new file mode 100644 index 000000000..7cab56b69 --- /dev/null +++ b/src/test/java/konkuk/thip/common/security/oauth2/auth/AppleLoginApiTest.java @@ -0,0 +1,120 @@ +package konkuk.thip.common.security.oauth2.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.common.security.oauth2.apple.AppleIdentityTokenVerifier; +import konkuk.thip.common.security.oauth2.apple.AppleLoginRequest; +import konkuk.thip.common.security.oauth2.apple.AppleTokenClient; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import konkuk.thip.user.domain.value.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.mockito.ArgumentMatchers.anyString; +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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("[통합] POST /auth/apple Apple 로그인 API 테스트") +class AppleLoginApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userJpaRepository; + + @MockBean + private AppleIdentityTokenVerifier appleIdentityTokenVerifier; + + @MockBean + private AppleTokenClient appleTokenClient; + + private static final String APPLE_SUB = "apple_test.001.abc"; + private static final String OAUTH2_ID = "apple_" + APPLE_SUB; + + @BeforeEach + void setUp() { + given(appleIdentityTokenVerifier.verify(anyString())).willReturn(APPLE_SUB); + given(appleTokenClient.exchangeAuthorizationCode(anyString())).willReturn("apple_refresh_token"); + } + + @Test + @DisplayName("신규 유저: isNewUser=true, SignupToken 반환") + void appleLogin_newUser_returnsSignupToken() throws Exception { + AppleLoginRequest request = new AppleLoginRequest("fake.identity.token", "fake_auth_code"); + + mockMvc.perform(post("/auth/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isNewUser").value(true)) + .andExpect(jsonPath("$.data.token").isNotEmpty()); + } + + @Test + @DisplayName("기존 유저: isNewUser=false, AccessToken 반환") + void appleLogin_existingUser_returnsAccessToken() throws Exception { + // given: Apple 유저 DB에 미리 저장 + UserJpaEntity existingUser = UserJpaEntity.builder() + .nickname("기존애플유저") + .oauth2Id(OAUTH2_ID) + .alias(Alias.WRITER) + .role(UserRole.USER) + .build(); + userJpaRepository.save(existingUser); + + AppleLoginRequest request = new AppleLoginRequest("fake.identity.token", "fake_auth_code"); + + mockMvc.perform(post("/auth/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isNewUser").value(false)) + .andExpect(jsonPath("$.data.token").isNotEmpty()); + } + + @Test + @DisplayName("identityToken이 없으면 400 반환") + void appleLogin_missingIdentityToken_returns400() throws Exception { + AppleLoginRequest request = new AppleLoginRequest("", "fake_auth_code"); + + mockMvc.perform(post("/auth/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("identityToken 검증 실패 시 401 반환") + void appleLogin_invalidIdentityToken_returns401() throws Exception { + given(appleIdentityTokenVerifier.verify(anyString())) + .willThrow(new konkuk.thip.common.exception.AuthException( + konkuk.thip.common.exception.code.ErrorCode.AUTH_APPLE_IDENTITY_TOKEN_INVALID)); + + AppleLoginRequest request = new AppleLoginRequest("invalid.token.here", "fake_auth_code"); + + mockMvc.perform(post("/auth/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/konkuk/thip/user/application/service/UserDeleteServiceAppleTest.java b/src/test/java/konkuk/thip/user/application/service/UserDeleteServiceAppleTest.java new file mode 100644 index 000000000..fd7190fda --- /dev/null +++ b/src/test/java/konkuk/thip/user/application/service/UserDeleteServiceAppleTest.java @@ -0,0 +1,102 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.common.security.oauth2.apple.AppleTokenClient; +import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.mockito.Mockito.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("[단위] Apple 유저 탈퇴 서비스 테스트") +class UserDeleteServiceAppleTest { + + @Autowired + private UserDeleteService userDeleteService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private JwtUtil jwtUtil; + + @MockBean + private AppleTokenClient appleTokenClient; + + @Test + @DisplayName("Apple 유저 탈퇴 시 저장된 refresh_token으로 Apple revoke가 호출된다") + void deleteUser_appleUser_revokesAppleToken() { + // given + UserJpaEntity user = TestEntityFactory.createUser(Alias.WRITER); + user = userJpaRepository.save(user); + user.updateAppleRefreshToken("apple_refresh_token_abc"); + userJpaRepository.save(user); + + // oauth2Id를 apple_로 시작하도록 반영하기 위해 직접 엔티티 수정 + // (TestEntityFactory.createUser는 kakao oauth2Id를 기본값으로 사용하므로 별도로 Apple 유저 생성) + UserJpaEntity appleUser = UserJpaEntity.builder() + .nickname("애플유저") + .oauth2Id("apple_test_sub_delete") + .alias(Alias.WRITER) + .role(konkuk.thip.user.domain.value.UserRole.USER) + .build(); + appleUser = userJpaRepository.save(appleUser); + appleUser.updateAppleRefreshToken("apple_refresh_token_xyz"); + userJpaRepository.save(appleUser); + + Long userId = appleUser.getUserId(); + String authToken = jwtUtil.createAccessToken(userId); + + // when + userDeleteService.deleteUser(userId, authToken); + + // then + verify(appleTokenClient, times(1)).revokeToken("apple_refresh_token_xyz"); + } + + @Test + @DisplayName("Apple 유저라도 refresh_token이 없으면 revoke를 호출하지 않는다") + void deleteUser_appleUser_withoutRefreshToken_doesNotRevoke() { + // given + UserJpaEntity appleUser = UserJpaEntity.builder() + .nickname("애플유저2") + .oauth2Id("apple_test_sub_no_token") + .alias(Alias.WRITER) + .role(konkuk.thip.user.domain.value.UserRole.USER) + .build(); + appleUser = userJpaRepository.save(appleUser); + Long userId = appleUser.getUserId(); + + // when + userDeleteService.deleteUser(userId, jwtUtil.createAccessToken(userId)); + + // then + verify(appleTokenClient, never()).revokeToken(any()); + } + + @Test + @DisplayName("Apple 유저가 아닌 경우 revoke를 호출하지 않는다") + void deleteUser_nonAppleUser_doesNotRevoke() { + // given + UserJpaEntity kakaoUser = TestEntityFactory.createUser(Alias.WRITER); + kakaoUser = userJpaRepository.save(kakaoUser); + Long userId = kakaoUser.getUserId(); + + // when + userDeleteService.deleteUser(userId, jwtUtil.createAccessToken(userId)); + + // then + verify(appleTokenClient, never()).revokeToken(any()); + } +} diff --git a/src/test/java/konkuk/thip/user/application/service/UserSignupServiceAppleTest.java b/src/test/java/konkuk/thip/user/application/service/UserSignupServiceAppleTest.java new file mode 100644 index 000000000..474a78ff1 --- /dev/null +++ b/src/test/java/konkuk/thip/user/application/service/UserSignupServiceAppleTest.java @@ -0,0 +1,92 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.common.security.oauth2.apple.AppleRefreshTokenStore; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.application.port.in.dto.UserSignupCommand; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("[단위] Apple 유저 회원가입 서비스 테스트") +class UserSignupServiceAppleTest { + + @Autowired + private UserSignupService userSignupService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AppleRefreshTokenStore appleRefreshTokenStore; + + @Test + @DisplayName("Apple 유저 회원가입 시 Redis에 저장된 refresh_token이 DB로 옮겨진다") + void signup_appleUser_movesRefreshTokenFromRedisToDb() { + // given + String oauth2Id = "apple_test_sub_12345"; + String refreshToken = "apple_refresh_token_value"; + appleRefreshTokenStore.save(oauth2Id, refreshToken); + + UserSignupCommand command = UserSignupCommand.builder() + .oauth2Id(oauth2Id) + .nickname("테스트닉네임") + .aliasName(Alias.WRITER.getValue()) + .build(); + + // when + userSignupService.signup(command); + + // then + UserJpaEntity saved = userJpaRepository.findByOauth2Id(oauth2Id).orElseThrow(); + assertThat(saved.getAppleRefreshToken()).isEqualTo(refreshToken); + assertThat(appleRefreshTokenStore.pop(oauth2Id)).isNull(); // Redis에서 삭제됨 + } + + @Test + @DisplayName("Apple 유저 회원가입 시 Redis에 refresh_token이 없으면 DB에도 저장되지 않는다") + void signup_appleUser_withoutRedisToken_doesNotSaveToDb() { + // given + String oauth2Id = "apple_test_sub_no_token"; + UserSignupCommand command = UserSignupCommand.builder() + .oauth2Id(oauth2Id) + .nickname("테스트닉네임2") + .aliasName(Alias.WRITER.getValue()) + .build(); + + // when + userSignupService.signup(command); + + // then + UserJpaEntity saved = userJpaRepository.findByOauth2Id(oauth2Id).orElseThrow(); + assertThat(saved.getAppleRefreshToken()).isNull(); + } + + @Test + @DisplayName("Apple 유저가 아닌 경우 Redis 조회 없이 회원가입이 완료된다") + void signup_nonAppleUser_doesNotCheckRedis() { + // given + UserSignupCommand command = UserSignupCommand.builder() + .oauth2Id("kakao_99999") + .nickname("카카오유저") + .aliasName(Alias.WRITER.getValue()) + .build(); + + // when + userSignupService.signup(command); + + // then + UserJpaEntity saved = userJpaRepository.findByOauth2Id("kakao_99999").orElseThrow(); + assertThat(saved.getAppleRefreshToken()).isNull(); + } +}