Merge pull request #129 from eikek/integration-endpoint

Integration endpoint
This commit is contained in:
eikek
2020-05-23 15:00:03 +02:00
committed by GitHub
22 changed files with 437 additions and 84 deletions

View File

@ -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 {

View File

@ -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)
}
}
}
}

View File

@ -83,10 +83,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] = {

View File

@ -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])
}

View File

@ -4,6 +4,7 @@ import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCollective
import docspell.restapi.model._
import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._
@ -28,16 +29,17 @@ object CollectiveRoutes {
case req @ POST -> Root / "settings" =>
for {
settings <- req.as[CollectiveSettings]
sett = OCollective.Settings(settings.language, settings.integrationEnabled)
res <-
backend.collective
.updateLanguage(user.account.collective, settings.language)
resp <- Ok(Conversions.basicResult(res, "Language updated."))
.updateSettings(user.account.collective, sett)
resp <- Ok(Conversions.basicResult(res, "Settings updated."))
} yield resp
case GET -> Root / "settings" =>
for {
collDb <- backend.collective.find(user.account.collective)
sett = collDb.map(c => CollectiveSettings(c.language))
sett = collDb.map(c => CollectiveSettings(c.language, c.integrationEnabled))
resp <- sett.toResponse()
} yield resp

View File

@ -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.exists(_.integrationEnabled), (), 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))
)
)
}
}
}
}

View File

@ -5,7 +5,6 @@ import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.{Ident, Priority}
import docspell.restapi.model.BasicResult
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator
@ -40,10 +39,6 @@ object UploadRoutes {
result <- backend.upload.submit(updata, user.account, true)
res <- Ok(basicResult(result))
} yield res
case GET -> Root / "checkfile" / checksum =>
Ok(BasicResult(false, s"not implemented $checksum"))
}
}
@ -64,9 +59,6 @@ object UploadRoutes {
result <- backend.upload.submit(updata, id, true)
res <- Ok(basicResult(result))
} yield res
case GET -> Root / "checkfile" / Ident(id) / checksum =>
Ok(BasicResult(false, s"not implemented $id $checksum"))
}
}
}

View File

@ -12,7 +12,8 @@ case class Flags(
appName: String,
baseUrl: LenientUri,
signupMode: SignupConfig.Mode,
docspellAssetPath: String
docspellAssetPath: String,
integrationEnabled: Boolean
)
object Flags {
@ -21,7 +22,8 @@ object Flags {
cfg.appName,
cfg.baseUrl,
cfg.backend.signup.mode,
s"/app/assets/docspell-webapp/${BuildInfo.version}"
s"/app/assets/docspell-webapp/${BuildInfo.version}",
cfg.integrationEndpoint.enabled
)
implicit val jsonEncoder: Encoder[Flags] =