diff --git a/README.md b/README.md index eec91e7..d032b32 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,29 @@ Interceptor для логирования запросов/ответов. ## settings-spring-jpa Модуль для хранения настроек + +## auth-core + +Модуль авторизации + +## auth-jwt-core + +Добавляет поддержку jwt-токенов (создание/хранение). Для работы этого модуля требуется прописать в пропертях: + +``` yaml +token.access: + issuer: ${app.issuer} + timeToLive: PT15M # 15 minutes + signatureAlgorithm: RS256 + keyPair: + public: | + -----BEGIN PUBLIC KEY----- + -----END PUBLIC KEY----- + private: | + -----BEGIN PRIVATE KEY----- + -----END PRIVATE KEY----- +token.refresh: + length: 20 + prefix: RT- + timeToLive: PT2H # 2 hours +``` diff --git a/auth-core/build.gradle.kts b/auth-core/build.gradle.kts new file mode 100644 index 0000000..44ddbfd --- /dev/null +++ b/auth-core/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("kotlin") + id("kotlin-spring") + kotlin("plugin.jpa") +} + +dependencies { + runtimeOnly("org.postgresql:postgresql") + + api(project(":common")) + + api(project(":common-spring-jpa")) + + api("org.springframework.boot:spring-boot-starter-data-jpa") + + implementation("org.liquibase:liquibase-core") + + implementation("org.springframework.boot:spring-boot-starter-security") + + testImplementation(project(":common-spring-test-jpa")) + + testImplementation("org.springframework.boot:spring-boot-starter-test") + + testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin") + + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + + testImplementation("org.testcontainers:testcontainers") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:junit-jupiter") +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthCoreConfiguration.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthCoreConfiguration.kt new file mode 100644 index 0000000..c88ad41 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthCoreConfiguration.kt @@ -0,0 +1,19 @@ +@file:Suppress("unused") +package ru.touchin.auth.core.configurations + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder + +@ComponentScan("ru.touchin.auth.core") +@ConfigurationPropertiesScan +class AuthCoreConfiguration { + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthCoreDatabaseConfiguration.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthCoreDatabaseConfiguration.kt new file mode 100644 index 0000000..082ce85 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthCoreDatabaseConfiguration.kt @@ -0,0 +1,13 @@ +@file:Suppress("unused") +package ru.touchin.auth.core.configurations + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.cache.annotation.EnableCaching +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import ru.touchin.common.spring.jpa.EnableJpaAuditingExtra + +@EntityScan("ru.touchin.auth.core") +@EnableJpaRepositories("ru.touchin.auth.core") +@EnableCaching +@EnableJpaAuditingExtra +class AuthCoreDatabaseConfiguration diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/converters/DeviceConverter.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/converters/DeviceConverter.kt new file mode 100644 index 0000000..f735788 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/converters/DeviceConverter.kt @@ -0,0 +1,15 @@ +package ru.touchin.auth.core.device.converters + +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.device.models.DeviceEntity + +object DeviceConverter { + + fun DeviceEntity.toDto(): Device { + return Device( + id = this.id!!, + platform = this.platform, + ) + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/dto/Device.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/dto/Device.kt new file mode 100644 index 0000000..cd6fe9a --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/dto/Device.kt @@ -0,0 +1,9 @@ +package ru.touchin.auth.core.device.dto + +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import java.util.* + +data class Device( + val id: UUID, + val platform: DevicePlatform, +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/dto/enums/DevicePlatform.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/dto/enums/DevicePlatform.kt new file mode 100644 index 0000000..06ff945 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/dto/enums/DevicePlatform.kt @@ -0,0 +1,5 @@ +package ru.touchin.auth.core.device.dto.enums + +enum class DevicePlatform { + Android, Huawei, Apple +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/exceptions/DeviceAlreadyLinkedException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/exceptions/DeviceAlreadyLinkedException.kt new file mode 100644 index 0000000..a3a22b9 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/exceptions/DeviceAlreadyLinkedException.kt @@ -0,0 +1,8 @@ +package ru.touchin.auth.core.device.exceptions + +import ru.touchin.common.exceptions.CommonException +import java.util.* + +class DeviceAlreadyLinkedException(deviceId: UUID) : CommonException( + "Device $deviceId already linked to other user" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/exceptions/DeviceNotFoundException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/exceptions/DeviceNotFoundException.kt new file mode 100644 index 0000000..f7ca396 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/exceptions/DeviceNotFoundException.kt @@ -0,0 +1,8 @@ +package ru.touchin.auth.core.device.exceptions + +import ru.touchin.common.exceptions.CommonNotFoundException +import java.util.* + +class DeviceNotFoundException(deviceId: UUID) : CommonNotFoundException( + "Device not found id=$deviceId" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/models/DeviceEntity.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/models/DeviceEntity.kt new file mode 100644 index 0000000..4510231 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/models/DeviceEntity.kt @@ -0,0 +1,34 @@ +package ru.touchin.auth.core.device.models + +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.user.models.UserEntity +import ru.touchin.common.spring.jpa.models.AuditableUuidIdEntity +import javax.persistence.Entity +import javax.persistence.JoinColumn +import javax.persistence.JoinTable +import javax.persistence.ManyToMany +import javax.persistence.Table + +@Entity +@Table(name = "devices") +class DeviceEntity: AuditableUuidIdEntity() { + + lateinit var platform: DevicePlatform + + @ManyToMany + @JoinTable( + name = "devices_users", + joinColumns = [JoinColumn(name = "device_id")], + inverseJoinColumns = [JoinColumn(name = "user_id")] + ) + lateinit var users: Set + + companion object { + fun create(platform: DevicePlatform): DeviceEntity { + return DeviceEntity().apply { + this.platform = platform + } + } + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/repository/DeviceRepository.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/repository/DeviceRepository.kt new file mode 100644 index 0000000..abe941b --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/repository/DeviceRepository.kt @@ -0,0 +1,28 @@ +package ru.touchin.auth.core.device.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.findByIdOrNull +import ru.touchin.auth.core.device.exceptions.DeviceNotFoundException +import ru.touchin.auth.core.device.models.DeviceEntity +import java.util.* +import javax.persistence.LockModeType + +interface DeviceRepository: JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT d FROM DeviceEntity d WHERE d.id = :deviceId") + fun findByIdWithLock(deviceId: UUID): DeviceEntity? + +} + +fun DeviceRepository.findByIdOrThrow(deviceId: UUID): DeviceEntity { + return findByIdOrNull(deviceId) + ?: throw DeviceNotFoundException(deviceId) +} + +fun DeviceRepository.findByIdWithLockOrThrow(deviceId: UUID): DeviceEntity { + return findByIdWithLock(deviceId) + ?: throw DeviceNotFoundException(deviceId) +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/services/DeviceCoreService.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/services/DeviceCoreService.kt new file mode 100644 index 0000000..b330c62 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/services/DeviceCoreService.kt @@ -0,0 +1,12 @@ +package ru.touchin.auth.core.device.services + +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import java.util.UUID + +interface DeviceCoreService { + + fun get(deviceId: UUID): Device + fun create(platform: DevicePlatform): Device + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/device/services/DeviceCoreServiceImpl.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/services/DeviceCoreServiceImpl.kt new file mode 100644 index 0000000..30c764c --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/device/services/DeviceCoreServiceImpl.kt @@ -0,0 +1,32 @@ +@file:Suppress("unused") +package ru.touchin.auth.core.device.services + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.touchin.auth.core.device.converters.DeviceConverter.toDto +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.device.repository.findByIdOrThrow +import java.util.* + +@Service +class DeviceCoreServiceImpl( + private val deviceRepository: DeviceRepository, +) : DeviceCoreService { + + + @Transactional(readOnly = true) + override fun get(deviceId: UUID): Device { + return deviceRepository.findByIdOrThrow(deviceId) + .toDto() + } + + @Transactional + override fun create(platform: DevicePlatform): Device { + return deviceRepository.save(DeviceEntity.create(platform)) + .toDto() + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/dto/RegistrationPolicy.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/dto/RegistrationPolicy.kt new file mode 100644 index 0000000..192cec8 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/dto/RegistrationPolicy.kt @@ -0,0 +1,5 @@ +package ru.touchin.auth.core.policy.dto + +data class RegistrationPolicy( + val multiAccountsPerDevice: Boolean +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/services/DefaultPolicyServiceImpl.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/services/DefaultPolicyServiceImpl.kt new file mode 100644 index 0000000..032b4d9 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/services/DefaultPolicyServiceImpl.kt @@ -0,0 +1,16 @@ +package ru.touchin.auth.core.policy.services + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.stereotype.Service +import ru.touchin.auth.core.policy.dto.RegistrationPolicy + +@Service +class DefaultPolicyServiceImpl : PolicyService { + + override fun getRegistrationPolicy(): RegistrationPolicy { + return RegistrationPolicy( + multiAccountsPerDevice = false + ) + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/services/PolicyService.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/services/PolicyService.kt new file mode 100644 index 0000000..3e4dae0 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/policy/services/PolicyService.kt @@ -0,0 +1,9 @@ +package ru.touchin.auth.core.policy.services + +import ru.touchin.auth.core.policy.dto.RegistrationPolicy + +interface PolicyService { + + fun getRegistrationPolicy(): RegistrationPolicy + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/converters/ScopeConverter.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/converters/ScopeConverter.kt new file mode 100644 index 0000000..5039130 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/converters/ScopeConverter.kt @@ -0,0 +1,12 @@ +package ru.touchin.auth.core.scope.converters + +import ru.touchin.auth.core.scope.dto.Scope +import ru.touchin.auth.core.scope.models.ScopeEntity + +object ScopeConverter { + + fun ScopeEntity.toDto(): Scope { + return Scope(name) + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/dto/Scope.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/dto/Scope.kt new file mode 100644 index 0000000..18a81a3 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/dto/Scope.kt @@ -0,0 +1,5 @@ +package ru.touchin.auth.core.scope.dto + +data class Scope( + val name: String +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/exceptions/ScopeNotFoundException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/exceptions/ScopeNotFoundException.kt new file mode 100644 index 0000000..0b1dff1 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/exceptions/ScopeNotFoundException.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.scope.exceptions + +import ru.touchin.common.exceptions.CommonNotFoundException + +class ScopeNotFoundException(scope: String) : CommonNotFoundException( + "Scope not found name=$scope" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/models/ScopeEntity.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/models/ScopeEntity.kt new file mode 100644 index 0000000..200ec34 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/models/ScopeEntity.kt @@ -0,0 +1,15 @@ +package ru.touchin.auth.core.scope.models + +import ru.touchin.common.spring.jpa.models.BaseEntity +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table + +@Entity +@Table(name = "scopes") +class ScopeEntity : BaseEntity() { + + @Id + lateinit var name: String + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/models/ScopeGroupEntity.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/models/ScopeGroupEntity.kt new file mode 100644 index 0000000..718657f --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/models/ScopeGroupEntity.kt @@ -0,0 +1,23 @@ +package ru.touchin.auth.core.scope.models + +import ru.touchin.common.spring.jpa.models.BaseUuidIdEntity +import javax.persistence.Entity +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "scope_groups") +class ScopeGroupEntity : BaseUuidIdEntity() { + + lateinit var groupName: String + + @ManyToOne + @JoinColumn(name = "scope_name") + lateinit var scope: ScopeEntity + + companion object { + const val DEFAULT_USER_SCOPE_GROUP = "DefaultUser" + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/repositories/ScopeGroupRepository.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/repositories/ScopeGroupRepository.kt new file mode 100644 index 0000000..bc48002 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/repositories/ScopeGroupRepository.kt @@ -0,0 +1,6 @@ +package ru.touchin.auth.core.scope.repositories + +import org.springframework.data.jpa.repository.JpaRepository +import ru.touchin.auth.core.scope.models.ScopeGroupEntity + +interface ScopeGroupRepository : JpaRepository diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/repositories/ScopeRepository.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/repositories/ScopeRepository.kt new file mode 100644 index 0000000..69e79ff --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/scope/repositories/ScopeRepository.kt @@ -0,0 +1,19 @@ +package ru.touchin.auth.core.scope.repositories + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.findByIdOrNull +import ru.touchin.auth.core.scope.exceptions.ScopeNotFoundException +import ru.touchin.auth.core.scope.models.ScopeEntity + +interface ScopeRepository : JpaRepository { + + @Query("SELECT sg.scope FROM ScopeGroupEntity sg WHERE sg.groupName = :groupName") + fun findByGroup(groupName: String): List + +} + +fun ScopeRepository.findByIdOrThrow(scope: String): ScopeEntity { + return findByIdOrNull(scope) + ?: throw ScopeNotFoundException(scope) +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/converters/UserConverter.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/converters/UserConverter.kt new file mode 100644 index 0000000..f5eefd1 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/converters/UserConverter.kt @@ -0,0 +1,18 @@ +package ru.touchin.auth.core.user.converters + +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.scope.converters.ScopeConverter.toDto +import ru.touchin.auth.core.user.dto.User +import ru.touchin.auth.core.user.models.UserEntity + +object UserConverter { + + fun UserEntity.toDto(device: Device?): User { + return User( + id = id!!, + device = device, + scopes = scopes.map { it.toDto() }.toSet() + ) + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/dto/User.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/dto/User.kt new file mode 100644 index 0000000..87926d0 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/dto/User.kt @@ -0,0 +1,11 @@ +package ru.touchin.auth.core.user.dto + +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.scope.dto.Scope +import java.util.* + +data class User( + val id: UUID, + val device: Device?, + val scopes: Set, +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/dto/enums/IdentifierType.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/dto/enums/IdentifierType.kt new file mode 100644 index 0000000..cab1c63 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/dto/enums/IdentifierType.kt @@ -0,0 +1,5 @@ +package ru.touchin.auth.core.user.dto.enums + +enum class IdentifierType { + Username, PhoneNumber, Email +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserAccountNotFoundException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserAccountNotFoundException.kt new file mode 100644 index 0000000..6f0fa34 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserAccountNotFoundException.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.user.exceptions + +import ru.touchin.common.exceptions.CommonNotFoundException + +class UserAccountNotFoundException(username: String) : CommonNotFoundException( + "User account not found username=$username" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserAlreadyRegisteredException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserAlreadyRegisteredException.kt new file mode 100644 index 0000000..460b063 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserAlreadyRegisteredException.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.user.exceptions + +import ru.touchin.common.exceptions.CommonException + +class UserAlreadyRegisteredException(phoneNumber: String) : CommonException( + "User '$phoneNumber' already registered" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserNotFoundException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserNotFoundException.kt new file mode 100644 index 0000000..e92182d --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/UserNotFoundException.kt @@ -0,0 +1,8 @@ +package ru.touchin.auth.core.user.exceptions + +import ru.touchin.common.exceptions.CommonNotFoundException +import java.util.* + +class UserNotFoundException(userId: UUID) : CommonNotFoundException ( + "User not found id=$userId" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/WrongPasswordException.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/WrongPasswordException.kt new file mode 100644 index 0000000..c17137e --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/exceptions/WrongPasswordException.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.user.exceptions + +import ru.touchin.common.exceptions.CommonException + +class WrongPasswordException(username: String) : CommonException( + "Wrong password for user '$username'" +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/models/UserAccountEntity.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/models/UserAccountEntity.kt new file mode 100644 index 0000000..f6189e9 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/models/UserAccountEntity.kt @@ -0,0 +1,28 @@ +package ru.touchin.auth.core.user.models + +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import ru.touchin.common.spring.jpa.models.AuditableUuidIdEntity +import java.time.ZonedDateTime +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "user_accounts") +class UserAccountEntity: AuditableUuidIdEntity() { + + lateinit var username: String + + var password: String? = null + + @Enumerated(EnumType.STRING) + lateinit var identifierType: IdentifierType + + @ManyToOne + @JoinColumn(name = "user_id") + lateinit var user: UserEntity + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/models/UserEntity.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/models/UserEntity.kt new file mode 100644 index 0000000..92c9d81 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/models/UserEntity.kt @@ -0,0 +1,37 @@ +package ru.touchin.auth.core.user.models + +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.scope.models.ScopeEntity +import ru.touchin.common.spring.jpa.models.AuditableUuidIdEntity +import java.time.ZonedDateTime +import javax.persistence.Entity +import javax.persistence.JoinColumn +import javax.persistence.JoinTable +import javax.persistence.ManyToMany +import javax.persistence.Table + +@Entity +@Table(name = "users") +class UserEntity: AuditableUuidIdEntity() { + + var anonymous: Boolean = true + + var confirmedAt: ZonedDateTime? = null + + @ManyToMany + @JoinTable( + name = "devices_users", + joinColumns = [JoinColumn(name = "user_id")], + inverseJoinColumns = [JoinColumn(name = "device_id")] + ) + lateinit var devices: Set + + @ManyToMany + @JoinTable( + name = "users_scopes", + joinColumns = [JoinColumn(name = "user_id")], + inverseJoinColumns = [JoinColumn(name = "scope_name")] + ) + lateinit var scopes: Set + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/repositories/UserAccountRepository.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/repositories/UserAccountRepository.kt new file mode 100644 index 0000000..dafe25e --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/repositories/UserAccountRepository.kt @@ -0,0 +1,26 @@ +@file:Suppress("unused") + +package ru.touchin.auth.core.user.repositories + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import ru.touchin.auth.core.user.exceptions.UserAccountNotFoundException +import ru.touchin.auth.core.user.models.UserAccountEntity +import java.util.* + +interface UserAccountRepository: JpaRepository { + + @Query(""" + SELECT ua + FROM UserAccountEntity ua + WHERE ua.username = :username AND identifierType = :identifierType + """) + fun findByUsername(username: String, identifierType: IdentifierType): UserAccountEntity? + +} + +fun UserAccountRepository.findByUsernameOrThrow(username: String, identifierType: IdentifierType): UserAccountEntity { + return findByUsername(username, identifierType) + ?: throw UserAccountNotFoundException(username) +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/repositories/UserRepository.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/repositories/UserRepository.kt new file mode 100644 index 0000000..cf4cd67 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/repositories/UserRepository.kt @@ -0,0 +1,14 @@ +package ru.touchin.auth.core.user.repositories + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.findByIdOrNull +import ru.touchin.auth.core.user.exceptions.UserNotFoundException +import ru.touchin.auth.core.user.models.UserEntity +import java.util.* + +interface UserRepository: JpaRepository + +fun UserRepository.findByIdOrThrow(userId: UUID): UserEntity { + return findByIdOrNull(userId) + ?: throw UserNotFoundException(userId) +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/UserCoreService.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/UserCoreService.kt new file mode 100644 index 0000000..4811b00 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/UserCoreService.kt @@ -0,0 +1,17 @@ +package ru.touchin.auth.core.user.services + +import ru.touchin.auth.core.user.dto.User +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import ru.touchin.auth.core.user.services.dto.NewAnonymousUser +import ru.touchin.auth.core.user.services.dto.NewUser +import ru.touchin.auth.core.user.services.dto.UserLogin + +interface UserCoreService { + + fun create(newAnonymousUser: NewAnonymousUser): User + fun create(newUser: NewUser): User + fun get(username: String, identifierType: IdentifierType): User + fun getOrNull(username: String, identifierType: IdentifierType): User? + fun login(userLogin: UserLogin): User + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImpl.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImpl.kt new file mode 100644 index 0000000..50c5235 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImpl.kt @@ -0,0 +1,132 @@ +@file:Suppress("unused") +package ru.touchin.auth.core.user.services + +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.touchin.auth.core.device.converters.DeviceConverter.toDto +import ru.touchin.auth.core.device.exceptions.DeviceAlreadyLinkedException +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.device.repository.findByIdWithLockOrThrow +import ru.touchin.auth.core.scope.models.ScopeGroupEntity +import ru.touchin.auth.core.scope.repositories.ScopeRepository +import ru.touchin.auth.core.user.converters.UserConverter.toDto +import ru.touchin.auth.core.user.dto.User +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import ru.touchin.auth.core.user.exceptions.UserAccountNotFoundException +import ru.touchin.auth.core.user.exceptions.UserAlreadyRegisteredException +import ru.touchin.auth.core.user.exceptions.WrongPasswordException +import ru.touchin.auth.core.user.models.UserAccountEntity +import ru.touchin.auth.core.user.models.UserEntity +import ru.touchin.auth.core.user.repositories.UserAccountRepository +import ru.touchin.auth.core.user.repositories.UserRepository +import ru.touchin.auth.core.user.repositories.findByUsernameOrThrow +import ru.touchin.auth.core.user.services.dto.NewAnonymousUser +import ru.touchin.auth.core.user.services.dto.NewUser +import ru.touchin.auth.core.user.services.dto.UserLogin + +@Service +class UserCoreServiceImpl( + private val userRepository: UserRepository, + private val userAccountRepository: UserAccountRepository, + private val deviceRepository: DeviceRepository, + private val scopeRepository: ScopeRepository, + private val passwordEncoder: PasswordEncoder, +) : UserCoreService { + + @Transactional + override fun create(newAnonymousUser: NewAnonymousUser): User { + val device = deviceRepository.findByIdWithLockOrThrow(newAnonymousUser.deviceId) + + if (device.users.isNotEmpty()) { + throw DeviceAlreadyLinkedException(newAnonymousUser.deviceId) + } + + val user = UserEntity().apply { + anonymous = true + devices = hashSetOf(device) + scopes = emptySet() + } + + return userRepository.save(user) + .toDto(device.toDto()) + } + + @Transactional + override fun create(newUser: NewUser): User { + userAccountRepository.findByUsername(newUser.username, newUser.identifierType) + ?.run { throw UserAlreadyRegisteredException(newUser.username) } + + val device = deviceRepository.findByIdWithLockOrThrow(newUser.deviceId) + + resetDeviceUsers(device) + + val defaultScopes = scopeRepository.findByGroup(ScopeGroupEntity.DEFAULT_USER_SCOPE_GROUP) + + val user = UserEntity() + .apply { + anonymous = false + devices = hashSetOf(device) + scopes = defaultScopes.toSet() + } + .also(userRepository::save) + + UserAccountEntity() + .apply { + username = newUser.username + password = newUser.password?.let(passwordEncoder::encode) + identifierType = newUser.identifierType + this.user = user + } + .also(userAccountRepository::save) + + return user.toDto(device.toDto()) + } + + @Transactional + override fun login(userLogin: UserLogin): User { + val device = deviceRepository.findByIdWithLockOrThrow(userLogin.deviceId) + + val userAccount = userAccountRepository.findByUsernameOrThrow(userLogin.username, userLogin.identifierType) + + if (userAccount.password != null) { + if (!passwordEncoder.matches(userLogin.password, userAccount.password!!)) { + throw WrongPasswordException(userLogin.username) + } + } + + resetDeviceUsers(device) + + val user = userAccount.user + .apply { + devices = hashSetOf(device) + } + .also(userRepository::save) + + return user.toDto(device.toDto()) + } + + @Transactional(readOnly = true) + override fun get(username: String, identifierType: IdentifierType): User { + return getOrNull(username, identifierType) + ?: throw UserAccountNotFoundException(username) + } + + @Transactional(readOnly = true) + override fun getOrNull(username: String, identifierType: IdentifierType): User? { + return userAccountRepository.findByUsername(username, identifierType) + ?.let { userAccount -> + val user = userAccount.user + + user.toDto(device = null) + } + } + + private fun resetDeviceUsers(device: DeviceEntity) { + deviceRepository.save(device.apply { + users = hashSetOf() + }) + } + +} diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/NewAnonymousUser.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/NewAnonymousUser.kt new file mode 100644 index 0000000..3735156 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/NewAnonymousUser.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.user.services.dto + +import java.util.* + +data class NewAnonymousUser( + val deviceId: UUID +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/NewUser.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/NewUser.kt new file mode 100644 index 0000000..8036198 --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/NewUser.kt @@ -0,0 +1,11 @@ +package ru.touchin.auth.core.user.services.dto + +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import java.util.* + +data class NewUser( + val deviceId: UUID, + val identifierType: IdentifierType, + val username: String, + val password: String?, +) diff --git a/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/UserLogin.kt b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/UserLogin.kt new file mode 100644 index 0000000..359625a --- /dev/null +++ b/auth-core/src/main/kotlin/ru/touchin/auth/core/user/services/dto/UserLogin.kt @@ -0,0 +1,11 @@ +package ru.touchin.auth.core.user.services.dto + +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import java.util.* + +data class UserLogin ( + val deviceId: UUID, + val identifierType: IdentifierType, + val username: String, + val password: String?, +) diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105011900__create_table__devices.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105011900__create_table__devices.yml new file mode 100644 index 0000000..82f7162 --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105011900__create_table__devices.yml @@ -0,0 +1,40 @@ +databaseChangeLog: + - changeSet: + id: 202105011900__create_table__devices + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: devices + changes: + - createTable: + tableName: devices + columns: + - column: + name: id + type: UUID + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_devices + - column: + name: platform + type: VARCHAR(16) + constraints: + nullable: false + - column: + name: created_by + type: VARCHAR(255) + - column: + name: updated_by + type: VARCHAR(255) + - column: + name: created_at + type: TIMESTAMP + defaultValueDate: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105052300__create_table__users.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105052300__create_table__users.yml new file mode 100644 index 0000000..6dc9fa9 --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105052300__create_table__users.yml @@ -0,0 +1,43 @@ +databaseChangeLog: + - changeSet: + id: 202105052300__create_table__users + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: users + changes: + - createTable: + tableName: users + columns: + - column: + name: id + type: UUID + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_users + - column: + name: anonymous + type: BOOLEAN + constraints: + nullable: false + - column: + name: confirmed_at + type: TIMESTAMP + - column: + name: created_by + type: VARCHAR(255) + - column: + name: updated_by + type: VARCHAR(255) + - column: + name: created_at + type: TIMESTAMP + defaultValueDate: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105052310__create_table__user_accounts.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105052310__create_table__user_accounts.yml new file mode 100644 index 0000000..977dbaa --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105052310__create_table__user_accounts.yml @@ -0,0 +1,62 @@ +databaseChangeLog: + - changeSet: + id: 202105052310__create_table__user_accounts + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: user_accounts + changes: + - createTable: + tableName: user_accounts + columns: + - column: + name: id + type: UUID + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_user_accounts + - column: + name: username + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: password + type: VARCHAR(255) + - column: + name: identifier_type + type: VARCHAR(32) + constraints: + nullable: false + - column: + name: user_id + type: UUID + constraints: + nullable: false + foreignKeyName: fk_user_accounts_users + references: users(id) + - column: + name: created_by + type: VARCHAR(255) + - column: + name: updated_by + type: VARCHAR(255) + - column: + name: created_at + type: TIMESTAMP + defaultValueDate: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + - createIndex: + tableName: user_accounts + columns: + - column: + name: username + indexName: idx_user_accounts_username + unique: true diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105052331__create_table__devices_users.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105052331__create_table__devices_users.yml new file mode 100644 index 0000000..0232b1a --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105052331__create_table__devices_users.yml @@ -0,0 +1,31 @@ +databaseChangeLog: + - changeSet: + id: 202105052331__create_table__devices_users + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: devices_users + changes: + - createTable: + tableName: devices_users + columns: + - column: + name: device_id + type: UUID + constraints: + nullable: false + references: devices(id) + foreignKeyName: fk_devices_users_device_id + - column: + name: user_id + type: UUID + constraints: + nullable: false + references: users(id) + foreignKeyName: fk_devices_users_user_id + - addPrimaryKey: + tableName: devices_users + columnNames: device_id, user_id + constraintName: pk_devices_users diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105081411__create_table__scopes.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105081411__create_table__scopes.yml new file mode 100644 index 0000000..07bc327 --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105081411__create_table__scopes.yml @@ -0,0 +1,35 @@ +databaseChangeLog: + - changeSet: + id: 202105081411__create_table__scopes + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: scopes + changes: + - createTable: + tableName: scopes + columns: + - column: + name: name + type: VARCHAR(255) + constraints: + primaryKey: true + primaryKeyName: pk_scopes + nullable: false + - column: + name: created_at + type: TIMESTAMP + defaultValueDate: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + - insert: + tableName: scopes + columns: + - column: + name: name + value: "app:api" diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105081431__create_table__scope_groups.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105081431__create_table__scope_groups.yml new file mode 100644 index 0000000..1f0dc60 --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105081431__create_table__scope_groups.yml @@ -0,0 +1,53 @@ +databaseChangeLog: + - changeSet: + id: 202105081431__create_table__scope_groups + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: scope_groups + changes: + - createTable: + tableName: scope_groups + columns: + - column: + name: id + type: UUID + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_scope_groups + - column: + name: group_name + type: VARCHAR(64) + constraints: + nullable: false + - column: + name: scope_name + type: VARCHAR(255) + constraints: + nullable: false + references: scopes(name) + foreignKeyName: fk_scope_groups_scopes_scope_name + - column: + name: created_at + type: TIMESTAMP + defaultValueDate: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + - insert: + tableName: scope_groups + columns: + - column: + name: id + value: "0f08f4af-0e59-4914-b25f-291889572152" + - column: + name: group_name + value: "DefaultUser" + - column: + name: scope_name + value: "app:api" diff --git a/auth-core/src/main/resources/db/changelog/auth/core/202105081531__create_table__users_scopes.yml b/auth-core/src/main/resources/db/changelog/auth/core/202105081531__create_table__users_scopes.yml new file mode 100644 index 0000000..c42ed52 --- /dev/null +++ b/auth-core/src/main/resources/db/changelog/auth/core/202105081531__create_table__users_scopes.yml @@ -0,0 +1,31 @@ +databaseChangeLog: + - changeSet: + id: 202105081531__create_table__users_scopes + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: users_scopes + changes: + - createTable: + tableName: users_scopes + columns: + - column: + name: user_id + type: UUID + constraints: + nullable: false + references: users(id) + foreignKeyName: fk_users_scopes_user_id + - column: + name: scope_name + type: VARCHAR(255) + constraints: + nullable: false + references: scopes(name) + foreignKeyName: fk_users_scopes_scope_name + - addPrimaryKey: + tableName: users_scopes + columnNames: user_id, scope_name + constraintName: pk_users_scopes diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/AuthCoreSlowTestConfiguration.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/AuthCoreSlowTestConfiguration.kt new file mode 100644 index 0000000..a5558fa --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/AuthCoreSlowTestConfiguration.kt @@ -0,0 +1,15 @@ +package ru.touchin.auth.core + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Profile +import ru.touchin.auth.core.configurations.AuthCoreDatabaseConfiguration + +@Profile("test-slow") +@EnableAutoConfiguration +@TestConfiguration +@ComponentScan +@Import(AuthCoreDatabaseConfiguration::class) +class AuthCoreSlowTestConfiguration diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/AuthCoreTestApplication.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/AuthCoreTestApplication.kt new file mode 100644 index 0000000..110655a --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/AuthCoreTestApplication.kt @@ -0,0 +1,13 @@ +package ru.touchin.auth.core + +import org.springframework.boot.SpringBootConfiguration +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import ru.touchin.auth.core.configurations.AuthCoreConfiguration + +@SpringBootConfiguration +@ContextConfiguration(classes = [AuthCoreConfiguration::class]) +@TestConfiguration +@Import(AuthCoreConfiguration::class, AuthCoreSlowTestConfiguration::class) +class AuthCoreTestApplication diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/device/repositories/DeviceLockService.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/device/repositories/DeviceLockService.kt new file mode 100644 index 0000000..1904d37 --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/device/repositories/DeviceLockService.kt @@ -0,0 +1,36 @@ +package ru.touchin.auth.core.device.repositories + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.device.repository.findByIdWithLockOrThrow +import java.time.Duration +import java.util.* + +@Service +@Transactional(propagation = Propagation.REQUIRES_NEW) +class DeviceLockService { + + @Autowired + private lateinit var deviceRepository: DeviceRepository + + fun getWithLock(id: UUID, pauseAfter: Duration): DeviceEntity? { + val device = deviceRepository.findByIdWithLockOrThrow(id) + + Thread.sleep(pauseAfter.toMillis()) + + return device + } + + fun create(f: () -> DeviceEntity): DeviceEntity { + return f.invoke() + } + + fun cleanup() { + deviceRepository.deleteAll() + } + +} diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/device/repositories/DeviceRepositoryTest.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/device/repositories/DeviceRepositoryTest.kt new file mode 100644 index 0000000..328f1c9 --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/device/repositories/DeviceRepositoryTest.kt @@ -0,0 +1,155 @@ +package ru.touchin.auth.core.device.repositories + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.dao.DataAccessException +import ru.touchin.auth.core.device.exceptions.DeviceNotFoundException +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.device.repository.findByIdOrThrow +import ru.touchin.auth.core.device.repository.findByIdWithLockOrThrow +import ru.touchin.auth.core.user.models.UserEntity +import ru.touchin.auth.core.user.repositories.UserRepository +import ru.touchin.common.spring.test.jpa.repository.RepositoryTest +import java.time.Duration +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.persistence.EntityManager + +@RepositoryTest +internal class DeviceRepositoryTest { + + @Autowired + private lateinit var deviceLockService: DeviceLockService + + @Autowired + private lateinit var deviceRepository: DeviceRepository + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var entityManager: EntityManager + + fun createDevice(platform: DevicePlatform): DeviceEntity { + return createDevice(DeviceEntity.create(platform)) + } + + fun createDevice(deviceModel: DeviceEntity): DeviceEntity { + return deviceRepository.saveAndFlush(deviceModel) + } + + @Test + @DisplayName("Можно создать `DeviceModel` для всех платформ без привязки к пользователю") + fun deviceShouldBeCreated() { + DevicePlatform.values().forEach(::createDevice) + + entityManager.clear() + + val createdPlatforms = deviceRepository.findAll() + .map(DeviceEntity::platform) + + assertEquals( + DevicePlatform.values().size, + createdPlatforms.size, + "Кол-во записей в базе должно совпадать с кол-вом платформ" + ) + + assertEquals( + DevicePlatform.values().toSet(), + createdPlatforms.toSet(), + "Платформы в базе должны совпадать со всеми `DevicePlatform`" + ) + } + + @Test + @DisplayName("Можно привязать существующего пользователя") + fun canBindUser() { + val user = userRepository.save(UserEntity()) + + val device = DeviceEntity.create(DevicePlatform.Apple) + .apply { + users = setOf(user) + } + + deviceRepository.saveAndFlush(device) + + entityManager.clear() + + val savedDevice = deviceRepository.findByIdOrThrow(device.id!!) + + assertEquals(1, savedDevice.users.size) + } + + @Test + @DisplayName("Должна быть ошибка при сохранении устройства с новым пользователем") + fun deviceShouldNotBeCreatedWithNewUser() { + val device = DeviceEntity.create(DevicePlatform.Android) + .apply { + users = setOf(UserEntity()) + } + + assertThrows(DataAccessException::class.java) { + deviceRepository.saveAndFlush(device) + } + } + + @Test + @DisplayName("Если девайс не найден, то должна быть ошибка DeviceNotFoundException") + fun shouldBeDeviceNotFoundException() { + val missingDeviceId = UUID.fromString("d3e957df-6686-421e-9807-7128c8e680ea") + + assertThrows(DeviceNotFoundException::class.java) { + deviceRepository.findByIdOrThrow(missingDeviceId) + } + + assertThrows(DeviceNotFoundException::class.java) { + deviceRepository.findByIdWithLockOrThrow(missingDeviceId) + } + } + + @Test + @DisplayName("Должен работать Lock") + fun shouldBeLockable() { + val device = deviceLockService.create { + createDevice(DevicePlatform.Huawei) + } + + val threadsCount = 2 + + val service = Executors.newFixedThreadPool(threadsCount) + val latch = CountDownLatch(threadsCount) + + val seq = ConcurrentLinkedQueue() + + service.submit { + deviceLockService.getWithLock(id = device.id!!, Duration.ofSeconds(1)) + latch.countDown() + seq.add(1) + } + + Thread.sleep(50) + + service.submit { + deviceLockService.getWithLock(id = device.id!!, Duration.ofSeconds(0)) + latch.countDown() + seq.add(2) + } + + latch.await(3, TimeUnit.SECONDS) + + deviceLockService.cleanup() + + assertEquals(1, seq.poll()) + assertEquals(2, seq.poll()) + + } + +} diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/device/services/DeviceCoreServiceImplTest.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/device/services/DeviceCoreServiceImplTest.kt new file mode 100644 index 0000000..8a416b8 --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/device/services/DeviceCoreServiceImplTest.kt @@ -0,0 +1,92 @@ +package ru.touchin.auth.core.device.services + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.spy +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.device.exceptions.DeviceNotFoundException +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.device.repository.DeviceRepository +import java.util.* + +@ActiveProfiles("test") +@SpringBootTest +internal class DeviceCoreServiceImplTest { + + private var deviceRepository: DeviceRepository = mock {} + + private var deviceCoreService: DeviceCoreService = spy( + DeviceCoreServiceImpl(deviceRepository) + ) + + @Test + @DisplayName("Device должен создаваться в базе") + fun deviceShouldBeCreated() { + val deviceId = UUID.fromString("0d3ded83-02c7-4bae-9cd2-3076e0bce046") + doAnswer { invocation -> + (invocation.getArgument(0) as DeviceEntity) + .apply { + id = deviceId + } + }.`when`(deviceRepository).save(any()) + + val actualDevice = deviceCoreService.create(DevicePlatform.Huawei) + + val expectedDevice = Device( + id = deviceId, + platform = DevicePlatform.Huawei, + ) + + assertEquals(expectedDevice, actualDevice) + } + + @Test + @DisplayName("Device можно получить через сервис") + fun deviceShouldBeGet() { + val deviceId = UUID.fromString("0d3ded83-02c7-4bae-9cd2-3076e0bce046") + + doReturn( + DeviceEntity() + .apply { + id = deviceId + platform = DevicePlatform.Android + } + .let { device -> + Optional.of(device) + } + ).`when`(deviceRepository).findById(deviceId) + + val actualDevice = deviceCoreService.get(deviceId) + + val expectedDevice = Device( + id = deviceId, + platform = DevicePlatform.Android, + ) + + assertEquals(expectedDevice, actualDevice) + } + + @Test + @DisplayName("Для несуществующих устройств должно быть брошено исключение DeviceNotFoundException") + fun deviceShouldBeNotFound() { + val missingDeviceId = UUID.fromString("d3e957df-6686-421e-9807-7128c8e680ea") + + doReturn( + Optional.empty() + ).`when`(deviceRepository).findById(any()) + + assertThrows { + deviceCoreService.get(missingDeviceId) + } + } + +} diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/scope/repositories/ScopeRepositoryTest.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/scope/repositories/ScopeRepositoryTest.kt new file mode 100644 index 0000000..5ee13c0 --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/scope/repositories/ScopeRepositoryTest.kt @@ -0,0 +1,92 @@ +package ru.touchin.auth.core.scope.repositories + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.internal.matchers.apachecommons.ReflectionEquals +import org.springframework.beans.factory.annotation.Autowired +import ru.touchin.auth.core.scope.exceptions.ScopeNotFoundException +import ru.touchin.auth.core.scope.models.ScopeEntity +import ru.touchin.auth.core.scope.models.ScopeGroupEntity +import ru.touchin.common.spring.test.jpa.repository.RepositoryTest +import java.time.ZonedDateTime +import javax.persistence.EntityManager + +@RepositoryTest +internal class ScopeRepositoryTest { + + @Autowired + private lateinit var scopeRepository: ScopeRepository + + @Autowired + private lateinit var scopeGroupRepository: ScopeGroupRepository + + @Autowired + private lateinit var entityManager: EntityManager + + fun createScope(): ScopeEntity { + return ScopeEntity() + .apply { + name = "admin" + createdAt = ZonedDateTime.now() + } + .also { scope -> + scopeRepository.saveAndFlush(scope) + } + + } + + @Test + @DisplayName("Можно создать `ScopeModel`") + fun scopeShouldBeCreated() { + val scope = createScope() + + entityManager.clear() + + val savedScope = scopeRepository.findByIdOrThrow(scope.name) + + assertTrue( + ReflectionEquals(scope, "createdAt").matches(savedScope) + ) + } + + @Test + @DisplayName("Имя scope должно быть уникалным") + fun scopeShouldBeUniqName() { + scopeGroupRepository.deleteAll() + scopeRepository.deleteAll() + + createScope() + + entityManager.clear() + + createScope() + + entityManager.clear() + + val scopes = scopeRepository.findAll() + + assertEquals(1, scopes.size) + } + + @Test + @DisplayName("Если scope не найден, то должна быть ошибка ScopeNotFoundException") + fun shouldBeScopeNotFoundException() { + val missingScope = "missing" + + assertThrows(ScopeNotFoundException::class.java) { + scopeRepository.findByIdOrThrow(missingScope) + } + } + + @Test + @DisplayName("Должен быть дефолтный scope") + fun shouldBeDefaultScope() { + val scopes = scopeRepository.findByGroup(ScopeGroupEntity.DEFAULT_USER_SCOPE_GROUP) + + assertEquals(1, scopes.size) + } + +} diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/user/repositories/UserRepositoryTest.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/user/repositories/UserRepositoryTest.kt new file mode 100644 index 0000000..00d487d --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/user/repositories/UserRepositoryTest.kt @@ -0,0 +1,138 @@ +package ru.touchin.auth.core.user.repositories + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.internal.matchers.apachecommons.ReflectionEquals +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.dao.DataAccessException +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.scope.models.ScopeEntity +import ru.touchin.auth.core.scope.repositories.ScopeRepository +import ru.touchin.auth.core.user.exceptions.UserNotFoundException +import ru.touchin.auth.core.user.models.UserEntity +import ru.touchin.common.spring.test.jpa.repository.RepositoryTest +import java.time.ZonedDateTime +import java.util.* +import javax.persistence.EntityManager + +@RepositoryTest +internal class UserRepositoryTest { + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var deviceRepository: DeviceRepository + + @Autowired + private lateinit var scopeRepository: ScopeRepository + + @Autowired + private lateinit var entityManager: EntityManager + + @Test + @DisplayName("Можно создать `UserModel` без привязки к устройствам или скоупам") + fun userShouldBeCreated() { + val user = userRepository.saveAndFlush( + UserEntity().apply { + anonymous = false + confirmedAt = ZonedDateTime.now() + devices = emptySet() + scopes = emptySet() + } + ) + + entityManager.clear() + + val savedUser = userRepository.findByIdOrThrow(user.id!!) + + assertTrue(ReflectionEquals(user, "createdAt").matches(savedUser)) + + } + + @Test + @DisplayName("Можно привязать к пользователю существующий device") + fun canBindDevice() { + val device = deviceRepository.saveAndFlush( + DeviceEntity.create(DevicePlatform.Apple) + ) + + val user = UserEntity() + .apply { + devices = setOf(device) + } + + userRepository.saveAndFlush(user) + + entityManager.clear() + + val savedUser = userRepository.findByIdOrThrow(user.id!!) + + assertEquals(1, savedUser.devices.size) + } + + @Test + @DisplayName("Можно привязать к пользователю существующий scope") + fun canBindScope() { + val scope = scopeRepository.saveAndFlush( + ScopeEntity().apply { + name = "admin" + } + ) + + val user = UserEntity() + .apply { + scopes = setOf(scope) + } + + userRepository.saveAndFlush(user) + + entityManager.clear() + + val savedUser = userRepository.findByIdOrThrow(user.id!!) + + assertEquals(1, savedUser.scopes.size) + } + + @Test + @DisplayName("Должна быть ошибка при сохранении пользователя с новым устройством") + fun shouldBeErrorIfDeviceNew() { + val user = UserEntity() + .apply { + devices = setOf(DeviceEntity.create(DevicePlatform.Huawei)) + } + + assertThrows(DataAccessException::class.java) { + userRepository.saveAndFlush(user) + } + } + + @Test + @DisplayName("Должна быть ошибка при сохранении пользователя с новым scope") + fun shouldBeErrorIfScopeNew() { + val user = UserEntity() + .apply { + scopes = setOf(ScopeEntity().apply { name = "admin" }) + } + + assertThrows(DataAccessException::class.java) { + userRepository.saveAndFlush(user) + } + } + + @Test + @DisplayName("Если пользователь не найден, то должна быть ошибка UserNotFoundException") + fun shouldBeUserNotFoundException() { + val missingUserId = UUID.fromString("d3e957df-6686-421e-9807-7128c8e680ea") + + assertThrows(UserNotFoundException::class.java) { + userRepository.findByIdOrThrow(missingUserId) + } + } + +} diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImplSlowTest.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImplSlowTest.kt new file mode 100644 index 0000000..f8ed40f --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImplSlowTest.kt @@ -0,0 +1,285 @@ +package ru.touchin.auth.core.user.services + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.crypto.password.PasswordEncoder +import ru.touchin.auth.core.device.dto.Device +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.device.repository.findByIdOrThrow +import ru.touchin.auth.core.device.services.DeviceCoreService +import ru.touchin.auth.core.user.dto.User +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import ru.touchin.auth.core.user.exceptions.UserAlreadyRegisteredException +import ru.touchin.auth.core.user.exceptions.WrongPasswordException +import ru.touchin.auth.core.user.repositories.UserAccountRepository +import ru.touchin.auth.core.user.repositories.UserRepository +import ru.touchin.auth.core.user.repositories.findByIdOrThrow +import ru.touchin.auth.core.user.services.dto.NewAnonymousUser +import ru.touchin.auth.core.user.services.dto.NewUser +import ru.touchin.auth.core.user.services.dto.UserLogin +import ru.touchin.common.spring.test.jpa.repository.RepositoryTest +import java.lang.IllegalArgumentException +import java.util.* +import javax.persistence.EntityManager + +@RepositoryTest +internal class UserCoreServiceImplSlowTest { + + @Autowired + lateinit var userCoreService: UserCoreService + + @Autowired + lateinit var userRepository: UserRepository + + @Autowired + lateinit var deviceCoreService: DeviceCoreService + + @Autowired + lateinit var deviceRepository: DeviceRepository + + @Autowired + lateinit var userAccountRepository: UserAccountRepository + + @Autowired + lateinit var entityManager: EntityManager + + @Autowired + lateinit var passwordEncoder: PasswordEncoder + + private fun createDevice(): Device { + return deviceCoreService.create(DevicePlatform.Android).also { + entityManager.flush() + entityManager.clear() + } + } + + private fun createNewUser(deviceId: UUID, password: String? = null, username: String = "manager"): User { + val newUser = NewUser( + deviceId = deviceId, + username = username, + password = password, + identifierType = IdentifierType.Email, + ) + + return userCoreService.create(newUser).also { + entityManager.flush() + entityManager.clear() + } + } + + @Test + @DisplayName("Username должен быть уникальным для одного и того же IdentifierType") + fun usernameShouldBeUniq() { + val device1 = createDevice() + + createNewUser(device1.id, password = null) + + val device2 = createDevice() + + assertThrows { + createNewUser(device2.id, password = "qwerty") + } + + assertThrows { + createNewUser( + deviceId = UUID.fromString("2ecc93c9-fccb-4cc9-8d69-18a39b83e707"), + password = "qwerty" + ) + } + } + + @Test + @DisplayName(""" + Анонимные пользователи должны отлинковываться от устройства на которое регистрируется обычный пользователь + """) + fun anonymousUsersShouldBeUnbinded() { + val device = createDevice() + + val anonymousUser = userCoreService.create( + NewAnonymousUser(device.id) + ).also { + entityManager.flush() + entityManager.clear() + } + + val anonymousUserEntity = userRepository.findByIdOrThrow(anonymousUser.id) + + assertEquals(1, anonymousUserEntity.devices.size) + assertEquals(device.id, anonymousUserEntity.devices.first().id!!) + + val user = createNewUser(device.id) + + val deviceEntity = deviceRepository.findByIdOrThrow(device.id) + + assertEquals(1, deviceEntity.users.size) + assertEquals(user.id, deviceEntity.users.first().id!!) + + } + + @Test + @DisplayName(""" + Обычные пользователи должны отлинковываться от устройства на которое регистрируется обычный пользователь + """) + fun usersShouldBeUnbinded() { + val device = createDevice() + + val apiUser = createNewUser(device.id, username = "user@gmail.com") + + val anonymousUserEntity = userRepository.findByIdOrThrow(apiUser.id) + + assertEquals(1, anonymousUserEntity.devices.size) + assertEquals(device.id, anonymousUserEntity.devices.first().id!!) + + val user = createNewUser(device.id, username = "manager@gmail.com") + + val deviceEntity = deviceRepository.findByIdOrThrow(device.id) + + assertEquals(1, deviceEntity.users.size) + assertEquals(user.id, deviceEntity.users.first().id!!) + + } + + @Test + @DisplayName(""" + При регистрации пользователя, должен создаваться аккаунт в базе + """) + fun accountShouldBeCreated() { + val device = createDevice() + + val newUser = NewUser( + deviceId = device.id, + username = "+71232322023", + password = "qwerty", + identifierType = IdentifierType.PhoneNumber, + ) + + userCoreService.create(newUser).also { + entityManager.flush() + entityManager.clear() + } + + val account = userAccountRepository.findByUsername(newUser.username, IdentifierType.PhoneNumber) + + assertNotNull(account) + assertEquals(newUser.username, account!!.username) + assertTrue(passwordEncoder.matches(newUser.password!!, account.password!!)) + assertEquals(newUser.identifierType, account.identifierType) + + } + + @Test + @DisplayName("Должен быть успешный логин с паролем") + fun loginShouldBeOk() { + val device = createDevice() + + val regUser = createNewUser(deviceId = device.id, password = "qwerty", username = "employee" ) + + val logingUser = userCoreService.login( + UserLogin( + deviceId = device.id, + username = "employee", + password = "qwerty", + identifierType = IdentifierType.Email, + ) + ) + + assertEquals(regUser, logingUser) + } + + @Test + @DisplayName("Пустой пароль должен подходить") + fun emptyPassLoginShouldBeOk() { + val device = createDevice() + + createNewUser(deviceId = device.id, password = null, username = "employee") + + assertDoesNotThrow { + userCoreService.login( + UserLogin( + deviceId = device.id, + username = "employee", + password = null, + identifierType = IdentifierType.Email, + ) + ) + } + } + + @Test + @DisplayName("Пустой пароль не должен подходиить") + fun emptyPassLoginShouldBeFail() { + val device = createDevice() + + createNewUser(deviceId = device.id, password = "qwerty", username = "employee") + + assertThrows { + userCoreService.login( + UserLogin( + deviceId = device.id, + username = "employee", + password = null, + identifierType = IdentifierType.Email, + ) + ) + } + } + + @Test + @DisplayName("Если пароль не подходит, то WrongPasswordException") + fun shouldBeWrongPasswordException() { + val device = createDevice() + + createNewUser(deviceId = device.id, password = "qwerty", username = "employee") + + assertThrows { + userCoreService.login( + UserLogin( + deviceId = device.id, + username = "employee", + password = "qwerTy", + identifierType = IdentifierType.Email, + ) + ) + } + } + + @Test + @DisplayName("При логине старые пользователи должны отлинковываться от устройства") + fun prevUsersShouldBeUnbindedAfterLogin() { + val device = createDevice() + + createNewUser(deviceId = device.id, password = "qwerty", username = "employee1") + createNewUser(deviceId = device.id, password = "qwerty", username = "employee2") + val regUserE3 = createNewUser(deviceId = device.id, password = "qwerty", username = "employee3") + + val actualDeviceE3 = deviceRepository.findByIdOrThrow(deviceId = device.id) + + assertEquals(1, actualDeviceE3.users.size) + assertEquals(regUserE3.id, actualDeviceE3.users.first().id!!) + + val loginUserE2 = userCoreService.login( + UserLogin( + deviceId = device.id, + username = "employee2", + password = "qwerty", + identifierType = IdentifierType.Email, + ) + ).also { + entityManager.flush() + entityManager.clear() + } + + val actualDeviceE2 = deviceRepository.findByIdOrThrow(deviceId = device.id) + + assertEquals(1, actualDeviceE2.users.size) + assertEquals(loginUserE2.id, actualDeviceE2.users.first().id!!) + } + +} diff --git a/auth-core/src/test/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImplTest.kt b/auth-core/src/test/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImplTest.kt new file mode 100644 index 0000000..333351c --- /dev/null +++ b/auth-core/src/test/kotlin/ru/touchin/auth/core/user/services/UserCoreServiceImplTest.kt @@ -0,0 +1,186 @@ +package ru.touchin.auth.core.user.services + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argThat +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.spy +import com.nhaarman.mockitokotlin2.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.test.context.ActiveProfiles +import ru.touchin.auth.core.device.converters.DeviceConverter.toDto +import ru.touchin.auth.core.device.exceptions.DeviceAlreadyLinkedException +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.device.dto.enums.DevicePlatform +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.policy.dto.RegistrationPolicy +import ru.touchin.auth.core.policy.services.PolicyService +import ru.touchin.auth.core.scope.dto.Scope +import ru.touchin.auth.core.scope.models.ScopeEntity +import ru.touchin.auth.core.scope.repositories.ScopeRepository +import ru.touchin.auth.core.user.dto.User +import ru.touchin.auth.core.user.dto.enums.IdentifierType +import ru.touchin.auth.core.user.models.UserEntity +import ru.touchin.auth.core.user.repositories.UserAccountRepository +import ru.touchin.auth.core.user.repositories.UserRepository +import ru.touchin.auth.core.user.services.dto.NewAnonymousUser +import ru.touchin.auth.core.user.services.dto.NewUser +import java.util.* + +@ActiveProfiles("test") +@SpringBootTest +internal class UserCoreServiceImplTest { + + private var userRepository: UserRepository = mock {} + private var userAccountRepository: UserAccountRepository = mock {} + private var deviceRepository: DeviceRepository = mock {} + private var scopeRepository: ScopeRepository = mock {} + + private var userCoreService: UserCoreService = spy(UserCoreServiceImpl( + userRepository = userRepository, + userAccountRepository = userAccountRepository, + deviceRepository = deviceRepository, + scopeRepository = scopeRepository, + passwordEncoder = BCryptPasswordEncoder(), + )) + + private val deviceWithoutUser = DeviceEntity().apply { + id = UUID.fromString("0d3ded83-02c7-4bae-9cd2-3076e0bce046") + platform = DevicePlatform.Android + users = emptySet() + } + + private val deviceWithAnonymousUser = DeviceEntity().apply { + id = UUID.fromString("1ba666de-a0c9-4656-900c-864ebf1c0640") + platform = DevicePlatform.Huawei + users = hashSetOf(UserEntity()) + } + + private val deviceWithUser = DeviceEntity().apply { + id = UUID.fromString("193061cd-b121-414f-952f-426eea3d9be2") + platform = DevicePlatform.Apple + users = hashSetOf(UserEntity().apply { + anonymous = false + }) + } + + private val defaultUserId = UUID.fromString("0daacbb9-74cd-4c98-bdbd-584dfcf5d342") + + @BeforeEach + fun prepareMocks() { + doReturn( + deviceWithoutUser + ).`when`(deviceRepository).findByIdWithLock(deviceWithoutUser.id!!) + + doReturn( + deviceWithAnonymousUser + ).`when`(deviceRepository).findByIdWithLock(deviceWithAnonymousUser.id!!) + + doReturn( + deviceWithUser + ).`when`(deviceRepository).findByIdWithLock(deviceWithUser.id!!) + + doAnswer { invocation -> + (invocation.getArgument(0) as UserEntity) + .apply { + id = defaultUserId + } + }.`when`(userRepository).save(any()) + } + + @Test + @DisplayName("Должен создаваться анонимный пользователь") + fun anonymousUserShouldBeCreated() { + val actualUser = userCoreService.create( + NewAnonymousUser( + deviceId = deviceWithoutUser.id!! + ) + ) + + val expectedUser = User( + id = defaultUserId, + device = deviceWithoutUser.toDto(), + scopes = emptySet() + ) + + assertEquals(expectedUser, actualUser) + } + + @Test + @DisplayName("Новый анонимный пользователь не может быть привязан к уже занятому устройству") + fun shouldBeDeviceAlreadyLinkedException() { + assertThrows { + userCoreService.create( + NewAnonymousUser( + deviceId = deviceWithAnonymousUser.id!! + ) + ) + } + } + + @Test + @DisplayName("У анонимного пользователя должен быть флаг anonymous=true и confirmed=false") + fun shouldBeAnonymousFlags() { + userCoreService.create( + NewAnonymousUser( + deviceId = deviceWithoutUser.id!!, + ) + ) + + verify(userRepository).save(argThat { anonymous }) + verify(userRepository).save(argThat { confirmedAt == null }) + } + + @Test + @DisplayName("У обычного пользователя должен быть флаг anonymous=false и confirmed=false") + fun shouldNotBeAnonymousFlags() { + userCoreService.create( + NewUser( + deviceId = deviceWithoutUser.id!!, + username = "user", + password = null, + identifierType = IdentifierType.Username, + ) + ) + + verify(userRepository).save(argThat { !anonymous }) + verify(userRepository).save(argThat { confirmedAt == null }) + } + + @Test + @DisplayName("Должен создаваться обычный пользователь") + fun userShouldBeCreated() { + doReturn( + listOf( + ScopeEntity().apply { + name = "app:api" + } + ) + ).`when`(scopeRepository).findByGroup(any()) + + val actualUser = userCoreService.create( + NewUser( + deviceId = deviceWithoutUser.id!!, + username = "admin", + password = "foo", + identifierType = IdentifierType.PhoneNumber, + ) + ) + + val expectedUser = User( + id = defaultUserId, + device = deviceWithoutUser.toDto(), + scopes = setOf(Scope("app:api")) + ) + + assertEquals(expectedUser, actualUser) + } + +} diff --git a/auth-core/src/test/resources/application-test-slow.yml b/auth-core/src/test/resources/application-test-slow.yml new file mode 100644 index 0000000..40fc4f3 --- /dev/null +++ b/auth-core/src/test/resources/application-test-slow.yml @@ -0,0 +1,3 @@ +spring: + config: + import: "test-slow.yml" diff --git a/auth-core/src/test/resources/application-test.yml b/auth-core/src/test/resources/application-test.yml new file mode 100644 index 0000000..6a6b0b3 --- /dev/null +++ b/auth-core/src/test/resources/application-test.yml @@ -0,0 +1,4 @@ +spring: + config: + import: "test.yml" + diff --git a/auth-core/src/test/resources/db/changelog/db.changelog-master.yaml b/auth-core/src/test/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..f2c6f26 --- /dev/null +++ b/auth-core/src/test/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,6 @@ +databaseChangeLog: + - changeset: + id: 1 + author: test + - includeAll: + path: classpath*:/db/changelog/auth/core diff --git a/auth-jwt-core/build.gradle.kts b/auth-jwt-core/build.gradle.kts new file mode 100644 index 0000000..229285c --- /dev/null +++ b/auth-jwt-core/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("kotlin") + id("kotlin-spring") + kotlin("plugin.jpa") +} + +dependencies { + implementation(project(":auth-core")) + + implementation("com.auth0:java-jwt") + implementation("org.springframework.security:spring-security-oauth2-jose") +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthTokenConfiguration.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthTokenConfiguration.kt new file mode 100644 index 0000000..201edc4 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthTokenConfiguration.kt @@ -0,0 +1,12 @@ +package ru.touchin.auth.core.configurations + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import + +@Configuration +@Import(AuthCoreConfiguration::class) +@ComponentScan("ru.touchin.auth.core.tokens") +@ConfigurationPropertiesScan("ru.touchin.auth.core.tokens") +class AuthTokenConfiguration diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthTokenDatabaseConfiguration.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthTokenDatabaseConfiguration.kt new file mode 100644 index 0000000..36e7030 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/configurations/AuthTokenDatabaseConfiguration.kt @@ -0,0 +1,10 @@ +package ru.touchin.auth.core.configurations + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import + +@Configuration +@Import(AuthCoreDatabaseConfiguration::class) +@EntityScan("ru.touchin.auth.core.tokens") +class AuthTokenDatabaseConfiguration diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/config/AccessTokenBeanConfig.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/config/AccessTokenBeanConfig.kt new file mode 100644 index 0000000..e707958 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/config/AccessTokenBeanConfig.kt @@ -0,0 +1,60 @@ +package ru.touchin.auth.core.tokens.access.config + +import com.auth0.jwt.algorithms.Algorithm +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import ru.touchin.auth.core.tokens.access.properties.AccessTokenProperties +import ru.touchin.common.string.StringUtils.emptyString +import java.security.KeyFactory +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.* + +@Configuration +class AccessTokenBeanConfig(private val accessTokenProperties: AccessTokenProperties) { + + @Bean + fun accessTokenSigningAlgorithm(): Algorithm { + return Algorithm.RSA256( + accessTokenPublicKey(), + accessTokenPrivateKey() + ) + } + + @Bean("accessTokenPublicKey") + fun accessTokenPublicKey(): RSAPublicKey { + val keySpecX509 = getKeySpec(accessTokenProperties.keyPair.public, ::X509EncodedKeySpec) + + return keyFactory.generatePublic(keySpecX509) as RSAPublicKey + } + + @Bean("accessTokenPrivateKey") + fun accessTokenPrivateKey(): RSAPrivateKey { + val keySpecPKCS8 = getKeySpec(accessTokenProperties.keyPair.private, ::PKCS8EncodedKeySpec) + + return keyFactory.generatePrivate(keySpecPKCS8) as RSAPrivateKey + } + + private fun getKeySpec(key: String, keySpecFn: (ByteArray) -> T): T { + val rawKey = getRawKey(key) + + return Base64.getDecoder() + .decode(rawKey) + .let(keySpecFn) + } + + private fun getRawKey(key: String): String { + return key + .replace("-----BEGIN .+KEY-----".toRegex(), emptyString()) + .replace("-----END .+KEY-----".toRegex(), emptyString()) + .replace("\n", emptyString()) + .trim() + } + + companion object { + val keyFactory: KeyFactory = KeyFactory.getInstance("RSA") + } + +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/dto/AccessToken.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/dto/AccessToken.kt new file mode 100644 index 0000000..3c2fbbd --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/dto/AccessToken.kt @@ -0,0 +1,8 @@ +package ru.touchin.auth.core.tokens.access.dto + +import java.time.Duration + +data class AccessToken( + val value: String, + val timeToLive: Duration +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/dto/AccessTokenRequest.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/dto/AccessTokenRequest.kt new file mode 100644 index 0000000..824ddea --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/dto/AccessTokenRequest.kt @@ -0,0 +1,6 @@ +package ru.touchin.auth.core.tokens.access.dto + +data class AccessTokenRequest( + val subject: String, + val claims: List>, +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/exceptions/AccessTokenMalformedException.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/exceptions/AccessTokenMalformedException.kt new file mode 100644 index 0000000..d77c88a --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/exceptions/AccessTokenMalformedException.kt @@ -0,0 +1,5 @@ +package ru.touchin.auth.core.tokens.access.exceptions + +import ru.touchin.common.exceptions.CommonException + +class AccessTokenMalformedException(description: String?) : CommonException(description) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/exceptions/UnsupportedClaimType.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/exceptions/UnsupportedClaimType.kt new file mode 100644 index 0000000..f088ee4 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/exceptions/UnsupportedClaimType.kt @@ -0,0 +1,5 @@ +package ru.touchin.auth.core.tokens.access.exceptions + +import ru.touchin.common.exceptions.CommonException + +class UnsupportedClaimType(typeName: String?) : CommonException("Unsupported claim type: $typeName") diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/properties/AccessTokenProperties.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/properties/AccessTokenProperties.kt new file mode 100644 index 0000000..6c34ef0 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/properties/AccessTokenProperties.kt @@ -0,0 +1,17 @@ +package ru.touchin.auth.core.tokens.access.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm +import java.time.Duration + +data class AccessTokenKeyPair(val public: String, val private: String) + +@ConstructorBinding +@ConfigurationProperties(prefix = "token.access") +data class AccessTokenProperties( + val keyPair: AccessTokenKeyPair, + val issuer: String, + val timeToLive: Duration, + val signatureAlgorithm: SignatureAlgorithm +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/services/AccessTokenService.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/services/AccessTokenService.kt new file mode 100644 index 0000000..cc6d910 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/services/AccessTokenService.kt @@ -0,0 +1,10 @@ +package ru.touchin.auth.core.tokens.access.services + +import ru.touchin.auth.core.tokens.access.dto.AccessToken +import ru.touchin.auth.core.tokens.access.dto.AccessTokenRequest + +interface AccessTokenService { + + fun create(accessTokenRequest: AccessTokenRequest): AccessToken + +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/services/JwtAccessTokenServiceImpl.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/services/JwtAccessTokenServiceImpl.kt new file mode 100644 index 0000000..0d7f812 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/access/services/JwtAccessTokenServiceImpl.kt @@ -0,0 +1,50 @@ +package ru.touchin.auth.core.tokens.access.services + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTCreator +import com.auth0.jwt.algorithms.Algorithm +import org.springframework.stereotype.Service +import ru.touchin.auth.core.tokens.access.dto.AccessToken +import ru.touchin.auth.core.tokens.access.dto.AccessTokenRequest +import ru.touchin.auth.core.tokens.access.exceptions.UnsupportedClaimType +import ru.touchin.auth.core.tokens.access.properties.AccessTokenProperties +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@Service +class JwtAccessTokenServiceImpl( + private val accessTokenProperties: AccessTokenProperties, + private val accessTokenSigningAlgorithm: Algorithm +) : AccessTokenService { + + private fun sign(builder: JWTCreator.Builder) = builder.sign(accessTokenSigningAlgorithm) + + private fun getExpirationDate(): Date { + return LocalDateTime.now() + .plus(accessTokenProperties.timeToLive) + .atZone(ZoneId.systemDefault()) + .toInstant() + .let(Date::from) + } + + override fun create(accessTokenRequest: AccessTokenRequest): AccessToken { + val token = JWT.create() + .withIssuer(accessTokenProperties.issuer) + .withExpiresAt(getExpirationDate()) + .withSubject(accessTokenRequest.subject) + .apply { + accessTokenRequest.claims.forEach { (name, value) -> + when(value) { + is String -> withClaim(name, value) + is Int -> withClaim(name, value) + else -> throw UnsupportedClaimType(value::class.simpleName) + } + } + } + .let(this::sign) + + return AccessToken(token, accessTokenProperties.timeToLive) + } + +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/dto/RefreshToken.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/dto/RefreshToken.kt new file mode 100644 index 0000000..c4988b6 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/dto/RefreshToken.kt @@ -0,0 +1,10 @@ +package ru.touchin.auth.core.tokens.refresh.dto + +import ru.touchin.auth.core.user.dto.User +import java.time.ZonedDateTime + +data class RefreshToken( + val value: String, + val expiresAt: ZonedDateTime, + val user: User, +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/exceptions/RefreshTokenExpiredException.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/exceptions/RefreshTokenExpiredException.kt new file mode 100644 index 0000000..8e34f52 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/exceptions/RefreshTokenExpiredException.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.tokens.refresh.exceptions + +import ru.touchin.common.exceptions.CommonException + +class RefreshTokenExpiredException(value: String) : CommonException( + "Refresh token with value $value is expired" +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/exceptions/RefreshTokenNotFoundException.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/exceptions/RefreshTokenNotFoundException.kt new file mode 100644 index 0000000..74fa39e --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/exceptions/RefreshTokenNotFoundException.kt @@ -0,0 +1,7 @@ +package ru.touchin.auth.core.tokens.refresh.exceptions + +import ru.touchin.common.exceptions.CommonNotFoundException + +class RefreshTokenNotFoundException(value: String) : CommonNotFoundException( + "No token found with value $value" +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/models/RefreshTokenEntity.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/models/RefreshTokenEntity.kt new file mode 100644 index 0000000..4995eae --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/models/RefreshTokenEntity.kt @@ -0,0 +1,47 @@ +package ru.touchin.auth.core.tokens.refresh.models + +import ru.touchin.auth.core.device.models.DeviceEntity +import ru.touchin.auth.core.scope.models.ScopeEntity +import ru.touchin.auth.core.tokens.refresh.exceptions.RefreshTokenExpiredException +import ru.touchin.auth.core.user.models.UserEntity +import ru.touchin.common.date.DateUtils.isExpired +import ru.touchin.common.spring.jpa.models.AuditableUuidIdEntity +import java.time.ZonedDateTime +import javax.persistence.Entity +import javax.persistence.JoinColumn +import javax.persistence.JoinTable +import javax.persistence.ManyToMany +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "refresh_tokens") +class RefreshTokenEntity : AuditableUuidIdEntity() { + + lateinit var value: String + + lateinit var expiresAt: ZonedDateTime + + @ManyToOne + @JoinColumn(name = "user_id") + lateinit var user: UserEntity + + @ManyToOne + @JoinColumn(name = "device_id") + var device: DeviceEntity? = null + + @ManyToMany + @JoinTable( + name = "refresh_token_scopes", + joinColumns = [JoinColumn(name = "refresh_token_id")], + inverseJoinColumns = [JoinColumn(name = "scope_name")] + ) + lateinit var scopes: Set + + fun validate(): RefreshTokenEntity = this.apply { + if (expiresAt.isExpired()) { + throw RefreshTokenExpiredException(value) + } + } + +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/properties/RefreshTokenProperties.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/properties/RefreshTokenProperties.kt new file mode 100644 index 0000000..e1ec5aa --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/properties/RefreshTokenProperties.kt @@ -0,0 +1,13 @@ +package ru.touchin.auth.core.tokens.refresh.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import java.time.Duration + +@ConstructorBinding +@ConfigurationProperties(prefix = "token.refresh") +data class RefreshTokenProperties( + val length: Int, + val prefix: String, + val timeToLive: Duration +) diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/repositories/RefreshTokenRepository.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/repositories/RefreshTokenRepository.kt new file mode 100644 index 0000000..cd87d9b --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/repositories/RefreshTokenRepository.kt @@ -0,0 +1,16 @@ +package ru.touchin.auth.core.tokens.refresh.repositories + +import org.springframework.data.jpa.repository.JpaRepository +import ru.touchin.auth.core.tokens.refresh.exceptions.RefreshTokenNotFoundException +import ru.touchin.auth.core.tokens.refresh.models.RefreshTokenEntity + +interface RefreshTokenRepository : JpaRepository { + + fun findByValue(value: String): RefreshTokenEntity? + +} + +fun RefreshTokenRepository.findByValueOrThrow(value: String): RefreshTokenEntity { + return findByValue(value) + ?: throw RefreshTokenNotFoundException(value) +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/RefreshTokenService.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/RefreshTokenService.kt new file mode 100644 index 0000000..b364a96 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/RefreshTokenService.kt @@ -0,0 +1,11 @@ +package ru.touchin.auth.core.tokens.refresh.services + +import ru.touchin.auth.core.tokens.refresh.dto.RefreshToken +import ru.touchin.auth.core.tokens.refresh.services.dto.NewRefreshToken + +interface RefreshTokenService { + + fun get(value: String): RefreshToken + fun create(token: NewRefreshToken): RefreshToken + +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/RefreshTokenServiceImpl.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/RefreshTokenServiceImpl.kt new file mode 100644 index 0000000..0177b4f --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/RefreshTokenServiceImpl.kt @@ -0,0 +1,77 @@ +package ru.touchin.auth.core.tokens.refresh.services + +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.touchin.auth.core.device.converters.DeviceConverter.toDto +import ru.touchin.auth.core.device.repository.DeviceRepository +import ru.touchin.auth.core.scope.dto.Scope +import ru.touchin.auth.core.scope.repositories.ScopeRepository +import ru.touchin.auth.core.user.repositories.UserRepository +import ru.touchin.auth.core.user.repositories.findByIdOrThrow +import ru.touchin.auth.core.tokens.refresh.dto.RefreshToken +import ru.touchin.auth.core.tokens.refresh.models.RefreshTokenEntity +import ru.touchin.auth.core.tokens.refresh.properties.RefreshTokenProperties +import ru.touchin.auth.core.tokens.refresh.repositories.RefreshTokenRepository +import ru.touchin.auth.core.tokens.refresh.repositories.findByValueOrThrow +import ru.touchin.auth.core.tokens.refresh.services.dto.NewRefreshToken +import ru.touchin.auth.core.user.converters.UserConverter.toDto +import ru.touchin.common.random.SecureRandomStringGenerator +import java.time.ZonedDateTime + +@Service +class RefreshTokenServiceImpl( + private val refreshTokenProperties: RefreshTokenProperties, + private val refreshTokenRepository: RefreshTokenRepository, + private val userRepository: UserRepository, + private val deviceRepository: DeviceRepository, + private val scopeRepository: ScopeRepository, +) : RefreshTokenService { + + @Transactional(readOnly = true) + override fun get(value: String): RefreshToken { + return refreshTokenRepository.findByValueOrThrow(value) + .toDto() + } + + @Transactional + override fun create(token: NewRefreshToken): RefreshToken { + val user = userRepository.findByIdOrThrow(token.userId) + val device = token.deviceId?.let(deviceRepository::findByIdOrNull) + val scopes = scopeRepository.findAllById(token.scopes.map(Scope::name)) + + val model = RefreshTokenEntity().apply { + expiresAt = getExpirationDate() + value = generateTokenValue() + this.user = user + this.device = device + this.scopes = scopes.toSet() + } + + return refreshTokenRepository.save(model) + .toDto() + } + + private fun getExpirationDate(): ZonedDateTime { + return ZonedDateTime.now().plus(refreshTokenProperties.timeToLive) + } + + private fun generateTokenValue(): String { + return refreshTokenProperties.let { + it.prefix + SecureRandomStringGenerator.generate(it.length) + } + } + + companion object { + fun RefreshTokenEntity.toDto(): RefreshToken { + val device = device?.toDto() + + return RefreshToken( + value = value, + expiresAt = expiresAt, + user = user.toDto(device) + ) + } + } + +} diff --git a/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/dto/NewRefreshToken.kt b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/dto/NewRefreshToken.kt new file mode 100644 index 0000000..17a3d96 --- /dev/null +++ b/auth-jwt-core/src/main/kotlin/ru/touchin/auth/core/tokens/refresh/services/dto/NewRefreshToken.kt @@ -0,0 +1,10 @@ +package ru.touchin.auth.core.tokens.refresh.services.dto + +import ru.touchin.auth.core.scope.dto.Scope +import java.util.* + +data class NewRefreshToken( + val userId: UUID, + val deviceId: UUID?, + val scopes: Set +) diff --git a/auth-jwt-core/src/main/resources/db/changelog/auth/core/202105201851__create_table__refresh_tokens.yml b/auth-jwt-core/src/main/resources/db/changelog/auth/core/202105201851__create_table__refresh_tokens.yml new file mode 100644 index 0000000..1f355ec --- /dev/null +++ b/auth-jwt-core/src/main/resources/db/changelog/auth/core/202105201851__create_table__refresh_tokens.yml @@ -0,0 +1,68 @@ +databaseChangeLog: + - changeSet: + id: 202105201851__create_table__refresh_tokens + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: refresh_tokens + changes: + - createTable: + tableName: refresh_tokens + columns: + - column: + name: id + type: UUID + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_refresh_tokens + - column: + name: user_id + type: UUID + constraints: + nullable: false + references: users(id) + foreignKeyName: fk_refresh_tokens_user_id + - column: + name: device_id + type: UUID + constraints: + nullable: true + references: devices(id) + foreignKeyName: fk_refresh_tokens_device_id + - column: + name: value + type: VARCHAR(255) + constraints: + unique: true + nullable: false + - column: + name: expires_at + type: TIMESTAMP WITH TIME ZONE + constraints: + nullable: false + - column: + name: created_by + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: updated_by + type: VARCHAR(255) + - column: + name: created_at + type: TIMESTAMP + defaultValueDate: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + - createIndex: + indexName: idx_refresh_tokens_user_id + tableName: refresh_tokens + columns: + - column: + name: user_id diff --git a/auth-jwt-core/src/main/resources/db/changelog/auth/core/202105201901__create_table__refresh_token_scopes.yml b/auth-jwt-core/src/main/resources/db/changelog/auth/core/202105201901__create_table__refresh_token_scopes.yml new file mode 100644 index 0000000..6e1f2e2 --- /dev/null +++ b/auth-jwt-core/src/main/resources/db/changelog/auth/core/202105201901__create_table__refresh_token_scopes.yml @@ -0,0 +1,31 @@ +databaseChangeLog: + - changeSet: + id: 202105201901__create_table__refresh_token_scopes + author: touchin + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: refresh_token_scopes + changes: + - createTable: + tableName: refresh_token_scopes + columns: + - column: + name: refresh_token_id + type: UUID + constraints: + nullable: false + references: refresh_tokens(id) + foreignKeyName: fk_refresh_token_scopes_refresh_token_id + - column: + name: scope_name + type: VARCHAR(255) + constraints: + nullable: false + references: scopes(name) + foreignKeyName: fk_refresh_token_scopes_scope_name + - addPrimaryKey: + tableName: refresh_token_scopes + columnNames: refresh_token_id, scope_name + constraintName: pk_refresh_token_scopes diff --git a/build.gradle.kts b/build.gradle.kts index 14bc81b..08c607d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,6 +66,8 @@ subprojects { dependency("tech.units:indriya:2.1.2") dependency("org.locationtech.spatial4j:spatial4j:0.8") + + dependency("com.auth0:java-jwt:3.10.3") } } diff --git a/settings.gradle.kts b/settings.gradle.kts index c4f4005..45f8200 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,5 @@ include("exception-handler-logger-spring-web") include("version-spring-web") include("response-wrapper-spring-web") include("settings-spring-jpa") +include("auth-core") +include("auth-jwt-core")