From 668abf214078597dd49f9cf5d6c87f3f55719979 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 4 Jan 2021 16:32:54 +0100 Subject: [PATCH] Add a reset-password admin route --- build.sbt | 2 + .../docspell/backend/ops/OCollective.scala | 22 ++++++ .../main/scala/docspell/common/Password.scala | 9 +++ .../src/main/resources/docspell-openapi.yml | 67 +++++++++++++++++-- .../docspell/restserver/RestServer.scala | 3 +- .../restserver/http4s/QueryParam.scala | 5 -- .../restserver/routes/UserRoutes.scala | 27 +++++++- 7 files changed, 120 insertions(+), 15 deletions(-) diff --git a/build.sbt b/build.sbt index ff0f60c5..52eed4f0 100644 --- a/build.sbt +++ b/build.sbt @@ -130,6 +130,8 @@ val openapiScalaSettings = Seq( .addMapping(CustomMapping.forFormatType({ case "ident" => field => field.copy(typeDef = TypeDef("Ident", Imports("docspell.common.Ident"))) + case "accountid" => + field => field.copy(typeDef = TypeDef("AccountId", Imports("docspell.common.AccountId"))) case "collectivestate" => field => field.copy(typeDef = diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index f68be8b7..1bee773b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -44,6 +44,8 @@ trait OCollective[F[_]] { newPass: Password ): F[PassChangeResult] + def resetPassword(accountId: AccountId): F[PassResetResult] + def getContacts( collective: Ident, query: Option[String], @@ -77,6 +79,15 @@ object OCollective { type Classifier = RClassifierSetting.Classifier val Classifier = RClassifierSetting.Classifier + sealed trait PassResetResult + object PassResetResult { + case class Success(newPw: Password) extends PassResetResult + case object NotFound extends PassResetResult + + def success(np: Password): PassResetResult = Success(np) + def notFound: PassResetResult = NotFound + } + sealed trait PassChangeResult object PassChangeResult { case object UserNotFound extends PassChangeResult @@ -184,6 +195,17 @@ object OCollective { def tagCloud(collective: Ident): F[List[TagCount]] = store.transact(QCollective.tagCloud(collective)) + def resetPassword(accountId: AccountId): F[PassResetResult] = + for { + newPass <- Password.generate[F] + n <- store.transact( + RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass)) + ) + res = + if (n <= 0) PassResetResult.notFound + else PassResetResult.success(newPass) + } yield res + def changePassword( accountId: AccountId, current: Password, diff --git a/modules/common/src/main/scala/docspell/common/Password.scala b/modules/common/src/main/scala/docspell/common/Password.scala index 88a9d09d..c7b7bef9 100644 --- a/modules/common/src/main/scala/docspell/common/Password.scala +++ b/modules/common/src/main/scala/docspell/common/Password.scala @@ -1,5 +1,8 @@ package docspell.common +import cats.effect.Sync +import cats.implicits._ + import io.circe.{Decoder, Encoder} final class Password(val pass: String) extends AnyVal { @@ -18,6 +21,12 @@ object Password { def apply(pass: String): Password = new Password(pass) + def generate[F[_]: Sync]: F[Password] = + for { + id <- Ident.randomId[F] + pass = id.id.take(11) + } yield Password(pass) + implicit val passwordEncoder: Encoder[Password] = Encoder.encodeString.contramap(_.pass) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index d72425d0..cbde344a 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -151,9 +151,9 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" - /open/fts/reIndexAll/{id}: + /admin/fts/reIndexAll: post: - tags: [Full-Text Index] + tags: [Full-Text Index, Admin] summary: Re-creates the full-text index. description: | Clears the full-text index and inserts all data from the @@ -162,10 +162,10 @@ paths: by a job executor. Note that this affects all data of all collectives. - The `id` is required and refers to the key given in the config - file to ensure that only admins can call this route. - parameters: - - $ref: "#/components/parameters/id" + This is an admin route, so you need to provide the secret from + the config file as header `Docspell-Admin-Secret`. + security: + - adminHeader: [] responses: 200: description: Ok @@ -1184,6 +1184,31 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" + + /admin/user/resetPassword: + post: + tags: [ Collective, Admin ] + summary: Reset a user password. + description: | + Resets a user password to some random string which is returned + as the result. This is an admin route, so you need to specify + the secret from the config file via a http header + `Docspell-Admin-Secret`. + security: + - adminHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResetPassword" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ResetPasswordResult" + /sec/source: get: tags: [ Source ] @@ -3416,6 +3441,32 @@ paths: components: schemas: + ResetPassword: + description: | + The account to reset the password. + required: + - account + properties: + account: + type: string + format: accountid + + ResetPasswordResult: + description: | + Contains the newly generated password or an error. + required: + - success + - newPassword + - message + properties: + success: + type: boolean + newPassword: + type: string + format: password + message: + type: string + ItemsAndFieldValue: description: | Holds a list of item ids and a custom field value. @@ -5421,6 +5472,10 @@ components: type: apiKey in: header name: X-Docspell-Auth + adminHeader: + type: apiKey + in: header + name: Docspell-Admin-Secret parameters: id: name: id diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 92754340..1f582c47 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -103,7 +103,8 @@ object RestServer { def adminRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = Router( - "fts" -> FullTextIndexRoutes.admin(cfg, restApp.backend) + "fts" -> FullTextIndexRoutes.admin(cfg, restApp.backend), + "user" -> UserRoutes.admin(restApp.backend) ) def redirectTo[F[_]: Effect](path: String): HttpRoutes[F] = { diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index 4d91d959..aa846c7e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -17,11 +17,6 @@ object QueryParam { implicit val queryStringDecoder: QueryParamDecoder[QueryString] = QueryParamDecoder[String].map(s => QueryString(s.trim.toLowerCase)) - // implicit val booleanDecoder: QueryParamDecoder[Boolean] = - // QueryParamDecoder.fromUnsafeCast(qp => Option(qp.value).exists(_.equalsIgnoreCase("true")))( - // "Boolean" - // ) - object FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full") object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning") diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala index dbe9008d..bdf0ac57 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala @@ -5,10 +5,10 @@ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken -import docspell.common.Ident +import docspell.backend.ops.OCollective +import docspell.common._ import docspell.restapi.model._ import docspell.restserver.conv.Conversions._ -import docspell.restserver.http4s.ResponseGenerator import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ @@ -18,7 +18,7 @@ import org.http4s.dsl.Http4sDsl object UserRoutes { def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { - val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + val dsl = new Http4sDsl[F] {} import dsl._ HttpRoutes.of { @@ -63,4 +63,25 @@ object UserRoutes { } } + def admin[F[_]: Effect](backend: BackendApp[F]): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { case req @ POST -> Root / "resetPassword" => + for { + input <- req.as[ResetPassword] + result <- backend.collective.resetPassword(input.account) + resp <- Ok(result match { + case OCollective.PassResetResult.Success(np) => + ResetPasswordResult(true, np, "Password updated") + case OCollective.PassResetResult.NotFound => + ResetPasswordResult( + false, + Password(""), + "Password update failed. User not found." + ) + }) + } yield resp + } + } }