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")