From 2b46cc7970d88a2d0ac75456c8999daba2d0a4b2 Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 29 Aug 2021 21:27:56 +0200 Subject: [PATCH 1/4] Add a simple module for totps This is a wrapper around the nice and minimal java-otp library [0]. [0] https://github.com/jchambers/java-otp --- build.sbt | 17 +++- .../src/main/scala/docspell/totp/Key.scala | 79 +++++++++++++++++++ .../src/main/scala/docspell/totp/Mac.scala | 51 ++++++++++++ .../scala/docspell/totp/OnetimePassword.scala | 28 +++++++ .../main/scala/docspell/totp/PassLength.scala | 43 ++++++++++ .../main/scala/docspell/totp/Settings.scala | 42 ++++++++++ .../src/main/scala/docspell/totp/Totp.scala | 66 ++++++++++++++++ .../test/scala/docspell/totp/KeyTest.scala | 38 +++++++++ .../test/scala/docspell/totp/TotpTest.scala | 61 ++++++++++++++ project/Dependencies.scala | 10 +++ 10 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 modules/totp/src/main/scala/docspell/totp/Key.scala create mode 100644 modules/totp/src/main/scala/docspell/totp/Mac.scala create mode 100644 modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala create mode 100644 modules/totp/src/main/scala/docspell/totp/PassLength.scala create mode 100644 modules/totp/src/main/scala/docspell/totp/Settings.scala create mode 100644 modules/totp/src/main/scala/docspell/totp/Totp.scala create mode 100644 modules/totp/src/test/scala/docspell/totp/KeyTest.scala create mode 100644 modules/totp/src/test/scala/docspell/totp/TotpTest.scala diff --git a/build.sbt b/build.sbt index 8f477e79..013e7714 100644 --- a/build.sbt +++ b/build.sbt @@ -335,6 +335,20 @@ val query = Dependencies.scalaJsStubs ) +val totp = project + .in(file("modules/totp")) + .disablePlugins(RevolverPlugin) + .settings(sharedSettings) + .settings(testSettingsMUnit) + .settings( + name := "docspell-totp", + libraryDependencies ++= + Dependencies.javaOtp ++ + Dependencies.scodecBits ++ + Dependencies.fs2 ++ + Dependencies.circe + ) + val store = project .in(file("modules/store")) .disablePlugins(RevolverPlugin) @@ -676,7 +690,8 @@ val root = project restapi, restserver, query.jvm, - query.js + query.js, + totp ) // --- Helpers diff --git a/modules/totp/src/main/scala/docspell/totp/Key.scala b/modules/totp/src/main/scala/docspell/totp/Key.scala new file mode 100644 index 00000000..5c4b2e72 --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Key.scala @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.security.{Key => JKey} +import javax.crypto.KeyGenerator +import javax.crypto.spec.SecretKeySpec + +import cats.effect._ + +import io.circe.generic.semiauto +import io.circe.{Decoder, Encoder} +import scodec.bits.ByteVector + +final case class Key(data: ByteVector, mac: Mac) { + def toJavaKey: JKey = + new SecretKeySpec(data.toArray, mac.identifier) + + /** Renders the mac and data into one string which can be consumed by `fromString` */ + def asString: String = + s"${mac.identifier}::${data.toBase32}" +} + +object Key { + + def fromSecretKey(sk: JKey): Either[String, Key] = + for { + mac <- Mac.fromString(sk.getAlgorithm) + key = Key(ByteVector.view(sk.getEncoded), mac) + } yield key + + def generate[F[_]: Sync](mac: Mac): F[Key] = Sync[F].delay { + val jkey = generateJavaKey(mac) + Key(ByteVector.view(jkey.getEncoded), mac) + } + + def fromString(str: String): Either[String, Key] = { + val (macStr, dataStr) = str.span(_ != ':') + if (dataStr.isEmpty) Left(s"No separator found in key string: $str") + else + for { + mac <- Mac.fromString(macStr) + data <- ByteVector.fromBase32Descriptive(dataStr.dropWhile(_ == ':')) + } yield Key(data, mac) + } + + def unsafeFromString(str: String): Key = + fromString(str).fold(sys.error, identity) + + private[totp] def generateJavaKey(mac: Mac): JKey = { + val keyGen = KeyGenerator.getInstance(mac.identifier) + keyGen.init(mac.keyLengthBits) + keyGen.generateKey() + } + + implicit val jsonEncoder: Encoder[Key] = + Codec.jsonEncoder + + implicit val jsonDecoder: Decoder[Key] = + Codec.jsonDecoder + + private object Codec { + implicit val byteVectorEncoder: Encoder[ByteVector] = + Encoder.encodeString.contramap(_.toBase32) + + implicit val byteVectorDecoder: Decoder[ByteVector] = + Decoder.decodeString.emap(s => ByteVector.fromBase32Descriptive(s)) + + val jsonEncoder: Encoder[Key] = + semiauto.deriveEncoder[Key] + + val jsonDecoder: Decoder[Key] = + semiauto.deriveDecoder[Key] + } +} diff --git a/modules/totp/src/main/scala/docspell/totp/Mac.scala b/modules/totp/src/main/scala/docspell/totp/Mac.scala new file mode 100644 index 00000000..49a185b3 --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Mac.scala @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import cats.data.NonEmptyList + +import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator +import io.circe.{Decoder, Encoder} + +sealed trait Mac { + def identifier: String + def keyLengthBits: Int +} +object Mac { + case object Sha1 extends Mac { + val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA1 + val keyLengthBits = 160 + } + case object Sha256 extends Mac { + val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256 + val keyLengthBits = 256 + } + case object Sha512 extends Mac { + val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA512 + val keyLengthBits = 512 + } + + val all: NonEmptyList[Mac] = + NonEmptyList.of(Sha1, Sha256, Sha512) + + def fromString(str: String): Either[String, Mac] = + str.toLowerCase match { + case "hmacsha1" => Right(Sha1) + case "hmacsha256" => Right(Sha256) + case "hmacsha512" => Right(Sha512) + case _ => Left(s"Unknown mac name: $str") + } + + def unsafeFromString(str: String): Mac = + fromString(str).fold(sys.error, identity) + + implicit val jsonEncoder: Encoder[Mac] = + Encoder.encodeString.contramap(_.identifier) + + implicit val jsonDecoder: Decoder[Mac] = + Decoder.decodeString.emap(fromString) +} diff --git a/modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala b/modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala new file mode 100644 index 00000000..9ce2355f --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import io.circe.{Decoder, Encoder} + +final class OnetimePassword(val pass: String) extends AnyVal { + override def toString: String = "***" +} + +object OnetimePassword { + + def apply(pass: String): OnetimePassword = + new OnetimePassword(pass) + + def unapply(op: OnetimePassword): Option[String] = + Some(op.pass) + + implicit val jsonEncoder: Encoder[OnetimePassword] = + Encoder.encodeString.contramap(_.pass) + + implicit val jsonDecoder: Decoder[OnetimePassword] = + Decoder.decodeString.map(OnetimePassword.apply) +} diff --git a/modules/totp/src/main/scala/docspell/totp/PassLength.scala b/modules/totp/src/main/scala/docspell/totp/PassLength.scala new file mode 100644 index 00000000..b47ca0df --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/PassLength.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import cats.data.NonEmptyList + +import io.circe.{Decoder, Encoder} + +sealed trait PassLength { + def toInt: Int +} + +object PassLength { + case object Chars6 extends PassLength { + val toInt = 6 + } + case object Chars8 extends PassLength { + val toInt = 8 + } + + val all: NonEmptyList[PassLength] = + NonEmptyList.of(Chars6, Chars8) + + def fromInt(n: Int): Either[String, PassLength] = + n match { + case 6 => Right(Chars6) + case 8 => Right(Chars8) + case _ => Left(s"Invalid length: $n! Must be either 6 or 8") + } + + def unsafeFromInt(n: Int): PassLength = + fromInt(n).fold(sys.error, identity) + + implicit val jsonEncoder: Encoder[PassLength] = + Encoder.encodeInt.contramap(_.toInt) + + implicit val jsonDecoder: Decoder[PassLength] = + Decoder.decodeInt.emap(fromInt) +} diff --git a/modules/totp/src/main/scala/docspell/totp/Settings.scala b/modules/totp/src/main/scala/docspell/totp/Settings.scala new file mode 100644 index 00000000..343a099b --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Settings.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.util.concurrent.TimeUnit + +import scala.concurrent.duration._ + +import io.circe.generic.semiauto +import io.circe.{Decoder, Encoder} + +case class Settings(mac: Mac, passLength: PassLength, duration: FiniteDuration) + +object Settings { + val default = + Settings(Mac.Sha1, PassLength.Chars6, 30.seconds) + + implicit val jsonEncoder: Encoder[Settings] = + Codec.jsonEncoder + + implicit val jsonDecoder: Decoder[Settings] = + Codec.jsonDecoder + + private object Codec { + + implicit val durationEncoder: Encoder[FiniteDuration] = + Encoder.encodeLong.contramap(_.toSeconds) + + implicit val durationDecoder: Decoder[FiniteDuration] = + Decoder.decodeLong.map(secs => FiniteDuration(secs, TimeUnit.SECONDS)) + + val jsonEncoder: Encoder[Settings] = + semiauto.deriveEncoder[Settings] + + val jsonDecoder: Decoder[Settings] = + semiauto.deriveDecoder[Settings] + } +} diff --git a/modules/totp/src/main/scala/docspell/totp/Totp.scala b/modules/totp/src/main/scala/docspell/totp/Totp.scala new file mode 100644 index 00000000..fa5b5d5b --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Totp.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.time.Instant + +import fs2.Stream + +import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator + +/** Generator for time based one time passwords. */ +trait Totp { + + /** The settings used to generate passwords. */ + def settings: Settings + + /** Generate the password for the given key and time. */ + def generate(key: Key, time: Instant): OnetimePassword + + /** Generate a stream of passwords using the given key and starting at the given time. + */ + def generateStream[F[_]](key: Key, time: Instant): Stream[F, OnetimePassword] + + /** Checks whether the given password matches using the current time. */ + def checkPassword(key: Key, otp: OnetimePassword, time: Instant): Boolean +} + +object Totp { + + val default: Totp = + Totp(Settings.default) + + def apply(setts: Settings): Totp = + new Totp { + + val settings = setts + private val generator = makeGenerator(setts) + + def generate(key: Key, time: Instant): OnetimePassword = + OnetimePassword(generator.generateOneTimePasswordString(key.toJavaKey, time)) + + def generateStream[F[_]](key: Key, time: Instant): Stream[F, OnetimePassword] = + Stream.emit(generate(key, time)) ++ generateStream( + key, + time.plus(generator.getTimeStep) + ) + + def checkPassword(key: Key, given: OnetimePassword, time: Instant): Boolean = { + val pass = generate(key, time) + pass == given + } + } + + private def makeGenerator(settings: Settings): TimeBasedOneTimePasswordGenerator = { + val duration = java.time.Duration.ofNanos(settings.duration.toNanos) + new TimeBasedOneTimePasswordGenerator( + duration, + settings.passLength.toInt, + settings.mac.identifier + ) + } +} diff --git a/modules/totp/src/test/scala/docspell/totp/KeyTest.scala b/modules/totp/src/test/scala/docspell/totp/KeyTest.scala new file mode 100644 index 00000000..01121b22 --- /dev/null +++ b/modules/totp/src/test/scala/docspell/totp/KeyTest.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import cats.effect._ +import cats.effect.unsafe.implicits._ + +import docspell.totp.{Key, Mac} + +import io.circe.syntax._ +import munit._ + +class KeyTest extends FunSuite { + + test("generate and read in key") { + val jkey = Key.generateJavaKey(Mac.Sha1) + val key = Key.fromSecretKey(jkey).fold(sys.error, identity) + assertEquals(jkey, key.toJavaKey) + } + + test("generate key") { + for (mac <- Mac.all.toList) { + val key = Key.generate[IO](mac).unsafeRunSync() + assertEquals(key.data.length.toInt * 8, key.mac.keyLengthBits) + } + } + + test("encode/decode json") { + val key = Key.generate[IO](Mac.Sha1).unsafeRunSync() + val keyJson = key.asJson + val newKey = keyJson.as[Key].fold(throw _, identity) + assertEquals(key, newKey) + } +} diff --git a/modules/totp/src/test/scala/docspell/totp/TotpTest.scala b/modules/totp/src/test/scala/docspell/totp/TotpTest.scala new file mode 100644 index 00000000..207553cb --- /dev/null +++ b/modules/totp/src/test/scala/docspell/totp/TotpTest.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.time.Instant + +import scala.concurrent.duration._ + +import cats.Id +import cats.effect._ +import cats.effect.unsafe.implicits._ + +import munit._ +import scodec.bits.ByteVector + +class TotpTest extends FunSuite { + + val totp = Totp.default + val key = Key(ByteVector.fromValidBase64("GGFWIWYnHB8F5Dp87iS2HP86k4A="), Mac.Sha1) + val time = Instant.parse("2021-08-29T18:42:00Z") + + test("generate password") { + val otp = totp.generate(key, time) + assertEquals("410352", otp.pass) + } + + test("generate stream") { + val otp3 = totp.generateStream[Id](key, time).take(3).compile.toList + assertEquals(otp3.map(_.pass), List("410352", "557347", "512023")) + } + + for { + mac <- Mac.all.toList + plen <- PassLength.all.toList + } test(s"generate ${mac.identifier} with ${plen.toInt} characters") { + val key = Key.generate[IO](mac).unsafeRunSync() + val totp = Totp(Settings(mac, plen, 30.seconds)) + val otp = totp.generate(key, time) + assertEquals(otp.pass.length, plen.toInt) + } + + test("check password at same time") { + assert(totp.checkPassword(key, OnetimePassword("410352"), time)) + } + + test("check password 15s later") { + assert(totp.checkPassword(key, OnetimePassword("410352"),time.plusSeconds(15))) + } + + test("check password 29s later") { + assert(totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(29))) + } + + test("check password 31s later (too late)") { + assert(!totp.checkPassword(key, OnetimePassword("410352"),time.plusSeconds(31))) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 200849a7..f1a48c42 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,6 +21,7 @@ object Dependencies { val H2Version = "1.4.200" val Http4sVersion = "0.23.1" val Icu4jVersion = "69.1" + val javaOtpVersion = "0.3.0" val JsoupVersion = "1.14.2" val KindProjectorVersion = "0.10.3" val KittensVersion = "2.3.2" @@ -36,6 +37,7 @@ object Dependencies { val PostgresVersion = "42.2.23" val PureConfigVersion = "0.16.0" val ScalaJavaTimeVersion = "2.3.0" + val ScodecBitsVersion = "1.1.27" val Slf4jVersion = "1.7.32" val StanfordNlpVersion = "4.2.2" val TikaVersion = "2.1.0" @@ -46,6 +48,14 @@ object Dependencies { val JQueryVersion = "3.5.1" val ViewerJSVersion = "0.5.9" + val scodecBits = Seq( + "org.scodec" %% "scodec-bits" % ScodecBitsVersion + ) + + val javaOtp = Seq( + "com.eatthepath" % "java-otp" % "0.3.0" + ) + val testContainer = Seq( "com.dimafeng" %% "testcontainers-scala-munit" % TestContainerVersion, "com.dimafeng" %% "testcontainers-scala-mariadb" % TestContainerVersion, From 309a52393af0a236048027b058ec85db806ec791 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 30 Aug 2021 16:15:13 +0200 Subject: [PATCH 2/4] 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 @@