From f74f8e5198701fc3f46308be6bf09f75f74d7759 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 23 May 2020 12:57:25 +0200 Subject: [PATCH] Add new way for uploading files to any collective Applications running next to docspell may want a way to upload files to any collective for integration purposes. This endpoint can be used for this. It is disabled by default and can be enabled via the configuration file. --- .../common/pureconfig/Implicits.scala | 3 + .../src/main/resources/reference.conf | 46 ++++++ .../scala/docspell/restserver/Config.scala | 35 +++- .../docspell/restserver/RestServer.scala | 9 +- .../restserver/http4s/Responses.scala | 29 ++++ .../routes/IntegrationEndpointRoutes.scala | 150 ++++++++++++++++++ 6 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala diff --git a/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala b/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala index 8fce1720..b6b3754d 100644 --- a/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala +++ b/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala @@ -40,6 +40,9 @@ object Implicits { implicit val caleventReader: ConfigReader[CalEvent] = ConfigReader[String].emap(reason(CalEvent.parse)) + implicit val priorityReader: ConfigReader[Priority] = + ConfigReader[String].emap(reason(Priority.fromString)) + def reason[A: ClassTag]( f: String => Either[String, A] ): String => Either[FailureReason, A] = diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 4e165dc5..edbe3c53 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -31,6 +31,52 @@ docspell.server { session-valid = "5 minutes" } + # This endpoint allows to upload files to any collective. The + # intention is that local software integrates with docspell more + # easily. Therefore the endpoint is not protected by the usual + # means. + # + # For security reasons, this endpoint is disabled by default. If + # enabled, you can choose from some ways to protect it. It may be a + # good idea to further protect this endpoint using a firewall, such + # that outside traffic is not routed. + # + # NOTE: If all protection methods are disabled, the endpoint is not + # protected at all! + integration-endpoint { + enabled = false + + # The priority to use when submitting files through this endpoint. + priority = "low" + + # IPv4 addresses to allow access. An empty list, if enabled, + # prohibits all requests. IP addresses may be specified as simple + # globs: a part marked as `*' matches any octet, like in + # `192.168.*.*`. The `127.0.0.1' (the default) matches the + # loopback address. + allowed-ips { + enabled = true + ips = [ "127.0.0.1" ] + } + + # Requests are expected to use http basic auth when uploading + # files. + http-basic { + enabled = false + realm = "Docspell Integration" + user = "docspell-int" + password = "docspell-int" + } + + # Requests are expected to supply some specific header when + # uploading files. + http-header { + enabled = false + header-name = "Docspell-Integration" + header-value = "some-secret" + } + } + # Configuration for the backend. backend { diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index 95688331..db66c98b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -1,5 +1,6 @@ package docspell.restserver +import java.net.InetAddress import docspell.backend.auth.Login import docspell.backend.{Config => BackendConfig} import docspell.common._ @@ -10,10 +11,42 @@ case class Config( baseUrl: LenientUri, bind: Config.Bind, backend: BackendConfig, - auth: Login.Config + auth: Login.Config, + integrationEndpoint: Config.IntegrationEndpoint ) object Config { case class Bind(address: String, port: Int) + + case class IntegrationEndpoint( + enabled: Boolean, + priority: Priority, + allowedIps: IntegrationEndpoint.AllowedIps, + httpBasic: IntegrationEndpoint.HttpBasic, + httpHeader: IntegrationEndpoint.HttpHeader + ) + + object IntegrationEndpoint { + case class HttpBasic(enabled: Boolean, realm: String, user: String, password: String) + case class HttpHeader(enabled: Boolean, headerName: String, headerValue: String) + case class AllowedIps(enabled: Boolean, ips: Set[String]) { + + def containsAddress(inet: InetAddress): Boolean = { + val ip = inet.getHostAddress + lazy val ipParts = ip.split('.') + + def checkSingle(pattern: String): Boolean = + pattern == ip || (inet.isLoopbackAddress && pattern == "127.0.0.1") || (pattern + .split('.') + .zip(ipParts) + .foldLeft(true) { + case (r, (a, b)) => + r && (a == "*" || a == b) + }) + + ips.exists(checkSingle) + } + } + } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 02008552..169dfb47 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -82,10 +82,11 @@ object RestServer { def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = Router( - "auth" -> LoginRoutes.login(restApp.backend.login, cfg), - "signup" -> RegisterRoutes(restApp.backend, cfg), - "upload" -> UploadRoutes.open(restApp.backend, cfg), - "checkfile" -> CheckFileRoutes.open(restApp.backend) + "auth" -> LoginRoutes.login(restApp.backend.login, cfg), + "signup" -> RegisterRoutes(restApp.backend, cfg), + "upload" -> UploadRoutes.open(restApp.backend, cfg), + "checkfile" -> CheckFileRoutes.open(restApp.backend), + "integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg) ) def redirectTo[F[_]: Effect](path: String): HttpRoutes[F] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala new file mode 100644 index 00000000..ce31bb03 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala @@ -0,0 +1,29 @@ +package docspell.restserver.http4s + +import fs2.{Pure, Stream} +import fs2.text.utf8Encode +import org.http4s._ +import org.http4s.headers._ + +object Responses { + + private[this] val pureForbidden: Response[Pure] = + Response( + Status.Forbidden, + body = Stream("Forbidden").through(utf8Encode), + headers = Headers(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`) :: Nil) + ) + + private[this] val pureUnauthorized: Response[Pure] = + Response( + Status.Unauthorized, + body = Stream("Unauthorized").through(utf8Encode), + headers = Headers(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`) :: Nil) + ) + + def forbidden[F[_]]: Response[F] = + pureForbidden.copy(body = pureForbidden.body.covary[F]) + + def unauthorized[F[_]]: Response[F] = + pureUnauthorized.copy(body = pureUnauthorized.body.covary[F]) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala new file mode 100644 index 00000000..c34fd1f7 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala @@ -0,0 +1,150 @@ +package docspell.restserver.routes + +import cats.data.{EitherT, OptionT} +import cats.effect._ +import cats.implicits._ +import docspell.backend.BackendApp +import docspell.common._ +import docspell.restserver.Config +import docspell.restserver.conv.Conversions._ +import docspell.restserver.http4s.Responses +import org.http4s._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import org.http4s.EntityDecoder._ +import org.http4s.headers.{Authorization, `WWW-Authenticate`} +import org.http4s.multipart.Multipart +import org.http4s.util.CaseInsensitiveString +import org.log4s.getLogger + +object IntegrationEndpointRoutes { + private[this] val logger = getLogger + + def open[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ POST -> Root / "item" / Ident(collective) => + (for { + _ <- checkEnabled(cfg.integrationEndpoint) + _ <- authRequest(req, cfg.integrationEndpoint) + _ <- lookupCollective(collective, backend) + res <- EitherT.liftF[F, Response[F], Response[F]]( + uploadFile(collective, backend, cfg, dsl)(req) + ) + } yield res).fold(identity, identity) + } + } + + def checkEnabled[F[_]: Effect]( + cfg: Config.IntegrationEndpoint + ): EitherT[F, Response[F], Unit] = + EitherT.cond[F](cfg.enabled, (), Response.notFound[F]) + + def authRequest[F[_]: Effect]( + req: Request[F], + cfg: Config.IntegrationEndpoint + ): EitherT[F, Response[F], Unit] = { + val service = + SourceIpAuth[F](cfg.allowedIps) <+> HeaderAuth(cfg.httpHeader) <+> HttpBasicAuth( + cfg.httpBasic + ) + service.run(req).toLeft(()) + } + + def lookupCollective[F[_]: Effect]( + coll: Ident, + backend: BackendApp[F] + ): EitherT[F, Response[F], Unit] = + for { + opt <- EitherT.liftF(backend.collective.find(coll)) + res <- EitherT.cond[F](opt.isDefined, (), Response.notFound[F]) + } yield res + + def uploadFile[F[_]: Effect]( + coll: Ident, + backend: BackendApp[F], + cfg: Config, + dsl: Http4sDsl[F] + )( + req: Request[F] + ): F[Response[F]] = { + import dsl._ + for { + multipart <- req.as[Multipart[F]] + updata <- readMultipart( + multipart, + logger, + cfg.integrationEndpoint.priority, + cfg.backend.files.validMimeTypes + ) + account = AccountId(coll, Ident.unsafe("docspell-system")) + result <- backend.upload.submit(updata, account, true) + res <- Ok(basicResult(result)) + } yield res + } + + object HeaderAuth { + def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.HttpHeader): HttpRoutes[F] = + if (cfg.enabled) checkHeader(cfg) + else HttpRoutes.empty[F] + + def checkHeader[F[_]: Effect]( + cfg: Config.IntegrationEndpoint.HttpHeader + ): HttpRoutes[F] = + HttpRoutes { req => + val h = req.headers.find(_.name == CaseInsensitiveString(cfg.headerName)) + if (h.exists(_.value == cfg.headerValue)) OptionT.none[F, Response[F]] + else OptionT.pure(Responses.forbidden[F]) + } + } + + object SourceIpAuth { + def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.AllowedIps): HttpRoutes[F] = + if (cfg.enabled) checkIps(cfg) + else HttpRoutes.empty[F] + + def checkIps[F[_]: Effect]( + cfg: Config.IntegrationEndpoint.AllowedIps + ): HttpRoutes[F] = + HttpRoutes { req => + //The `req.from' take the X-Forwarded-For header into account, + //which is not desirable here. The `http-header' auth config + //can be used to authenticate based on headers. + val from = req.remote.flatMap(remote => Option(remote.getAddress)) + if (from.exists(cfg.containsAddress)) OptionT.none[F, Response[F]] + else OptionT.pure(Responses.forbidden[F]) + } + } + + object HttpBasicAuth { + def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.HttpBasic): HttpRoutes[F] = + if (cfg.enabled) checkHttpBasic(cfg) + else HttpRoutes.empty[F] + + def checkHttpBasic[F[_]: Effect]( + cfg: Config.IntegrationEndpoint.HttpBasic + ): HttpRoutes[F] = + HttpRoutes { req => + req.headers.get(Authorization) match { + case Some(auth) => + auth.credentials match { + case BasicCredentials(user, pass) + if user == cfg.user && pass == cfg.password => + OptionT.none[F, Response[F]] + case _ => + OptionT.pure(Responses.forbidden[F]) + } + case None => + OptionT.pure( + Responses + .unauthorized[F] + .withHeaders( + `WWW-Authenticate`(Challenge("Basic", cfg.realm)) + ) + ) + } + } + } +}