diff --git a/modules/backend/src/main/scala/docspell/backend/auth/ShareToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/ShareToken.scala new file mode 100644 index 00000000..c26124d6 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/auth/ShareToken.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.auth + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.Common +import docspell.common.{Ident, Timestamp} + +import scodec.bits.ByteVector + +/** Can be used as an authenticator to access data behind a share. */ +final case class ShareToken(created: Timestamp, id: Ident, salt: String, sig: String) { + def asString = s"${created.toMillis}-${TokenUtil.b64enc(id.id)}-$salt-$sig" + + def sigValid(key: ByteVector): Boolean = { + val newSig = TokenUtil.sign(this, key) + TokenUtil.constTimeEq(sig, newSig) + } + def sigInvalid(key: ByteVector): Boolean = + !sigValid(key) +} + +object ShareToken { + + def fromString(s: String): Either[String, ShareToken] = + s.split("-", 4) match { + case Array(ms, id, salt, sig) => + for { + created <- ms.toLongOption.toRight("Invalid timestamp") + idStr <- TokenUtil.b64dec(id).toRight("Cannot read authenticator data") + shareId <- Ident.fromString(idStr) + } yield ShareToken(Timestamp.ofMillis(created), shareId, salt, sig) + + case _ => + Left("Invalid authenticator") + } + + def create[F[_]: Sync](shareId: Ident, key: ByteVector): F[ShareToken] = + for { + now <- Timestamp.current[F] + salt <- Common.genSaltString[F] + cd = ShareToken(now, shareId, salt, "") + sig = TokenUtil.sign(cd, key) + } yield cd.copy(sig = sig) + +} diff --git a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala index 7958ed0a..9bba4823 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala @@ -18,17 +18,24 @@ private[auth] object TokenUtil { def sign(cd: RememberToken, key: ByteVector): String = { val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt - val mac = Mac.getInstance("HmacSHA1") - mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) - ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 + signRaw(raw, key) } def sign(cd: AuthToken, key: ByteVector): String = { val raw = cd.nowMillis.toString + cd.account.asString + cd.requireSecondFactor + cd.salt + signRaw(raw, key) + } + + def sign(sd: ShareToken, key: ByteVector): String = { + val raw = s"${sd.created.toMillis}${sd.id.id}${sd.salt}" + signRaw(raw, key) + } + + private def signRaw(data: String, key: ByteVector): String = { val mac = Mac.getInstance("HmacSHA1") mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) - ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 + ByteVector.view(mac.doFinal(data.getBytes(utf8))).toBase64 } def b64enc(s: String): String = diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala index 68b86f11..005938a0 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala @@ -11,11 +11,15 @@ import cats.effect._ import cats.implicits._ import docspell.backend.PasswordCrypt +import docspell.backend.auth.ShareToken +import docspell.backend.ops.OShare.VerifyResult import docspell.common._ import docspell.query.ItemQuery import docspell.store.Store import docspell.store.records.RShare +import scodec.bits.ByteVector + trait OShare[F[_]] { def findAll(collective: Ident): F[List[RShare]] @@ -31,10 +35,32 @@ trait OShare[F[_]] { share: OShare.NewShare, removePassword: Boolean ): F[OShare.ChangeResult] + + def verify(key: ByteVector)(id: Ident, password: Option[Password]): F[VerifyResult] + def verifyToken(key: ByteVector)(token: String): F[VerifyResult] } object OShare { + sealed trait VerifyResult { + def toEither: Either[String, ShareToken] = + this match { + case VerifyResult.Success(token) => Right(token) + case _ => Left("Authentication failed.") + } + } + object VerifyResult { + case class Success(token: ShareToken) extends VerifyResult + case object NotFound extends VerifyResult + case object PasswordMismatch extends VerifyResult + case object InvalidToken extends VerifyResult + + def success(token: ShareToken): VerifyResult = Success(token) + def notFound: VerifyResult = NotFound + def passwordMismatch: VerifyResult = PasswordMismatch + def invalidToken: VerifyResult = InvalidToken + } + final case class NewShare( cid: Ident, name: Option[String], @@ -55,6 +81,8 @@ object OShare { def apply[F[_]: Async](store: Store[F]): OShare[F] = new OShare[F] { + private[this] val logger = Logger.log4s[F](org.log4s.getLogger) + def findAll(collective: Ident): F[List[RShare]] = store.transact(RShare.findAllByCollective(collective)) @@ -112,5 +140,51 @@ object OShare { def findOne(id: Ident, collective: Ident): OptionT[F, RShare] = RShare.findOne(id, collective).mapK(store.transform) + + def verify( + key: ByteVector + )(id: Ident, password: Option[Password]): F[VerifyResult] = + RShare + .findCurrentActive(id) + .mapK(store.transform) + .semiflatMap { share => + val pwCheck = + share.password.map(encPw => password.exists(PasswordCrypt.check(_, encPw))) + + // add the password (if existing) to the server secret key; this way the token + // invalidates when the user changes the password + val shareKey = + share.password.map(pw => key ++ pw.asByteVector).getOrElse(key) + + val token = ShareToken.create(id, shareKey) + pwCheck match { + case Some(true) => token.map(VerifyResult.success) + case None => token.map(VerifyResult.success) + case Some(false) => VerifyResult.passwordMismatch.pure[F] + } + } + .getOrElse(VerifyResult.notFound) + + def verifyToken(key: ByteVector)(token: String): F[VerifyResult] = + ShareToken.fromString(token) match { + case Right(st) => + RShare + .findActivePassword(st.id) + .mapK(store.transform) + .semiflatMap { password => + val shareKey = + password.map(pw => key ++ pw.asByteVector).getOrElse(key) + if (st.sigValid(shareKey)) VerifyResult.success(st).pure[F] + else + logger.info( + s"Signature failure for share: ${st.id.id}" + ) *> VerifyResult.invalidToken.pure[F] + } + .getOrElse(VerifyResult.notFound) + + case Left(err) => + logger.debug(s"Invalid session token: $err") *> + VerifyResult.invalidToken.pure[F] + } } } diff --git a/modules/common/src/main/scala/docspell/common/Password.scala b/modules/common/src/main/scala/docspell/common/Password.scala index da83364c..7c2daeb0 100644 --- a/modules/common/src/main/scala/docspell/common/Password.scala +++ b/modules/common/src/main/scala/docspell/common/Password.scala @@ -6,18 +6,29 @@ package docspell.common +import java.nio.charset.StandardCharsets + import cats.effect.Sync import cats.implicits._ import io.circe.{Decoder, Encoder} +import scodec.bits.ByteVector final class Password(val pass: String) extends AnyVal { def isEmpty: Boolean = pass.isEmpty + def nonEmpty: Boolean = pass.nonEmpty + def length: Int = pass.length + + def asByteVector: ByteVector = + ByteVector.view(pass.getBytes(StandardCharsets.UTF_8)) override def toString: String = if (pass.isEmpty) "" else "***" + def compare(other: Password): Boolean = + this.pass.zip(other.pass).forall { case (a, b) => a == b } && + this.nonEmpty && this.length == other.length } object Password { diff --git a/modules/common/src/main/scala/docspell/common/Timestamp.scala b/modules/common/src/main/scala/docspell/common/Timestamp.scala index b9aa104f..c056c2a8 100644 --- a/modules/common/src/main/scala/docspell/common/Timestamp.scala +++ b/modules/common/src/main/scala/docspell/common/Timestamp.scala @@ -70,6 +70,9 @@ object Timestamp { def atUtc(ldt: LocalDateTime): Timestamp = from(ldt.atZone(UTC)) + def ofMillis(ms: Long): Timestamp = + Timestamp(Instant.ofEpochMilli(ms)) + def daysBetween(ts0: Timestamp, ts1: Timestamp): Long = ChronoUnit.DAYS.between(ts0.toUtcDate, ts1.toUtcDate) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index ac2cc363..f1e5176b 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -538,6 +538,37 @@ paths: application/json: schema: $ref: "#/components/schemas/InviteResult" + + /open/share/verify: + post: + operationId: "open-share-verify" + tags: [ Share ] + summary: Verify a secret for a share + description: | + Given the share id and optionally a password, it verifies the + correctness of the given data. As a result, a token is + returned that must be used with all `share/*` routes. If the + password is missing, but required, the response indicates + this. Then the requests needs to be replayed with the correct + password to retrieve the token. + + The token is also added as a session cookie to the response. + + The token is used to avoid passing the user define password + with every request. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ShareSecret" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ShareVerifyResult" + /sec/auth/session: post: operationId: "sec-auth-session" @@ -4186,6 +4217,38 @@ paths: components: schemas: + ShareSecret: + description: | + The secret (the share id + optional password) to access a + share. + required: + - shareId + properties: + shareId: + type: string + format: ident + password: + type: string + format: password + + ShareVerifyResult: + description: | + The data returned when verifying a `ShareSecret`. + required: + - success + - token + - passwordRequired + - message + properties: + success: + type: boolean + token: + type: string + passwordRequired: + type: boolean + message: + type: string + ShareData: description: | Editable data for a share. diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 6c620a0b..cceacfdd 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -10,7 +10,7 @@ import cats.effect._ import cats.implicits._ import fs2.Stream -import docspell.backend.auth.AuthToken +import docspell.backend.auth.{AuthToken, ShareToken} import docspell.common._ import docspell.oidc.CodeFlowRoutes import docspell.restserver.auth.OpenId @@ -44,9 +44,12 @@ object RestServer { "/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token => securedRoutes(cfg, restApp, token) }, - "/api/v1/admin" -> AdminRoutes(cfg.adminEndpoint) { + "/api/v1/admin" -> AdminAuth(cfg.adminEndpoint) { adminRoutes(cfg, restApp) }, + "/api/v1/share" -> ShareAuth(restApp.backend.share, cfg.auth) { token => + shareRoutes(cfg, restApp, token) + }, "/api/doc" -> templates.doc, "/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]), "/app" -> EnvMiddleware(templates.app), @@ -120,7 +123,8 @@ object RestServer { "signup" -> RegisterRoutes(restApp.backend, cfg), "upload" -> UploadRoutes.open(restApp.backend, cfg), "checkfile" -> CheckFileRoutes.open(restApp.backend), - "integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg) + "integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg), + "share" -> ShareRoutes.verify(restApp.backend, cfg) ) def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = @@ -132,6 +136,15 @@ object RestServer { "attachments" -> AttachmentRoutes.admin(restApp.backend) ) + def shareRoutes[F[_]: Async]( + cfg: Config, + restApp: RestApp[F], + token: ShareToken + ): HttpRoutes[F] = + Router( + "search" -> ShareSearchRoutes(restApp.backend, cfg, token) + ) + def redirectTo[F[_]: Async](path: String): HttpRoutes[F] = { val dsl = new Http4sDsl[F] {} import dsl._ diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/ShareCookieData.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/ShareCookieData.scala new file mode 100644 index 00000000..0c3b0bdf --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/ShareCookieData.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.auth + +import docspell.backend.auth.ShareToken +import docspell.common._ + +import org.http4s._ +import org.typelevel.ci.CIString + +final case class ShareCookieData(token: ShareToken) { + def asString: String = token.asString + + def asCookie(baseUrl: LenientUri): ResponseCookie = { + val sec = baseUrl.scheme.exists(_.endsWith("s")) + val path = baseUrl.path / "api" / "v1" + ResponseCookie( + name = ShareCookieData.cookieName, + content = asString, + domain = None, + path = Some(path.asString), + httpOnly = true, + secure = sec, + maxAge = None, + expires = None + ) + } + + def addCookie[F[_]](baseUrl: LenientUri)( + resp: Response[F] + ): Response[F] = + resp.addCookie(asCookie(baseUrl)) +} + +object ShareCookieData { + val cookieName = "docspell_share" + val headerName = "Docspell-Share-Auth" + + def fromCookie[F[_]](req: Request[F]): Option[String] = + for { + header <- req.headers.get[headers.Cookie] + cookie <- header.values.toList.find(_.name == cookieName) + } yield cookie.content + + def fromHeader[F[_]](req: Request[F]): Option[String] = + req.headers + .get(CIString(headerName)) + .map(_.head.value) + + def fromRequest[F[_]](req: Request[F]): Option[String] = + fromCookie(req).orElse(fromHeader(req)) + + def delete(baseUrl: LenientUri): ResponseCookie = + ResponseCookie( + name = cookieName, + content = "", + domain = None, + path = Some(baseUrl.path / "api" / "v1").map(_.asString), + httpOnly = true, + secure = baseUrl.scheme.exists(_.endsWith("s")), + maxAge = None, + expires = None + ) + +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/AdminRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/AdminAuth.scala similarity index 92% rename from modules/restserver/src/main/scala/docspell/restserver/routes/AdminRoutes.scala rename to modules/restserver/src/main/scala/docspell/restserver/routes/AdminAuth.scala index 59491091..333f8d10 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/AdminRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/AdminAuth.scala @@ -10,6 +10,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect._ import cats.implicits._ +import docspell.common.Password import docspell.restserver.Config import docspell.restserver.http4s.Responses @@ -19,7 +20,7 @@ import org.http4s.dsl.Http4sDsl import org.http4s.server._ import org.typelevel.ci.CIString -object AdminRoutes { +object AdminAuth { private val adminHeader = CIString("Docspell-Admin-Secret") def apply[F[_]: Async](cfg: Config.AdminEndpoint)( @@ -55,6 +56,5 @@ object AdminRoutes { req.headers.get(adminHeader).map(_.head.value) private def compareSecret(s1: String)(s2: String): Boolean = - s1.length > 0 && s1.length == s2.length && - s1.zip(s2).forall { case (a, b) => a == b } + Password(s1).compare(Password(s2)) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAuth.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAuth.scala new file mode 100644 index 00000000..ad2c41ab --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAuth.scala @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import cats.implicits._ + +import docspell.backend.auth.{Login, ShareToken} +import docspell.backend.ops.OShare +import docspell.backend.ops.OShare.VerifyResult +import docspell.restserver.auth.ShareCookieData + +import org.http4s._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import org.http4s.server._ + +object ShareAuth { + + def authenticateRequest[F[_]: Async]( + validate: String => F[VerifyResult] + )(req: Request[F]): F[OShare.VerifyResult] = + ShareCookieData.fromRequest(req) match { + case Some(tokenStr) => + validate(tokenStr) + case None => + VerifyResult.notFound.pure[F] + } + + private def getToken[F[_]: Async]( + auth: String => F[VerifyResult] + ): Kleisli[F, Request[F], Either[String, ShareToken]] = + Kleisli(r => authenticateRequest(auth)(r).map(_.toEither)) + + def of[F[_]: Async](S: OShare[F], cfg: Login.Config)( + pf: PartialFunction[AuthedRequest[F, ShareToken], F[Response[F]]] + ): HttpRoutes[F] = { + val dsl: Http4sDsl[F] = new Http4sDsl[F] {} + import dsl._ + + val authUser = getToken[F](S.verifyToken(cfg.serverSecret)) + + val onFailure: AuthedRoutes[String, F] = + Kleisli(req => OptionT.liftF(Forbidden(req.context))) + + val middleware: AuthMiddleware[F, ShareToken] = + AuthMiddleware(authUser, onFailure) + + middleware(AuthedRoutes.of(pf)) + } + + def apply[F[_]: Async](S: OShare[F], cfg: Login.Config)( + f: ShareToken => HttpRoutes[F] + ): HttpRoutes[F] = { + val dsl: Http4sDsl[F] = new Http4sDsl[F] {} + import dsl._ + + val authUser = getToken[F](S.verifyToken(cfg.serverSecret)) + + val onFailure: AuthedRoutes[String, F] = + Kleisli(req => OptionT.liftF(Forbidden(req.context))) + + val middleware: AuthMiddleware[F, ShareToken] = + AuthMiddleware(authUser, onFailure) + + middleware(AuthedRoutes(authReq => f(authReq.context).run(authReq.req))) + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala index 846bc7bc..1e5947a6 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala @@ -9,13 +9,18 @@ package docspell.restserver.routes import cats.data.OptionT import cats.effect._ import cats.implicits._ + import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OShare +import docspell.backend.ops.OShare.VerifyResult import docspell.common.{Ident, Timestamp} import docspell.restapi.model._ -import docspell.restserver.http4s.ResponseGenerator +import docspell.restserver.Config +import docspell.restserver.auth.ShareCookieData +import docspell.restserver.http4s.{ClientRequestInfo, ResponseGenerator} import docspell.store.records.RShare + import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ @@ -66,6 +71,31 @@ object ShareRoutes { } } + def verify[F[_]: Async](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + import dsl._ + + HttpRoutes.of { case req @ POST -> Root / "verify" => + for { + secret <- req.as[ShareSecret] + res <- backend.share + .verify(cfg.auth.serverSecret)(secret.shareId, secret.password) + resp <- res match { + case VerifyResult.Success(token) => + val cd = ShareCookieData(token) + Ok(ShareVerifyResult(true, token.asString, false, "Success")) + .map(cd.addCookie(ClientRequestInfo.getBaseUrl(cfg, req))) + case VerifyResult.PasswordMismatch => + Ok(ShareVerifyResult(false, "", true, "Failed")) + case VerifyResult.NotFound => + Ok(ShareVerifyResult(false, "", false, "Failed")) + case VerifyResult.InvalidToken => + Ok(ShareVerifyResult(false, "", false, "Failed")) + } + } yield resp + } + } + def mkNewShare(data: ShareData, user: AuthToken): OShare.NewShare = OShare.NewShare( user.account.collective, diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala new file mode 100644 index 00000000..720b5d2f --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.effect._ + +import docspell.backend.BackendApp +import docspell.backend.auth.ShareToken +import docspell.common.Logger +import docspell.restserver.Config + +import org.http4s.HttpRoutes + +object ShareSearchRoutes { + + def apply[F[_]: Async]( + backend: BackendApp[F], + cfg: Config, + token: ShareToken + ): HttpRoutes[F] = { + val logger = Logger.log4s[F](org.log4s.getLogger) + logger.trace(s"$backend $cfg $token") + ??? + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RShare.scala b/modules/store/src/main/scala/docspell/store/records/RShare.scala index 4cef0929..7d6ae9bd 100644 --- a/modules/store/src/main/scala/docspell/store/records/RShare.scala +++ b/modules/store/src/main/scala/docspell/store/records/RShare.scala @@ -101,6 +101,28 @@ object RShare { .option ) + private def activeCondition(t: Table, id: Ident, current: Timestamp): Condition = + t.id === id && t.enabled === true && t.publishedUntil > current + + def findActive(id: Ident, current: Timestamp): OptionT[ConnectionIO, RShare] = + OptionT( + Select( + select(T.all), + from(T), + activeCondition(T, id, current) + ).build.query[RShare].option + ) + + def findCurrentActive(id: Ident): OptionT[ConnectionIO, RShare] = + OptionT.liftF(Timestamp.current[ConnectionIO]).flatMap(now => findActive(id, now)) + + def findActivePassword(id: Ident): OptionT[ConnectionIO, Option[Password]] = + OptionT(Timestamp.current[ConnectionIO].flatMap { now => + Select(select(T.password), from(T), activeCondition(T, id, now)).build + .query[Option[Password]] + .option + }) + def findAllByCollective(cid: Ident): ConnectionIO[List[RShare]] = Select(select(T.all), from(T), T.cid === cid) .orderBy(T.publishedAt.desc)