diff --git a/authentication/authentication-service/build.gradle.kts b/authentication/authentication-service/build.gradle.kts
new file mode 100644
index 0000000..a3df7f3
--- /dev/null
+++ b/authentication/authentication-service/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+ id("com.saveourtool.template.build.spring-boot-kotlin-configuration")
+ id("com.saveourtool.template.build.mysql-local-run-configuration")
+}
+
+dependencies {
+ implementation(projects.common)
+ implementation(projects.authentication.authenticationUtils)
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
+ implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
+ implementation("org.springframework.cloud:spring-cloud-starter-gateway")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
+ implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
+ implementation("org.springframework.boot:spring-boot-starter-data-jpa")
+ implementation(libs.kotlin.logging)
+}
+
+mysqlLocalRun {
+ databaseName = "authentication"
+ liquibaseChangelogPath = project.layout.projectDirectory.file("src/db/db.changelog.xml")
+}
\ No newline at end of file
diff --git a/authentication/authentication-service/src/db/db.changelog.xml b/authentication/authentication-service/src/db/db.changelog.xml
new file mode 100644
index 0000000..e896b1f
--- /dev/null
+++ b/authentication/authentication-service/src/db/db.changelog.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/authentication/authentication-service/src/db/original-login.xml b/authentication/authentication-service/src/db/original-login.xml
new file mode 100644
index 0000000..57e8ddd
--- /dev/null
+++ b/authentication/authentication-service/src/db/original-login.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/authentication/authentication-service/src/db/user.xml b/authentication/authentication-service/src/db/user.xml
new file mode 100644
index 0000000..9a1cad0
--- /dev/null
+++ b/authentication/authentication-service/src/db/user.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/AuthenticationApplication.kt b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/AuthenticationApplication.kt
new file mode 100644
index 0000000..78b39f3
--- /dev/null
+++ b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/AuthenticationApplication.kt
@@ -0,0 +1,11 @@
+package com.saveourtool.template.authentication
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.runApplication
+
+@SpringBootApplication
+class AuthenticationApplication
+
+fun main(args: Array) {
+ runApplication(*args)
+}
\ No newline at end of file
diff --git a/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/OriginalLogin.kt b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/OriginalLogin.kt
new file mode 100644
index 0000000..4e99961
--- /dev/null
+++ b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/OriginalLogin.kt
@@ -0,0 +1,25 @@
+package com.saveourtool.template.authentication.entities
+
+import jakarta.persistence.Entity
+import jakarta.persistence.GeneratedValue
+import jakarta.persistence.GenerationType
+import jakarta.persistence.Id
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
+
+/**
+ * @property name
+ * @property user
+ * @property source
+ */
+@Entity
+class OriginalLogin(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Long? = null,
+ var name: String,
+ var source: String,
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ var user: User,
+)
diff --git a/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/Role.kt b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/Role.kt
new file mode 100644
index 0000000..fd9eea8
--- /dev/null
+++ b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/Role.kt
@@ -0,0 +1,45 @@
+package com.saveourtool.template.authentication.entities
+
+/**
+ * User roles
+ * @property formattedName string representation of the [Role] that should be printed
+ * @property priority
+ */
+enum class Role(
+ val formattedName: String,
+ private val priority: Int,
+) {
+ /**
+ * Has no role (synonym to null)
+ */
+ NONE("None", 0),
+
+ /**
+ * Has readonly access to public projects.
+ */
+ VIEWER("Viewer", 1),
+
+ /**
+ * admin in organization
+ */
+ ADMIN("Admin", 2),
+
+ /**
+ * User that has created this project
+ */
+ OWNER("Owner", 3),
+ ;
+
+ /**
+ * @return this role with default prefix for spring-security
+ */
+ fun asSpringSecurityRole() = "ROLE_$name"
+
+ companion object {
+ /**
+ * @param springSecurityRole
+ * @return [Role] found by [springSecurityRole] using [asSpringSecurityRole]
+ */
+ fun fromSpringSecurityRole(springSecurityRole: String): Role? = entries.find { it.asSpringSecurityRole() == springSecurityRole }
+ }
+}
\ No newline at end of file
diff --git a/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/User.kt b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/User.kt
new file mode 100644
index 0000000..300c49d
--- /dev/null
+++ b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/entities/User.kt
@@ -0,0 +1,42 @@
+package com.saveourtool.template.authentication.entities
+
+import com.saveourtool.template.authentication.AppUserDetails
+import jakarta.persistence.Entity
+import jakarta.persistence.GeneratedValue
+import jakarta.persistence.GenerationType
+import jakarta.persistence.Id
+import org.springframework.security.core.userdetails.UserDetails
+
+/**
+ * @property name
+ * @property password *in plain text*
+ * @property role role of this user
+ * @property email email of user
+ */
+@Entity
+class User(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Long? = null,
+ var name: String,
+ var password: String?,
+ var role: String,
+ var email: String? = null,
+) {
+ /**
+ * @return [id] as not null with validating
+ * @throws IllegalArgumentException when [id] is not set that means entity is not saved yet
+ */
+ fun requiredId(): Long = requireNotNull(id) {
+ "Entity is not saved yet: $this"
+ }
+
+ /**
+ * @return
+ */
+ fun toUserDetails(): UserDetails = AppUserDetails(
+ requiredId(),
+ name,
+ role,
+ )
+}
diff --git a/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/repository/OriginalLoginRepository.kt b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/repository/OriginalLoginRepository.kt
new file mode 100644
index 0000000..5f2dcd0
--- /dev/null
+++ b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/repository/OriginalLoginRepository.kt
@@ -0,0 +1,23 @@
+package com.saveourtool.template.gateway.repository
+
+import com.saveourtool.template.gateway.entities.OriginalLogin
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+/**
+ * Repository to access data about original user logins and sources
+ */
+@Repository
+interface OriginalLoginRepository : JpaRepository{
+ /**
+ * @param name
+ * @param source
+ * @return user or null if no results have been found
+ */
+ fun findByNameAndSource(name: String, source: String): OriginalLogin?
+
+ /**
+ * @param id id of user
+ */
+ fun deleteByUserId(id: Long)
+}
diff --git a/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/repository/UserRepository.kt b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/repository/UserRepository.kt
new file mode 100644
index 0000000..0835901
--- /dev/null
+++ b/authentication/authentication-service/src/main/kotlin/com/saveourtool/template/authentication/repository/UserRepository.kt
@@ -0,0 +1,17 @@
+package com.saveourtool.template.gateway.repository
+
+import com.saveourtool.template.gateway.entities.User
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+/**
+ * Repository to access data about users
+ */
+@Repository
+interface UserRepository : JpaRepository {
+ /**
+ * @param username
+ * @return user or null if no results have been found
+ */
+ fun findByName(username: String): User?
+}
diff --git a/authentication/authentication-utils/build.gradle.kts b/authentication/authentication-utils/build.gradle.kts
new file mode 100644
index 0000000..0e1fcf7
--- /dev/null
+++ b/authentication/authentication-utils/build.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+ id("com.saveourtool.template.build.spring-boot-kotlin-configuration")
+}
+
+dependencies {
+ implementation("org.springframework.boot:spring-boot-starter-security")
+ implementation("com.fasterxml.jackson.core:jackson-annotations")
+ implementation(libs.kotlin.logging)
+}
\ No newline at end of file
diff --git a/authentication/authentication-utils/src/main/kotlin/com/saveourtool/template/authentication/AppUserDetails.kt b/authentication/authentication-utils/src/main/kotlin/com/saveourtool/template/authentication/AppUserDetails.kt
new file mode 100644
index 0000000..7af021d
--- /dev/null
+++ b/authentication/authentication-utils/src/main/kotlin/com/saveourtool/template/authentication/AppUserDetails.kt
@@ -0,0 +1,100 @@
+package com.saveourtool.template.authentication
+
+import com.fasterxml.jackson.annotation.JsonIgnore
+import io.github.oshai.kotlinlogging.KotlinLogging.logger
+import org.springframework.http.HttpHeaders
+import org.springframework.security.core.GrantedAuthority
+import org.springframework.security.core.authority.AuthorityUtils
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken
+
+class AppUserDetails(
+ val id: Long,
+ val name: String,
+ val role: String,
+): UserDetails {
+
+ /**
+ * @return [PreAuthenticatedAuthenticationToken]
+ */
+ fun toPreAuthenticatedAuthenticationToken() =
+ PreAuthenticatedAuthenticationToken(this, null, authorities)
+
+ /**
+ * Populates `X-Authorization-*` headers
+ *
+ * @param httpHeaders
+ */
+ fun populateHeaders(httpHeaders: HttpHeaders) {
+ httpHeaders.set(AUTHORIZATION_ID, id.toString())
+ httpHeaders.set(AUTHORIZATION_NAME, name)
+ httpHeaders.set(AUTHORIZATION_ROLES, role)
+ }
+
+ @JsonIgnore
+ override fun getAuthorities(): MutableCollection = AuthorityUtils.commaSeparatedStringToAuthorityList(role)
+
+ @JsonIgnore
+ override fun getPassword(): String? = null
+
+ @JsonIgnore
+ override fun getUsername(): String = name
+
+ @JsonIgnore
+ override fun isAccountNonExpired(): Boolean = true
+
+ @JsonIgnore
+ override fun isAccountNonLocked(): Boolean = true
+
+ @JsonIgnore
+ override fun isCredentialsNonExpired(): Boolean = true
+
+ @JsonIgnore
+ override fun isEnabled(): Boolean = true
+
+ @Suppress("UastIncorrectHttpHeaderInspection")
+ companion object {
+ @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION")
+ private val log = logger {}
+
+ /**
+ * `X-Authorization-Roles` used to specify application's user id
+ */
+ const val AUTHORIZATION_ID = "X-Authorization-Id"
+
+ /**
+ * `X-Authorization-Roles` used to specify application's username
+ */
+ const val AUTHORIZATION_NAME = "X-Authorization-Name"
+
+ /**
+ * `X-Authorization-Roles` used to specify application's user roles
+ */
+ const val AUTHORIZATION_ROLES = "X-Authorization-Roles"
+
+ /**
+ * An attribute to store application's user
+ */
+ const val APPLICATION_USER_ATTRIBUTE = "application-user"
+
+ /**
+ * @return [AppUserDetails] created from values in headers
+ */
+ fun HttpHeaders.toAppUserDetails(): AppUserDetails? {
+ return AppUserDetails(
+ id = getSingleHeader(AUTHORIZATION_ID)?.toLong() ?: return logWarnAndReturnEmpty(AUTHORIZATION_ID),
+ name = getSingleHeader(AUTHORIZATION_NAME) ?: return logWarnAndReturnEmpty(AUTHORIZATION_NAME),
+ role = getSingleHeader(AUTHORIZATION_ROLES) ?: return logWarnAndReturnEmpty(AUTHORIZATION_ROLES),
+ )
+ }
+
+ private fun HttpHeaders.getSingleHeader(headerName: String) = get(headerName)?.singleOrNull()
+
+ private fun logWarnAndReturnEmpty(missedHeaderName: String): T? {
+ log.debug {
+ "Header $missedHeaderName is not provided: skipping pre-authenticated save-user authentication"
+ }
+ return null
+ }
+ }
+}
diff --git a/backend-webflux/build.gradle.kts b/backend-webflux/build.gradle.kts
index fc0f306..8100d3b 100644
--- a/backend-webflux/build.gradle.kts
+++ b/backend-webflux/build.gradle.kts
@@ -5,12 +5,11 @@ plugins {
}
dependencies {
- implementation(libs.springdoc.openapi.starter.common)
- implementation(libs.springdoc.openapi.starter.webflux.ui)
+ implementation(projects.common)
+ implementation("org.springdoc:springdoc-openapi-starter-webflux-ui")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
- implementation(projects.common)
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
diff --git a/backend-webmvc/build.gradle.kts b/backend-webmvc/build.gradle.kts
index c819691..80ec805 100644
--- a/backend-webmvc/build.gradle.kts
+++ b/backend-webmvc/build.gradle.kts
@@ -5,11 +5,10 @@ plugins {
}
dependencies {
- implementation(libs.springdoc.openapi.starter.common)
- implementation(libs.springdoc.openapi.starter.webmvc.ui)
+ implementation(projects.common)
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
- implementation(projects.common)
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index a1671ca..350aa4f 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -9,6 +9,9 @@ kotlin {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.core)
+ implementation("org.springframework:spring-web")
+ implementation("io.projectreactor:reactor-core")
+ implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
}
}
}
diff --git a/common/src/jvmMain/kotlin/com/saveourtool/template/util/ReactorUtils.kt b/common/src/jvmMain/kotlin/com/saveourtool/template/util/ReactorUtils.kt
new file mode 100644
index 0000000..ac75aa6
--- /dev/null
+++ b/common/src/jvmMain/kotlin/com/saveourtool/template/util/ReactorUtils.kt
@@ -0,0 +1,42 @@
+/**
+ * Utility methods for working with Reactor publishers
+ */
+
+package com.saveourtool.template.util
+
+import org.springframework.http.HttpStatus
+import org.springframework.web.server.ResponseStatusException
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+import reactor.core.scheduler.Scheduler
+import reactor.core.scheduler.Schedulers
+import reactor.kotlin.core.publisher.switchIfEmpty
+import reactor.kotlin.core.publisher.toMono
+
+private val ioScheduler: Scheduler = Schedulers.boundedElastic()
+
+/**
+ * @param status
+ * @param messageCreator
+ * @return original [Mono] or [Mono.error] with [status] otherwise
+ */
+fun Mono.switchIfEmptyToResponseException(status: HttpStatus, messageCreator: (() -> String?) = { null }) = switchIfEmpty {
+ Mono.error(ResponseStatusException(status, messageCreator()))
+}
+
+
+/**
+ * Taking from https://projectreactor.io/docs/core/release/reference/#faq.wrap-blocking
+ *
+ * @param supplier blocking operation like JDBC
+ * @return [Mono] from result of blocking operation [T]
+ * @see blockingToFlux
+ */
+fun blockingToMono(supplier: () -> T?): Mono = supplier.toMono().subscribeOn(ioScheduler)
+
+/**
+ * @param supplier blocking operation like JDBC
+ * @return [Flux] from result of blocking operation [List] of [T]
+ * @see blockingToMono
+ */
+fun blockingToFlux(supplier: () -> Iterable): Flux = blockingToMono(supplier).flatMapIterable { it }
diff --git a/gateway/build.gradle.kts b/gateway/build.gradle.kts
new file mode 100644
index 0000000..e5ebd7b
--- /dev/null
+++ b/gateway/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+ id("com.saveourtool.template.build.spring-boot-kotlin-configuration")
+ id("com.saveourtool.template.build.mysql-local-run-configuration")
+}
+
+dependencies {
+ implementation(projects.common)
+ implementation(projects.authentication.authenticationUtils)
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
+ implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
+ implementation("org.springframework.cloud:spring-cloud-starter-gateway")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
+ implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
+ implementation("org.springframework.boot:spring-boot-starter-data-jpa")
+}
+
+mysqlLocalRun {
+ databaseName = "gateway"
+ liquibaseChangelogPath = project.layout.projectDirectory.file("src/db/db.changelog.xml")
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/AuthorizationHeadersGatewayFilterFactory.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/AuthorizationHeadersGatewayFilterFactory.kt
new file mode 100644
index 0000000..db89202
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/AuthorizationHeadersGatewayFilterFactory.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) Huawei Technologies Co., Ltd. 2024-2024. All rights reserved.
+ */
+
+package com.saveourtool.template.gateway
+
+import com.saveourtool.template.gateway.service.AppUserDetailsService
+import org.springframework.cloud.gateway.filter.GatewayFilter
+import org.springframework.cloud.gateway.filter.GatewayFilterChain
+import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory
+import org.springframework.http.HttpHeaders
+import org.springframework.stereotype.Component
+import org.springframework.web.server.ServerWebExchange
+import java.security.Principal
+
+/**
+ * Filter, that mutate existing exchange,
+ * inserts user's info into Authorization headers instead of existing value, not paying attention to the credentials,
+ * since at this moment they are already checked by gateway.
+ */
+@Component
+class AuthorizationHeadersGatewayFilterFactory(
+ private val appUserDetailsService: AppUserDetailsService,
+// private val backendService: BackendService,
+) : AbstractGatewayFilterFactory() {
+ override fun apply(config: Any?): GatewayFilter = GatewayFilter { exchange: ServerWebExchange, chain: GatewayFilterChain ->
+ exchange.getPrincipal()
+ .flatMap { principal ->
+ exchange.session.flatMap { session ->
+ backendService.findByPrincipal(principal, session)
+ }
+ }
+ .map { user ->
+ exchange.mutate()
+ .request { builder ->
+ builder.headers { headers: HttpHeaders ->
+ headers.remove(HttpHeaders.AUTHORIZATION)
+ user.populateHeaders(headers)
+ }
+ }
+ .build()
+ }
+ .defaultIfEmpty(exchange)
+ .flatMap { chain.filter(it) }
+ }
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/GatewayApplication.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/GatewayApplication.kt
new file mode 100644
index 0000000..53f7183
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/GatewayApplication.kt
@@ -0,0 +1,14 @@
+package com.saveourtool.template.gateway
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.runApplication
+
+/**
+ * @since 2024-03-19
+ */
+@SpringBootApplication
+class GatewayApplication
+
+fun main(args: Array) {
+ runApplication(*args)
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/OriginalLogin.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/OriginalLogin.kt
new file mode 100644
index 0000000..f4ba006
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/OriginalLogin.kt
@@ -0,0 +1,25 @@
+package com.saveourtool.template.gateway.entities
+
+import jakarta.persistence.Entity
+import jakarta.persistence.GeneratedValue
+import jakarta.persistence.GenerationType
+import jakarta.persistence.Id
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
+
+/**
+ * @property name
+ * @property user
+ * @property source
+ */
+@Entity
+class OriginalLogin(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Long? = null,
+ var name: String,
+ var source: String,
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ var user: User,
+)
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/Role.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/Role.kt
new file mode 100644
index 0000000..60e5d35
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/Role.kt
@@ -0,0 +1,45 @@
+package com.saveourtool.template.gateway.entities
+
+/**
+ * User roles
+ * @property formattedName string representation of the [Role] that should be printed
+ * @property priority
+ */
+enum class Role(
+ val formattedName: String,
+ private val priority: Int,
+) {
+ /**
+ * Has no role (synonym to null)
+ */
+ NONE("None", 0),
+
+ /**
+ * Has readonly access to public projects.
+ */
+ VIEWER("Viewer", 1),
+
+ /**
+ * admin in organization
+ */
+ ADMIN("Admin", 2),
+
+ /**
+ * User that has created this project
+ */
+ OWNER("Owner", 3),
+ ;
+
+ /**
+ * @return this role with default prefix for spring-security
+ */
+ fun asSpringSecurityRole() = "ROLE_$name"
+
+ companion object {
+ /**
+ * @param springSecurityRole
+ * @return [Role] found by [springSecurityRole] using [asSpringSecurityRole]
+ */
+ fun fromSpringSecurityRole(springSecurityRole: String): Role? = entries.find { it.asSpringSecurityRole() == springSecurityRole }
+ }
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/User.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/User.kt
new file mode 100644
index 0000000..e29a0ec
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/entities/User.kt
@@ -0,0 +1,42 @@
+package com.saveourtool.template.gateway.entities
+
+import com.saveourtool.template.authentication.AppUserDetails
+import jakarta.persistence.Entity
+import jakarta.persistence.GeneratedValue
+import jakarta.persistence.GenerationType
+import jakarta.persistence.Id
+import org.springframework.security.core.userdetails.UserDetails
+
+/**
+ * @property name
+ * @property password *in plain text*
+ * @property role role of this user
+ * @property email email of user
+ */
+@Entity
+class User(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Long? = null,
+ var name: String,
+ var password: String?,
+ var role: String,
+ var email: String? = null,
+) {
+ /**
+ * @return [id] as not null with validating
+ * @throws IllegalArgumentException when [id] is not set that means entity is not saved yet
+ */
+ fun requiredId(): Long = requireNotNull(id) {
+ "Entity is not saved yet: $this"
+ }
+
+ /**
+ * @return
+ */
+ fun toUserDetails(): UserDetails = AppUserDetails(
+ requiredId(),
+ name,
+ role,
+ )
+}
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/repository/OriginalLoginRepository.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/repository/OriginalLoginRepository.kt
new file mode 100644
index 0000000..5f2dcd0
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/repository/OriginalLoginRepository.kt
@@ -0,0 +1,23 @@
+package com.saveourtool.template.gateway.repository
+
+import com.saveourtool.template.gateway.entities.OriginalLogin
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+/**
+ * Repository to access data about original user logins and sources
+ */
+@Repository
+interface OriginalLoginRepository : JpaRepository{
+ /**
+ * @param name
+ * @param source
+ * @return user or null if no results have been found
+ */
+ fun findByNameAndSource(name: String, source: String): OriginalLogin?
+
+ /**
+ * @param id id of user
+ */
+ fun deleteByUserId(id: Long)
+}
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/repository/UserRepository.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/repository/UserRepository.kt
new file mode 100644
index 0000000..0835901
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/repository/UserRepository.kt
@@ -0,0 +1,17 @@
+package com.saveourtool.template.gateway.repository
+
+import com.saveourtool.template.gateway.entities.User
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+/**
+ * Repository to access data about users
+ */
+@Repository
+interface UserRepository : JpaRepository {
+ /**
+ * @param username
+ * @return user or null if no results have been found
+ */
+ fun findByName(username: String): User?
+}
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/security/WebSecurityConfig.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/security/WebSecurityConfig.kt
new file mode 100644
index 0000000..06cbe13
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/security/WebSecurityConfig.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) Huawei Technologies Co., Ltd. 2024-2024. All rights reserved.
+ */
+
+package com.saveourtool.template.gateway.security
+
+import com.saveourtool.template.gateway.service.UserService
+import com.saveourtool.template.gateway.utils.StoringServerAuthenticationSuccessHandler
+import com.saveourtool.template.util.blockingToMono
+import org.springframework.context.annotation.Bean
+import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
+import org.springframework.security.config.web.server.ServerHttpSecurity
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.web.server.SecurityWebFilterChain
+import org.springframework.security.web.server.authentication.DelegatingServerAuthenticationSuccessHandler
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler
+
+/**
+ * @since 2024-03-20
+ */
+@EnableWebFluxSecurity
+class WebSecurityConfig(
+ private val userService: UserService,
+) {
+ @Bean
+ fun securityWebFilterChain(
+ http: ServerHttpSecurity
+ ): SecurityWebFilterChain = http
+ .oauth2Login {
+ it.authenticationSuccessHandler(
+ DelegatingServerAuthenticationSuccessHandler(
+ StoringServerAuthenticationSuccessHandler(backendService),
+ RedirectServerAuthenticationSuccessHandler("/"),
+ )
+ )
+ it.authenticationFailureHandler(
+ RedirectServerAuthenticationFailureHandler("/error")
+ )
+ }
+ .httpBasic { httpBasicSpec ->
+ // Authenticate by comparing received basic credentials with existing one from DB
+ httpBasicSpec.authenticationManager(
+ UserDetailsRepositoryReactiveAuthenticationManager { username ->
+ blockingToMono {
+ userService.findByName(username)
+ }
+ .filter { it.password != null }
+ .map {
+
+ }
+
+ backendService.findByName(username).cast()
+ }
+ )
+ }
+ .build()
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/service/AppUserDetailsService.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/service/AppUserDetailsService.kt
new file mode 100644
index 0000000..baa0eb8
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/service/AppUserDetailsService.kt
@@ -0,0 +1,93 @@
+package com.saveourtool.template.gateway.service
+
+import com.saveourtool.template.authentication.AppUserDetails
+import com.saveourtool.template.authentication.AppUserDetails.Companion.APPLICATION_USER_ATTRIBUTE
+import com.saveourtool.template.util.switchIfEmptyToResponseException
+import org.springframework.http.HttpStatus
+import org.springframework.security.authentication.BadCredentialsException
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
+import org.springframework.stereotype.Component
+import org.springframework.web.server.ResponseStatusException
+import org.springframework.web.server.WebSession
+import reactor.core.publisher.Mono
+import reactor.kotlin.core.publisher.switchIfEmpty
+import reactor.kotlin.core.publisher.toMono
+import java.security.Principal
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.locks.ReentrantReadWriteLock
+import kotlin.concurrent.read
+import kotlin.concurrent.write
+
+/**
+ * A temporary cache for [AppUserDetails]
+ */
+@Component
+class AppUserDetailsService {
+ private val reentrantReadWriteLock = ReentrantReadWriteLock()
+ private val idGenerator = AtomicLong()
+ private val storage: HashMap = hashMapOf()
+
+ /**
+ * Saves a new [AppUserDetails] in DB
+ *
+ * @param source
+ * @param nameInSource
+ * @return [Mono] with saved [AppUserDetails]
+ */
+ fun createNewIfRequired(source: String, nameInSource: String): Mono = reentrantReadWriteLock.write {
+ AppUserDetails(
+ id = idGenerator.incrementAndGet(),
+ name = nameInSource,
+ role = "VIEWER",
+ )
+ .also { storage[it.id] = it }
+ .toMono()
+ }
+
+ /**
+ * Find current user [SaveUserDetails] by [principal].
+ *
+ * @param principal current user [Principal]
+ * @param session current [WebSession]
+ * @return current user [SaveUserDetails]
+ */
+ fun findByPrincipal(principal: Principal, session: WebSession): Mono = when (principal) {
+ is OAuth2AuthenticationToken -> session.getAppUserDetails().switchIfEmpty {
+ findByOriginalLogin(principal.authorizedClientRegistrationId, principal.name)
+ }
+ is UsernamePasswordAuthenticationToken -> (principal.principal as? SaveUserDetails)
+ .toMono()
+ .switchIfEmptyToResponseException(HttpStatus.INTERNAL_SERVER_ERROR) {
+ "Unexpected principal type ${principal.principal.javaClass} in ${UsernamePasswordAuthenticationToken::class}"
+ }
+ else -> Mono.error(BadCredentialsException("Unsupported authentication type: ${principal::class}"))
+ }
+
+ /**
+ * @param id [AppUserDetails.id]
+ * @return cached [AppUserDetails] or null
+ */
+ fun get(id: Long): AppUserDetails? = reentrantReadWriteLock.read {
+ storage[id]
+ }
+
+ /**
+ * Caches provided [appUserDetails]
+ *
+ * @param appUserDetails [AppUserDetails]
+ */
+ fun save(appUserDetails: AppUserDetails): Unit = reentrantReadWriteLock.write {
+ storage[appUserDetails.id] = appUserDetails
+ }
+
+ private fun WebSession.getAppUserDetails(): Mono = this
+ .getAttribute(APPLICATION_USER_ATTRIBUTE)
+ .toMono()
+ .switchIfEmptyToResponseException(HttpStatus.INTERNAL_SERVER_ERROR) {
+ "Not found attribute $APPLICATION_USER_ATTRIBUTE for ${OAuth2AuthenticationToken::class}"
+ }
+ .mapNotNull { id ->
+ get(id)
+ }
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/service/UserService.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/service/UserService.kt
new file mode 100644
index 0000000..9b75aaf
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/service/UserService.kt
@@ -0,0 +1,51 @@
+package com.saveourtool.template.gateway.service
+
+import com.saveourtool.template.gateway.entities.OriginalLogin
+import com.saveourtool.template.gateway.entities.Role
+import com.saveourtool.template.gateway.entities.User
+import com.saveourtool.template.gateway.repository.OriginalLoginRepository
+import com.saveourtool.template.gateway.repository.UserRepository
+import org.springframework.transaction.annotation.Transactional
+
+/**
+ * Service for [User]
+ */
+open class UserService(
+ private val userRepository: UserRepository,
+ private val originalLoginRepository: OriginalLoginRepository,
+) {
+ /**
+ * @param username
+ * @return existed [User]
+ */
+ fun findByName(username: String): User? =
+ userRepository.findByName(username)
+
+ /**
+ * @param source source (where the user identity is coming from)
+ * @param nameInSource name provided by source
+ * @return existed [User] or a new created [User]
+ */
+ @Transactional
+ open fun getOrCreateNew(
+ source: String,
+ nameInSource: String,
+ ): User = originalLoginRepository.findByNameAndSource(nameInSource, source)
+ ?.user
+ ?: run {
+ val newUser = User(
+ name = nameInSource,
+ password = null,
+ role = Role.VIEWER.formattedName,
+ )
+ .let { userRepository.save(it) }
+ originalLoginRepository.save(
+ OriginalLogin(
+ name = nameInSource,
+ source = source,
+ user = newUser
+ )
+ )
+ newUser
+ }
+}
\ No newline at end of file
diff --git a/gateway/src/main/kotlin/com/saveourtool/template/gateway/utils/StoringServerAuthenticationSuccessHandler.kt b/gateway/src/main/kotlin/com/saveourtool/template/gateway/utils/StoringServerAuthenticationSuccessHandler.kt
new file mode 100644
index 0000000..2dbb7f2
--- /dev/null
+++ b/gateway/src/main/kotlin/com/saveourtool/template/gateway/utils/StoringServerAuthenticationSuccessHandler.kt
@@ -0,0 +1,39 @@
+package com.saveourtool.template.gateway.utils
+
+import com.saveourtool.template.authentication.AppUserDetails.Companion.APPLICATION_USER_ATTRIBUTE
+import com.saveourtool.template.gateway.service.AppUserDetailsService
+
+import org.slf4j.LoggerFactory
+import org.springframework.security.authentication.BadCredentialsException
+import org.springframework.security.core.Authentication
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
+import org.springframework.security.web.server.WebFilterExchange
+import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler
+import reactor.core.publisher.Mono
+
+/**
+ * [ServerAuthenticationSuccessHandler] that saves user data in database on successful login
+ */
+class StoringServerAuthenticationSuccessHandler(
+ private val appUserDetailsService: AppUserDetailsService,
+) : ServerAuthenticationSuccessHandler {
+ private val logger = LoggerFactory.getLogger(javaClass)
+
+ override fun onAuthenticationSuccess(
+ webFilterExchange: WebFilterExchange,
+ authentication: Authentication
+ ): Mono {
+ logger.info("Authenticated user ${authentication.name} with authentication type ${authentication::class}, will send data to backend")
+
+ val (source, nameInSource) = if (authentication is OAuth2AuthenticationToken) {
+ authentication.authorizedClientRegistrationId to authentication.principal.name
+ } else {
+ throw BadCredentialsException("Not supported authentication type ${authentication::class}")
+ }
+ return appUserDetailsService.createNewIfRequired(source, nameInSource).flatMap { appUser ->
+ webFilterExchange.exchange.session.map {
+ it.attributes[APPLICATION_USER_ATTRIBUTE] = appUser
+ }
+ }.then()
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ad3b107..53b5cf9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,7 +3,10 @@ kotlin = "1.9.22"
kotlin-wrappers = "1.0.0-pre.715"
kotlin-serialization = "1.6.3"
spring-boot = "3.2.3"
-springdoc = "2.3.0"
+spring-cloud = "2023.0.0"
+spring-data = "2023.1.4"
+springdoc = "2.4.0"
+kotlin-logging = "6.0.3"
[plugins]
@@ -14,10 +17,12 @@ kotlin-multiplatform-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gra
kotlin-serialization-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" }
-spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
-springdoc-openapi-starter-common = { module = "org.springdoc:springdoc-openapi-starter-common", version.ref = "springdoc" }
-springdoc-openapi-starter-webflux-ui = { module = "org.springdoc:springdoc-openapi-starter-webflux-ui", version.ref = "springdoc" }
-springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" }
+spring-boot-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
+spring-cloud-bom = { module = "org.springframework.cloud:spring-cloud-dependencies", version.ref = "spring-cloud" }
+spring-data-bom = { module = "org.springframework.data:spring-data-bom", version.ref = "spring-data" }
+springdoc-openapi-bom = { module = "org.springdoc:springdoc-openapi", version.ref = "springdoc" }
kotlin-wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version.ref = "kotlin-wrappers" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlin-serialization"}
+
+kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "kotlin-logging" }
diff --git a/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/Utils.kt b/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/Utils.kt
index 0f8f394..e1a7c1f 100644
--- a/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/Utils.kt
+++ b/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/Utils.kt
@@ -1,3 +1,22 @@
package com.saveourtool.template.build
+import org.gradle.accessors.dm.LibrariesForLibs
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Dependency
+import org.gradle.api.artifacts.MinimalExternalModuleDependency
+import org.gradle.api.provider.Provider
+import org.gradle.kotlin.dsl.the
+
fun kotlinw(target: String): String = "org.jetbrains.kotlin-wrappers:kotlin-$target"
+
+internal fun addAllSpringRelatedBoms(
+ project: Project,
+ implementation: (Provider) -> Dependency?,
+) {
+ @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION")
+ val libs = project.the()
+ implementation(project.dependencies.enforcedPlatform(libs.spring.boot.bom))
+ implementation(project.dependencies.platform(libs.spring.cloud.bom))
+ implementation(project.dependencies.platform(libs.spring.data.bom))
+ implementation(project.dependencies.platform(libs.springdoc.openapi.bom))
+}
\ No newline at end of file
diff --git a/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/kotlin-mpp-with-jvm-configuration.gradle.kts b/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/kotlin-mpp-with-jvm-configuration.gradle.kts
index fd37296..28a784d 100644
--- a/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/kotlin-mpp-with-jvm-configuration.gradle.kts
+++ b/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/kotlin-mpp-with-jvm-configuration.gradle.kts
@@ -8,16 +8,13 @@ plugins {
kotlin("multiplatform")
}
-@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION")
-val libs = the()
-
kotlin {
jvm()
sourceSets {
jvmMain {
dependencies {
- implementation(project.dependencies.enforcedPlatform(libs.spring.boot.dependencies))
+ addAllSpringRelatedBoms(project, ::implementation)
}
}
}
diff --git a/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/spring-boot-kotlin-configuration.gradle.kts b/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/spring-boot-kotlin-configuration.gradle.kts
index 4743fc1..7833ccd 100644
--- a/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/spring-boot-kotlin-configuration.gradle.kts
+++ b/gradle/plugins/src/main/kotlin/com/saveourtool/template/build/spring-boot-kotlin-configuration.gradle.kts
@@ -1,10 +1,7 @@
package com.saveourtool.template.build
-import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.*
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
@@ -16,10 +13,8 @@ plugins {
configureKotlinCompile()
-@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION")
-val libs = the()
dependencies {
- implementation(project.dependencies.enforcedPlatform(libs.spring.boot.dependencies))
+ addAllSpringRelatedBoms(project, ::implementation)
}
tasks.withType {
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 902a226..7240256 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -24,8 +24,11 @@ plugins {
includeBuild("gradle/plugins")
include("common")
+include("authentication:authentication-service")
+include("authentication:authentication-utils")
include("backend-webmvc")
include("backend-webflux")
+include("gateway")
include("frontend")
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")