Add a reset-password admin route

This commit is contained in:
Eike Kettner 2021-01-04 16:32:54 +01:00
parent 2a172ce720
commit 668abf2140
7 changed files with 120 additions and 15 deletions

View File

@ -130,6 +130,8 @@ val openapiScalaSettings = Seq(
.addMapping(CustomMapping.forFormatType({ .addMapping(CustomMapping.forFormatType({
case "ident" => case "ident" =>
field => field.copy(typeDef = TypeDef("Ident", Imports("docspell.common.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" => case "collectivestate" =>
field => field =>
field.copy(typeDef = field.copy(typeDef =

View File

@ -44,6 +44,8 @@ trait OCollective[F[_]] {
newPass: Password newPass: Password
): F[PassChangeResult] ): F[PassChangeResult]
def resetPassword(accountId: AccountId): F[PassResetResult]
def getContacts( def getContacts(
collective: Ident, collective: Ident,
query: Option[String], query: Option[String],
@ -77,6 +79,15 @@ object OCollective {
type Classifier = RClassifierSetting.Classifier type Classifier = RClassifierSetting.Classifier
val 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 sealed trait PassChangeResult
object PassChangeResult { object PassChangeResult {
case object UserNotFound extends PassChangeResult case object UserNotFound extends PassChangeResult
@ -184,6 +195,17 @@ object OCollective {
def tagCloud(collective: Ident): F[List[TagCount]] = def tagCloud(collective: Ident): F[List[TagCount]] =
store.transact(QCollective.tagCloud(collective)) 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( def changePassword(
accountId: AccountId, accountId: AccountId,
current: Password, current: Password,

View File

@ -1,5 +1,8 @@
package docspell.common package docspell.common
import cats.effect.Sync
import cats.implicits._
import io.circe.{Decoder, Encoder} import io.circe.{Decoder, Encoder}
final class Password(val pass: String) extends AnyVal { final class Password(val pass: String) extends AnyVal {
@ -18,6 +21,12 @@ object Password {
def apply(pass: String): Password = def apply(pass: String): Password =
new Password(pass) 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] = implicit val passwordEncoder: Encoder[Password] =
Encoder.encodeString.contramap(_.pass) Encoder.encodeString.contramap(_.pass)

View File

@ -151,9 +151,9 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/BasicResult" $ref: "#/components/schemas/BasicResult"
/open/fts/reIndexAll/{id}: /admin/fts/reIndexAll:
post: post:
tags: [Full-Text Index] tags: [Full-Text Index, Admin]
summary: Re-creates the full-text index. summary: Re-creates the full-text index.
description: | description: |
Clears the full-text index and inserts all data from the 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 by a job executor. Note that this affects all data of all
collectives. collectives.
The `id` is required and refers to the key given in the config This is an admin route, so you need to provide the secret from
file to ensure that only admins can call this route. the config file as header `Docspell-Admin-Secret`.
parameters: security:
- $ref: "#/components/parameters/id" - adminHeader: []
responses: responses:
200: 200:
description: Ok description: Ok
@ -1184,6 +1184,31 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/BasicResult" $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: /sec/source:
get: get:
tags: [ Source ] tags: [ Source ]
@ -3416,6 +3441,32 @@ paths:
components: components:
schemas: 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: ItemsAndFieldValue:
description: | description: |
Holds a list of item ids and a custom field value. Holds a list of item ids and a custom field value.
@ -5421,6 +5472,10 @@ components:
type: apiKey type: apiKey
in: header in: header
name: X-Docspell-Auth name: X-Docspell-Auth
adminHeader:
type: apiKey
in: header
name: Docspell-Admin-Secret
parameters: parameters:
id: id:
name: id name: id

View File

@ -103,7 +103,8 @@ object RestServer {
def adminRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = def adminRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
Router( 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] = { def redirectTo[F[_]: Effect](path: String): HttpRoutes[F] = {

View File

@ -17,11 +17,6 @@ object QueryParam {
implicit val queryStringDecoder: QueryParamDecoder[QueryString] = implicit val queryStringDecoder: QueryParamDecoder[QueryString] =
QueryParamDecoder[String].map(s => QueryString(s.trim.toLowerCase)) 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 FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full")
object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning") object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning")

View File

@ -5,10 +5,10 @@ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.common.Ident import docspell.backend.ops.OCollective
import docspell.common._
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.conv.Conversions._ import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityDecoder._
@ -18,7 +18,7 @@ import org.http4s.dsl.Http4sDsl
object UserRoutes { object UserRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { 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._ import dsl._
HttpRoutes.of { 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
}
}
} }