From 309a52393af0a236048027b058ec85db806ec791 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 30 Aug 2021 16:15:13 +0200 Subject: [PATCH] Initial impl for totp --- build.sbt | 8 +- .../scala/docspell/backend/BackendApp.scala | 3 + .../docspell/backend/auth/AuthToken.scala | 22 ++- .../scala/docspell/backend/auth/Login.scala | 12 +- .../scala/docspell/backend/ops/OTotp.scala | 152 +++++++++++++++++ .../scala/docspell/common/AccountId.scala | 8 +- .../src/main/resources/docspell-openapi.yml | 155 ++++++++++++++++++ .../docspell/restserver/RestServer.scala | 2 + .../restserver/routes/LoginRoutes.scala | 5 +- .../restserver/routes/TotpRoutes.scala | 91 ++++++++++ .../restserver/src/main/templates/index.html | 4 + .../db/migration/postgresql/V1.26.1__totp.sql | 7 + .../main/scala/docspell/store/AddResult.scala | 1 + .../docspell/store/impl/DoobieMeta.scala | 4 + .../scala/docspell/store/records/RTotp.scala | 99 +++++++++++ .../scala/docspell/store/records/RUser.scala | 11 ++ .../test/scala/docspell/totp/TotpTest.scala | 4 +- 17 files changed, 568 insertions(+), 20 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/TotpRoutes.scala create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.26.1__totp.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RTotp.scala diff --git a/build.sbt b/build.sbt index 013e7714..505dd0c5 100644 --- a/build.sbt +++ b/build.sbt @@ -250,6 +250,10 @@ val openapiScalaSettings = Seq( field => field .copy(typeDef = TypeDef("Duration", Imports("docspell.common.Duration"))) + case "uri" => + field => + field + .copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri"))) }) ) @@ -371,7 +375,7 @@ val store = project libraryDependencies ++= Dependencies.testContainer.map(_ % Test) ) - .dependsOn(common, query.jvm) + .dependsOn(common, query.jvm, totp) val extract = project .in(file("modules/extract")) @@ -496,7 +500,7 @@ val backend = project Dependencies.http4sClient ++ Dependencies.emil ) - .dependsOn(store, joexapi, ftsclient) + .dependsOn(store, joexapi, ftsclient, totp) val webapp = project .in(file("modules/webapp")) diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index b94fa742..55d7bf81 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -46,6 +46,7 @@ trait BackendApp[F[_]] { def customFields: OCustomFields[F] def simpleSearch: OSimpleSearch[F] def clientSettings: OClientSettings[F] + def totp: OTotp[F] } object BackendApp { @@ -59,6 +60,7 @@ object BackendApp { for { utStore <- UserTaskStore(store) queue <- JobQueue(store) + totpImpl <- OTotp(store) loginImpl <- Login[F](store) signupImpl <- OSignup[F](store) joexImpl <- OJoex(JoexClient(httpClient), store) @@ -103,6 +105,7 @@ object BackendApp { val customFields = customFieldsImpl val simpleSearch = simpleSearchImpl val clientSettings = clientSettingsImpl + val totp = totpImpl } def apply[F[_]: Async]( diff --git a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala index 07ea592f..531a6616 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala @@ -16,8 +16,15 @@ import docspell.common._ import scodec.bits.ByteVector -case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: String) { - def asString = s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$salt-$sig" +case class AuthToken( + nowMillis: Long, + account: AccountId, + requireSecondFactor: Boolean, + salt: String, + sig: String +) { + def asString = + s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$requireSecondFactor-$salt-$sig" def sigValid(key: ByteVector): Boolean = { val newSig = TokenUtil.sign(this, key) @@ -42,13 +49,14 @@ case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: Str object AuthToken { def fromString(s: String): Either[String, AuthToken] = - s.split("\\-", 4) match { - case Array(ms, as, salt, sig) => + s.split("\\-", 5) match { + case Array(ms, as, fa, salt, sig) => for { millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data") acc <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data") accId <- AccountId.parse(acc) - } yield AuthToken(millis, accId, salt, sig) + twofac <- Right[String, Boolean](java.lang.Boolean.parseBoolean(fa)) + } yield AuthToken(millis, accId, twofac, salt, sig) case _ => Left("Invalid authenticator") @@ -58,7 +66,7 @@ object AuthToken { for { salt <- Common.genSaltString[F] millis = Instant.now.toEpochMilli - cd = AuthToken(millis, accountId, salt, "") + cd = AuthToken(millis, accountId, false, salt, "") sig = TokenUtil.sign(cd, key) } yield cd.copy(sig = sig) @@ -66,7 +74,7 @@ object AuthToken { for { now <- Timestamp.current[F] salt <- Common.genSaltString[F] - data = AuthToken(now.toMillis, token.account, salt, "") + data = AuthToken(now.toMillis, token.account, token.requireSecondFactor, salt, "") sig = TokenUtil.sign(data, key) } yield data.copy(sig = sig) } diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala index 51fbcc5f..2fda1faf 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala @@ -68,11 +68,15 @@ object Login { case object InvalidTime extends Result { val toEither = Left("Authentication failed.") } + case object InvalidFactor extends Result { + val toEither = Left("Authentication requires second factor.") + } def ok(session: AuthToken, remember: Option[RememberToken]): Result = Ok(session, remember) - def invalidAuth: Result = InvalidAuth - def invalidTime: Result = InvalidTime + def invalidAuth: Result = InvalidAuth + def invalidTime: Result = InvalidTime + def invalidFactor: Result = InvalidFactor } def apply[F[_]: Async](store: Store[F]): Resource[F, Login[F]] = @@ -87,6 +91,8 @@ object Login { logF.warn("Cookie signature invalid!") *> Result.invalidAuth.pure[F] else if (at.isExpired(config.sessionValid)) logF.debug("Auth Cookie expired") *> Result.invalidTime.pure[F] + else if (at.requireSecondFactor) + logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F] else Result.ok(at, None).pure[F] case Left(_) => Result.invalidAuth.pure[F] @@ -136,7 +142,7 @@ object Login { if (checkNoPassword(data)) logF.info("RememberMe auth successful") *> okResult(data.account) else - logF.warn("RememberMe auth not successfull") *> Result.invalidAuth.pure[F] + logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F] ) } yield res).getOrElseF( logF.info("RememberMe not found in database.") *> Result.invalidAuth.pure[F] diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala b/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala new file mode 100644 index 00000000..634579a4 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.backend.ops +import cats.effect._ +import cats.implicits._ + +import docspell.backend.ops.OTotp.{ConfirmResult, InitResult, OtpState} +import docspell.common._ +import docspell.store.records.{RTotp, RUser} +import docspell.store.{AddResult, Store, UpdateResult} +import docspell.totp.{Key, OnetimePassword, Totp} + +import org.log4s.getLogger + +trait OTotp[F[_]] { + + def state(accountId: AccountId): F[OtpState] + + def initialize(accountId: AccountId): F[InitResult] + + def confirmInit(accountId: AccountId, otp: OnetimePassword): F[ConfirmResult] + + def disable(accountId: AccountId): F[UpdateResult] +} + +object OTotp { + private[this] val logger = getLogger + + sealed trait OtpState { + def isEnabled: Boolean + def isDisabled = !isEnabled + def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A + } + object OtpState { + final case class Enabled(created: Timestamp) extends OtpState { + val isEnabled = true + def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A = + fe(this) + } + case object Disabled extends OtpState { + val isEnabled = false + def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A = + fd(this) + } + } + + sealed trait InitResult + object InitResult { + final case class Success(accountId: AccountId, key: Key) extends InitResult { + def authenticatorUrl(issuer: String): LenientUri = + LenientUri.unsafe( + s"otpauth://totp/$issuer:${accountId.asString}?secret=${key.data.toBase32}&issuer=$issuer" + ) + } + case object AlreadyExists extends InitResult + case object NotFound extends InitResult + final case class Failed(ex: Throwable) extends InitResult + + def success(accountId: AccountId, key: Key): InitResult = + Success(accountId, key) + + def alreadyExists: InitResult = AlreadyExists + + def failed(ex: Throwable): InitResult = Failed(ex) + } + + sealed trait ConfirmResult + object ConfirmResult { + case object Success extends ConfirmResult + case object Failed extends ConfirmResult + } + + def apply[F[_]: Async](store: Store[F]): Resource[F, OTotp[F]] = + Resource.pure[F, OTotp[F]](new OTotp[F] { + val totp = Totp.default + val log = Logger.log4s[F](logger) + + def initialize(accountId: AccountId): F[InitResult] = + for { + _ <- log.info(s"Initializing TOTP for account ${accountId.asString}") + userId <- store.transact(RUser.findIdByAccount(accountId)) + result <- userId match { + case Some(uid) => + for { + record <- RTotp.generate[F](uid, totp.settings.mac) + un <- store.transact(RTotp.updateDisabled(record)) + an <- + if (un != 0) + AddResult.entityExists("Entity exists, but update was ok").pure[F] + else store.add(RTotp.insert(record), RTotp.existsByLogin(accountId)) + innerResult <- + if (un != 0) InitResult.success(accountId, record.secret).pure[F] + else + an match { + case AddResult.EntityExists(msg) => + log.warn( + s"A totp record already exists for account '${accountId.asString}': $msg!" + ) *> + InitResult.alreadyExists.pure[F] + case AddResult.Failure(ex) => + log.warn( + s"Failed to setup totp record for '${accountId.asString}': ${ex.getMessage}" + ) *> + InitResult.failed(ex).pure[F] + case AddResult.Success => + InitResult.success(accountId, record.secret).pure[F] + } + } yield innerResult + case None => + log.warn(s"No user found for account: ${accountId.asString}!") *> + InitResult.NotFound.pure[F] + } + } yield result + + def confirmInit(accountId: AccountId, otp: OnetimePassword): F[ConfirmResult] = + for { + _ <- log.info(s"Confirm TOTP setup for account ${accountId.asString}") + key <- store.transact(RTotp.findEnabledByLogin(accountId, false)) + now <- Timestamp.current[F] + res <- key match { + case None => + ConfirmResult.Failed.pure[F] + case Some(r) => + val check = totp.checkPassword(r.secret, otp, now.value) + if (check) + store + .transact(RTotp.setEnabled(accountId, true)) + .map(_ => ConfirmResult.Success) + else ConfirmResult.Failed.pure[F] + } + } yield res + + def disable(accountId: AccountId): F[UpdateResult] = + UpdateResult.fromUpdate(store.transact(RTotp.setEnabled(accountId, false))) + + def state(accountId: AccountId): F[OtpState] = + for { + record <- store.transact(RTotp.findEnabledByLogin(accountId, true)) + result = record match { + case Some(r) => + OtpState.Enabled(r.created) + case None => + OtpState.Disabled + } + } yield result + }) + +} diff --git a/modules/common/src/main/scala/docspell/common/AccountId.scala b/modules/common/src/main/scala/docspell/common/AccountId.scala index 59044be1..10ba0a7f 100644 --- a/modules/common/src/main/scala/docspell/common/AccountId.scala +++ b/modules/common/src/main/scala/docspell/common/AccountId.scala @@ -9,13 +9,13 @@ package docspell.common import io.circe._ case class AccountId(collective: Ident, user: Ident) { - def asString = - s"${collective.id}/${user.id}" + if (collective == user) user.id + else s"${collective.id}/${user.id}" } object AccountId { - private[this] val sepearatorChars: String = "/\\:" + private[this] val separatorChars: String = "/\\:" def parse(str: String): Either[String, AccountId] = { val input = str.replaceAll("\\s+", "").trim @@ -36,7 +36,7 @@ object AccountId { invalid } - val separated = sepearatorChars.foldRight(invalid)((c, v) => v.orElse(parse0(c))) + val separated = separatorChars.foldRight(invalid)((c, v) => v.orElse(parse0(c))) separated.orElse(Ident.fromString(str).map(id => AccountId(id, id))) } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index d1c7ff8f..5475bf80 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1275,6 +1275,91 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/user/otp/state: + get: + operationId: "sec-user-otp-state" + tags: [ Collective ] + summary: Gets the otp state for the current user. + description: | + Returns whether the current account as OTP enabled or not. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/OtpState" + + /sec/user/otp/init: + post: + operationId: "sec-user-otp-init" + tags: [ Collective, Authentication ] + summary: Initialize two factor auth via OTP + description: | + Requests to enable two factor authentication for this user. A + secret key is generated and returned. The client is expected + to insert it into some OTP application. Currently, only time + based OTP is supported. + + The request body is empty. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/OtpResult" + + /sec/user/otp/confirm: + post: + operationId: "sec-user-otp-confirm" + tags: [ Collective, Authentication ] + summary: Confirms two factor authentication + description: | + Confirms using two factor authentication by sending a one time + password. If the password is correct, this enables two factor + authentication for the current user. + + If there exists no unapproved otp request or the password is + not correct, an error is returned. If 2fa is already enabled + for this account, success is returned. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OtpConfirm" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/user/otp/disable: + post: + operationId: "sec-user-otp-disable" + tags: [ Collective, Authentication ] + summary: Disables two factor authentication. + description: | + Disables two factor authentication for the current user. If + the user has no two factor authentication enabled, this + returns success, too. + + After this completes successfully, two factor auth can be + enabled again by initializing it anew. + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/clientSettings/{clientId}: parameters: - $ref: "#/components/parameters/clientId" @@ -1364,6 +1449,30 @@ paths: application/json: schema: $ref: "#/components/schemas/ResetPasswordResult" + /admin/user/resetOTP: + post: + operationId: "admin-user-reset-otp" + tags: [ Collective, Admin ] + summary: Disables OTP two factor auth for the given user. + description: | + Removes the OTP setup for the given user account. The account + can login afterwards with a correct password. A second factor + is not required. Two factor auth can be setup again for this + account. + security: + - adminHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResetPassword" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" /admin/attachments/generatePreviews: post: @@ -3885,6 +3994,49 @@ paths: components: schemas: + OtpState: + description: | + The state for OTP for an account + required: + - enabled + properties: + enabled: + type: boolean + created: + type: integer + format: date-time + OtpResult: + description: | + The result from initializing OTP. It contains the shared + secret. + required: + - authenticatorUrl + - secret + - authType + - issuer + properties: + authenticatorUrl: + type: string + format: uri + secret: + type: string + authType: + type: string + enum: + - totp + issuer: + type: string + + OtpConfirm: + description: | + Transports a one time password. + required: + - otp + properties: + otp: + type: string + format: password + ResetPassword: description: | The account to reset the password. @@ -5888,6 +6040,7 @@ components: required: - collective - user + - requireSecondFactor - success - message - validMs @@ -5910,6 +6063,8 @@ components: How long the token is valid in ms. type: integer format: int64 + requireSecondFactor: + type: boolean VersionInfo: description: | Information about the software. diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 190ba7b2..39f63b40 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -76,6 +76,7 @@ object RestServer { "organization" -> OrganizationRoutes(restApp.backend, token), "person" -> PersonRoutes(restApp.backend, token), "source" -> SourceRoutes(restApp.backend, token), + "user/otp" -> TotpRoutes(restApp.backend, cfg, token), "user" -> UserRoutes(restApp.backend, token), "collective" -> CollectiveRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token), @@ -109,6 +110,7 @@ object RestServer { def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = Router( "fts" -> FullTextIndexRoutes.admin(cfg, restApp.backend), + "user/otp" -> TotpRoutes.admin(restApp.backend), "user" -> UserRoutes.admin(restApp.backend), "info" -> InfoRoutes.admin(cfg), "attachments" -> AttachmentRoutes.admin(restApp.backend) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala index a6f38d2c..3067e912 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala @@ -82,7 +82,8 @@ object LoginRoutes { true, "Login successful", Some(cd.asString), - cfg.auth.sessionValid.millis + cfg.auth.sessionValid.millis, + token.requireSecondFactor ) ).map(cd.addCookie(getBaseUrl(cfg, req))) .map(resp => @@ -93,7 +94,7 @@ object LoginRoutes { } yield resp case _ => - Ok(AuthResult("", account, false, "Login failed.", None, 0L)) + Ok(AuthResult("", account, false, "Login failed.", None, 0L, false)) } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/TotpRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/TotpRoutes.scala new file mode 100644 index 00000000..a25e5a12 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/TotpRoutes.scala @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.backend.ops.OTotp +import docspell.restapi.model._ +import docspell.restserver.Config +import docspell.restserver.conv.Conversions +import docspell.totp.OnetimePassword + +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object TotpRoutes { + def apply[F[_]: Async]( + backend: BackendApp[F], + cfg: Config, + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root / "state" => + for { + result <- backend.totp.state(user.account) + resp <- Ok( + result.fold(en => OtpState(true, en.created.some), _ => OtpState(false, None)) + ) + } yield resp + case POST -> Root / "init" => + for { + result <- backend.totp.initialize(user.account) + resp <- result match { + case OTotp.InitResult.AlreadyExists => + UnprocessableEntity(BasicResult(false, "A totp setup already exists!")) + case OTotp.InitResult.NotFound => + NotFound(BasicResult(false, "User not found")) + case OTotp.InitResult.Failed(ex) => + InternalServerError(BasicResult(false, ex.getMessage)) + case s @ OTotp.InitResult.Success(_, key) => + val issuer = cfg.appName + val uri = s.authenticatorUrl(issuer) + Ok(OtpResult(uri, key.data.toBase32, "totp", issuer)) + } + } yield resp + + case req @ POST -> Root / "confirm" => + for { + data <- req.as[OtpConfirm] + result <- backend.totp.confirmInit(user.account, OnetimePassword(data.otp.pass)) + resp <- result match { + case OTotp.ConfirmResult.Success => + Ok(BasicResult(true, "TOTP setup successful.")) + case OTotp.ConfirmResult.Failed => + Ok(BasicResult(false, "TOTP setup failed!")) + } + } yield resp + + case POST -> Root / "disable" => + for { + result <- backend.totp.disable(user.account) + resp <- Ok(Conversions.basicResult(result, "TOTP setup disabled.")) + } yield resp + } + } + + def admin[F[_]: Async](backend: BackendApp[F]): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { case req @ POST -> Root / "resetOTP" => + for { + data <- req.as[ResetPassword] + result <- backend.totp.disable(data.account) + resp <- Ok(Conversions.basicResult(result, "TOTP setup disabled.")) + } yield resp + } + } +} diff --git a/modules/restserver/src/main/templates/index.html b/modules/restserver/src/main/templates/index.html index 59c7ef59..6ae141fb 100644 --- a/modules/restserver/src/main/templates/index.html +++ b/modules/restserver/src/main/templates/index.html @@ -39,6 +39,10 @@