diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..609f72b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,6 +47,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) + implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) runtimeOnly(libs.postgresql) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..28cf8d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ grpc-java = "1.60.0" springdoc = "2.8.6" [libraries] +spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index a6607e5..8eee586 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -16,6 +16,7 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), + password = rs.getString("password"), library = null, ) } @@ -56,6 +57,15 @@ class UserRepository( return users.firstOrNull() } + override fun findByEmail(email: String): User? { + val users = jdbc.query( + "SELECT id, name, email, password FROM users WHERE email = ?", + userRowMapper, + email, + ) + return users.firstOrNull() + } + override fun findAll(): List = jdbc.query( "SELECT id, name, email FROM users", diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..189e927 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory +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.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class CustomOAuth2UserService( + private val userRepository: UserRepositoryPort, + private val idGenerator: IdGenerator, +) : DefaultOAuth2UserService() { + + companion object { + private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) + } + + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val registrationId = userRequest.clientRegistration.registrationId + + log.debug("Processing OAuth2 login for provider: {}", registrationId) + + return try { + val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) + val user = findOrCreateUser(userInfo) + UserPrincipal.create(user, oAuth2User.attributes) + } catch (e: Exception) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } + } + + private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { + val existingUser = userRepository.findByEmail(userInfo.getEmail()) + + return if (existingUser != null) { + log.debug("User found by email: {}", userInfo.getEmail()) + existingUser + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", // OAuth2 пользователи не имеют пароля + library = null, + ) + userRepository.save(newUser) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..dcff86e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -0,0 +1,16 @@ +package com.project.movienight.adapters.security.oauth2 + +class GoogleOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["sub"] as String + + override fun getEmail(): String = attributes["email"] as String + + override fun getName(): String = attributes["name"] as String + + override fun getProvider(): String = "google" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..5a155b9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -0,0 +1,9 @@ +package com.project.movienight.adapters.security.oauth2 + +interface OAuth2UserInfo { + fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..001b789 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -0,0 +1,18 @@ +package com.project.movienight.adapters.security.oauth2 + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User + +object OAuth2UserInfoFactory { + + fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + val attributes = user.attributes + + return when (registrationId.lowercase()) { + "google" -> GoogleOAuth2UserInfo(attributes) + "yandex" -> YandexOAuth2UserInfo(attributes) + "vk" -> VkOAuth2UserInfo(attributes) + else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..5fbbee1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.domain.model.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User +import java.util.* + +class UserPrincipal( + private val user: User, + private val attributes: Map? = null, +) : OAuth2User, UserDetails { + + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority("ROLE_USER")) + } + + override fun getPassword(): String = user.password + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + companion object { + fun create(user: User, attributes: Map? = null): UserPrincipal { + return UserPrincipal(user, attributes) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..492e74c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class VkOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String { + val response = attributes["response"] as? List> + return response?.firstOrNull()?.get("id")?.toString() ?: "" + } + + override fun getEmail(): String = attributes["email"] as? String ?: "" + + override fun getName(): String { + val response = attributes["response"] as? List> + val first = response?.firstOrNull() + val firstName = first?.get("first_name") as? String ?: "" + val lastName = first?.get("last_name") as? String ?: "" + return "$firstName $lastName".trim() + } + + override fun getProvider(): String = "vk" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..2929389 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -0,0 +1,20 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class YandexOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["id"]?.toString() ?: "" + + override fun getEmail(): String { + val emails = attributes["emails"] as? List> + return emails?.firstOrNull()?.get("value") ?: "" + } + + override fun getName(): String = attributes["display_name"] as? String ?: "" + + override fun getProvider(): String = "yandex" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index af8ef0a..cfefe9b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -8,6 +8,8 @@ interface UserRepositoryPort { fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..5eb754e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -32,6 +32,7 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, + password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..db9142b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,5 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, + val password: String, val library: FilmLibrary?, )