diff --git a/src/main/java/com/example/cp_main_be/domain/member/auth/dto/request/SupabaseLoginRequest.java b/src/main/java/com/example/cp_main_be/domain/member/auth/dto/request/SupabaseLoginRequest.java new file mode 100644 index 00000000..a562179f --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/member/auth/dto/request/SupabaseLoginRequest.java @@ -0,0 +1,8 @@ +package com.example.cp_main_be.domain.member.auth.dto.request; + +import lombok.Getter; + +@Getter +public class SupabaseLoginRequest { + private String accessToken; +} diff --git a/src/main/java/com/example/cp_main_be/domain/member/auth/presentation/AuthController.java b/src/main/java/com/example/cp_main_be/domain/member/auth/presentation/AuthController.java index 85c22536..c1c256c3 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/auth/presentation/AuthController.java +++ b/src/main/java/com/example/cp_main_be/domain/member/auth/presentation/AuthController.java @@ -1,6 +1,7 @@ package com.example.cp_main_be.domain.member.auth.presentation; import com.example.cp_main_be.domain.member.auth.dto.request.RegistrationRequest; +import com.example.cp_main_be.domain.member.auth.dto.request.SupabaseLoginRequest; import com.example.cp_main_be.domain.member.auth.dto.response.AnonymousRegistrationResponse; import com.example.cp_main_be.domain.member.auth.dto.response.TokenRefreshResponse; import com.example.cp_main_be.domain.member.auth.service.AuthService; @@ -39,4 +40,15 @@ public ResponseEntity> signup( AnonymousRegistrationResponse response = authService.registerNewUser(request, deviceId); return ResponseEntity.ok(ApiResponse.success(response)); } + + @Operation(summary = "Supabase 소셜 로그인", description = "Supabase OAuth 토큰을 우리 서비스 토큰으로 교환합니다.") + @PostMapping("/supabase") + public ResponseEntity> loginWithSupabase( + @RequestBody SupabaseLoginRequest request, + @RequestHeader(value = "X-Client-Device-Id", required = false) String deviceId) { + + AnonymousRegistrationResponse response = + authService.loginWithSupabase(request.getAccessToken(), deviceId); + return ResponseEntity.ok(ApiResponse.success(response)); + } } diff --git a/src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java b/src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java index f84db247..cd45f1c8 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java +++ b/src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java @@ -13,9 +13,13 @@ import com.example.cp_main_be.global.common.CustomApiException; import com.example.cp_main_be.global.common.ErrorCode; import com.example.cp_main_be.global.jwt.JwtTokenProvider; +import com.example.cp_main_be.global.supabase.SupabaseAuthClient; +import com.example.cp_main_be.global.supabase.SupabaseUserResponse; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -33,6 +37,7 @@ public class AuthService { private final Logger logger = LoggerFactory.getLogger(AuthService.class); private final WishTreeService wishTreeService; private final GardenRepository gardenRepository; + private final SupabaseAuthClient supabaseAuthClient; /** 리프레시 토큰으로 액세스 토큰 재발급 + (권장) 리프레시 토큰 롤링 */ @Transactional @@ -87,7 +92,68 @@ public AnonymousRegistrationResponse registerNewUser( .gardens(new ArrayList<>()) .build(); - // 1. 사용자를 먼저 저장합니다. + User savedUser = saveAndInitializeUser(newUser); + return issueTokens(savedUser, true, deviceId); + } + + @Transactional + public AnonymousRegistrationResponse loginWithSupabase(String accessToken, String deviceId) { + SupabaseUserResponse supabaseUser = supabaseAuthClient.fetchUser(accessToken); + + String oauthSubject = supabaseUser.getId(); + String oauthProvider = extractProvider(supabaseUser); + if (oauthSubject == null || oauthProvider == null || oauthProvider.isBlank()) { + throw new CustomApiException(ErrorCode.INVALID_REQUEST); + } + + Optional existing = + userRepository.findByOauthProviderAndOauthSubject(oauthProvider, oauthSubject); + + if (existing.isPresent()) { + User user = existing.get(); + boolean updated = applyProfileUpdates(user, supabaseUser); + if (updated) { + userRepository.save(user); + } + return issueTokens(user, false, deviceId); + } + + String nickname = buildUniqueNickname(supabaseUser); + UUID newUuid = UUID.randomUUID(); + + User newUser = + User.builder() + .uuid(newUuid) + .nickname(nickname) + .email(supabaseUser.getEmail()) + .profileImageUrl(extractProfileImageUrl(supabaseUser)) + .oauthProvider(oauthProvider) + .oauthSubject(oauthSubject) + .avatarList(new ArrayList<>()) + .diaries(new ArrayList<>()) + .gardens(new ArrayList<>()) + .build(); + + User savedUser = saveAndInitializeUser(newUser); + return issueTokens(savedUser, true, deviceId); + } + + /** 특정 리프레시 토큰 무효화(로그아웃) */ + public void revokeRefreshToken(String refreshToken) { + refreshTokenRepository.deleteByToken(refreshToken); + } + + /** 해당 유저의 전체 리프레시 토큰 무효화(강제 로그아웃 All) */ + public void revokeAllByUser(UUID userUuid) { + refreshTokenRepository.deleteAllByUserUuid(userUuid); + } + + /** 만료된 리프레시 토큰 청소 (스케쥴러로 주기적으로 호출) */ + public void purgeExpiredTokens() { + refreshTokenRepository.deleteAllByExpiresAtBefore(LocalDateTime.now()); + } + + private User saveAndInitializeUser(User newUser) { User savedUser = userRepository.save(newUser); Garden firstGarden = @@ -100,20 +166,20 @@ public AnonymousRegistrationResponse registerNewUser( Garden.builder().user(savedUser).slotNumber(4).isLocked(true).build(); // 4번은 잠김 gardenRepository.saveAll(List.of(firstGarden, secondGarden, thirdGarden, fourthGarden)); - - // 2. 위시트리 관련 로직을 수행합니다. - // 만약 여기서 예외가 발생하면, 위에서 저장한 newUser까지 모두 롤백됩니다. wishTreeService.addPointsToWishTree(savedUser.getId(), 0L); - // 3. 모든 것이 성공했을 때만 토큰을 생성하고 저장합니다. - String accessToken = jwtTokenProvider.generateAccessToken(newUuid.toString()); - String refreshToken = jwtTokenProvider.generateRefreshToken(newUuid.toString()); + return savedUser; + } + + private AnonymousRegistrationResponse issueTokens(User user, boolean isNewUser, String deviceId) { + String accessToken = jwtTokenProvider.generateAccessToken(user.getUuid().toString()); + String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUuid().toString()); LocalDateTime expiry = jwtTokenProvider.getExpirationLocalDateTime(refreshToken); RefreshToken rt = RefreshToken.builder() .token(refreshToken) - .userUuid(newUuid) + .userUuid(user.getUuid()) .expiresAt(expiry) .deviceId(deviceId) .build(); @@ -122,24 +188,106 @@ public AnonymousRegistrationResponse registerNewUser( return AnonymousRegistrationResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) - .userId(savedUser.getId()) // save() 후 반환된 객체의 ID 사용 - .nickname(nickname) - .isNewUser(true) + .userId(user.getId()) + .nickname(user.getNickname()) + .isNewUser(isNewUser) .build(); } - /** 특정 리프레시 토큰 무효화(로그아웃) */ - public void revokeRefreshToken(String refreshToken) { - refreshTokenRepository.deleteByToken(refreshToken); + private String extractProvider(SupabaseUserResponse supabaseUser) { + Map appMetadata = supabaseUser.getAppMetadata(); + if (appMetadata == null) { + return null; + } + Object provider = appMetadata.get("provider"); + if (provider instanceof String providerStr && !providerStr.isBlank()) { + return providerStr; + } + Object providers = appMetadata.get("providers"); + if (providers instanceof List providerList && !providerList.isEmpty()) { + Object first = providerList.get(0); + if (first instanceof String firstProvider && !firstProvider.isBlank()) { + return firstProvider; + } + } + return null; } - /** 해당 유저의 전체 리프레시 토큰 무효화(강제 로그아웃 All) */ - public void revokeAllByUser(UUID userUuid) { - refreshTokenRepository.deleteAllByUserUuid(userUuid); + private boolean applyProfileUpdates(User user, SupabaseUserResponse supabaseUser) { + boolean updated = false; + + if (user.getEmail() == null && supabaseUser.getEmail() != null) { + user.setEmail(supabaseUser.getEmail()); + updated = true; + } + + String profileImageUrl = extractProfileImageUrl(supabaseUser); + if (user.getProfileImageUrl() == null && profileImageUrl != null) { + user.setProfileImageUrl(profileImageUrl); + updated = true; + } + + return updated; } - /** 만료된 리프레시 토큰 청소 (스케쥴러로 주기적으로 호출) */ - public void purgeExpiredTokens() { - refreshTokenRepository.deleteAllByExpiresAtBefore(LocalDateTime.now()); + private String extractProfileImageUrl(SupabaseUserResponse supabaseUser) { + Map userMetadata = supabaseUser.getUserMetadata(); + if (userMetadata == null) { + return null; + } + Object avatarUrl = userMetadata.get("avatar_url"); + if (avatarUrl instanceof String avatarStr && !avatarStr.isBlank()) { + return avatarStr; + } + Object pictureUrl = userMetadata.get("picture"); + if (pictureUrl instanceof String pictureStr && !pictureStr.isBlank()) { + return pictureStr; + } + return null; + } + + private String buildUniqueNickname(SupabaseUserResponse supabaseUser) { + String base = "user"; + Map userMetadata = supabaseUser.getUserMetadata(); + if (userMetadata != null) { + base = + firstNonBlank( + userMetadata, "nickname", "name", "full_name", "preferred_username", "user_name"); + } + + if (base == null || base.isBlank()) { + base = "user"; + } + + String sanitized = base.replaceAll("\\s+", ""); + sanitized = sanitized.replaceAll("[^a-zA-Z0-9._-]", ""); + if (sanitized.isBlank()) { + sanitized = "user"; + } + + String candidate = sanitized; + int attempts = 0; + while (userRepository.existsByNickname(candidate) && attempts < 5) { + candidate = sanitized + randomSuffix(); + attempts++; + } + if (userRepository.existsByNickname(candidate)) { + candidate = "user" + randomSuffix(); + } + return candidate; + } + + private String firstNonBlank(Map metadata, String... keys) { + for (String key : keys) { + Object value = metadata.get(key); + if (value instanceof String str && !str.isBlank()) { + return str; + } + } + return null; + } + + private String randomSuffix() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 6); } } diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java b/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java index b5064dba..658c3597 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java @@ -53,6 +53,12 @@ public void updateLastVisitedGarden(Long gardenId) { private String profileImageUrl; + @Column(name = "oauth_provider") + private String oauthProvider; + + @Column(name = "oauth_subject") + private String oauthSubject; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List avatarList = new ArrayList<>(); diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java b/src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java index 5059b8b7..f815194b 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java @@ -25,4 +25,6 @@ public interface UserRepository extends JpaRepository { Optional findByIdWithGardensAndAvatars(@Param("userId") Long userId); Boolean existsByNickname(String nickname); + + Optional findByOauthProviderAndOauthSubject(String oauthProvider, String oauthSubject); } diff --git a/src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java b/src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java index ccb96d2a..ecb4381d 100644 --- a/src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java +++ b/src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java @@ -36,6 +36,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti authorize .requestMatchers( "/api/v1/auth/signup", + "/api/v1/auth/supabase", "/api/v1/auth/refresh", "/api/v1/policy", "/swagger-ui/**", // Swagger UI 페이지 diff --git a/src/main/java/com/example/cp_main_be/global/supabase/SupabaseAuthClient.java b/src/main/java/com/example/cp_main_be/global/supabase/SupabaseAuthClient.java new file mode 100644 index 00000000..0660267e --- /dev/null +++ b/src/main/java/com/example/cp_main_be/global/supabase/SupabaseAuthClient.java @@ -0,0 +1,44 @@ +package com.example.cp_main_be.global.supabase; + +import com.example.cp_main_be.global.common.CustomApiException; +import com.example.cp_main_be.global.common.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class SupabaseAuthClient { + + private final WebClient webClient; + + @Value("${supabase.url}") + private String supabaseUrl; + + @Value("${supabase.service-key}") + private String supabaseServiceKey; + + public SupabaseUserResponse fetchUser(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + throw new CustomApiException(ErrorCode.INVALID_REQUEST); + } + + return webClient + .get() + .uri(supabaseUrl + "/auth/v1/user") + .header("apikey", supabaseServiceKey) + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .onStatus( + status -> status.value() == 401 || status.value() == 403, + response -> Mono.error(new CustomApiException(ErrorCode.INVALID_TOKEN))) + .onStatus( + HttpStatusCode::isError, + response -> Mono.error(new CustomApiException(ErrorCode.INVALID_REQUEST))) + .bodyToMono(SupabaseUserResponse.class) + .block(); + } +} diff --git a/src/main/java/com/example/cp_main_be/global/supabase/SupabaseUserResponse.java b/src/main/java/com/example/cp_main_be/global/supabase/SupabaseUserResponse.java new file mode 100644 index 00000000..03227efd --- /dev/null +++ b/src/main/java/com/example/cp_main_be/global/supabase/SupabaseUserResponse.java @@ -0,0 +1,20 @@ +package com.example.cp_main_be.global.supabase; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SupabaseUserResponse { + + private String id; + private String email; + + @JsonProperty("app_metadata") + private Map appMetadata; + + @JsonProperty("user_metadata") + private Map userMetadata; +} diff --git a/src/main/resources/db/migration/V4__add_user_oauth_fields.sql b/src/main/resources/db/migration/V4__add_user_oauth_fields.sql new file mode 100644 index 00000000..a966d52d --- /dev/null +++ b/src/main/resources/db/migration/V4__add_user_oauth_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS oauth_provider varchar(50), + ADD COLUMN IF NOT EXISTS oauth_subject varchar(255);