diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ec3f50dbc..414e2297f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -134,3 +134,13 @@ There is following workflow to make changes to component code: - Commit and push changes - Create pull-request and merge changes in corresponding branch (**main**, **adoption** or **release**) - Run "**Release**" GitHub workflow to publish new release + +## Database migration + +There is the following workflow to modify the database: +- Use flyway db migration tool for any changes in database scheme +- The default location of flyway migration files is `/db/migration` +- The format of flyway migration files is `V[version]__[description]` (e.g. `V1__create_user_table`), +where `[version]` is a migration sequence number (1,2,3,..), +`[description]` is a short description of the migration +- It is not allowed to make any changes to migration files after merging into the main branch \ No newline at end of file diff --git a/admin-auth/LICENSE b/admin-auth/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/admin-auth/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/admin-auth/build.gradle.kts b/admin-auth/build.gradle.kts new file mode 100644 index 000000000..7ce9f9d22 --- /dev/null +++ b/admin-auth/build.gradle.kts @@ -0,0 +1,81 @@ +import java.net.URI +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import com.hierynomus.gradle.license.tasks.LicenseCheck +import com.hierynomus.gradle.license.tasks.LicenseFormat + +plugins { + kotlin("jvm") + id("com.github.hierynomus.license") + kotlin("plugin.serialization") +} + +group = "com.epam.drill.admin.auth" +version = rootProject.version + +val kotlinVersion: String by parent!!.extra +val microutilsLoggingVersion: String by parent!!.extra +val ktorVersion: String by parent!!.extra +val kodeinVersion: String by parent!!.extra +val kotlinxSerializationVersion: String by parent!!.extra +val mockitoKotlinVersion: String by parent!!.extra +val jbcryptVersion: String by parent!!.extra +val exposedVersion: String by parent!!.extra +val flywaydbVersion: String by parent!!.extra +val testContainersVersion: String by parent!!.extra +val postgresSqlVersion: String by parent!!.extra +val zaxxerHikaricpVersion: String by parent!!.extra + +repositories { + mavenLocal() + mavenCentral() +} + +kotlin.sourceSets { + all { + languageSettings.optIn("kotlin.Experimental") + languageSettings.optIn("kotlin.ExperimentalStdlibApi") + languageSettings.optIn("kotlin.time.ExperimentalTime") + languageSettings.optIn("io.ktor.locations.KtorExperimentalLocationsAPI") + languageSettings.optIn("io.ktor.util.InternalAPI") + } +} + +dependencies { + implementation(kotlin("stdlib-jdk8")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") + implementation("io.github.microutils:kotlin-logging-jvm:$microutilsLoggingVersion") + implementation("org.kodein.di:kodein-di-framework-ktor-server-jvm:$kodeinVersion") + implementation("io.ktor:ktor-server-core:$ktorVersion") + implementation("io.ktor:ktor-server-netty:$ktorVersion") + implementation("io.ktor:ktor-serialization:$ktorVersion") + implementation("io.ktor:ktor-locations:$ktorVersion") + implementation("io.ktor:ktor-auth:$ktorVersion") + implementation("io.ktor:ktor-auth-jwt:$ktorVersion") + implementation("org.mindrot:jbcrypt:$jbcryptVersion") + implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") + api("org.flywaydb:flyway-core:$flywaydbVersion") + testImplementation(kotlin("test-junit5")) + testImplementation("io.ktor:ktor-server-test-host:$ktorVersion") + testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlinVersion") + testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion") + testImplementation("org.testcontainers:testcontainers:$testContainersVersion") + testImplementation("org.testcontainers:junit-jupiter:$testContainersVersion") + testImplementation("org.testcontainers:postgresql:$testContainersVersion") + testImplementation("org.postgresql:postgresql:$postgresSqlVersion") + testImplementation("com.zaxxer:HikariCP:$zaxxerHikaricpVersion") +} + +tasks { + test { + useJUnitPlatform() + } + withType { + kotlinOptions.jvmTarget = "1.8" + } +} + +license { + headerURI = URI("https://raw.githubusercontent.com/Drill4J/drill4j/develop/COPYRIGHT") + include("**/*.kt") +} diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/DatabaseConfig.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/DatabaseConfig.kt new file mode 100644 index 000000000..40f45ff9b --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/DatabaseConfig.kt @@ -0,0 +1,43 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.config + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import org.flywaydb.core.Flyway +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import javax.sql.DataSource + +object DatabaseConfig { + + private var database: Database? = null + private var dispatcher: CoroutineDispatcher = Dispatchers.IO + + fun init(dataSource: DataSource) { + database = Database.connect(dataSource) + Flyway.configure() + .dataSource(dataSource) + .schemas("auth") + .baselineOnMigrate(true) + .locations("classpath:auth/db/migration") + .load() + .migrate() + } + + suspend fun transaction(block: suspend () -> T): T = + newSuspendedTransaction(dispatcher, database) { block() } +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/DiConfig.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/DiConfig.kt new file mode 100644 index 000000000..439ed9339 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/DiConfig.kt @@ -0,0 +1,102 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.config + +import com.epam.drill.admin.auth.config.UserRepoType.DB +import com.epam.drill.admin.auth.config.UserRepoType.ENV +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.repository.impl.EnvUserRepository +import com.epam.drill.admin.auth.repository.impl.DatabaseUserRepository +import com.epam.drill.admin.auth.service.* +import com.epam.drill.admin.auth.service.impl.* +import com.epam.drill.admin.auth.service.transaction.TransactionalUserAuthenticationService +import com.epam.drill.admin.auth.service.transaction.TransactionalUserManagementService +import io.ktor.application.* +import mu.KotlinLogging +import org.kodein.di.* + +private val logger = KotlinLogging.logger {} + +enum class UserRepoType { + DB, + ENV +} + +val securityDiConfig: DI.Builder.(Application) -> Unit = { _ -> + bindJwt() + bind() with eagerSingleton { SecurityConfig(di) } + bind() with singleton { PasswordStrengthConfig(di) } +} + +val usersDiConfig: DI.Builder.(Application) -> Unit = { application -> + userRepositoriesConfig(application.userRepoType) + userServicesConfig(application.userRepoType) +} + +fun DI.Builder.bindJwt() { + bind() with singleton { JwtConfig(di) } + bind() with singleton { JwtTokenService(instance()) } + bind() with provider { instance() } +} + +fun DI.Builder.userServicesConfig(userRepoType: UserRepoType) { + bind() with singleton { + UserAuthenticationServiceImpl( + userRepository = instance(), + passwordService = instance() + ).let { service -> + if (userRepoType == DB) + TransactionalUserAuthenticationService(service) + else + service + } + } + bind() with singleton { + UserManagementServiceImpl( + userRepository = instance(), + passwordService = instance() + ).let { service -> + if (userRepoType == DB) + TransactionalUserManagementService(service) + else + service + } + } + bind() with singleton { PasswordGeneratorImpl(config = instance()) } + bind() with singleton { PasswordValidatorImpl(config = instance()) } + bind() with singleton { PasswordServiceImpl(instance(), instance()) } +} + +fun DI.Builder.userRepositoriesConfig(userRepoType: UserRepoType) { + bind() with singleton { + logger.info { "The user repository type is $userRepoType" } + when (userRepoType) { + DB -> DatabaseUserRepository() + ENV -> EnvUserRepository( + env = instance().environment.config, + passwordService = instance() + ) + } + } +} + +private val Application.userRepoType: UserRepoType + get() = environment.config + .config("drill") + .config("auth") + .propertyOrNull("userRepoType") + ?.getString()?.let { UserRepoType.valueOf(it) } + ?: DB \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/JwtConfig.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/JwtConfig.kt new file mode 100644 index 000000000..b0db61a62 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/JwtConfig.kt @@ -0,0 +1,60 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.config + +import io.ktor.application.* +import io.ktor.config.* +import mu.KotlinLogging +import org.kodein.di.* +import javax.crypto.KeyGenerator +import kotlin.time.* + +private val logger = KotlinLogging.logger {} + +class JwtConfig(override val di: DI) : DIAware { + private val app by instance() + private val jwt: ApplicationConfig + get() = app.environment.config + .config("drill") + .config("auth") + .config("jwt") + + private val generatedSecret: String by lazy { + logger.warn { + "The generated secret key for the JWT is used. " + + "To set your secret key, use the DRILL_JWT_SECRET environment variable." + } + generateSecret() + } + val secret: String + get() = jwt.propertyOrNull("secret")?.getString() ?: generatedSecret + + val issuer: String + get() = jwt.propertyOrNull("issuer")?.getString() ?: "Drill4J App" + + val lifetime: Duration + get() = jwt.propertyOrNull("lifetime")?.getDuration() ?: Duration.minutes(30) + + val audience: String? + get() = jwt.propertyOrNull("audience")?.getString() +} + +private fun ApplicationConfigValue.getDuration(): Duration { + return Duration.parse(getString()) +} + + +internal fun generateSecret() = KeyGenerator.getInstance("HmacSHA512").generateKey().encoded.contentToString() \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/PasswordStrengthConfig.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/PasswordStrengthConfig.kt new file mode 100644 index 000000000..4f1ac93cf --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/PasswordStrengthConfig.kt @@ -0,0 +1,43 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.config + +import io.ktor.application.* +import io.ktor.config.* +import org.kodein.di.DI +import org.kodein.di.DIAware +import org.kodein.di.instance + +class PasswordStrengthConfig(override val di: DI) : DIAware { + private val app by instance() + private val jwt: ApplicationConfig + get() = app.environment.config + .config("drill") + .config("auth") + .config("password") + + val minLength: Int + get() = jwt.propertyOrNull("minLength")?.getString()?.toInt() ?: 6 + + val mustContainUppercase: Boolean + get() = jwt.propertyOrNull("mustContainUppercase")?.getString()?.toBoolean() ?: false + + val mustContainLowercase: Boolean + get() = jwt.propertyOrNull("mustContainLowercase")?.getString()?.toBoolean() ?: false + + val mustContainDigit: Boolean + get() = jwt.propertyOrNull("mustContainDigit")?.getString()?.toBoolean() ?: false +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/RoleBasedAuthorization.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/RoleBasedAuthorization.kt new file mode 100644 index 000000000..08cf0fadb --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/RoleBasedAuthorization.kt @@ -0,0 +1,73 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.config + +import com.epam.drill.admin.auth.exception.NotAuthorizedException +import com.epam.drill.admin.auth.exception.NotAuthenticatedException +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.principal.User +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* + +class RoleBasedAuthorization { + + fun interceptPipeline( + pipeline: ApplicationCallPipeline, + roles: Set + ) { + pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, Authentication.ChallengePhase) + pipeline.insertPhaseAfter(Authentication.ChallengePhase, AuthorizationPhase) + + pipeline.intercept(AuthorizationPhase) { + val principal = call.authentication.principal() ?: throw NotAuthenticatedException() + if (!roles.contains(principal.role)) { + throw NotAuthorizedException() + } + } + } + + companion object Feature : ApplicationFeature { + override val key = AttributeKey("RoleBasedAuthorization") + override fun install( + pipeline: ApplicationCallPipeline, + configure: Unit.() -> Unit + ): RoleBasedAuthorization { + return RoleBasedAuthorization() + } + + val AuthorizationPhase = PipelinePhase("Authorization") + } + +} + +class AuthorizedRouteSelector : RouteSelector() { + override fun evaluate(context: RoutingResolveContext, segmentIndex: Int) = RouteSelectorEvaluation.Constant +} + +fun Route.withRole(vararg role: Role, build: Route.() -> Unit) = authorizedRoute(role.toSet(), build = build) + +private fun Route.authorizedRoute( + roles: Set, + build: Route.() -> Unit +): Route { + val authorizedRoute = createChild(AuthorizedRouteSelector()) + application.feature(RoleBasedAuthorization).interceptPipeline(authorizedRoute, roles) + authorizedRoute.build() + return authorizedRoute +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/SecurityConfig.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/SecurityConfig.kt new file mode 100644 index 000000000..a89f1f3c2 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/config/SecurityConfig.kt @@ -0,0 +1,81 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.config + +import com.auth0.jwt.interfaces.Payload +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.service.impl.JwtTokenService +import com.epam.drill.admin.auth.principal.User +import com.epam.drill.admin.auth.service.UserAuthenticationService +import com.epam.drill.admin.auth.model.LoginPayload +import com.epam.drill.admin.auth.model.UserInfoView +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.auth.jwt.* +import io.ktor.http.* +import io.ktor.http.auth.* +import org.kodein.di.* + + +class SecurityConfig(override val di: DI) : DIAware { + private val app by instance() + private val authService by instance() + private val jwtTokenService by instance() + + init { + app.install(Authentication) { + configureJwt("jwt") + configureBasic("basic") + } + } + + private fun Authentication.Configuration.configureBasic(name: String? = null) { + basic(name) { + realm = "Access to the http(s) services" + validate { + authService.signIn(LoginPayload(username = it.name, password = it.password)).toPrincipal() + } + } + } + + private fun Authentication.Configuration.configureJwt(name: String? = null) { + jwt(name) { + realm = "Access to the http(s) and the ws(s) services" + verifier(jwtTokenService.verifier) + validate { + it.payload.toPrincipal() + } + authHeader { call -> + val headerValue = call.request.headers[HttpHeaders.Authorization] ?: "Bearer ${call.parameters["token"]}" + parseAuthorizationHeader(headerValue) + } + } + } +} + +private fun Payload.toPrincipal(): User { + return User( + username = subject, + role = Role.valueOf(getClaim("role").asString()) + ) +} + +private fun UserInfoView.toPrincipal(): User { + return User( + username = username, + role = role + ) +} diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/entity/UserEntity.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/entity/UserEntity.kt new file mode 100644 index 000000000..65dc6382c --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/entity/UserEntity.kt @@ -0,0 +1,24 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.entity + +data class UserEntity( + var id: Int? = null, + var username: String, + var passwordHash: String, + var role: String, + var blocked: Boolean = false +) \ No newline at end of file diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/common/UserData.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/NotAuthenticatedException.kt similarity index 78% rename from admin-core/src/main/kotlin/com/epam/drill/admin/common/UserData.kt rename to admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/NotAuthenticatedException.kt index 2cd952141..1e5f3be85 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/common/UserData.kt +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/NotAuthenticatedException.kt @@ -13,13 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.epam.drill.admin.common +package com.epam.drill.admin.auth.exception -import kotlinx.serialization.Serializable - - -@Serializable -data class UserData( - val name: String, - val password: String, -) +class NotAuthenticatedException(message: String? = null): Exception(message ?: "Invalid credentials") \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/NotAuthorizedException.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/NotAuthorizedException.kt new file mode 100644 index 000000000..7ed8c5152 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/NotAuthorizedException.kt @@ -0,0 +1,18 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.exception + +class NotAuthorizedException: Exception("Access denied") \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/UserNotFoundException.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/UserNotFoundException.kt new file mode 100644 index 000000000..bd7c5e5be --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/UserNotFoundException.kt @@ -0,0 +1,18 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.exception + +class UserNotFoundException: Exception("User not found") \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/UserValidationException.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/UserValidationException.kt new file mode 100644 index 000000000..8bf656d1b --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/exception/UserValidationException.kt @@ -0,0 +1,18 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.exception + +open class UserValidationException(message: String): Exception(message) \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/model/Models.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/model/Models.kt new file mode 100644 index 000000000..6ff84f821 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/model/Models.kt @@ -0,0 +1,71 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.model + +import com.epam.drill.admin.auth.principal.Role +import kotlinx.serialization.* + +@Serializable +data class DataResponse(val data: T, val message: String? = null) + +@Serializable +data class MessageResponse(val message: String) + +@Serializable +data class TokenView(val token: String) + +@Serializable +data class UserInfoView( + val username: String, + val role: Role +) + +@Serializable +data class UserView( + val id: Int?, + val username: String, + val role: Role, + val blocked: Boolean +) + +@Serializable +data class CredentialsView( + val username: String, + val password: String, +) + +@Serializable +data class EditUserPayload( + val role: Role +) + +@Serializable +data class LoginPayload( + val username: String, + val password: String +) + +@Serializable +data class RegistrationPayload( + val username: String, + val password: String +) + +@Serializable +data class ChangePasswordPayload( + val oldPassword: String, + val newPassword: String +) diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/principal/Role.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/principal/Role.kt new file mode 100644 index 000000000..323ee50cf --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/principal/Role.kt @@ -0,0 +1,22 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.principal + +enum class Role { + USER, + ADMIN, + UNDEFINED +} \ No newline at end of file diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/User.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/principal/User.kt similarity index 83% rename from admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/User.kt rename to admin-auth/src/main/kotlin/com/epam/drill/admin/auth/principal/User.kt index ec13cfe21..36a63d952 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/User.kt +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/principal/User.kt @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.epam.drill.admin.jwt.user +package com.epam.drill.admin.auth.principal import io.ktor.auth.* data class User( - val id: Int, - val name: String, - val password: String, - val role: String, + val username: String, + val role: Role ) : Principal diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/source/UserSourceImpl.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/UserRepository.kt similarity index 51% rename from admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/source/UserSourceImpl.kt rename to admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/UserRepository.kt index b726e3990..48c81b337 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/source/UserSourceImpl.kt +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/UserRepository.kt @@ -13,22 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.epam.drill.admin.jwt.user.source +package com.epam.drill.admin.auth.repository -import com.epam.drill.admin.jwt.user.* -import io.ktor.auth.* +import com.epam.drill.admin.auth.entity.UserEntity -class UserSourceImpl : UserSource { +typealias Id = Int - val testUser = User(1, System.getenv("DRILL_USERNAME") ?: "guest", System.getenv("DRILL_PASSWORD") ?: "", "admin") +interface UserRepository { + suspend fun findAll(): List - override fun findUserById(id: Int): User? = users[id] + suspend fun findById(id: Int): UserEntity? - override fun findUserByCredentials( - credential: UserPasswordCredential, - ): User? = users.values.find { it.name == credential.name && it.password == credential.password } + suspend fun findByUsername(username: String): UserEntity? - private val users = listOf(testUser).associateBy(User::id) + suspend fun create(entity: UserEntity): Id + suspend fun update(entity: UserEntity) -} + suspend fun deleteById(id: Int) +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/impl/DatabaseUserRepository.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/impl/DatabaseUserRepository.kt new file mode 100644 index 000000000..bf51bdd42 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/impl/DatabaseUserRepository.kt @@ -0,0 +1,66 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.repository.impl + +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.table.UserTable +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.statements.UpdateBuilder + +class DatabaseUserRepository : UserRepository { + override suspend fun findAll(): List { + return UserTable.selectAll().map { it.toEntity() } + } + + override suspend fun findById(id: Int): UserEntity? { + return UserTable.select { UserTable.id eq id }.map { it.toEntity() }.firstOrNull() + } + + override suspend fun findByUsername(username: String): UserEntity? { + return UserTable.select { UserTable.username.lowerCase() eq username.lowercase() }.map { it.toEntity() }.firstOrNull() + } + + override suspend fun create(entity: UserEntity): Int { + return UserTable.insertAndGetId { entity.mapTo(it) }.value + } + + override suspend fun update(entity: UserEntity) { + UserTable.update( + where = { UserTable.id eq entity.id }, + body = { entity.mapTo(it) } + ) + } + + override suspend fun deleteById(id: Int) { + UserTable.deleteWhere { UserTable.id eq id } + } +} + +private fun ResultRow.toEntity() = UserEntity( + id = this[UserTable.id].value, + username = this[UserTable.username], + passwordHash = this[UserTable.passwordHash], + role = this[UserTable.role], + blocked = this[UserTable.blocked] +) + +private fun UserEntity.mapTo(it: UpdateBuilder) { + it[UserTable.username] = username + it[UserTable.passwordHash] = passwordHash + it[UserTable.role] = role + it[UserTable.blocked] = blocked +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/impl/EnvUserRepository.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/impl/EnvUserRepository.kt new file mode 100644 index 000000000..74cba27d8 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/repository/impl/EnvUserRepository.kt @@ -0,0 +1,87 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.repository.impl + +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.repository.Id +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.service.PasswordService +import io.ktor.config.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +class EnvUserRepository( + private val env: ApplicationConfig, + private val passwordService: PasswordService +) : UserRepository { + + private var users: Map + + @Serializable + private data class UserConfig( + val username: String, + val password: String, + val role: Role + ) + + init { + users = getUsersFromEnv() + .map { Json.decodeFromString(UserConfig.serializer(), it) } + .map { it.toEntity() } + .associateBy { it.id!! } //id cannot be null because is always filled by toEntity() function + .toMap() + } + + override suspend fun findAll(): List { + return users.values.toList() + } + + override suspend fun findById(id: Int): UserEntity? { + return users[id] + } + + override suspend fun findByUsername(username: String): UserEntity? { + return users[genId(username.lowercase())] + } + + override suspend fun create(entity: UserEntity): Id { + throw UnsupportedOperationException("User creation is not supported") + } + + override suspend fun update(entity: UserEntity) { + throw UnsupportedOperationException("User update is not supported") + } + + override suspend fun deleteById(id: Int) { + throw UnsupportedOperationException("User deletion is not supported") + } + + private fun getUsersFromEnv() = env + .config("drill") + .config("auth") + .propertyOrNull("envUsers")?.getList() ?: emptyList() + + private fun UserConfig.toEntity(): UserEntity { + return UserEntity( + id = genId(username.lowercase()), + username = this.username, + passwordHash = passwordService.hashPassword(password), + role = role.name) + } + + private fun genId(username: String) = username.hashCode() +} diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/RouteUtils.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/RouteUtils.kt new file mode 100644 index 000000000..63b9a1e63 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/RouteUtils.kt @@ -0,0 +1,42 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.route + +import com.epam.drill.admin.auth.model.DataResponse +import com.epam.drill.admin.auth.model.MessageResponse +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.response.* + +suspend inline fun ApplicationCall.ok(data: T, message: String? = null) { + respond(HttpStatusCode.OK, DataResponse(data, message)) +} + +suspend fun ApplicationCall.ok(message: String) { + respond(HttpStatusCode.OK, MessageResponse(message)) +} + +suspend fun ApplicationCall.validationError(cause: Exception) { + respond(HttpStatusCode.BadRequest, MessageResponse(cause.message ?: "User data is invalid")) +} + +suspend fun ApplicationCall.unauthorizedError(cause: Exception) { + respond(HttpStatusCode.Unauthorized, MessageResponse(cause.message ?: "User is not authenticated")) +} + +suspend fun ApplicationCall.accessDeniedError(cause: Exception) { + respond(HttpStatusCode.Forbidden, MessageResponse(cause.message ?: "Access denied")) +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/UserAuthenticationRoutes.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/UserAuthenticationRoutes.kt new file mode 100644 index 000000000..b68c4a28a --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/UserAuthenticationRoutes.kt @@ -0,0 +1,147 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.route + +import com.epam.drill.admin.auth.exception.* +import com.epam.drill.admin.auth.service.TokenService +import com.epam.drill.admin.auth.service.UserAuthenticationService +import com.epam.drill.admin.auth.model.* +import com.epam.drill.admin.auth.principal.User +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.locations.post +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.response.header +import io.ktor.routing.* +import kotlinx.serialization.Serializable +import mu.KotlinLogging +import org.kodein.di.instance +import org.kodein.di.ktor.closestDI as di + +private val logger = KotlinLogging.logger {} + +@Location("/sign-in") +object SignIn + +@Location("/sign-up") +object SignUp + +@Location("/user-info") +object UserInfo + +@Location("/update-password") +object UpdatePassword + +@Deprecated("Use /sign-in") +@Location("/api/login") +object Login + +fun StatusPages.Configuration.authStatusPages() { + exception { cause -> + logger.trace(cause) { "401 User is not authenticated" } + call.unauthorizedError(cause) + } + exception { cause -> + logger.trace(cause) { "400 User data is invalid" } + call.validationError(cause) + } + exception { cause -> + logger.trace(cause) { "403 Access denied" } + call.accessDeniedError(cause) + } +} + +fun Route.userAuthenticationRoutes() { + signInRoute() + signUpRoute() +} + +fun Route.userProfileRoutes() { + userInfoRoute() + updatePasswordRoute() +} + +fun Route.signInRoute() { + val authService by di().instance() + val tokenService by di().instance() + + post { + val loginPayload = call.receive() + val userView = authService.signIn(loginPayload) + val token = tokenService.issueToken(userView) + call.response.header(HttpHeaders.Authorization, token) + call.ok(TokenView(token), "User successfully authenticated.") + } +} + +fun Route.signUpRoute() { + val authService by di().instance() + + post { + val payload = call.receive() + authService.signUp(payload) + call.ok( + "User registration request accepted. " + + "Please contact the administrator to confirm the registration." + ) + } +} + +fun Route.userInfoRoute() { + val authService by di().instance() + + get { + val principal = call.principal() ?: throw NotAuthenticatedException() + val userInfoView = authService.getUserInfo(principal) + call.ok(userInfoView) + } +} + +fun Route.updatePasswordRoute() { + val authService by di().instance() + + post { + val changePasswordPayload = call.receive() + val principal = call.principal() ?: throw NotAuthenticatedException() + authService.updatePassword(principal, changePasswordPayload) + call.ok("Password successfully changed.") + } +} + +@Deprecated("The /api/login route is outdated, please use /sign-in") +fun Route.loginRoute() { + val authService by di().instance() + val tokenService by di().instance() + + post { + val loginPayload = call.receive() + val userView = authService.signIn(LoginPayload(username = loginPayload.name, password = loginPayload.password)) + val token = tokenService.issueToken(userView) + call.response.header(HttpHeaders.Authorization, token) + call.respond(HttpStatusCode.OK, TokenView(token)) + } +} + +@Serializable +@Deprecated("use LoginPayload") +data class UserData( + val name: String, + val password: String, +) \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/UserManagementRoutes.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/UserManagementRoutes.kt new file mode 100644 index 000000000..d2e715a41 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/route/UserManagementRoutes.kt @@ -0,0 +1,119 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.route + +import com.epam.drill.admin.auth.service.UserManagementService +import com.epam.drill.admin.auth.model.EditUserPayload +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.Route +import io.ktor.routing.route +import org.kodein.di.instance +import org.kodein.di.ktor.closestDI as di + +@Location("/users") +object Users { + @Location("/{userId}") + data class Id(val userId: Int) + + @Location("/{userId}/block") + data class Block(val userId: Int) + + @Location("/{userId}/unblock") + data class Unblock(val userId: Int) + + @Location("/{userId}/reset-password") + data class ResetPassword(val userId: Int) +} + +fun Route.userManagementRoutes() { + getUsersRoute() + getUserRoute() + editUserRoute() + deleteUserRoute() + blockUserRoute() + unblockUserRoute() + resetPasswordRoute() +} + +fun Route.getUsersRoute() { + val service by di().instance() + + get { + val users = service.getUsers() + call.ok(users) + } +} + +fun Route.getUserRoute() { + val service by di().instance() + + get { params -> + val userView = service.getUser(params.userId) + call.ok(userView) + } +} + +fun Route.editUserRoute() { + val service by di().instance() + + put { (userId) -> + val editUserPayload = call.receive() + val userView = service.updateUser(userId, editUserPayload) + call.ok(userView, "User successfully edited.") + } +} + +fun Route.deleteUserRoute() { + val service by di().instance() + + delete { (userId) -> + service.deleteUser(userId) + call.ok("User successfully deleted.") + } +} + +fun Route.blockUserRoute() { + val service by di().instance() + + patch { (userId) -> + service.blockUser(userId) + call.ok("User successfully blocked.") + } +} + +fun Route.unblockUserRoute() { + val service by di().instance() + + patch { (userId) -> + service.unblockUser(userId) + call.ok("User successfully unblocked.") + } +} + +fun Route.resetPasswordRoute() { + val service by di().instance() + + patch { (userId) -> + val credentialsView = service.resetPassword(userId) + call.ok(credentialsView, "Password reset successfully.") + } +} + diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/PasswordService.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/PasswordService.kt new file mode 100644 index 000000000..e954a12a9 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/PasswordService.kt @@ -0,0 +1,29 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service + +interface PasswordService: PasswordGenerator, PasswordValidator { + fun hashPassword(password: String): String + fun matchPasswords(candidate: String, hashed: String): Boolean +} + +interface PasswordGenerator { + fun generatePassword(): String +} + +interface PasswordValidator { + fun validatePasswordRequirements(password: String) +} \ No newline at end of file diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/source/UserSource.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/TokenService.kt similarity index 71% rename from admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/source/UserSource.kt rename to admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/TokenService.kt index 7b7be1ee0..175e43ea2 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/user/source/UserSource.kt +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/TokenService.kt @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.epam.drill.admin.jwt.user.source +package com.epam.drill.admin.auth.service -import com.epam.drill.admin.jwt.user.* -import io.ktor.auth.* +import com.epam.drill.admin.auth.model.UserInfoView -interface UserSource { - fun findUserById(id: Int): User? - - fun findUserByCredentials(credential: UserPasswordCredential): User? -} +interface TokenService { + fun issueToken(user: UserInfoView): String + fun verifyToken(token: String) +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/UserAuthenticationService.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/UserAuthenticationService.kt new file mode 100644 index 000000000..ac03f677f --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/UserAuthenticationService.kt @@ -0,0 +1,29 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service + +import com.epam.drill.admin.auth.model.* +import com.epam.drill.admin.auth.principal.User + +interface UserAuthenticationService { + suspend fun signIn(payload: LoginPayload): UserInfoView + + suspend fun signUp(payload: RegistrationPayload) + + suspend fun getUserInfo(principal: User): UserInfoView + + suspend fun updatePassword(principal: User, payload: ChangePasswordPayload) +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/UserManagementService.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/UserManagementService.kt new file mode 100644 index 000000000..cde6f829b --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/UserManagementService.kt @@ -0,0 +1,37 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service + +import com.epam.drill.admin.auth.model.CredentialsView +import com.epam.drill.admin.auth.model.EditUserPayload +import com.epam.drill.admin.auth.model.UserView + +interface UserManagementService { + suspend fun getUsers(): List + + suspend fun getUser(userId: Int): UserView + + suspend fun updateUser(userId: Int, payload: EditUserPayload): UserView + + suspend fun deleteUser(userId: Int) + + suspend fun blockUser(userId: Int) + + suspend fun unblockUser(userId: Int) + + suspend fun resetPassword(userId: Int): CredentialsView + +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/JwtTokenService.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/JwtTokenService.kt new file mode 100644 index 000000000..cf1dec44b --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/JwtTokenService.kt @@ -0,0 +1,51 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.impl + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.epam.drill.admin.auth.config.JwtConfig +import com.epam.drill.admin.auth.model.UserInfoView +import com.epam.drill.admin.auth.service.TokenService +import java.util.* +import kotlin.time.Duration + +class JwtTokenService(jwtConfig: JwtConfig) : TokenService { + + private val algorithm: Algorithm = Algorithm.HMAC512(jwtConfig.secret) + private val issuer: String = jwtConfig.issuer + private val audience: String? = jwtConfig.audience + private val lifetime: Duration = jwtConfig.lifetime + + val verifier: JWTVerifier = JWT.require(algorithm) + .withIssuer(jwtConfig.issuer) + .build() + + override fun issueToken(user: UserInfoView): String = JWT.create() + .withSubject(user.username) + .withIssuer(issuer) + .withAudience(audience) + .withClaim("role", user.role.name) + .withExpiresAt(lifetime.toExpiration()) + .sign(algorithm) + + override fun verifyToken(token: String) { + verifier.verify(token) + } + + private fun Duration.toExpiration() = Date(System.currentTimeMillis() + this.inWholeMilliseconds) +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordGeneratorImpl.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordGeneratorImpl.kt new file mode 100644 index 000000000..c6a6e0091 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordGeneratorImpl.kt @@ -0,0 +1,63 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.impl + +import com.epam.drill.admin.auth.config.PasswordStrengthConfig +import com.epam.drill.admin.auth.service.PasswordGenerator +import kotlin.random.Random + + +const val ALPHABETIC_UPPERCASE_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +const val ALPHABETIC_LOWERCASE_CHARSET = "abcdefghijklmnopqrstuvwxyz" +const val DIGITS_CHARSET = "0123456789" + +class PasswordGeneratorImpl( + private val minLength: Int = 6, + private val mustContainUppercase: Boolean = false, + private val mustContainLowercase: Boolean = false, + private val mustContainDigit: Boolean = false +) : PasswordGenerator { + private val random = Random.Default + + constructor(config: PasswordStrengthConfig) : this( + minLength = config.minLength, + mustContainUppercase = config.mustContainUppercase, + mustContainLowercase = config.mustContainLowercase, + mustContainDigit = config.mustContainDigit + ) + + override fun generatePassword(): String { + val allChars = buildString { + append(ALPHABETIC_UPPERCASE_CHARSET) + append(ALPHABETIC_LOWERCASE_CHARSET) + append(DIGITS_CHARSET) + } + val passwordLength = minLength + val password = buildString { + //It is guaranteed that an additional character of each type will be added if required + if (mustContainUppercase) append(ALPHABETIC_UPPERCASE_CHARSET[random.nextInt(ALPHABETIC_UPPERCASE_CHARSET.length)]) + if (mustContainLowercase) append(ALPHABETIC_LOWERCASE_CHARSET[random.nextInt(ALPHABETIC_LOWERCASE_CHARSET.length)]) + if (mustContainDigit) append(DIGITS_CHARSET[random.nextInt(DIGITS_CHARSET.length)]) + + //Fill in the rest of the password with random characters + repeat(passwordLength - length) { + append(allChars[random.nextInt(allChars.length)]) + } + } + //Shuffle it so that there are not always specific symbols at the beginning + return password.toList().shuffled().joinToString("") + } +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordServiceImpl.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordServiceImpl.kt new file mode 100644 index 000000000..0230e6d51 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordServiceImpl.kt @@ -0,0 +1,42 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.impl + +import com.epam.drill.admin.auth.service.PasswordGenerator +import com.epam.drill.admin.auth.service.PasswordService +import com.epam.drill.admin.auth.service.PasswordValidator +import org.mindrot.jbcrypt.BCrypt + +class PasswordServiceImpl( + private val passwordGenerator: PasswordGenerator, + private val passwordValidator: PasswordValidator +) : PasswordService { + override fun hashPassword(password: String): String { + return BCrypt.hashpw(password, BCrypt.gensalt()) + } + + override fun matchPasswords(candidate: String, hashed: String): Boolean { + return BCrypt.checkpw(candidate, hashed) + } + + override fun generatePassword(): String { + return passwordGenerator.generatePassword() + } + + override fun validatePasswordRequirements(password: String) { + passwordValidator.validatePasswordRequirements(password) + } +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordValidatorImpl.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordValidatorImpl.kt new file mode 100644 index 000000000..accc24d6d --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/PasswordValidatorImpl.kt @@ -0,0 +1,50 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.impl + +import com.epam.drill.admin.auth.config.PasswordStrengthConfig +import com.epam.drill.admin.auth.exception.UserValidationException +import com.epam.drill.admin.auth.service.PasswordValidator + +class PasswordValidatorImpl( + private val minLength: Int = 6, + private val mustContainUppercase: Boolean = false, + private val mustContainLowercase: Boolean = false, + private val mustContainDigit: Boolean = false +) : PasswordValidator { + private val hasAtLeastMinLength = { password: String -> password.length >= minLength } + private val hasUppercase = { password: String -> password.any { it.isUpperCase() } } + private val hasLowercase = { password: String -> password.any { it.isLowerCase() } } + private val hasDigit = { password: String -> password.any { it.isDigit() } } + + constructor(config: PasswordStrengthConfig) : this( + minLength = config.minLength, + mustContainUppercase = config.mustContainUppercase, + mustContainLowercase = config.mustContainLowercase, + mustContainDigit = config.mustContainDigit + ) + + override fun validatePasswordRequirements(password: String) { + if (!hasAtLeastMinLength(password)) + throw UserValidationException("Password must have at least $minLength characters") + if (mustContainUppercase && !hasUppercase(password)) + throw UserValidationException("Password must contain uppercase letters") + if (mustContainLowercase && !hasLowercase(password)) + throw UserValidationException("Password must contain lowercase letters") + if (mustContainDigit && !hasDigit(password)) + throw UserValidationException("Password must contain numbers") + } +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/UserAuthenticationServiceImpl.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/UserAuthenticationServiceImpl.kt new file mode 100644 index 000000000..cfa8b5254 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/UserAuthenticationServiceImpl.kt @@ -0,0 +1,78 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.impl + +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.exception.* +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.service.UserAuthenticationService +import com.epam.drill.admin.auth.service.PasswordService +import com.epam.drill.admin.auth.model.* +import com.epam.drill.admin.auth.principal.User + + +class UserAuthenticationServiceImpl( + private val userRepository: UserRepository, + private val passwordService: PasswordService +) : UserAuthenticationService { + override suspend fun signIn(payload: LoginPayload): UserInfoView { + val userEntity = userRepository.findByUsername(payload.username)?.takeIf { userEntity -> + passwordService.matchPasswords(payload.password, userEntity.passwordHash) + } ?: throw NotAuthenticatedException("Username or password is incorrect") + if (userEntity.blocked || Role.UNDEFINED.name == userEntity.role) + throw NotAuthorizedException() + return userEntity.toView() + } + + override suspend fun signUp(payload: RegistrationPayload) { + if (userRepository.findByUsername(payload.username) != null) + throw UserValidationException("User '${payload.username}' already exists") + passwordService.validatePasswordRequirements(payload.password) + val passwordHash = passwordService.hashPassword(payload.password) + userRepository.create(payload.toUserEntity(passwordHash)) + } + + override suspend fun getUserInfo(principal: User): UserInfoView { + val userEntity = userRepository.findByUsername(principal.username) ?: throw UserNotFoundException() + return userEntity.toView() + } + + override suspend fun updatePassword(principal: User, payload: ChangePasswordPayload) { + val userEntity = userRepository.findByUsername(principal.username) ?: throw UserNotFoundException() + if (!passwordService.matchPasswords(payload.oldPassword, userEntity.passwordHash)) + throw UserValidationException("Old password is incorrect") + passwordService.validatePasswordRequirements(payload.newPassword) + userEntity.passwordHash = passwordService.hashPassword(payload.newPassword) + userRepository.update(userEntity) + } + +} + +private fun RegistrationPayload.toUserEntity(passwordHash: String): UserEntity { + return UserEntity( + username = this.username, + passwordHash = passwordHash, + role = Role.UNDEFINED.name, + ) +} + +private fun UserEntity.toView(): UserInfoView { + return UserInfoView( + username = this.username, + role = Role.valueOf(this.role) + ) +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/UserManagementServiceImpl.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/UserManagementServiceImpl.kt new file mode 100644 index 000000000..7c363b46d --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/impl/UserManagementServiceImpl.kt @@ -0,0 +1,88 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.impl + +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.exception.UserNotFoundException +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.service.UserManagementService +import com.epam.drill.admin.auth.service.PasswordService +import com.epam.drill.admin.auth.model.CredentialsView +import com.epam.drill.admin.auth.model.EditUserPayload +import com.epam.drill.admin.auth.model.UserView + +class UserManagementServiceImpl( + private val userRepository: UserRepository, + private val passwordService: PasswordService +) : UserManagementService { + override suspend fun getUsers(): List { + return userRepository.findAll().map { it.toView() } + } + + override suspend fun getUser(userId: Int): UserView { + return findUser(userId).toView() + } + + override suspend fun updateUser(userId: Int, payload: EditUserPayload): UserView { + val userEntity = findUser(userId) + userEntity.role = payload.role.name + userRepository.update(userEntity) + return userEntity.toView() + } + + override suspend fun deleteUser(userId: Int) { + userRepository.deleteById(userId) + } + + override suspend fun blockUser(userId: Int) { + val userEntity = findUser(userId) + userEntity.blocked = true + userRepository.update(userEntity) + } + + override suspend fun unblockUser(userId: Int) { + val userEntity = findUser(userId) + userEntity.blocked = false + userRepository.update(userEntity) + } + + override suspend fun resetPassword(userId: Int): CredentialsView { + val userEntity = findUser(userId) + val newPassword = passwordService.generatePassword() + userEntity.passwordHash = passwordService.hashPassword(newPassword) + userRepository.update(userEntity) + return userEntity.toCredentialsView(newPassword) + } + + private suspend fun findUser(userId: Int) = userRepository.findById(userId) ?: throw UserNotFoundException() +} + +private fun UserEntity.toCredentialsView(newPassword: String): CredentialsView { + return CredentialsView( + username = this.username, + password = newPassword + ) +} + +private fun UserEntity.toView(): UserView { + return UserView( + id = this.id, + username = this.username, + role = Role.valueOf(this.role), + blocked = this.blocked + ) +} diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/transaction/TransactionalUserAuthenticationService.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/transaction/TransactionalUserAuthenticationService.kt new file mode 100644 index 000000000..b2683c21b --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/transaction/TransactionalUserAuthenticationService.kt @@ -0,0 +1,41 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.transaction + +import com.epam.drill.admin.auth.principal.User +import com.epam.drill.admin.auth.service.UserAuthenticationService +import com.epam.drill.admin.auth.config.DatabaseConfig.transaction +import com.epam.drill.admin.auth.model.* + +class TransactionalUserAuthenticationService( + private val delegate: UserAuthenticationService +) : UserAuthenticationService by delegate { + override suspend fun signIn(payload: LoginPayload) = transaction { + delegate.signIn(payload) + } + + override suspend fun signUp(payload: RegistrationPayload) = transaction { + delegate.signUp(payload) + } + + override suspend fun updatePassword(principal: User, payload: ChangePasswordPayload) = transaction { + delegate.updatePassword(principal, payload) + } + + override suspend fun getUserInfo(principal: User) = transaction { + delegate.getUserInfo(principal) + } +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/transaction/TransactionalUserManagementService.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/transaction/TransactionalUserManagementService.kt new file mode 100644 index 000000000..6f0ade404 --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/service/transaction/TransactionalUserManagementService.kt @@ -0,0 +1,54 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.service.transaction + +import com.epam.drill.admin.auth.model.CredentialsView +import com.epam.drill.admin.auth.model.EditUserPayload +import com.epam.drill.admin.auth.model.UserView +import com.epam.drill.admin.auth.service.UserManagementService +import com.epam.drill.admin.auth.config.DatabaseConfig.transaction + +class TransactionalUserManagementService( + private val delegate: UserManagementService +): UserManagementService by delegate { + override suspend fun getUsers(): List = transaction { + delegate.getUsers() + } + + override suspend fun getUser(userId: Int): UserView = transaction { + delegate.getUser(userId) + } + + override suspend fun updateUser(userId: Int, payload: EditUserPayload): UserView = transaction { + delegate.updateUser(userId, payload) + } + + override suspend fun deleteUser(userId: Int) = transaction { + delegate.deleteUser(userId) + } + + override suspend fun blockUser(userId: Int) = transaction { + delegate.blockUser(userId) + } + + override suspend fun unblockUser(userId: Int) = transaction { + delegate.unblockUser(userId) + } + + override suspend fun resetPassword(userId: Int): CredentialsView = transaction { + delegate.resetPassword(userId) + } +} \ No newline at end of file diff --git a/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/table/UserTable.kt b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/table/UserTable.kt new file mode 100644 index 000000000..caea38cfd --- /dev/null +++ b/admin-auth/src/main/kotlin/com/epam/drill/admin/auth/table/UserTable.kt @@ -0,0 +1,25 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth.table + +import org.jetbrains.exposed.dao.id.IntIdTable + +object UserTable: IntIdTable(name = "auth.user") { + val username = varchar("username", 100) + val passwordHash = varchar("password_hash", 100) + val role = varchar("role", 20) + var blocked = bool("blocked") +} \ No newline at end of file diff --git a/admin-auth/src/main/resources/auth/db/migration/V1__Auth_schema_init.sql b/admin-auth/src/main/resources/auth/db/migration/V1__Auth_schema_init.sql new file mode 100644 index 000000000..fdce5092a --- /dev/null +++ b/admin-auth/src/main/resources/auth/db/migration/V1__Auth_schema_init.sql @@ -0,0 +1,16 @@ +CREATE TABLE auth.user ( + id serial PRIMARY KEY, + username VARCHAR (100) NOT NULL, + password_hash VARCHAR (100) NOT NULL, + role VARCHAR (20) NOT NULL, + blocked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE UNIQUE INDEX user_username_idx ON auth.user ((lower(username))); + +--INSERT DEFAULT USER user:user +INSERT INTO auth.user (username, password_hash, role) +VALUES ('admin', '$2a$10$Aach5gd4gTGUFXemUEtA/OT2i7bveGi9af1n5xqDqSjWmeZ7I27oe', 'ADMIN'); +--INSERT DEFAULT USER admin:admin +INSERT INTO auth.user (username, password_hash, role) +VALUES ('user', '$2a$10$cnuotKyF9YlzChdEEuLLfeCstYkH7C65zbVX1VHmABPKp4S8lmG1C', 'USER'); diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/EnvRepositoryTest.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/EnvRepositoryTest.kt new file mode 100644 index 000000000..ce7404a6b --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/EnvRepositoryTest.kt @@ -0,0 +1,124 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.repository.impl.EnvUserRepository +import com.epam.drill.admin.auth.service.PasswordService +import io.ktor.config.* +import kotlinx.coroutines.runBlocking +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import kotlin.test.* + +class EnvRepositoryTest { + @Mock + lateinit var passwordService: PasswordService + + @BeforeTest + fun setup() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `given users from env config, findAll must return all users`() = runBlocking { + val repository = prepareEnvUserRepository( + user("user", role = Role.USER), + user("admin", role = Role.ADMIN) + ) + + val users = repository.findAll() + + assertEquals(2, users.size) + assertTrue(users.any { it.username == "user" && it.passwordHash == "hash" && it.role == "USER" }) + assertTrue(users.any { it.username == "admin" && it.passwordHash == "hash" && it.role == "ADMIN" }) + } + + @Test + fun `given username hash, findById must return the respective user`() = runBlocking { + val repository = prepareEnvUserRepository( + user("guest"), + user("foobar"), + user("admin") + ) + + val user = repository.findById("foobar".hashCode()) + + assertNotNull(user) + assertTrue(user.username == "foobar") + } + + @Test + fun `given id generated from username in lowercase, findById must return the respective user`() = runBlocking { + val repository = prepareEnvUserRepository( + user("guest"), + user("FooBar"), + user("admin") + ) + + val user = repository.findById("foobar".hashCode()) + + assertNotNull(user) + assertTrue(user.username == "FooBar") + } + + @Test + fun `given username findByUsername must return the respective user`() = runBlocking { + val repository = prepareEnvUserRepository( + user("guest"), + user("foobar"), + user("foobar123") + ) + + val user = repository.findByUsername("foobar") + + assertNotNull(user) + assertTrue(user.username == "foobar") + } + + @Test + fun `given username with variable casing, findByUsername must return corresponding user`() = runBlocking { + val repository = prepareEnvUserRepository( + user("guest"), + user("fooBAR"), + user("FooBar123") + ) + + val user = repository.findByUsername("FooBar") + + assertNotNull(user) + assertTrue(user.username == "fooBAR") + } + + private fun prepareEnvUserRepository(vararg users: String): EnvUserRepository { + whenever(passwordService.hashPassword(any())).thenAnswer { "hash" } + val repository = EnvUserRepository( + MapApplicationConfig().apply { + put("drill.auth.envUsers", users.toList()) + }, + passwordService + ) + return repository + } +} + +private fun user( + username: String, + password: String = "secret", + role: Role = Role.USER +) = "{\"username\": \"$username\", \"password\": \"$password\", \"role\": \"${role.name}\"}" \ No newline at end of file diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/PasswordServiceTests.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/PasswordServiceTests.kt new file mode 100644 index 000000000..e16602144 --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/PasswordServiceTests.kt @@ -0,0 +1,115 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.exception.UserValidationException +import com.epam.drill.admin.auth.service.impl.PasswordGeneratorImpl +import com.epam.drill.admin.auth.service.impl.PasswordServiceImpl +import com.epam.drill.admin.auth.service.impl.PasswordValidatorImpl +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.mock +import kotlin.test.Test +import kotlin.test.assertTrue + +class PasswordGeneratorTest { + + @Test + fun `given minLength set to 10, generatePassword result must contain 10 characters`() { + val generator = PasswordGeneratorImpl(minLength = 10) + val password = generator.generatePassword() + assertTrue { password.length == 10 } + } + + @Test + fun `given mustHaveUppercase set to true, generatePassword result must contain uppercase characters`() { + val generator = PasswordGeneratorImpl(mustContainUppercase = true) + val password = generator.generatePassword() + assertTrue { password.any { it.isUpperCase() } } + } + + @Test + fun `given mustHaveLowercase set to true, generatePassword result must contain lowercase characters`() { + val generator = PasswordGeneratorImpl(mustContainLowercase = true) + val password = generator.generatePassword() + assertTrue { password.any { it.isLowerCase() } } + } + + @Test + fun `given mustHaveDigits set to true, generatePassword result must contain digits`() { + val generator = PasswordGeneratorImpl(mustContainDigit = true) + val password = generator.generatePassword() + assertTrue { password.any { it.isDigit() } } + } +} + +class PasswordHashingTest { + @Test + fun `given password and its hash matchPasswords must return true`() { + val passwordService = PasswordServiceImpl(mock(), mock()) + val password = "secret" + + val hashedPassword = passwordService.hashPassword(password) + val valid = passwordService.matchPasswords(password, hashedPassword) + + assertTrue { valid } + } + +} + +class ValidatePasswordTest { + @Test + fun `given less than 10 characters password validatePassword must fail`() { + val validator = PasswordValidatorImpl(minLength = 10) + assertThrows { validator.validatePasswordRequirements("less10") } + } + + @Test + fun `given password without upper case characters validatePassword must fail`() { + val validator = PasswordValidatorImpl(mustContainUppercase = true) + assertThrows { validator.validatePasswordRequirements("onlylowercase") } + } + + @Test + fun `given password without lower case characters validatePassword must fail`() { + val validator = PasswordValidatorImpl(mustContainLowercase = true) + assertThrows { validator.validatePasswordRequirements("ONLYUPPERCASE") } + } + + @Test + fun `given password without digits characters validatePassword must fail`() { + val validator = PasswordValidatorImpl(mustContainDigit = true) + assertThrows { validator.validatePasswordRequirements("AlphabeticCharsOnly") } + } + + @Test + fun `given password satisfying all requirements validatePassword must succeed`() { + val validator = PasswordValidatorImpl( + minLength = 10, + mustContainUppercase = true, + mustContainLowercase = true, + mustContainDigit = true) + + assertDoesNotThrow { validator.validatePasswordRequirements("ABCabc12345") } + } + + @Test + fun `given password with non-latin characters validatePassword must succeed`() { + val validator = PasswordValidatorImpl(mustContainLowercase = true, mustContainUppercase = true) + assertDoesNotThrow { validator.validatePasswordRequirements("Котик123") } + } + +} \ No newline at end of file diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/RoleBasedAuthorizationTest.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/RoleBasedAuthorizationTest.kt new file mode 100644 index 000000000..a2954776b --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/RoleBasedAuthorizationTest.kt @@ -0,0 +1,155 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.config.RoleBasedAuthorization +import com.epam.drill.admin.auth.config.withRole +import com.epam.drill.admin.auth.exception.NotAuthorizedException +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.principal.Role.ADMIN +import com.epam.drill.admin.auth.principal.Role.USER +import com.epam.drill.admin.auth.principal.User +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.server.testing.* +import kotlin.test.* + +class RoleBasedAuthorizationTest { + + @Test + fun `given user with admin role, request only-admins should return 200 OK`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/only-admins") { + addBasicAuth("admin", "secret") + }) { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } + + @Test + fun `given user with user role, request only-admins should return 403 Access denied`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/only-admins") { + addBasicAuth("user", "secret") + }) { + assertEquals(HttpStatusCode.Forbidden, response.status()) + } + } + } + + @Test + fun `given user with user role, request only-users should return 200 OK`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/only-users") { + addBasicAuth("user", "secret") + }) { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } + + @Test + fun `given user with admin role, request only-users should return 403 Access denied`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/only-users") { + addBasicAuth("admin", "secret") + }) { + assertEquals(HttpStatusCode.Forbidden, response.status()) + } + } + } + + @Test + fun `given user with user role, request admins-or-users should return 200 OK`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/admins-or-users") { + addBasicAuth("user", "secret") + }) { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } + + @Test + fun `given guest without role, request admins-or-users should return 403 Access denied`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/admins-or-users") { + addBasicAuth("guest", "secret") + }) { + assertEquals(HttpStatusCode.Forbidden, response.status()) + } + } + } + + @Test + fun `given guest without role, request all should return 200 OK`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/all") { + addBasicAuth("guest", "secret") + }) { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } + + private val config: Application.() -> Unit = { + install(StatusPages) { + exception { _ -> + call.respond(HttpStatusCode.Forbidden, "Access denied") + } + } + install(RoleBasedAuthorization) + install(Authentication) { + basic { + validate { + when (it.name) { + "user" -> User(it.name, USER) + "admin" -> User(it.name, ADMIN) + else -> User(it.name, Role.UNDEFINED) + } + } + } + } + routing { + authenticate { + withRole(ADMIN) { + get("/only-admins") { + call.respond(HttpStatusCode.OK) + } + } + withRole(USER) { + get("/only-users") { + call.respond(HttpStatusCode.OK) + } + } + withRole(ADMIN, USER) { + get("/admins-or-users") { + call.respond(HttpStatusCode.OK) + } + } + get("/all") { + call.respond(HttpStatusCode.OK) + } + } + } + } + +} \ No newline at end of file diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/SecurityConfigTest.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/SecurityConfigTest.kt new file mode 100644 index 000000000..05936456a --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/SecurityConfigTest.kt @@ -0,0 +1,157 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.config.SecurityConfig +import com.epam.drill.admin.auth.config.JwtConfig +import com.epam.drill.admin.auth.service.impl.JwtTokenService +import com.epam.drill.admin.auth.config.generateSecret +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.service.UserAuthenticationService +import com.epam.drill.admin.auth.model.LoginPayload +import com.epam.drill.admin.auth.model.UserInfoView +import com.epam.drill.admin.auth.model.UserView +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.config.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.server.testing.* +import org.kodein.di.* +import org.kodein.di.ktor.di +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever +import java.util.* +import kotlin.test.* + +class SecurityConfigTest { + + private val testSecret = generateSecret() + + @Mock + lateinit var authService: UserAuthenticationService + + @BeforeTest + fun setup() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `given user with basic auth, request basic-only must succeed`() { + wheneverBlocking(authService) { signIn(LoginPayload(username = "admin", password = "secret")) } + .thenReturn(UserInfoView(username = "admin", role = Role.ADMIN)) + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/basic-only") { + addBasicAuth("admin", "secret") + }) { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } + + @Test + fun `given user without basic auth, request basic-only must fail with 401 status`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/basic-only") { + //not to add basic auth + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + @Test + fun `given user with valid jwt token, request jwt-only must succeed`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/jwt-only") { + addJwtToken( + username = "admin", + secret = testSecret + ) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + } + } + } + + @Test + fun `given user without jwt token, request jwt-only must fail with 401 status`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/jwt-only") { + //not to add jwt token + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + @Test + fun `given user with expired jwt token, request jwt-only must fail with 401 status`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/jwt-only") { + addJwtToken( + username = "admin", + secret = testSecret, + expiresAt = Date(System.currentTimeMillis() - 1000) + ) + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + @Test + fun `given user with invalid jwt token, request jwt-only must fail with 401 status`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/jwt-only") { + addJwtToken( + username = "admin", + secret = "wrong_secret" + ) + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + private val config: Application.() -> Unit = { + environment { + put("drill.auth.jwt.issuer", "test issuer") + put("drill.auth.jwt.lifetime", "1m") + put("drill.auth.jwt.audience", "test audience") + put("drill.auth.jwt.secret", testSecret) + } + di { + bind() with singleton { JwtTokenService(JwtConfig(di)) } + bind() with eagerSingleton { SecurityConfig(di) } + bind() with provider { authService } + } + routing { + authenticate("jwt") { + get("/jwt-only") { + call.respond(HttpStatusCode.OK) + } + } + authenticate("basic") { + get("/basic-only") { + call.respond(HttpStatusCode.OK) + } + } + } + } +} \ No newline at end of file diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/TestUtils.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/TestUtils.kt new file mode 100644 index 000000000..de74575a3 --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/TestUtils.kt @@ -0,0 +1,73 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.epam.drill.admin.auth.model.DataResponse +import com.epam.drill.admin.auth.principal.Role +import io.ktor.application.* +import io.ktor.config.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.mockito.kotlin.whenever +import org.mockito.stubbing.OngoingStubbing +import kotlin.test.assertNotNull +import java.util.* + + +fun TestApplicationRequest.addBasicAuth(username: String, password: String) { + val encodedCredentials = String(Base64.getEncoder().encode("$username:$password".toByteArray())) + addHeader(HttpHeaders.Authorization, "Basic $encodedCredentials") +} + +fun TestApplicationRequest.addJwtToken( + username: String, + secret: String, + expiresAt: Date = Date(System.currentTimeMillis() + 10_000), + role: String = Role.UNDEFINED.name, + issuer: String? = "test issuer", + audience: String? = null +) { + val token = JWT.create() + .withSubject(username) + .withIssuer(issuer) + .withAudience(audience) + .withClaim("role", role) + .withExpiresAt(expiresAt) + .sign(Algorithm.HMAC512(secret)) + addHeader(HttpHeaders.Authorization, "Bearer $token") +} + +fun TestApplicationCall.assertResponseNotNull(serializer: KSerializer): T { + val value = assertNotNull(response.content) + val response = Json.decodeFromString(DataResponse.serializer(serializer), value) + return response.data +} + +fun Application.environment(configuration: MapApplicationConfig.() -> Unit) { + (this.environment.config as MapApplicationConfig).apply { + configuration() + } +} + +fun wheneverBlocking(mock: M, methodCall: suspend M.() -> T): OngoingStubbing { + return runBlocking { whenever(mock.methodCall()) } +} + diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserAuthenticationTests.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserAuthenticationTests.kt new file mode 100644 index 000000000..2c11584ca --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserAuthenticationTests.kt @@ -0,0 +1,324 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.model.* +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.route.authStatusPages +import com.epam.drill.admin.auth.route.userAuthenticationRoutes +import com.epam.drill.admin.auth.service.PasswordService +import com.epam.drill.admin.auth.service.TokenService +import com.epam.drill.admin.auth.service.UserAuthenticationService +import com.epam.drill.admin.auth.service.impl.UserAuthenticationServiceImpl +import com.epam.drill.admin.auth.principal.User +import com.epam.drill.admin.auth.route.userProfileRoutes +import io.ktor.auth.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.application.* +import io.ktor.routing.* +import io.ktor.serialization.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import org.kodein.di.bind +import org.kodein.di.eagerSingleton +import org.kodein.di.instance +import org.kodein.di.ktor.di +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import kotlin.test.* + +val USER_GUEST + get() = UserEntity(id = 1, username = "guest", passwordHash = "hash", role = "UNDEFINED").copy() + +/** + * Testing /sign-in, /sign-up, /user-info and /reset-password routers and UserAuthenticationServiceImpl + */ +class UserAuthenticationTest { + + @Mock + lateinit var userRepository: UserRepository + + @Mock + lateinit var passwordService: PasswordService + + @Mock + lateinit var tokenService: TokenService + + @BeforeTest + fun setup() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `given correct username and password 'POST sign-in' must return an access token`() { + val testUsername = "foobar" + wheneverBlocking(userRepository) { findByUsername(testUsername) } + .thenReturn(UserEntity(id = 1, username = testUsername, passwordHash = "hash", role = "USER")) + whenever(passwordService.matchPasswords("secret", "hash")) + .thenReturn(true) + whenever(tokenService.issueToken(any())).thenReturn("token") + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-in") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val payload = LoginPayload(username = testUsername, password = "secret") + setBody(Json.encodeToString(LoginPayload.serializer(), payload)) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + val response = assertResponseNotNull(TokenView.serializer()) + assertEquals("token", response.token) + } + } + } + + @Test + fun `given unique username 'POST sign-up' must succeed and user must be created`() { + wheneverBlocking(userRepository) { findByUsername("foobar") } + .thenReturn(null) + whenever(passwordService.hashPassword("secret")) + .thenReturn("hash") + wheneverBlocking(userRepository) { create(any()) } + .thenReturn(1) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-up") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = RegistrationPayload(username = "foobar", password = "secret") + setBody(Json.encodeToString(RegistrationPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + verifyBlocking(userRepository) { + create( + UserEntity( + username = "foobar", + passwordHash = "hash", + role = Role.UNDEFINED.name + ) + ) + } + } + } + } + + @Test + fun `'GET user-info' must return user info`() { + val testUsername = "johnny" + wheneverBlocking(userRepository) { findByUsername(testUsername) } + .thenReturn(UserEntity(id = 1, username = testUsername, passwordHash = "hash", role = "USER")) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/user-info") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + addBasicAuth(testUsername, "secret") + }) { + assertEquals(HttpStatusCode.OK, response.status()) + val userInfo = assertResponseNotNull(UserInfoView.serializer()) + assertEquals(testUsername, userInfo.username) + assertEquals(Role.USER, userInfo.role) + } + } + } + + @Test + fun `given correct old password 'POST update-password' must succeed`() { + wheneverBlocking(userRepository) { findByUsername("guest") } + .thenReturn(USER_GUEST) + whenever(passwordService.matchPasswords("secret", "hash")) + .thenReturn(true) + whenever(passwordService.hashPassword("secret2")) + .thenReturn("hash2") + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/update-password") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + addBasicAuth("guest", "secret") + val form = ChangePasswordPayload(oldPassword = "secret", newPassword = "secret2") + setBody(Json.encodeToString(ChangePasswordPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + verifyBlocking(userRepository) { update(USER_GUEST.copy(passwordHash = "hash2")) } + } + } + } + + @Test + fun `given incorrect username 'POST sign-in' must fail with 401 status`() { + wheneverBlocking(userRepository) { findByUsername("unknown") } + .thenReturn(null) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-in") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = LoginPayload(username = "unknown", password = "secret") + setBody(Json.encodeToString(LoginPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + @Test + fun `given incorrect password 'POST sign-in' must fail with 401 status`() { + wheneverBlocking(userRepository) { findByUsername("guest") } + .thenReturn(USER_GUEST) + whenever(passwordService.matchPasswords("incorrect", "hash")) + .thenReturn(false) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-in") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = LoginPayload(username = "guest", password = "incorrect") + setBody(Json.encodeToString(LoginPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + @Test + fun `given username of blocked user 'POST sign-in' must fail with 403 status`() { + wheneverBlocking(userRepository) { findByUsername("blocked_user") } + .thenReturn( + UserEntity( + id = 1, username = "blocked_user", passwordHash = "hash", role = "USER", blocked = true + ) + ) + whenever(passwordService.matchPasswords("secret", "hash")) + .thenReturn(true) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-in") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = LoginPayload(username = "blocked_user", password = "secret") + setBody(Json.encodeToString(LoginPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.Forbidden, response.status()) + } + } + } + + @Test + fun `given username of user without role 'POST sign-in' must fail with 403 status`() { + wheneverBlocking(userRepository) { findByUsername("undefined_role") } + .thenReturn( + UserEntity( + id = 1, username = "undefined_role", passwordHash = "hash", role = "UNDEFINED", blocked = false + ) + ) + whenever(passwordService.matchPasswords("secret", "hash")) + .thenReturn(true) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-in") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = LoginPayload(username = "undefined_role", password = "secret") + setBody(Json.encodeToString(LoginPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.Forbidden, response.status()) + } + } + } + + @Test + fun `given already existing username 'POST sign-up' must fail with 400 status`() { + wheneverBlocking(userRepository) { findByUsername("guest") } + .thenReturn(USER_GUEST) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/sign-up") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = RegistrationPayload(username = "guest", password = "secret") + setBody(Json.encodeToString(RegistrationPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.BadRequest, response.status()) + } + } + } + + @Test + fun `without authentication 'POST update-password' must fail with 401 status`() { + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/update-password") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + //not add auth + val form = ChangePasswordPayload(oldPassword = "secret", newPassword = "secret2") + setBody(Json.encodeToString(ChangePasswordPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.Unauthorized, response.status()) + } + } + } + + @Test + fun `given incorrect old password 'POST update-password' must fail with 400 status`() { + wheneverBlocking(userRepository) { findByUsername("guest") } + .thenReturn(USER_GUEST) + whenever(passwordService.matchPasswords("incorrect", "hash")) + .thenReturn(false) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Post, "/update-password") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + addBasicAuth("guest", "secret") + val form = ChangePasswordPayload(oldPassword = "incorrect", newPassword = "secret2") + setBody(Json.encodeToString(ChangePasswordPayload.serializer(), form)) + }) { + assertEquals(HttpStatusCode.BadRequest, response.status()) + } + } + } + + private val config: Application.() -> Unit = { + install(Locations) + install(ContentNegotiation) { + json() + } + install(StatusPages) { + authStatusPages() + } + install(Authentication) { + basic { + validate { + User(it.name, Role.UNDEFINED) + } + } + } + di { + bind() with eagerSingleton { userRepository } + bind() with eagerSingleton { passwordService } + bind() with eagerSingleton { tokenService } + bind() with eagerSingleton { + UserAuthenticationServiceImpl( + instance(), + instance() + ) + } + } + routing { + userAuthenticationRoutes() + authenticate { + userProfileRoutes() + } + } + } +} diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserManagementTests.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserManagementTests.kt new file mode 100644 index 000000000..0a8813bfa --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserManagementTests.kt @@ -0,0 +1,201 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.repository.UserRepository +import com.epam.drill.admin.auth.route.userManagementRoutes +import com.epam.drill.admin.auth.service.PasswordService +import com.epam.drill.admin.auth.service.UserManagementService +import com.epam.drill.admin.auth.service.impl.UserManagementServiceImpl +import com.epam.drill.admin.auth.model.EditUserPayload +import com.epam.drill.admin.auth.model.UserView +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.routing.* +import io.ktor.serialization.* +import io.ktor.server.testing.* +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.kodein.di.bind +import org.kodein.di.eagerSingleton +import org.kodein.di.instance +import org.kodein.di.ktor.di +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import kotlin.test.* + +val USER_ADMIN + get() = UserEntity(id = 1, username = "admin", passwordHash = "hash1", role = "ADMIN").copy() +val USER_USER + get() = UserEntity(id = 2, username = "user", passwordHash = "hash2", role = "USER").copy() + +/** + * Testing /users routers and UserManagementServiceImpl + */ +class UserManagementTest { + + @Mock + lateinit var userRepository: UserRepository + @Mock + lateinit var passwordService: PasswordService + + @BeforeTest + fun setup() { + MockitoAnnotations.openMocks(this) + } + + + @Test + fun `'GET users' must return the expected number of users from repository`() { + wheneverBlocking(userRepository) { findAll() } + .thenReturn(listOf(USER_ADMIN, USER_USER)) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/users") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + val response: List = assertResponseNotNull(ListSerializer(UserView.serializer())) + assertEquals(2, response.size) + } + } + } + + @Test + fun `given existing user identifier 'GET users {id}' must return the respective user`() { + wheneverBlocking(userRepository) { findById(1) } + .thenReturn(USER_ADMIN) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Get, "/users/1") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + val response: UserView = assertResponseNotNull(UserView.serializer()) + assertEquals("admin", response.username) + } + } + } + + @Test + fun `given user identifier and role 'PUT users {id}' must change user role in repository and return changed user`() { + wheneverBlocking(userRepository) { findById(1) } + .thenReturn(USER_ADMIN) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Put, "/users/1") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val form = EditUserPayload(role = Role.USER) + setBody(Json.encodeToString(EditUserPayload.serializer(), form)) + }) { + verifyBlocking(userRepository) { update(USER_ADMIN.copy(role = "USER")) } + assertEquals(HttpStatusCode.OK, response.status()) + val response: UserView = assertResponseNotNull(UserView.serializer()) + assertEquals(Role.USER, response.role) + } + } + } + + @Test + fun `given user identifier 'DELETE users {id}' must delete that user in repository`() { + wheneverBlocking(userRepository) { findById(1) } + .thenReturn(USER_ADMIN) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Delete, "/users/1") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + verifyBlocking(userRepository) { deleteById(1) } + } + } + } + + @Test + fun `given user identifier 'PATCH users {id} block' must block that user in repository`() { + wheneverBlocking(userRepository) { findById(1) } + .thenReturn(USER_ADMIN) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Patch, "/users/1/block") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + verifyBlocking(userRepository) { update(USER_ADMIN.copy(blocked = true)) } + } + } + } + + @Test + fun `given user identifier 'PATCH users {id} unblock' must unblock that user in repository`() { + wheneverBlocking(userRepository) { findById(1) } + .thenReturn(USER_ADMIN.copy(blocked = true)) + + withTestApplication(config) { + with(handleRequest(HttpMethod.Patch, "/users/1/unblock") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + verifyBlocking(userRepository) { update(USER_ADMIN.copy(blocked = false)) } + } + } + } + + @Test + fun `given user identifier 'PATCH users {id} reset-password' must generate and return a new password of that user`() { + wheneverBlocking(userRepository) { findById(1) } + .thenReturn(USER_ADMIN) + whenever(passwordService.generatePassword()) + .thenReturn("newsecret") + whenever(passwordService.hashPassword("newsecret")) + .thenReturn("newhash") + + withTestApplication(config) { + with(handleRequest(HttpMethod.Patch, "/users/1/reset-password") { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }) { + assertEquals(HttpStatusCode.OK, response.status()) + verifyBlocking(userRepository) { update(USER_ADMIN.copy(passwordHash = "newhash")) } + } + } + } + + private val config: Application.() -> Unit = { + install(Locations) + install(ContentNegotiation) { + json() + } + di { + bind() with eagerSingleton { userRepository } + bind() with eagerSingleton { passwordService } + bind() with eagerSingleton { + UserManagementServiceImpl( + instance(), + instance() + ) + } + } + routing { + userManagementRoutes() + } + } +} \ No newline at end of file diff --git a/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserRepositoryImplTest.kt b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserRepositoryImplTest.kt new file mode 100644 index 000000000..c3eaced60 --- /dev/null +++ b/admin-auth/src/test/kotlin/com/epam/drill/admin/auth/UserRepositoryImplTest.kt @@ -0,0 +1,260 @@ +/** + * Copyright 2020 - 2022 EPAM Systems + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.epam.drill.admin.auth + +import com.epam.drill.admin.auth.config.DatabaseConfig +import com.epam.drill.admin.auth.entity.UserEntity +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.repository.impl.DatabaseUserRepository +import com.epam.drill.admin.auth.table.UserTable +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.statements.InsertStatement +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import kotlin.test.* +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName + +@Testcontainers +class UserRepositoryImplTest { + + private val repository = DatabaseUserRepository() + + companion object { + @Container + private val postgresqlContainer = PostgreSQLContainer( + DockerImageName.parse("postgres:14.1") + ).apply { + withDatabaseName("testdb") + withUsername("testuser") + withPassword("testpassword") + } + + @JvmStatic + @BeforeAll + fun setup() { + postgresqlContainer.start() + val dataSource = HikariDataSource(HikariConfig().apply { + this.jdbcUrl = postgresqlContainer.jdbcUrl + this.username = postgresqlContainer.username + this.password = postgresqlContainer.password + this.driverClassName = postgresqlContainer.driverClassName + this.validate() + }) + DatabaseConfig.init(dataSource) + } + } + + @Test + fun `given unique username, create must insert user and return id`() = withTransaction { + val userEntity = UserEntity( + username = "uniquename", passwordHash = "hash", role = "USER" + ) + val id = repository.create(userEntity) + + assertEquals(1, UserTable.select { UserTable.id eq id }.count()) + UserTable.select { UserTable.id eq id }.first().let { + assertEquals(userEntity.username, it[UserTable.username]) + assertEquals(userEntity.passwordHash, it[UserTable.passwordHash]) + assertEquals(userEntity.role, it[UserTable.role]) + assertEquals(userEntity.blocked, it[UserTable.blocked]) + } + } + + @Test + fun `given non-unique username, create must fail`() = withTransaction { + insertUser { + it[username] = "nonuniquename" + } + + assertFails { + repository.create( + UserEntity( + username = "nonuniquename", passwordHash = "hash", role = "USER" + ) + ) + } + } + + @Test + fun `given case insensitive username, create must fail`() = withTransaction { + insertUser { + it[username] = "FooBar" + } + + assertFails { + repository.create( + UserEntity( + username = "foobar", passwordHash = "hash", role = "USER" + ) + ) + } + } + + @Test + fun `given existing id, update must change fields for exactly one user`() = withTransaction { + insertUsers(1..10) + val id = insertUser(11) { + it[username] = "foo" + it[passwordHash] = "hash1" + it[role] = "USER" + } + insertUsers(12..20) + + repository.update( + UserEntity( + id = id, username = "bar", passwordHash = "hash2", role = "ADMIN", blocked = true + ) + ) + + assertEquals(1, UserTable.select { UserTable.username eq "bar" }.count()) + UserTable.select { UserTable.username eq "bar" }.first().let { + assertEquals("bar", it[UserTable.username]) + assertEquals("hash2", it[UserTable.passwordHash]) + assertEquals("ADMIN", it[UserTable.role]) + assertTrue(it[UserTable.blocked]) + } + } + + @Test + fun `after database migration, findAll must return default users`() = withTransaction { + val users = repository.findAll() + + assertEquals(2, users.size)//insert after db migration + assertTrue(users.any { it.username == "user" }) + assertTrue(users.any { it.username == "admin" }) + } + + @Test + fun `given existing username, findByUsername must return user`() = withTransaction { + insertUsers(1..10) + insertUser(11) { + it[username] = "foobar" + } + insertUsers(12..20) + + val user = repository.findByUsername("foobar") + + assertNotNull(user) + assertEquals("foobar", user.username) + } + + @Test + fun `findByUsername must return even blocked user`() = withTransaction { + insertUsers(1..10) + insertUser(11) { + it[username] = "foobar" + it[blocked] = true + } + insertUsers(12..20) + + val user = repository.findByUsername("foobar") + + assertNotNull(user) + assertEquals("foobar", user.username) + assertTrue(user.blocked) + } + + @Test + fun `findByUsername must be case insensitive`() = withTransaction { + insertUsers(1..10) + insertUser(11) { + it[username] = "FooBar" + } + insertUsers(12..20) + + val user = repository.findByUsername("foobar") + + assertNotNull(user) + assertEquals("FooBar", user.username) + } + + @Test + fun `given existing id, findById must return user`() = withTransaction { + val ids = insertUsers(1..10) + ids.forEach { id -> + + val user = repository.findById(id) + + assertNotNull(user) + assertEquals(id, user.id) + } + } + + @Test + fun `given non-existent id, findById must return null`() = withTransaction { + val ids = insertUsers(1..10) + assertFalse { ids.contains(12345) } + + val user = repository.findById(12345) + + assertNull(user) + } + + @Test + fun `given existent id, delete must delete user`() = withTransaction { + val id = insertUser { + it[username] = "foo" + } + + repository.deleteById(id) + + assertEquals(0, UserTable.select { UserTable.id eq id }.count()) + } + + @Test + fun `given non-existent id, delete must not fail`() = withTransaction { + assertDoesNotThrow { + repository.deleteById(12345) + } + } +} + +private fun withTransaction(test: suspend () -> Unit) { + runBlocking { + newSuspendedTransaction { + test() + rollback() + } + } +} + +private fun insertUsers( + range: IntRange, overrideColumns: UserTable.(InsertStatement<*>) -> Unit = {} +): Set { + val ids = mutableSetOf() + range.forEach { index -> + ids += insertUser(index, overrideColumns) + } + return ids +} + +private fun insertUser(index: Int = 1, overrideColumns: UserTable.(InsertStatement<*>) -> Unit = {}) = UserTable.insertAndGetId { + it[username] = "username$index" + it[passwordHash] = "hash$index" + it[role] = Role.values()[index % Role.values().size].name + it[blocked] = false + overrideColumns(it) +}.value + diff --git a/admin-core/build.gradle.kts b/admin-core/build.gradle.kts index f98d8d69a..4a2b85cbb 100644 --- a/admin-core/build.gradle.kts +++ b/admin-core/build.gradle.kts @@ -26,7 +26,6 @@ val kodeinVersion: String by parent!!.extra val microutilsLoggingVersion: String by parent!!.extra val mapdbVersion: String by parent!!.extra val flywaydbVersion: String by parent!!.extra -val postgresEmbeddedVersion: String by parent!!.extra repositories { mavenLocal() @@ -52,7 +51,7 @@ dependencies { implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("io.ktor:ktor-serialization:$ktorVersion") implementation("io.github.microutils:kotlin-logging-jvm:$microutilsLoggingVersion") - implementation("org.kodein.di:kodein-di-jvm:$kodeinVersion") + implementation("org.kodein.di:kodein-di-framework-ktor-server-jvm:$kodeinVersion") implementation("org.flywaydb:flyway-core:$flywaydbVersion") implementation("org.mapdb:mapdb:$mapdbVersion") { exclude(group = "org.eclipse.collections", module = "eclipse-collections") @@ -63,8 +62,8 @@ dependencies { implementation("org.eclipse.collections:eclipse-collections-api:11.1.0") implementation("org.eclipse.collections:eclipse-collections-forkjoin:11.1.0") implementation("ch.qos.logback:logback-classic:1.2.3") - implementation("ru.yandex.qatools.embed:postgresql-embedded:$postgresEmbeddedVersion") + implementation(project(":admin-auth")) implementation(project(":admin-analytics")) implementation(project(":common")) implementation(project(":plugin-api-admin")) diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/DrillApplication.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/DrillApplication.kt index 57f1583da..3121b2f49 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/DrillApplication.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/DrillApplication.kt @@ -15,126 +15,130 @@ */ package com.epam.drill.admin +import com.epam.drill.admin.auth.* +import com.epam.drill.admin.auth.config.* +import com.epam.drill.admin.auth.config.DatabaseConfig +import com.epam.drill.admin.auth.principal.Role.ADMIN +import com.epam.drill.admin.auth.route.* import com.epam.drill.admin.config.* import com.epam.drill.admin.di.* -import com.epam.drill.admin.jwt.config.* -import com.epam.drill.admin.jwt.user.source.* import com.epam.drill.admin.kodein.* -import com.epam.drill.admin.security.installAuthentication import com.epam.drill.admin.store.* import com.epam.dsm.* import com.zaxxer.hikari.* import io.ktor.application.* import io.ktor.auth.* -import io.ktor.auth.jwt.* import io.ktor.features.* import io.ktor.http.* import io.ktor.http.cio.websocket.* import io.ktor.locations.* import io.ktor.response.* +import io.ktor.routing.* import io.ktor.websocket.* -import kotlinx.coroutines.* import mu.* import org.flywaydb.core.* -import ru.yandex.qatools.embed.postgresql.* -import ru.yandex.qatools.embed.postgresql.distribution.* -import java.io.* import java.time.* -val drillHomeDir = File(System.getenv("DRILL_HOME") ?: "") - -val drillWorkDir = drillHomeDir.resolve("work") - -val userSource: UserSource = UserSourceImpl() - private val logger = KotlinLogging.logger {} @Suppress("unused") -fun Application.module() = kodeinApplication( - AppBuilder { - withInstallation { +fun Application.module() { + install(StatusPages) { + exception { cause -> + logger.error(cause) { "Build application finished with exception" } + call.respond(HttpStatusCode.InternalServerError, "Internal Server Error") + throw cause + } + authStatusPages() + } + install(CallLogging) + install(Locations) + install(WebSockets) { + pingPeriod = Duration.ofSeconds(15) + timeout = Duration.ofSeconds(150) + maxFrameSize = Long.MAX_VALUE + masking = false + } - install(StatusPages) { - exception { cause -> - logger.error(cause) { "Build application finished with exception" } - call.respond(HttpStatusCode.InternalServerError, "Internal Server Error") - throw cause - } - } - install(CallLogging) - install(Locations) - install(WebSockets) { - pingPeriod = Duration.ofSeconds(15) - timeout = Duration.ofSeconds(150) - maxFrameSize = Long.MAX_VALUE - masking = false - } + install(ContentNegotiation) { + converters() + } - install(ContentNegotiation) { - converters() - } + interceptorForApplicationJson() - interceptorForApplicationJson() + enableSwaggerSupport() - enableSwaggerSupport() + install(CORS) { + anyHost() + allowCredentials = true + method(HttpMethod.Post) + method(HttpMethod.Get) + method(HttpMethod.Delete) + method(HttpMethod.Put) + method(HttpMethod.Patch) + header(HttpHeaders.Authorization) + header(HttpHeaders.ContentType) + exposeHeader(HttpHeaders.Authorization) + exposeHeader(HttpHeaders.ContentType) + } - installAuthentication() + install(RoleBasedAuthorization) - install(CORS) { - anyHost() - allowCredentials = true - method(HttpMethod.Post) - method(HttpMethod.Get) - method(HttpMethod.Delete) - method(HttpMethod.Put) - method(HttpMethod.Patch) - header(HttpHeaders.Authorization) - header(HttpHeaders.ContentType) - exposeHeader(HttpHeaders.Authorization) - exposeHeader(HttpHeaders.ContentType) - } - - } + kodein { + withKModule { kodeinModule("securityConfig", securityDiConfig) } + withKModule { kodeinModule("usersConfig", usersDiConfig) } withKModule { kodeinModule("storage", storage) } withKModule { kodeinModule("wsHandler", wsHandler) } withKModule { kodeinModule("handlers", handlers) } withKModule { kodeinModule("pluginServices", pluginServices) } - val host = drillDatabaseHost - val port = drillDatabasePort - val dbName = drillDatabaseName - val userName = drillDatabaseUserName - val password = drillDatabasePassword - val maxPoolSize = drillDatabaseMaxPoolSize - if (isEmbeddedMode) { - logger.info { "starting dev mode for db..." } - val postgres = EmbeddedPostgres(Version.V11_1, drillWorkDir.absolutePath) - postgres.start( - host, - port, - dbName, - userName, - password - ) - } - hikariConfig = HikariConfig().apply { - this.driverClassName = "org.postgresql.Driver" - this.jdbcUrl = "jdbc:postgresql://$host:$port/$dbName?reWriteBatchedInserts=true" - this.username = userName - this.password = password - this.maximumPoolSize = maxPoolSize - this.isAutoCommit = true - this.transactionIsolation = "TRANSACTION_REPEATABLE_READ" - this.validate() + initDB() + } + + routing { + loginRoute() + route("/api") { + userAuthenticationRoutes() + authenticate("jwt") { + userProfileRoutes() + } + authenticate("jwt", "basic") { + withRole(ADMIN) { + userManagementRoutes() + } + } } - adminStore.createProcedureIfTableExist() - val flyway = Flyway.configure() - .dataSource(hikariConfig.jdbcUrl, hikariConfig.username, hikariConfig.password) - .schemas(adminStore.hikariConfig.schema) - .baselineOnMigrate(true) - .load() - flyway.migrate() } -) +} + +private fun Application.initDB() { + val host = drillDatabaseHost + val port = drillDatabasePort + val dbName = drillDatabaseName + val userName = drillDatabaseUserName + val password = drillDatabasePassword + val maxPoolSize = drillDatabaseMaxPoolSize + hikariConfig = HikariConfig().apply { + this.driverClassName = "org.postgresql.Driver" + this.jdbcUrl = "jdbc:postgresql://$host:$port/$dbName?reWriteBatchedInserts=true" + this.username = userName + this.password = password + this.maximumPoolSize = maxPoolSize + this.isAutoCommit = true + this.transactionIsolation = "TRANSACTION_REPEATABLE_READ" + this.validate() + } + adminStore.createProcedureIfTableExist() + val dataSource = HikariDataSource(hikariConfig) + + val flyway = Flyway.configure() + .dataSource(dataSource) + .schemas(adminStore.hikariConfig.schema) + .baselineOnMigrate(true) + .load() + flyway.migrate() + + DatabaseConfig.init(dataSource) +} lateinit var hikariConfig: HikariConfig diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/api/routes/ApiRoot.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/api/routes/ApiRoot.kt index 0e3374b20..9890ddf4d 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/api/routes/ApiRoot.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/api/routes/ApiRoot.kt @@ -34,10 +34,6 @@ class ApiRoot(val prefix: String = "api") { const val GROUP = "Group Endpoints" } - @Group(SYSTEM) - @Location("/login") - data class Login(val parent: ApiRoot) - @Group(SYSTEM) @Location("/version") data class Version(val parent: ApiRoot) diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/config/Application.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/config/Application.kt index e1145ef27..7e8f782dc 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/config/Application.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/config/Application.kt @@ -32,8 +32,6 @@ val Application.drillDefaultPackages: List val Application.isDevMode: Boolean get() = drillConfig.propertyOrNull("devMode")?.getString()?.toBoolean() ?: false -val Application.isEmbeddedMode: Boolean - get() = drillConfig.propertyOrNull("embeddedMode")?.getString()?.toBoolean() ?: false val Application.agentSocketTimeout: Duration get() = Duration.seconds(drillConfig.config("agents") diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/config/Content.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/config/Content.kt index d3a909341..2b6e5787b 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/config/Content.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/config/Content.kt @@ -66,7 +66,7 @@ private object EmptyContentConverter : ContentConverter { context: PipelineContext, contentType: ContentType, value: Any, - ): Any? = value + ): Any = value } /** diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/core/WsExtension.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/core/WsExtension.kt index 0ce5568a6..6a299f9be 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/core/WsExtension.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/core/WsExtension.kt @@ -16,36 +16,45 @@ package com.epam.drill.admin.core import com.auth0.jwt.exceptions.* +import com.epam.drill.admin.auth.config.withRole +import com.epam.drill.admin.auth.principal.Role import com.epam.drill.admin.common.* import com.epam.drill.admin.common.serialization.* -import com.epam.drill.admin.jwt.config.* -import com.epam.drill.common.* +import com.epam.drill.admin.auth.service.TokenService +import io.ktor.auth.* import io.ktor.http.cio.websocket.* import io.ktor.routing.* import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.channels.* import mu.* +import org.kodein.di.instance +import org.kodein.di.ktor.closestDI as di private val logger = KotlinLogging.logger {} fun Route.authWebSocket( path: String, - jwtConfig: JwtConfig, protocol: String? = null, handler: suspend DefaultWebSocketServerSession.() -> Unit, ) { - webSocket(path, protocol) { - socketAuthentication(jwtConfig) - try { - handler(this) - } catch (ex: Exception) { - closeExceptionally(ex) + val tokenService by di().instance() + + authenticate("jwt") { + withRole(Role.USER) { + webSocket(path, protocol) { + socketAuthentication(tokenService) + try { + handler(this) + } catch (ex: Exception) { + closeExceptionally(ex) + } + } } } } -private suspend fun DefaultWebSocketServerSession.socketAuthentication(jwtConfig: JwtConfig) { +private suspend fun DefaultWebSocketServerSession.socketAuthentication(tokenService: TokenService) { val token = call.parameters["token"] if (token == null) { @@ -54,20 +63,19 @@ private suspend fun DefaultWebSocketServerSession.socketAuthentication(jwtConfig close() return } - verifyToken(token, jwtConfig) launch { while (true) { delay(10_000) - verifyToken(token, jwtConfig) + verifyToken(token, tokenService) } } } -private suspend fun DefaultWebSocketServerSession.verifyToken(token: String, jwtConfig: JwtConfig,) { +private suspend fun DefaultWebSocketServerSession.verifyToken(token: String, tokenService: TokenService) { try { - jwtConfig.verifier.verify(token) + tokenService.verifyToken(token) } catch (ex: JWTVerificationException) { when (ex) { is TokenExpiredException -> Unit //Ignore, since we don't have token refreshing diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/AgentEndpoints.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/AgentEndpoints.kt index c66c4c5ff..03e653c88 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/AgentEndpoints.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/AgentEndpoints.kt @@ -21,11 +21,9 @@ import com.epam.drill.admin.agent.* import com.epam.drill.admin.agent.config.* import com.epam.drill.admin.api.agent.* import com.epam.drill.admin.api.routes.* -import com.epam.drill.admin.cache.* -import com.epam.drill.admin.cache.impl.* +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.config.withRole import com.epam.drill.admin.endpoints.* -import com.epam.drill.admin.plugin.* -import com.epam.drill.admin.plugin.AgentCacheKey import com.epam.drill.admin.store.* import de.nielsfalk.ktor.swagger.* import io.ktor.application.* @@ -49,163 +47,165 @@ class AgentEndpoints(override val di: DI) : DIAware { app.routing { authenticate("jwt", "basic") { - post( - "Create agent" - .examples( - example( - "Petclinic", AgentCreationDto( - id = "petclinic", - agentType = AgentType.JAVA, - name = "Petclinic" + withRole(Role.USER) { + post( + "Create agent" + .examples( + example( + "Petclinic", AgentCreationDto( + id = "petclinic", + agentType = AgentType.JAVA, + name = "Petclinic" + ) ) ) - ) - .responds( - ok(), - HttpCodeResponse(HttpStatusCode.Conflict, emptyList()) - ) - ) { _, payload -> - logger.debug { "Creating agent with id ${payload.id}..." } - agentManager.prepare(payload)?.run { - logger.info { "Created agent ${payload.id}." } - call.respond(HttpStatusCode.Created, toDto(agentManager)) - } ?: run { - logger.warn { "Agent ${payload.id} already exists." } - call.respond(HttpStatusCode.Conflict, ErrorResponse("Agent '${payload.id}' already exists.")) + .responds( + ok(), + HttpCodeResponse(HttpStatusCode.Conflict, emptyList()) + ) + ) { _, payload -> + logger.debug { "Creating agent with id ${payload.id}..." } + agentManager.prepare(payload)?.run { + logger.info { "Created agent ${payload.id}." } + call.respond(HttpStatusCode.Created, toDto(agentManager)) + } ?: run { + logger.warn { "Agent ${payload.id} already exists." } + call.respond(HttpStatusCode.Conflict, ErrorResponse("Agent '${payload.id}' already exists.")) + } } - } - get( - "Agents metadata" - .examples() - .responds( - ok() - ) - ) { - val metadataAgents = agentManager.all().flatMap { - buildManager.buildData(it.id).agentBuildManager.agentBuilds.map { agentBuild -> - val agentBuildKey = AgentBuildKey(it.id, agentBuild.info.version) - mapOf(agentBuildKey to adminStore.loadAgentMetadata(agentBuildKey)) + get( + "Agents metadata" + .examples() + .responds( + ok() + ) + ) { + val metadataAgents = agentManager.all().flatMap { + buildManager.buildData(it.id).agentBuildManager.agentBuilds.map { agentBuild -> + val agentBuildKey = AgentBuildKey(it.id, agentBuild.info.version) + mapOf(agentBuildKey to adminStore.loadAgentMetadata(agentBuildKey)) + } } + call.respond(HttpStatusCode.OK, metadataAgents) } - call.respond(HttpStatusCode.OK, metadataAgents) - } - get( - "Agent parameters" - .examples() - .responds( - ok(), badRequest() - ) - ) { params -> - val (_, agentId) = params - val map = configHandler.load(agentId) ?: emptyMap() - call.respond(HttpStatusCode.OK, map) - } + get( + "Agent parameters" + .examples() + .responds( + ok(), badRequest() + ) + ) { params -> + val (_, agentId) = params + val map = configHandler.load(agentId) ?: emptyMap() + call.respond(HttpStatusCode.OK, map) + } - patch>( - "Update agent parameters" - .examples( - example( - "Agent parameters", mapOf( - "logLevel" to "DEBUG", - "logFile" to "Directory" + patch>( + "Update agent parameters" + .examples( + example( + "Agent parameters", mapOf( + "logLevel" to "DEBUG", + "logFile" to "Directory" + ) ) + ).responds( + ok(), notFound() ) - ).responds( - ok(), notFound() - ) - ) { location, updatedValues -> - val agentId = location.agentId - logger.debug { "Update parameters for agent with id $agentId params: $updatedValues" } - val (status, message) = configHandler.load(agentId)?.let { storageParameters -> - val newStorageParameters = storageParameters.toMutableMap() - updatedValues.forEach { (key, value) -> - newStorageParameters[key]?.let { - newStorageParameters[key] = it.copy(value = value) - } ?: logger.warn { "Cannot find and update the parameter '$key'" } - } - configHandler.store(agentId, newStorageParameters) - configHandler.updateAgent(agentId, updatedValues) - logger.debug { "Agent with id '$agentId' was updated successfully" } - HttpStatusCode.OK to EmptyContent - } ?: (HttpStatusCode.NotFound to ErrorResponse("agent '$agentId' not found")) - call.respond(status, message) - } + ) { location, updatedValues -> + val agentId = location.agentId + logger.debug { "Update parameters for agent with id $agentId params: $updatedValues" } + val (status, message) = configHandler.load(agentId)?.let { storageParameters -> + val newStorageParameters = storageParameters.toMutableMap() + updatedValues.forEach { (key, value) -> + newStorageParameters[key]?.let { + newStorageParameters[key] = it.copy(value = value) + } ?: logger.warn { "Cannot find and update the parameter '$key'" } + } + configHandler.store(agentId, newStorageParameters) + configHandler.updateAgent(agentId, updatedValues) + logger.debug { "Agent with id '$agentId' was updated successfully" } + HttpStatusCode.OK to EmptyContent + } ?: (HttpStatusCode.NotFound to ErrorResponse("agent '$agentId' not found")) + call.respond(status, message) + } - patch( - "Update agent configuration" - .examples( - example("Petclinic", agentUpdateExample) - ) - .responds( - ok(), badRequest() - ) - ) { location, au -> - val agentId = location.agentId - logger.debug { "Update configuration for agent with id $agentId" } + patch( + "Update agent configuration" + .examples( + example("Petclinic", agentUpdateExample) + ) + .responds( + ok(), badRequest() + ) + ) { location, au -> + val agentId = location.agentId + logger.debug { "Update configuration for agent with id $agentId" } - val (status, message) = if (buildManager.agentSessions(agentId).isNotEmpty()) { - agentManager.updateAgent(agentId, au) - logger.debug { "Agent with id '$agentId' was updated successfully" } - HttpStatusCode.OK to EmptyContent - } else { - logger.warn { "Agent with id'$agentId' was not found" } - HttpStatusCode.BadRequest to ErrorResponse("agent '$agentId' not found") + val (status, message) = if (buildManager.agentSessions(agentId).isNotEmpty()) { + agentManager.updateAgent(agentId, au) + logger.debug { "Agent with id '$agentId' was updated successfully" } + HttpStatusCode.OK to EmptyContent + } else { + logger.warn { "Agent with id'$agentId' was not found" } + HttpStatusCode.BadRequest to ErrorResponse("agent '$agentId' not found") + } + call.respond(status, message) } - call.respond(status, message) - } - post( - "Register agent" - .examples( - example("Petclinic", agentRegistrationExample) - ) - .responds( - ok(), badRequest() - ) - ) { payload, regInfo -> - logger.debug { "Registering agent with id ${payload.agentId}" } - val agentId = payload.agentId - val agInfo = agentManager[agentId] - val (status, message) = if (agInfo != null) { - agentManager.register(agInfo.id, regInfo) - logger.debug { "Agent with id '$agentId' has been registered" } - HttpStatusCode.OK to EmptyContent - } else { - logger.warn { "Agent with id'$agentId' was not found" } - HttpStatusCode.BadRequest to ErrorResponse("Agent '$agentId' not found") + post( + "Register agent" + .examples( + example("Petclinic", agentRegistrationExample) + ) + .responds( + ok(), badRequest() + ) + ) { payload, regInfo -> + logger.debug { "Registering agent with id ${payload.agentId}" } + val agentId = payload.agentId + val agInfo = agentManager[agentId] + val (status, message) = if (agInfo != null) { + agentManager.register(agInfo.id, regInfo) + logger.debug { "Agent with id '$agentId' has been registered" } + HttpStatusCode.OK to EmptyContent + } else { + logger.warn { "Agent with id'$agentId' was not found" } + HttpStatusCode.BadRequest to ErrorResponse("Agent '$agentId' not found") + } + call.respond(status, message) } - call.respond(status, message) - } - /** - * Also you should send action to plugin - * { - * "type": "REMOVE_PLUGIN_DATA" - * } - */ - delete( - "Remove all agent info" - .responds( - ok(), notFound(), badRequest() - ) - ) { payload -> - val agentId = payload.agentId - val (status, message) = if (agentManager.removePreregisteredAgent(agentId)) { - HttpStatusCode.OK to "Pre registered Agent '$agentId' has been completely removed." - } else { - //TODO EPMDJ-10354 Think about ability to remove online agent - if (buildManager.buildStatus(agentId) == BuildStatus.OFFLINE) { - agentManager.removeOfflineAgent(agentId) - HttpStatusCode.OK to "Offline Agent '$agentId' has been completely removed." + /** + * Also you should send action to plugin + * { + * "type": "REMOVE_PLUGIN_DATA" + * } + */ + delete( + "Remove all agent info" + .responds( + ok(), notFound(), badRequest() + ) + ) { payload -> + val agentId = payload.agentId + val (status, message) = if (agentManager.removePreregisteredAgent(agentId)) { + HttpStatusCode.OK to "Pre registered Agent '$agentId' has been completely removed." } else { - logger.debug { "Deleting online Agent '$agentId' isn't available." } - HttpStatusCode.BadRequest to ErrorResponse("Deleting online Agent '$agentId' isn't availabl.e") + //TODO EPMDJ-10354 Think about ability to remove online agent + if (buildManager.buildStatus(agentId) == BuildStatus.OFFLINE) { + agentManager.removeOfflineAgent(agentId) + HttpStatusCode.OK to "Offline Agent '$agentId' has been completely removed." + } else { + logger.debug { "Deleting online Agent '$agentId' isn't available." } + HttpStatusCode.BadRequest to ErrorResponse("Deleting online Agent '$agentId' isn't availabl.e") + } } + call.respond(status, message) } - call.respond(status, message) } } } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillAdminEndpoints.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillAdminEndpoints.kt index 75ae3fc1f..286d07ea1 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillAdminEndpoints.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillAdminEndpoints.kt @@ -22,6 +22,8 @@ import com.epam.drill.admin.api.LoggingConfigDto import com.epam.drill.admin.api.agent.BuildStatus import com.epam.drill.admin.api.routes.ApiRoot import com.epam.drill.admin.api.routes.WsRoot +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.config.withRole import com.epam.drill.admin.cache.CacheService import com.epam.drill.admin.cache.impl.MapDBCacheService import com.epam.drill.admin.endpoints.AgentManager @@ -60,78 +62,79 @@ class DrillAdminEndpoints(override val di: DI) : DIAware { app.routing { authenticate("jwt", "basic") { + withRole(Role.USER) { + post( + "Agent Toggle StandBy" + .responds( + ok(), notFound(), badRequest() + ) + ) { params -> + val (_, agentId) = params + logger.info { "Toggle agent $agentId" } + val (status, response) = agentManager[agentId]?.let { agentInfo -> + val status = buildManager.buildStatus(agentId) + val agentBuildKey = agentInfo.toAgentBuildKey() + when (status) { + BuildStatus.OFFLINE -> BuildStatus.ONLINE + BuildStatus.ONLINE -> BuildStatus.OFFLINE + else -> null + }?.let { newStatus -> + buildManager.instanceIds(agentId).forEach { (id, value) -> + buildManager.updateInstanceStatus(agentBuildKey, id, newStatus) + val toggleValue = newStatus == BuildStatus.ONLINE + agentInfo.plugins.map { pluginId -> + value.agentWsSession.sendToTopic( + TogglePayload(pluginId, toggleValue) + ) + }.forEach { it.await() } //TODO coroutine scope (supervisor) + } + buildManager.notifyBuild(agentBuildKey) + logger.info { "Agent $agentId toggled, new build status - $newStatus." } + HttpStatusCode.OK to EmptyContent + } ?: (HttpStatusCode.Conflict to ErrorResponse( + "Cannot toggle agent $agentId on status $status" + )) + } ?: (HttpStatusCode.NotFound to EmptyContent) + call.respond(status, response) + } - post( - "Agent Toggle StandBy" - .responds( - ok(), notFound(), badRequest() - ) - ) { params -> - val (_, agentId) = params - logger.info { "Toggle agent $agentId" } - val (status, response) = agentManager[agentId]?.let { agentInfo -> - val status = buildManager.buildStatus(agentId) - val agentBuildKey = agentInfo.toAgentBuildKey() - when (status) { - BuildStatus.OFFLINE -> BuildStatus.ONLINE - BuildStatus.ONLINE -> BuildStatus.OFFLINE - else -> null - }?.let { newStatus -> - buildManager.instanceIds(agentId).forEach { (id, value) -> - buildManager.updateInstanceStatus(agentBuildKey, id, newStatus) - val toggleValue = newStatus == BuildStatus.ONLINE - agentInfo.plugins.map { pluginId -> - value.agentWsSession.sendToTopic( - TogglePayload(pluginId, toggleValue) - ) - }.forEach { it.await() } //TODO coroutine scope (supervisor) - } - buildManager.notifyBuild(agentBuildKey) - logger.info { "Agent $agentId toggled, new build status - $newStatus." } - HttpStatusCode.OK to EmptyContent - } ?: (HttpStatusCode.Conflict to ErrorResponse( - "Cannot toggle agent $agentId on status $status" - )) - } ?: (HttpStatusCode.NotFound to EmptyContent) - call.respond(status, response) - } + put( + "Configure agent logging levels" + .examples( + example("Agent logging configuration", defaultLoggingConfig) + ) + .responds( + ok(), notFound(), badRequest() + ) + ) { (_, agentId), loggingConfig -> + logger.debug { "Attempt to configure logging levels for agent with id $agentId" } + loggingHandler.updateConfig(agentId, loggingConfig) + logger.debug { "Successfully sent request for logging levels configuration for agent with id $agentId" } + call.respond(HttpStatusCode.OK, EmptyContent) + } - put( - "Configure agent logging levels" - .examples( - example("Agent logging configuration", defaultLoggingConfig) - ) - .responds( - ok(), notFound(), badRequest() - ) - ) { (_, agentId), loggingConfig -> - logger.debug { "Attempt to configure logging levels for agent with id $agentId" } - loggingHandler.updateConfig(agentId, loggingConfig) - logger.debug { "Successfully sent request for logging levels configuration for agent with id $agentId" } - call.respond(HttpStatusCode.OK, EmptyContent) - } + get( + "Return cache stats" + .examples() + .responds( + ok() + ) + ) { + val cacheStats = (cacheService as? MapDBCacheService)?.stats() ?: emptyList() + call.respond(HttpStatusCode.OK, cacheStats) + } - get( - "Return cache stats" - .examples() - .responds( - ok() - ) - ) { - val cacheStats = (cacheService as? MapDBCacheService)?.stats() ?: emptyList() - call.respond(HttpStatusCode.OK, cacheStats) - } - - get( - "Clear cache" - .examples() - .responds( - ok() - ) - ) { - (cacheService as? MapDBCacheService)?.clear() - call.respond(HttpStatusCode.OK) + get( + "Clear cache" + .examples() + .responds( + ok() + ) + ) { + (cacheService as? MapDBCacheService)?.clear() + call.respond(HttpStatusCode.OK) + } } } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillServerWs.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillServerWs.kt index 205496c81..b10786120 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillServerWs.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/admin/DrillServerWs.kt @@ -21,7 +21,6 @@ import com.epam.drill.admin.common.* import com.epam.drill.admin.common.serialization.* import com.epam.drill.admin.core.* import com.epam.drill.admin.endpoints.* -import com.epam.drill.admin.jwt.config.jwtConfig import com.epam.drill.admin.websocket.* import io.ktor.application.* import io.ktor.http.cio.websocket.* @@ -47,7 +46,7 @@ class DrillServerWs(override val di:DI) : DIAware { init { app.routing { val socketName = "drill-admin-socket" - authWebSocket("/ws/$socketName", app.jwtConfig) { + authWebSocket("/ws/$socketName") { val session = this logger.debug { "$socketName: acquired ${session.toDebugString()}" } try { diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/DrillPluginWs.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/DrillPluginWs.kt index 1451318f6..34ebf39b7 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/DrillPluginWs.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/DrillPluginWs.kt @@ -20,7 +20,6 @@ import com.epam.drill.admin.common.* import com.epam.drill.admin.common.serialization.* import com.epam.drill.admin.core.* import com.epam.drill.admin.endpoints.* -import com.epam.drill.admin.jwt.config.jwtConfig import com.epam.drill.admin.plugin.* import com.epam.drill.admin.plugins.* import com.epam.drill.admin.websocket.* @@ -49,7 +48,7 @@ class DrillPluginWs(override val di: DI) : DIAware { init { app.routing { plugins.keys.forEach { pluginId -> - authWebSocket("/ws/plugins/$pluginId", app.jwtConfig) { handle(pluginId) } + authWebSocket("/ws/plugins/$pluginId") { handle(pluginId) } } } } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/PluginDispatcher.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/PluginDispatcher.kt index 3d2910c7e..76debe56b 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/PluginDispatcher.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/plugin/PluginDispatcher.kt @@ -18,9 +18,10 @@ package com.epam.drill.admin.endpoints.plugin import com.epam.drill.admin.agent.* import com.epam.drill.admin.api.agent.AgentStatus.* import com.epam.drill.admin.api.agent.BuildStatus.* -import com.epam.drill.admin.api.plugin.* import com.epam.drill.admin.api.routes.* import com.epam.drill.admin.api.websocket.* +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.config.withRole import com.epam.drill.admin.build.* import com.epam.drill.admin.build.AgentBuildData import com.epam.drill.admin.cache.* @@ -112,204 +113,205 @@ internal class PluginDispatcher(override val di: DI) : DIAware { } } authenticate("jwt", "basic") { + withRole(Role.USER) { + post( + "Dispatch Plugin Action" + .examples( + example("action", "some action name") + ) + .responds( + ok( + example("") + ), notFound() + ) + ) { payload, action -> + val (_, agentId, pluginId) = payload + logger.debug { "Dispatch action plugin with id $pluginId for agent with id $agentId" } + val agent = agentManager.entryOrNull(agentId) + val (statusCode, response) = agent?.run { + val plugin: Plugin? = this@PluginDispatcher.plugins[pluginId] + if (plugin != null) { + val buildStatus = buildManager.buildStatus(agentId) + val adminPluginPart = this[pluginId] + if (info.agentStatus == REGISTERED) { + adminPluginPart?.let { adminPart -> + val result = adminPart.processAction(action, buildManager::agentSessions) + val statusResponse = result.toStatusResponse() + HttpStatusCode.fromValue(statusResponse.code) to statusResponse + } ?: (HttpStatusCode.BadRequest to ErrorResponse( + "Cannot dispatch action: plugin $pluginId not initialized for agent $agentId." + )).also { logger.warn { "BadRequest with action: $action" } } + } else HttpStatusCode.BadRequest to ErrorResponse( + "Cannot dispatch action for plugin '$pluginId', agent '$agentId' is ${info.agentStatus}," + + " status of build is $buildStatus." + ).also { logger.warn { "BadRequest with action: $action" } } + } else HttpStatusCode.NotFound to ErrorResponse("Plugin with id $pluginId not found") + } ?: (HttpStatusCode.NotFound to ErrorResponse("Agent with id $pluginId not found")) + logger.info { "response for '$agentId': $response" } + sendResponse(response, statusCode) + } - post( - "Dispatch Plugin Action" - .examples( - example("action", "some action name") - ) - .responds( - ok( - example("") - ), notFound() - ) - ) { payload, action -> - val (_, agentId, pluginId) = payload - logger.debug { "Dispatch action plugin with id $pluginId for agent with id $agentId" } - val agent = agentManager.entryOrNull(agentId) - val (statusCode, response) = agent?.run { - val plugin: Plugin? = this@PluginDispatcher.plugins[pluginId] - if (plugin != null) { - val buildStatus = buildManager.buildStatus(agentId) - val adminPluginPart = this[pluginId] - if (info.agentStatus == REGISTERED) { - adminPluginPart?.let { adminPart -> - val result = adminPart.processAction(action, buildManager::agentSessions) - val statusResponse = result.toStatusResponse() - HttpStatusCode.fromValue(statusResponse.code) to statusResponse - } ?: (HttpStatusCode.BadRequest to ErrorResponse( - "Cannot dispatch action: plugin $pluginId not initialized for agent $agentId." - )).also { logger.warn { "BadRequest with action: $action" } } - } else HttpStatusCode.BadRequest to ErrorResponse( - "Cannot dispatch action for plugin '$pluginId', agent '$agentId' is ${info.agentStatus}," + - " status of build is $buildStatus." - ).also { logger.warn { "BadRequest with action: $action" } } - } else HttpStatusCode.NotFound to ErrorResponse("Plugin with id $pluginId not found") - } ?: (HttpStatusCode.NotFound to ErrorResponse("Agent with id $pluginId not found")) - logger.info { "response for '$agentId': $response" } - sendResponse(response, statusCode) - } - - // TODO EPMDJ-8531 Support multipart/form-data in ktor-swagger - post( - "Process plugin data" - .examples( - example( - "Petclinic", - """Multipart form-data with key-value pairs: + // TODO EPMDJ-8531 Support multipart/form-data in ktor-swagger + post( + "Process plugin data" + .examples( + example( + "Petclinic", + """Multipart form-data with key-value pairs: "action":{"type":"IMPORT_COVERAGE"}, "data": File(jacoco.exec) """.trimIndent() + ) ) - ) - .description("To try out this request, please use the Postman") - .responds(ok(), badRequest()) - ) { (_, agentId, pluginId), data -> - val parts: List = data.readAllParts() - val action = (parts.find { it.name == "action" } as PartData.FormItem).value - val inputStream = (parts.find { it.name == "data" } as PartData.FileItem).streamProvider() - logger.debug { "Process data with plugin with id $pluginId for agent with id $agentId" } - val agentEntry = agentManager.entryOrNull(agentId) - val (statusCode, response) = agentEntry?.run { - val plugin: Plugin? = this@PluginDispatcher.plugins[pluginId] - if (plugin != null) { - if (info.agentStatus == REGISTERED) { - this[pluginId]?.let { adminPart -> - val result = adminPart.doRawAction(action, inputStream) - val statusResponse = result.toStatusResponse() - HttpStatusCode.fromValue(statusResponse.code) to statusResponse - } ?: (HttpStatusCode.BadRequest to ErrorResponse( - "Cannot process data: plugin $pluginId not initialized for agent $agentId." - )) - } else HttpStatusCode.BadRequest to ErrorResponse( - "Cannot dispatch action for plugin '$pluginId', agent '$agentId' is not registered." - ) - } else HttpStatusCode.NotFound to ErrorResponse("Plugin with id $pluginId not found") - } ?: (HttpStatusCode.NotFound to ErrorResponse("Agent with id $pluginId not found")) - logger.info { "response for '$agentId': $response" } - sendResponse(response, statusCode) - } - - post( - "Dispatch defined plugin actions in defined group" - .examples( - example("action", "some action name") - ) - .responds( - ok( - example("") - ), notFound() - ) - ) { pluginParent, action -> - val pluginId = pluginParent.pluginId - val groupId = pluginParent.parent.parent.groupId - val agents = agentManager.agentsByGroup(groupId) - logger.debug { "Dispatch action plugin with id $pluginId for agents with groupId $groupId" } - val (statusCode, response) = plugins[pluginId]?.let { - processMultipleActions( - agents, - pluginId, - action - ) - } ?: (HttpStatusCode.NotFound to ErrorResponse("Plugin $pluginId not found.")) - logger.trace { "response for '$groupId': $response" } - call.respond(statusCode, response) - } - + .description("To try out this request, please use the Postman") + .responds(ok(), badRequest()) + ) { (_, agentId, pluginId), data -> + val parts: List = data.readAllParts() + val action = (parts.find { it.name == "action" } as PartData.FormItem).value + val inputStream = (parts.find { it.name == "data" } as PartData.FileItem).streamProvider() + logger.debug { "Process data with plugin with id $pluginId for agent with id $agentId" } + val agentEntry = agentManager.entryOrNull(agentId) + val (statusCode, response) = agentEntry?.run { + val plugin: Plugin? = this@PluginDispatcher.plugins[pluginId] + if (plugin != null) { + if (info.agentStatus == REGISTERED) { + this[pluginId]?.let { adminPart -> + val result = adminPart.doRawAction(action, inputStream) + val statusResponse = result.toStatusResponse() + HttpStatusCode.fromValue(statusResponse.code) to statusResponse + } ?: (HttpStatusCode.BadRequest to ErrorResponse( + "Cannot process data: plugin $pluginId not initialized for agent $agentId." + )) + } else HttpStatusCode.BadRequest to ErrorResponse( + "Cannot dispatch action for plugin '$pluginId', agent '$agentId' is not registered." + ) + } else HttpStatusCode.NotFound to ErrorResponse("Plugin with id $pluginId not found") + } ?: (HttpStatusCode.NotFound to ErrorResponse("Agent with id $pluginId not found")) + logger.info { "response for '$agentId': $response" } + sendResponse(response, statusCode) + } - //todo remove it cause it is duplicated in another place. EPMDJ-6145 - get { (_, agentId, pluginId, dataType) -> - logger.debug { "Get plugin data, agentId=$agentId, pluginId=$pluginId, dataType=$dataType" } - val dp: Plugin? = plugins[pluginId] - val agentInfo = agentManager[agentId] - val agentEntry = agentManager.entryOrNull(agentId) - val (statusCode: HttpStatusCode, response: Any) = when { - (dp == null) -> HttpStatusCode.NotFound to ErrorResponse("Plugin '$pluginId' not found") - (agentInfo == null) -> HttpStatusCode.NotFound to ErrorResponse("Agent '$agentId' not found") - (agentEntry == null) -> HttpStatusCode.NotFound to ErrorResponse("Data for agent '$agentId' not found") - else -> AgentSubscription(agentId, agentInfo.build.version).let { subscription -> - pluginCache.retrieveMessage( + post( + "Dispatch defined plugin actions in defined group" + .examples( + example("action", "some action name") + ) + .responds( + ok( + example("") + ), notFound() + ) + ) { pluginParent, action -> + val pluginId = pluginParent.pluginId + val groupId = pluginParent.parent.parent.groupId + val agents = agentManager.agentsByGroup(groupId) + logger.debug { "Dispatch action plugin with id $pluginId for agents with groupId $groupId" } + val (statusCode, response) = plugins[pluginId]?.let { + processMultipleActions( + agents, pluginId, - subscription, - "/data/$dataType" - ).toStatusResponsePair() - } + action + ) + } ?: (HttpStatusCode.NotFound to ErrorResponse("Plugin $pluginId not found.")) + logger.trace { "response for '$groupId': $response" } + call.respond(statusCode, response) } - sendResponse(response, statusCode) - } - post( - "Toggle Plugin" - .responds( - ok(), notFound() - ) - ) { params -> - val (_, agentId, pluginId) = params - logger.debug { "Toggle plugin with id $pluginId for agent with id $agentId" } - val dp: Plugin? = plugins[pluginId] - val session = buildManager.agentSessions(agentId) - val (statusCode, response) = when { - (dp == null) -> HttpStatusCode.NotFound to ErrorResponse("plugin with id $pluginId not found") - (session.isEmpty()) -> HttpStatusCode.NotFound to ErrorResponse("agent with id $agentId not found") - else -> { - session.applyEach { - sendToTopic( - message = TogglePayload(pluginId) - ) + + //todo remove it cause it is duplicated in another place. EPMDJ-6145 + get { (_, agentId, pluginId, dataType) -> + logger.debug { "Get plugin data, agentId=$agentId, pluginId=$pluginId, dataType=$dataType" } + val dp: Plugin? = plugins[pluginId] + val agentInfo = agentManager[agentId] + val agentEntry = agentManager.entryOrNull(agentId) + val (statusCode: HttpStatusCode, response: Any) = when { + (dp == null) -> HttpStatusCode.NotFound to ErrorResponse("Plugin '$pluginId' not found") + (agentInfo == null) -> HttpStatusCode.NotFound to ErrorResponse("Agent '$agentId' not found") + (agentEntry == null) -> HttpStatusCode.NotFound to ErrorResponse("Data for agent '$agentId' not found") + else -> AgentSubscription(agentId, agentInfo.build.version).let { subscription -> + pluginCache.retrieveMessage( + pluginId, + subscription, + "/data/$dataType" + ).toStatusResponsePair() } - HttpStatusCode.OK to EmptyContent } + sendResponse(response, statusCode) } - logger.debug { response } - call.respond(statusCode, response) - } - delete { (_, agentId, pluginId, buildVersion) -> - logger.debug { "Starting to remove a build '$buildVersion' for agent '$agentId', plugin '$pluginId'..." } - val (status, msg) = if (agentId in agentManager) { - if (plugins[pluginId] != null) { - val curBuildVersion = agentManager.buildVersionByAgentId(agentId) - if (buildVersion != curBuildVersion) { - pluginStoresDSM(pluginId).deleteBy { - (Stored::id.startsWith(agentKeyPattern(agentId, buildVersion))) - } - adminStore.deleteById(AgentBuildId(agentId, buildVersion)) - buildManager.buildData(agentId).run { - agentBuildManager.delete(buildVersion) + post( + "Toggle Plugin" + .responds( + ok(), notFound() + ) + ) { params -> + val (_, agentId, pluginId) = params + logger.debug { "Toggle plugin with id $pluginId for agent with id $agentId" } + val dp: Plugin? = plugins[pluginId] + val session = buildManager.agentSessions(agentId) + val (statusCode, response) = when { + (dp == null) -> HttpStatusCode.NotFound to ErrorResponse("plugin with id $pluginId not found") + (session.isEmpty()) -> HttpStatusCode.NotFound to ErrorResponse("agent with id $agentId not found") + else -> { + session.applyEach { + sendToTopic( + message = TogglePayload(pluginId) + ) } - (cacheService as? MapDBCacheService)?.clear( - AgentCacheKey(pluginId, agentId), - buildVersion - ) HttpStatusCode.OK to EmptyContent - } else HttpStatusCode.BadRequest to ErrorResponse("Can not remove a current build") - } else HttpStatusCode.BadRequest to ErrorResponse("Plugin '$pluginId' not found") - } else HttpStatusCode.BadRequest to ErrorResponse("Agent '$agentId' not found") - call.respond(status, msg) - } - - get { (_, agentId, pluginId) -> - logger.debug { "Get builds summary, agentId=$agentId, pluginId=$pluginId" } - val (status, message) = agentManager[agentId]?.let { - val buildsSummary = - buildManager.buildData(agentId).agentBuildManager.agentBuilds.map { agentBuild -> - val buildVersion = agentBuild.info.version - BuildSummaryDto( - buildVersion = buildVersion, - detectedAt = agentBuild.detectedAt, - summary = pluginCache.retrieveMessage( - pluginId = pluginId, - subscription = AgentSubscription( - agentId = agentId, - buildVersion = buildVersion - ), - destination = "/build/summary" - ).toJson() - ) } - HttpStatusCode.OK to buildsSummary - } ?: (HttpStatusCode.NotFound to ErrorResponse("Agent with id $agentId not found")) - call.respond(status, message) + } + logger.debug { response } + call.respond(statusCode, response) + } + + delete { (_, agentId, pluginId, buildVersion) -> + logger.debug { "Starting to remove a build '$buildVersion' for agent '$agentId', plugin '$pluginId'..." } + val (status, msg) = if (agentId in agentManager) { + if (plugins[pluginId] != null) { + val curBuildVersion = agentManager.buildVersionByAgentId(agentId) + if (buildVersion != curBuildVersion) { + pluginStoresDSM(pluginId).deleteBy { + (Stored::id.startsWith(agentKeyPattern(agentId, buildVersion))) + } + adminStore.deleteById(AgentBuildId(agentId, buildVersion)) + buildManager.buildData(agentId).run { + agentBuildManager.delete(buildVersion) + } + (cacheService as? MapDBCacheService)?.clear( + AgentCacheKey(pluginId, agentId), + buildVersion + ) + HttpStatusCode.OK to EmptyContent + } else HttpStatusCode.BadRequest to ErrorResponse("Can not remove a current build") + } else HttpStatusCode.BadRequest to ErrorResponse("Plugin '$pluginId' not found") + } else HttpStatusCode.BadRequest to ErrorResponse("Agent '$agentId' not found") + call.respond(status, msg) + } + + get { (_, agentId, pluginId) -> + logger.debug { "Get builds summary, agentId=$agentId, pluginId=$pluginId" } + val (status, message) = agentManager[agentId]?.let { + val buildsSummary = + buildManager.buildData(agentId).agentBuildManager.agentBuilds.map { agentBuild -> + val buildVersion = agentBuild.info.version + BuildSummaryDto( + buildVersion = buildVersion, + detectedAt = agentBuild.detectedAt, + summary = pluginCache.retrieveMessage( + pluginId = pluginId, + subscription = AgentSubscription( + agentId = agentId, + buildVersion = buildVersion + ), + destination = "/build/summary" + ).toJson() + ) + } + HttpStatusCode.OK to buildsSummary + } ?: (HttpStatusCode.NotFound to ErrorResponse("Agent with id $agentId not found")) + call.respond(status, message) + } } } } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/system/LoginEndpoint.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/system/LoginEndpoint.kt deleted file mode 100644 index 441b66b37..000000000 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/endpoints/system/LoginEndpoint.kt +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright 2020 - 2022 EPAM Systems - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.epam.drill.admin.endpoints.system - -import com.epam.drill.admin.* -import com.epam.drill.admin.api.routes.* -import com.epam.drill.admin.common.* -import com.epam.drill.admin.common.serialization.* -import com.epam.drill.admin.jwt.config.* -import de.nielsfalk.ktor.swagger.* -import io.ktor.application.* -import io.ktor.auth.* -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.response.* -import io.ktor.routing.* -import kotlinx.serialization.* -import mu.* - -class LoginEndpoint(val app: Application) { - private val logger = KotlinLogging.logger {} - - init { - app.routing { - val meta = "Login" - .examples( - example("user", UserData("guest", "")) - ) - .responds( - ok(), badRequest() - ) - post(meta) { _, userDataJson -> - //TODO EPMDJ-10038 remove after support login on all clients - var notEmptyUserDataJson = userDataJson - if (userDataJson.isBlank()) { - logger.warn { "Body is empty, login as guest" } - notEmptyUserDataJson = UserData.serializer() stringify UserData( - "guest", - "" - ) - } - val userData = UserData.serializer() parse notEmptyUserDataJson - val (username, password) = userData - val credentials = UserPasswordCredential(username, password) - logger.debug { "Login user with name $username" } - userSource.findUserByCredentials(credentials)?.let { user -> - val token = app.jwtConfig.makeToken(user, app.jwtLifetime) - call.response.header(HttpHeaders.Authorization, token) - logger.debug { "Login user with name $username was successfully" } - call.respond(HttpStatusCode.OK, JWT(token)) - } ?: call.respond(HttpStatusCode.BadRequest, "Invalid credentials") - } - - static { - resources("public") - } - } - } -} - -@Serializable -private data class JWT(val token: String) diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/group/Handler.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/group/Handler.kt index 909f77cf0..a2fc81e6d 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/group/Handler.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/group/Handler.kt @@ -23,6 +23,8 @@ import com.epam.drill.admin.api.group.GroupUpdateDto import com.epam.drill.admin.api.routes.ApiRoot import com.epam.drill.admin.api.routes.WsRoot import com.epam.drill.admin.api.websocket.GroupSubscription +import com.epam.drill.admin.auth.principal.Role +import com.epam.drill.admin.auth.config.withRole import com.epam.drill.admin.endpoints.* import com.epam.drill.admin.plugin.PluginCaches import com.epam.drill.admin.plugins.Plugins @@ -65,90 +67,92 @@ class GroupHandler(override val di: DI) : DIAware { init { app.routing { authenticate("jwt", "basic") { - put( - "Update group" - .examples( - example("group", GroupUpdateDto(name = "Some Group")) - ).responds(ok(), notFound()) - ) { (_, id), info -> - val statusCode = groupManager[id]?.let { group -> - groupManager.update( - group.copy( - name = info.name, - description = info.description, - environment = info.environment - ) - )?.let { sendUpdates(listOf(it)) } - HttpStatusCode.OK - } ?: HttpStatusCode.NotFound - call.respond(statusCode) - } - + withRole(Role.USER) { + put( + "Update group" + .examples( + example("group", GroupUpdateDto(name = "Some Group")) + ).responds(ok(), notFound()) + ) { (_, id), info -> + val statusCode = groupManager[id]?.let { group -> + groupManager.update( + group.copy( + name = info.name, + description = info.description, + environment = info.environment + ) + )?.let { sendUpdates(listOf(it)) } + HttpStatusCode.OK + } ?: HttpStatusCode.NotFound + call.respond(statusCode) + } - get( - "Get service group data" - .responds( - ok(), - notFound() - ) - ) { (pluginParent, pluginId, dataType) -> - val groupId = pluginParent.parent.groupId - logger.trace { "Get plugin data, groupId=${groupId}, pluginId=${pluginId}, dataType=$dataType" } - val (statusCode, response) = if (pluginId in plugins) { - val agents: List = agentManager.agentsByGroup(groupId) - if (agents.any()) { - pluginCache.retrieveMessage( - pluginId, - GroupSubscription(groupId), - "/group/data/$dataType" - ).toStatusResponsePair() - } else HttpStatusCode.NotFound to ErrorResponse( - "group $groupId not found" - ) - } else HttpStatusCode.NotFound to ErrorResponse("plugin '$pluginId' not found") - logger.trace { response } - call.respond(statusCode, response) - } - patch( - "Register agent in defined group" - .examples( - example( - "agentRegistrationInfo", - agentRegistrationExample + get( + "Get service group data" + .responds( + ok(), + notFound() ) - ) - ) { location, regInfo -> - val groupId = location.groupId - logger.debug { "Group $groupId: registering agents..." } - val agents: List = agentManager.agentsByGroup(groupId) - val agentInfos: List = agents.map { it.info } - val (status: HttpStatusCode, message: Any) = if (agents.isNotEmpty()) { - groupManager[groupId]?.let { groupDto -> - groupManager.update( - groupDto.copy( - name = regInfo.name, - description = regInfo.description, - environment = regInfo.environment, - systemSettings = regInfo.systemSettings + ) { (pluginParent, pluginId, dataType) -> + val groupId = pluginParent.parent.groupId + logger.trace { "Get plugin data, groupId=${groupId}, pluginId=${pluginId}, dataType=$dataType" } + val (statusCode, response) = if (pluginId in plugins) { + val agents: List = agentManager.agentsByGroup(groupId) + if (agents.any()) { + pluginCache.retrieveMessage( + pluginId, + GroupSubscription(groupId), + "/group/data/$dataType" + ).toStatusResponsePair() + } else HttpStatusCode.NotFound to ErrorResponse( + "group $groupId not found" + ) + } else HttpStatusCode.NotFound to ErrorResponse("plugin '$pluginId' not found") + logger.trace { response } + call.respond(statusCode, response) + } + + patch( + "Register agent in defined group" + .examples( + example( + "agentRegistrationInfo", + agentRegistrationExample ) - )?.let { sendUpdates(listOf(it)) } - } - val registeredAgentIds: List = agentInfos.register(regInfo) - if (registeredAgentIds.count() < agentInfos.count()) { - val agentIds = agentInfos.map { it.id } - logger.error { - """Group $groupId: not all agents registered successfully. + ) + ) { location, regInfo -> + val groupId = location.groupId + logger.debug { "Group $groupId: registering agents..." } + val agents: List = agentManager.agentsByGroup(groupId) + val agentInfos: List = agents.map { it.info } + val (status: HttpStatusCode, message: Any) = if (agents.isNotEmpty()) { + groupManager[groupId]?.let { groupDto -> + groupManager.update( + groupDto.copy( + name = regInfo.name, + description = regInfo.description, + environment = regInfo.environment, + systemSettings = regInfo.systemSettings + ) + )?.let { sendUpdates(listOf(it)) } + } + val registeredAgentIds: List = agentInfos.register(regInfo) + if (registeredAgentIds.count() < agentInfos.count()) { + val agentIds = agentInfos.map { it.id } + logger.error { + """Group $groupId: not all agents registered successfully. |Failed agents: ${agentIds - registeredAgentIds}. """.trimMargin() - } - } else logger.debug { "Group $groupId: registered agents $registeredAgentIds." } - HttpStatusCode.OK to "$registeredAgentIds registered" - } else "No agents found for group $groupId".let { - logger.error(it) - HttpStatusCode.InternalServerError to it + } + } else logger.debug { "Group $groupId: registered agents $registeredAgentIds." } + HttpStatusCode.OK to "$registeredAgentIds registered" + } else "No agents found for group $groupId".let { + logger.error(it) + HttpStatusCode.InternalServerError to it + } + call.respond(status, message) } - call.respond(status, message) } } } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/config/JwtConfig.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/config/JwtConfig.kt deleted file mode 100644 index 6803d53f4..000000000 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/jwt/config/JwtConfig.kt +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright 2020 - 2022 EPAM Systems - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.epam.drill.admin.jwt.config - -import com.auth0.jwt.* -import com.auth0.jwt.algorithms.* -import com.epam.drill.admin.config.* -import com.epam.drill.admin.jwt.user.* -import io.ktor.application.* -import io.ktor.config.* -import mu.KotlinLogging -import java.util.* -import javax.crypto.KeyGenerator -import kotlin.time.* - -val logger = KotlinLogging.logger {} -val Application.jwtProperties: ApplicationConfig - get() = drillConfig.config("jwt") - -val generatedSecret: String by lazy { - logger.warn { "The generated secret key for the JWT is used. " + - "To set your secret key, use the DRILL_JWT_SECRET environment variable." } - generateSecret() -} -val Application.jwtSecret: String - get() = jwtProperties.propertyOrNull("secret")?.getString() ?: generatedSecret - -val Application.jwtIssuer: String - get() = jwtProperties.propertyOrNull("issuer")?.getString() ?: "Drill4J App" - -val Application.jwtLifetime: Duration - get() = jwtProperties.propertyOrNull("lifetime")?.getDuration() ?: Duration.minutes(15) - -val Application.jwtAudience: String? - get() = jwtProperties.propertyOrNull("audience")?.getString() - -val Application.jwtAlgorithm: Algorithm - get() = Algorithm.HMAC512(jwtSecret) - -val Application.jwtConfig: JwtConfig - get() = JwtConfig( - algorithm = jwtAlgorithm, - issuer = jwtIssuer, - audience = jwtAudience - ) - - -class JwtConfig( - private val algorithm: Algorithm, - private val issuer: String, - private val audience: String? = null -) { - - val verifier: JWTVerifier = JWT.require(algorithm) - .withIssuer(issuer) - .build() - - fun makeToken(user: User, lifetime: Duration): String = JWT.create() - .withSubject("Authentication") - .withIssuer(issuer) - .withAudience(audience) - .withClaim("id", user.id) - .withClaim("role", user.role) - .withExpiresAt(lifetime.toExpiration()) - .sign(algorithm) -} - -private fun Duration.toExpiration() = Date(System.currentTimeMillis() + inWholeMilliseconds) - -private fun generateSecret() = KeyGenerator.getInstance("HmacSHA512").generateKey().encoded.contentToString() \ No newline at end of file diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinExt.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinExt.kt index c80e162e3..95d6dfde9 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinExt.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinExt.kt @@ -17,6 +17,8 @@ package com.epam.drill.admin.kodein import io.ktor.application.* import org.kodein.di.* +import org.kodein.di.ktor.DIFeature +import org.kodein.di.ktor.di fun AppBuilder(handler: AppBuilder.() -> Unit) = AppBuilder().apply { handler(this) } @@ -38,18 +40,26 @@ class AppBuilder { } fun Application.kodeinApplication( - appConfig: AppBuilder, - kodeinMapper: DI.MainBuilder.(Application) -> Unit = {}, -): DI { - val app = this - return DI { - bind() with singleton { app } + appConfig: AppBuilder +): DIFeature { + return di { appConfig.kodeinModules.forEach { import(kodeinConfig(this) { it(this) }, allowOverride = true) } appConfig.kodeinModules.clear() - kodeinMapper(this, app) } } +fun Application.kodein( + bindings: AppBuilder.() -> Unit +): DIFeature { + return di { + AppBuilder() + .apply(bindings) + .apply { + kodeinModules.forEach { import(kodeinConfig(this@di) { it(this) }, allowOverride = true) } + kodeinModules.clear() + } + } +} fun Application.kodeinConfig(mainBuilder: DI.MainBuilder, hand: KodeinConf.(DI.MainBuilder) -> DI.Module) = KodeinConf(this, mainBuilder).run { hand(this, mainBuilder) } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinModules.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinModules.kt index bced2c063..63f1988ab 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinModules.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/kodein/KodeinModules.kt @@ -15,7 +15,6 @@ */ package com.epam.drill.admin.di -import com.epam.drill.admin.* import com.epam.drill.admin.agent.* import com.epam.drill.admin.agent.config.* import com.epam.drill.admin.agent.logging.* @@ -26,15 +25,12 @@ import com.epam.drill.admin.endpoints.* import com.epam.drill.admin.endpoints.admin.* import com.epam.drill.admin.endpoints.agent.* import com.epam.drill.admin.endpoints.plugin.* -import com.epam.drill.admin.endpoints.system.* import com.epam.drill.admin.group.* -import com.epam.drill.admin.kodein.* import com.epam.drill.admin.notification.* import com.epam.drill.admin.plugin.* import com.epam.drill.admin.plugins.* import com.epam.drill.admin.service.* import com.epam.drill.admin.storage.* -import com.epam.drill.admin.store.* import com.epam.drill.admin.version.* import com.epam.drill.admin.websocket.* import io.ktor.application.* @@ -102,7 +98,6 @@ val handlers: DI.Builder.(Application) -> Unit } bind() with eagerSingleton { LocationAttributeRouteService() } bind() with eagerSingleton { PluginDispatcher(di) } - bind() with eagerSingleton { LoginEndpoint(instance()) } bind() with eagerSingleton { VersionEndpoints(di) } bind() with eagerSingleton { GroupHandler(di) } bind() with eagerSingleton { AgentHandler(di) } diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/notification/Endpoints.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/notification/Endpoints.kt index 0e133b9e6..e0530d3e0 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/notification/Endpoints.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/notification/Endpoints.kt @@ -15,7 +15,6 @@ */ package com.epam.drill.admin.notification -import com.epam.drill.admin.* import com.epam.drill.admin.endpoints.* import de.nielsfalk.ktor.swagger.* import io.ktor.application.* @@ -37,11 +36,13 @@ class NotificationEndpoints(override val di: DI) : DIAware { init { app.routing { - authenticate("jwt", "basic") { authenticated() } + authenticate("jwt", "basic") { + notificationRoutes() + } } } - private fun Route.authenticated() { + private fun Route.notificationRoutes() { val toggle = "Read/Unread notification" .examples( example("Read/Unread notification", diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/plugins/Plugins.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/plugins/Plugins.kt index 0041a1401..2553c15d5 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/plugins/Plugins.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/plugins/Plugins.kt @@ -26,8 +26,7 @@ import com.epam.drill.plugins.test2code.Plugin as Test2CodePlugin */ class Plugins( private val plugins: Map = mutableMapOf(), -) : Map by plugins { -} +) : Map by plugins /** * Plugin structure diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/security/SecurityConfig.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/security/SecurityConfig.kt deleted file mode 100644 index 45aedfa3e..000000000 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/security/SecurityConfig.kt +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright 2020 - 2022 EPAM Systems - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.epam.drill.admin.security - -import com.epam.drill.admin.jwt.config.jwtConfig -import com.epam.drill.admin.userSource -import io.ktor.application.* -import io.ktor.auth.* -import io.ktor.auth.jwt.* - -fun Application.installAuthentication() { - install(Authentication) { - jwt("jwt") { - realm = "Access to the http(s) and the ws(s) services" - verifier(jwtConfig.verifier) - validate { - it.payload.getClaim("id").asInt()?.let(userSource::findUserById) - } - } - basic("basic") { - realm = "Access to the http(s) services" - validate { - userSource.findUserByCredentials(it) - } - } - } -} \ No newline at end of file diff --git a/admin-core/src/main/kotlin/com/epam/drill/admin/version/Endpoints.kt b/admin-core/src/main/kotlin/com/epam/drill/admin/version/Endpoints.kt index b0bb402b0..87d74b6a0 100644 --- a/admin-core/src/main/kotlin/com/epam/drill/admin/version/Endpoints.kt +++ b/admin-core/src/main/kotlin/com/epam/drill/admin/version/Endpoints.kt @@ -34,10 +34,10 @@ class VersionEndpoints(override val di: DI) : DIAware { private val buildManager by instance() init { - app.routing { routes() } + app.routing { versionRoutes() } } - private fun Routing.routes() { + private fun Route.versionRoutes() { val versionMeta = "Get versions".responds( ok(example("sample", VersionDto("0.1.0", adminVersion))) ) diff --git a/admin-core/src/main/resources/application.conf b/admin-core/src/main/resources/application.conf index 1de6e6903..719bc6931 100644 --- a/admin-core/src/main/resources/application.conf +++ b/admin-core/src/main/resources/application.conf @@ -19,8 +19,6 @@ ktor { drill { devMode = false devMode = ${?DRILL_DEVMODE} - embeddedMode = false - embeddedMode = ${?DRILL_EMBEDDED_MODE} defaultPackages = "" defaultPackages = ${?DRILL_DEFAULT_PACKAGES} cache { @@ -69,12 +67,31 @@ drill { maximumPoolSize = 10 maximumPoolSize = ${?DRILL_DB_MAX_POOL_SIZE} } - jwt { - secret = ${?DRILL_JWT_SECRET} - issuer = "http://drill-4-j" - issuer = ${?DRILL_JWT_ISSUER} - audience = ${?DRILL_JWT_AUDIENCE} - lifetime = "1h" - lifetime = ${?DRILL_JWT_LIFETIME} + auth { + jwt { + secret = ${?DRILL_JWT_SECRET} + issuer = "http://drill-4-j" + issuer = ${?DRILL_JWT_ISSUER} + audience = ${?DRILL_JWT_AUDIENCE} + lifetime = "1h" + lifetime = ${?DRILL_JWT_LIFETIME} + } + password { + minLength = 6 + minLength = ${?DRILL_PASSWORD_MIN_LENGTH} + mustContainUppercase = false + mustContainUppercase = ${?DRILL_PASSWORD_CONTAIN_UPPERCASE} + mustContainLowercase = false + mustContainLowercase = ${?DRILL_PASSWORD_CONTAIN_LOWERCASE} + mustContainDigit = false + mustContainDigit = ${?DRILL_PASSWORD_CONTAIN_DIGIT} + } + userRepoType = "DB" + userRepoType = ${?DRILL_USER_REPO_TYPE} + envUsers = [ + "{\"username\": \"admin\", \"password\": \"admin\", \"role\": \"ADMIN\"}" + "{\"username\": \"user\", \"password\": \"user\", \"role\": \"USER\"}", + ] + envUsers = ${?DRILL_USERS} } } diff --git a/gradle.properties b/gradle.properties index f0de38dc7..1c6418de2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ ktorVersion = 1.6.8 bcelVersion = 6.6.0 kodeinVersion = 7.8.0 microutilsLoggingVersion = 2.0.11 -postgresSqlVersion = 42.3.1 +postgresSqlVersion = 42.3.8 testContainersVersion = 1.16.2 zaxxerHikaricpVersion = 4.0.3 shadowPluginVersion = 7.1.0 @@ -18,8 +18,10 @@ flywaydbVersion = 8.4.1 webjarsSwaggerUiVersion = 3.23.8 googleGsonVersion = 2.8.6 mapdbVersion = 3.0.8 -postgresEmbeddedVersion = 2.10 jibVersion = 3.1.4 +mockitoKotlinVersion = 4.1.0 +jbcryptVersion = 0.4 +h2Version = 1.4.200 sharedLibsRef = main sharedLibsLocalPath = lib-jvm-shared diff --git a/settings.gradle.kts b/settings.gradle.kts index aa4a13718..be4c94a81 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,7 +51,8 @@ includeSharedLib("test-plugin-agent") includeSharedLib("test2code-api") includeSharedLib("test2code-api") includeSharedLib("test2code-common") -include("admin-core") +include("admin-auth") include("test2code-admin") +include("admin-core") include("test-framework") include("tests") diff --git a/test-framework/build.gradle.kts b/test-framework/build.gradle.kts index 709d55cd7..e3ab5919a 100644 --- a/test-framework/build.gradle.kts +++ b/test-framework/build.gradle.kts @@ -38,7 +38,7 @@ dependencies { implementation("io.ktor:ktor-serialization:$ktorVersion") implementation("io.ktor:ktor-server-test-host:$ktorVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinxSerializationVersion") - implementation("org.kodein.di:kodein-di-jvm:$kodeinVersion") + implementation("org.kodein.di:kodein-di-framework-ktor-server-jvm:$kodeinVersion") implementation("org.apache.bcel:bcel:$bcelVersion") implementation("org.mapdb:mapdb:$mapdbVersion") implementation("io.github.microutils:kotlin-logging-jvm:$microutilsLoggingVersion") @@ -54,6 +54,7 @@ dependencies { api(project(":dsm")) api(project(":dsm-test-framework")) api(project(":admin-core")) + api(project(":admin-auth")) } kotlin.sourceSets.all { diff --git a/test-framework/src/main/kotlin/com/epam/drill/admin/endpoints/wsTestUtils.kt b/test-framework/src/main/kotlin/com/epam/drill/admin/endpoints/wsTestUtils.kt index 180422561..00ed82887 100644 --- a/test-framework/src/main/kotlin/com/epam/drill/admin/endpoints/wsTestUtils.kt +++ b/test-framework/src/main/kotlin/com/epam/drill/admin/endpoints/wsTestUtils.kt @@ -15,24 +15,29 @@ */ package com.epam.drill.admin.endpoints -import com.epam.drill.admin.api.routes.* import com.epam.drill.admin.common.* import com.epam.drill.admin.common.serialization.* import com.epam.drill.common.message.* -import com.epam.drill.e2e.* import io.ktor.http.* import io.ktor.http.cio.websocket.* import io.ktor.server.testing.* +import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.* import kotlin.test.* -//TODO move under com.epam.drill.e2e +@Serializable +data class UserData( + val username: String, + val password: String, +) + +//TODO move under com.epam.drill.e2e fun TestApplicationEngine.requestToken(): String { - val loginUrl = toApiUri(ApiRoot().let { ApiRoot.Login(it) }) + val loginUrl = "/api/sign-in" val token = handleRequest(HttpMethod.Post, loginUrl) { addHeader(HttpHeaders.ContentType, "${ContentType.Application.Json}") - setBody(UserData.serializer() stringify UserData("guest", "")) + setBody(UserData.serializer() stringify UserData("user", "user")) }.run { response.headers[HttpHeaders.Authorization] } assertNotNull(token, "token can't be empty") return token diff --git a/test-framework/src/main/kotlin/com/epam/drill/e2e/AppConfig.kt b/test-framework/src/main/kotlin/com/epam/drill/e2e/AppConfig.kt index b422bc651..f8d7f5b22 100644 --- a/test-framework/src/main/kotlin/com/epam/drill/e2e/AppConfig.kt +++ b/test-framework/src/main/kotlin/com/epam/drill/e2e/AppConfig.kt @@ -16,10 +16,12 @@ package com.epam.drill.e2e import com.epam.drill.admin.* +import com.epam.drill.admin.auth.config.* +import com.epam.drill.admin.auth.config.usersDiConfig +import com.epam.drill.admin.auth.route.userAuthenticationRoutes import com.epam.drill.admin.config.* import com.epam.drill.admin.di.* import com.epam.drill.admin.endpoints.* -import com.epam.drill.admin.jwt.config.* import com.epam.drill.admin.kodein.* import com.epam.drill.admin.plugin.PluginCaches import com.epam.drill.admin.plugin.PluginMetadata @@ -31,8 +33,6 @@ import com.epam.drill.admin.store.* import com.epam.dsm.* import com.epam.dsm.test.* import io.ktor.application.* -import io.ktor.auth.* -import io.ktor.auth.jwt.* import io.ktor.config.* import io.ktor.features.* import io.ktor.locations.* @@ -41,7 +41,10 @@ import org.kodein.di.* import java.io.* import com.epam.drill.admin.plugins.coverage.TestAdminPart import com.epam.drill.admin.plugins.test2CodePlugin -import com.epam.drill.admin.security.installAuthentication +import io.ktor.routing.* +import javax.sql.DataSource + +const val GUEST_USER = "{\"username\": \"user\", \"password\": \"user\", \"role\": \"USER\"}" class AppConfig(var projectDir: File, delayBeforeClearData: Long, useTest2CodePlugin: Boolean = false) { lateinit var wsTopic: WsTopic @@ -54,17 +57,23 @@ class AppConfig(var projectDir: File, delayBeforeClearData: Long, useTest2CodePl put("drill.plugins.remote.enabled", "false") put("drill.agents.socket.timeout", "90") put("drill.cache.type", "jvm") + put("drill.auth.userRepoType", "ENV") + put("drill.auth.envUsers", listOf(GUEST_USER)) } install(Locations) install(WebSockets) - installAuthentication() + install(ContentNegotiation) { converters() } + install(RoleBasedAuthorization) + enableSwaggerSupport() - kodeinApplication(AppBuilder { + kodein { + withKModule { kodeinModule("securityConfig", securityDiConfig) } + withKModule { kodeinModule("usersConfig", usersDiConfig) } withKModule { kodeinModule("pluginServices", testPluginServices(useTest2CodePlugin)) } withKModule { kodeinModule("storage", storage) } withKModule { kodeinModule("wsHandler", wsHandler) } @@ -80,7 +89,14 @@ class AppConfig(var projectDir: File, delayBeforeClearData: Long, useTest2CodePl } } } - }) + } + + routing { + route("/api") { + userAuthenticationRoutes() + } + } + environment.monitor.subscribe(ApplicationStopped) { println("test app stopping...") Thread.sleep(delayBeforeClearData)//for parallel tests, example: MultipleAgentRegistrationTest diff --git a/test-framework/src/main/kotlin/com/epam/drill/e2e/WsQueueDispatcher.kt b/test-framework/src/main/kotlin/com/epam/drill/e2e/WsQueueDispatcher.kt index d9dcf8f0c..17738e730 100644 --- a/test-framework/src/main/kotlin/com/epam/drill/e2e/WsQueueDispatcher.kt +++ b/test-framework/src/main/kotlin/com/epam/drill/e2e/WsQueueDispatcher.kt @@ -33,6 +33,8 @@ import com.epam.drill.common.message.* import com.epam.drill.common.message.Message import com.epam.drill.common.message.MessageType import com.epam.drill.common.ws.* +import com.epam.drill.common.ws.dto.PluginAction +import com.epam.drill.common.ws.dto.TogglePayload import io.ktor.application.* import io.ktor.http.cio.websocket.* import kotlinx.coroutines.* @@ -202,7 +204,7 @@ class Agent( is Communication.Plugin.DispatchEvent -> { val message = ProtoBuf.load( - com.epam.drill.common.ws.dto.PluginAction.serializer(), + PluginAction.serializer(), content ) plugin.doRawAction(message.message) @@ -210,7 +212,7 @@ class Agent( sendDelivered(url) } is Communication.Plugin.ToggleEvent -> { - val pluginId = ProtoBuf.load(com.epam.drill.common.ws.dto.TogglePayload.serializer(), content).pluginId + val pluginId = ProtoBuf.load(TogglePayload.serializer(), content).pluginId toggled(pluginId) sendDelivered(url) } diff --git a/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/Plugin.kt b/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/Plugin.kt index 9d506ad9b..83ef1bdb9 100644 --- a/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/Plugin.kt +++ b/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/Plugin.kt @@ -464,7 +464,7 @@ class Plugin( sendLabels() sendFilters() sendActiveSessions() - + finishSessionJob() calculateMetricsJob() } diff --git a/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/coverage/Calc.kt b/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/coverage/Calc.kt index b618ae7e5..af25e14f9 100644 --- a/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/coverage/Calc.kt +++ b/test2code-admin/src/main/kotlin/com/epam/drill/plugins/test2code/coverage/Calc.kt @@ -43,7 +43,7 @@ internal fun Sequence.bundle( val classMethods = tree.packages.flatMap { it.classes }.associate { fullClassname(it.path, it.name) to it.methods } - val covered = probesByClasses.values.sumBy { probes -> probes.count { it } } + val covered = probesByClasses.values.sumOf { probes -> probes.count { it } } val packages = probesByClasses.keys.groupBy { classPath(it) }.map { (pkgName, classNames) -> val classes = classNames.map { fullClassname -> val probes = probesByClasses.getValue(fullClassname) diff --git a/test2code-admin/src/test/kotlin/com/epam/drill/plugins/test2code/JsCoverageTest.kt b/test2code-admin/src/test/kotlin/com/epam/drill/plugins/test2code/JsCoverageTest.kt index d386d4b8f..718c56765 100644 --- a/test2code-admin/src/test/kotlin/com/epam/drill/plugins/test2code/JsCoverageTest.kt +++ b/test2code-admin/src/test/kotlin/com/epam/drill/plugins/test2code/JsCoverageTest.kt @@ -32,7 +32,7 @@ class JsCoverageTest : PostgresBased("js_coverage") { @Test fun `coverageData for sessionHolder with custom js probes`() { runBlocking { - val coverageData = calculateCoverage() { + val coverageData = calculateCoverage { this.execSession(manualTestType) { sessionId -> addProbes(sessionId) { probes } } @@ -87,7 +87,7 @@ class JsCoverageTest : PostgresBased("js_coverage") { @Test fun `should merge probes`() = runBlocking { - val coverageData = calculateCoverage() { + val coverageData = calculateCoverage { this.execSession(manualTestType) { sessionId -> addProbes(sessionId) { probes } addProbes(sessionId) { probes2 } diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts index 318641d38..3d20a8a32 100644 --- a/tests/build.gradle.kts +++ b/tests/build.gradle.kts @@ -43,7 +43,8 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:$kotlinxCollectionsVersion") testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") - testImplementation("org.kodein.di:kodein-di-jvm:$kodeinVersion") + // testImplementation("org.kodein.di:kodein-di-jvm:$kodeinVersion") + testImplementation("org.kodein.di:kodein-di-framework-ktor-server-jvm:$kodeinVersion") testImplementation("io.ktor:ktor-server-test-host:$ktorVersion") testImplementation("io.ktor:ktor-auth:$ktorVersion") testImplementation("io.ktor:ktor-auth-jwt:$ktorVersion") @@ -60,6 +61,7 @@ dependencies { testImplementation(kotlin("test-junit5")) testImplementation(project(":admin-core")) + testImplementation(project(":admin-auth")) testImplementation(project(":common")) testImplementation(project(":ktor-swagger")) testImplementation(project(":plugin-api-admin")) diff --git a/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillPluginWsTest.kt b/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillPluginWsTest.kt index 6d3e91535..ba30972eb 100644 --- a/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillPluginWsTest.kt +++ b/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillPluginWsTest.kt @@ -17,6 +17,10 @@ package com.epam.drill.admin.endpoints import com.epam.drill.admin.* import com.epam.drill.admin.api.websocket.* +import com.epam.drill.admin.auth.config.RoleBasedAuthorization +import com.epam.drill.admin.auth.route.userAuthenticationRoutes +import com.epam.drill.admin.auth.config.securityDiConfig +import com.epam.drill.admin.auth.config.usersDiConfig import com.epam.drill.admin.cache.* import com.epam.drill.admin.cache.impl.* import com.epam.drill.admin.common.* @@ -24,18 +28,20 @@ import com.epam.drill.admin.common.serialization.* import com.epam.drill.admin.config.* import com.epam.drill.admin.di.* import com.epam.drill.admin.endpoints.plugin.* -import com.epam.drill.admin.endpoints.system.* import com.epam.drill.admin.kodein.* import com.epam.drill.admin.plugin.* import com.epam.drill.admin.storage.* +import com.epam.drill.e2e.GUEST_USER import com.epam.drill.e2e.testPluginServices import com.epam.drill.plugin.api.end.* import com.epam.drill.testdata.* import com.epam.dsm.test.* import io.ktor.application.* +import io.ktor.config.* import io.ktor.features.* import io.ktor.http.cio.websocket.* import io.ktor.locations.* +import io.ktor.routing.* import io.ktor.serialization.* import io.ktor.server.testing.* import io.ktor.websocket.* @@ -43,6 +49,7 @@ import kotlinx.coroutines.channels.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.* import org.kodein.di.* +import org.kodein.di.ktor.closestDI as di import java.io.* import java.util.* import kotlin.test.* @@ -59,9 +66,11 @@ class PluginWsTest { private val storageDir = File("build/tmp/test/${this::class.simpleName}-${UUID.randomUUID()}") - private lateinit var kodeinApplication: DI - private val testApp: Application.() -> Unit = { + (environment.config as MapApplicationConfig).apply { + put("drill.auth.userRepoType", "ENV") + put("drill.auth.envUsers", listOf(GUEST_USER)) + } install(Locations) install(WebSockets) @@ -69,15 +78,17 @@ class PluginWsTest { json() } + install(RoleBasedAuthorization) + enableSwaggerSupport() hikariConfig = TestDatabaseContainer.createDataSource() TestDatabaseContainer.startOnce() - kodeinApplication = kodeinApplication(AppBuilder { - + kodein { + withKModule { kodeinModule("securityConfig", securityDiConfig) } + withKModule { kodeinModule("usersConfig", usersDiConfig) } withKModule { kodeinModule("pluginServices", testPluginServices()) } withKModule { kodeinModule("test") { - bind() with eagerSingleton { LoginEndpoint(instance()) } bind() with eagerSingleton { DrillPluginWs(di) } bind() with singleton { WsTopic(di) } if (app.drillCacheType == "mapdb") { @@ -90,7 +101,13 @@ class PluginWsTest { } } - }) + } + + routing { + route("/api") { + userAuthenticationRoutes() + } + } } @AfterTest @@ -367,8 +384,8 @@ class PluginWsTest { ) } - private suspend fun sendListData(destination: String, message: List) { - val ps by kodeinApplication.di.instance() + private suspend fun TestApplicationCall.sendListData(destination: String, message: List) { + val ps by di().instance() val sender = ps.sender(pluginId) sender.send(AgentSendContext(agentId, buildVersion), destination, message) } @@ -386,7 +403,7 @@ class PluginWsTest { handleWebSocketConversation(socketUrl()) { incoming, outgoing -> val destination = "/pluginTopic1" val messageForTest = TestMessage("testMessage") - val wsPluginService by kodeinApplication.instance() + val wsPluginService by di().instance() val sender = wsPluginService.sender(pluginId) val sendContext = AgentSendContext(agentId, buildVersion) @Suppress("DEPRECATION") diff --git a/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillServerWsTest.kt b/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillServerWsTest.kt index ba7df2af2..15bece108 100644 --- a/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillServerWsTest.kt +++ b/tests/src/test/kotlin/com/epam/drill/admin/endpoints/DrillServerWsTest.kt @@ -18,6 +18,10 @@ package com.epam.drill.admin.endpoints import com.epam.drill.admin.* +import com.epam.drill.admin.auth.config.RoleBasedAuthorization +import com.epam.drill.admin.auth.route.userAuthenticationRoutes +import com.epam.drill.admin.auth.config.securityDiConfig +import com.epam.drill.admin.auth.config.usersDiConfig import com.epam.drill.admin.cache.* import com.epam.drill.admin.cache.impl.* import com.epam.drill.admin.common.* @@ -25,23 +29,23 @@ import com.epam.drill.admin.common.serialization.* import com.epam.drill.admin.config.* import com.epam.drill.admin.di.* import com.epam.drill.admin.endpoints.admin.* -import com.epam.drill.admin.endpoints.system.* -import com.epam.drill.admin.jwt.config.* import com.epam.drill.admin.kodein.* import com.epam.drill.admin.notification.* -import com.epam.drill.admin.security.installAuthentication import com.epam.drill.admin.storage.* import com.epam.drill.admin.websocket.* +import com.epam.drill.e2e.GUEST_USER import com.epam.drill.e2e.testPluginServices import com.epam.dsm.test.* import io.ktor.application.* import io.ktor.auth.* import io.ktor.auth.jwt.* +import io.ktor.config.* import io.ktor.features.* import io.ktor.http.* import io.ktor.http.ContentType.Application.Json import io.ktor.http.cio.websocket.* import io.ktor.locations.* +import io.ktor.routing.* import io.ktor.server.testing.* import io.ktor.websocket.* import kotlinx.coroutines.* @@ -49,6 +53,7 @@ import kotlinx.coroutines.channels.* import kotlinx.serialization.builtins.* import kotlinx.serialization.json.* import org.kodein.di.* +import org.kodein.di.ktor.closestDI as di import kotlin.test.* @@ -56,19 +61,26 @@ internal class DrillServerWsTest { private lateinit var notificationsManager: NotificationManager private val testApp: Application.() -> Unit = { + (environment.config as MapApplicationConfig).apply { + put("drill.auth.userRepoType", "ENV") + put("drill.auth.envUsers", listOf(GUEST_USER)) + } install(Locations) install(WebSockets) - installAuthentication() install(ContentNegotiation) { converters() } + install(RoleBasedAuthorization) + enableSwaggerSupport() TestDatabaseContainer.startOnce() hikariConfig = TestDatabaseContainer.createDataSource() - kodeinApplication(AppBuilder { + kodein { + withKModule { kodeinModule("securityConfig", securityDiConfig) } + withKModule { kodeinModule("usersConfig", usersDiConfig) } withKModule { kodeinModule("pluginServices", testPluginServices()) } withKModule { kodeinModule("wsHandler", wsHandler) } withKModule { @@ -83,7 +95,6 @@ internal class DrillServerWsTest { notificationsManager } bind() with eagerSingleton { NotificationEndpoints(di) } - bind() with eagerSingleton { LoginEndpoint(instance()) } bind() with eagerSingleton { AgentManager(di) } bind() with eagerSingleton { BuildStorage() } bind() with eagerSingleton { BuildManager(di) } @@ -92,7 +103,13 @@ internal class DrillServerWsTest { } } - }) + } + + routing { + route("/api") { + userAuthenticationRoutes() + } + } } @AfterTest @@ -200,12 +217,8 @@ internal class DrillServerWsTest { fun `get UNAUTHORIZED event if token is invalid`() { withTestApplication(testApp) { val invalidToken = requestToken() + "1" - handleWebSocketConversation("/ws/drill-admin-socket?token=${invalidToken}") { incoming, _ -> - val tmp = incoming.receive() - assertTrue { tmp is Frame.Text } - val response = JsonObject.serializer() parse (tmp as Frame.Text).readText() - assertEquals(WsMessageType.UNAUTHORIZED.name, response[WsSendMessage::type.name]?.toContentString()) - } + val call = handleWebSocket("/ws/drill-admin-socket?token=${invalidToken}") {} + assertEquals(401, call.response.status()?.value) } }