mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-05 22:55:58 +00:00
Authorize share access
This commit is contained in:
parent
97922340d9
commit
f4596db63d
@ -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)
|
||||||
|
|
||||||
|
}
|
@ -18,17 +18,24 @@ private[auth] object TokenUtil {
|
|||||||
|
|
||||||
def sign(cd: RememberToken, key: ByteVector): String = {
|
def sign(cd: RememberToken, key: ByteVector): String = {
|
||||||
val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt
|
val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt
|
||||||
val mac = Mac.getInstance("HmacSHA1")
|
signRaw(raw, key)
|
||||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
|
||||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def sign(cd: AuthToken, key: ByteVector): String = {
|
def sign(cd: AuthToken, key: ByteVector): String = {
|
||||||
val raw =
|
val raw =
|
||||||
cd.nowMillis.toString + cd.account.asString + cd.requireSecondFactor + cd.salt
|
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")
|
val mac = Mac.getInstance("HmacSHA1")
|
||||||
mac.init(new SecretKeySpec(key.toArray, "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 =
|
def b64enc(s: String): String =
|
||||||
|
@ -11,11 +11,15 @@ import cats.effect._
|
|||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
import docspell.backend.PasswordCrypt
|
import docspell.backend.PasswordCrypt
|
||||||
|
import docspell.backend.auth.ShareToken
|
||||||
|
import docspell.backend.ops.OShare.VerifyResult
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.query.ItemQuery
|
import docspell.query.ItemQuery
|
||||||
import docspell.store.Store
|
import docspell.store.Store
|
||||||
import docspell.store.records.RShare
|
import docspell.store.records.RShare
|
||||||
|
|
||||||
|
import scodec.bits.ByteVector
|
||||||
|
|
||||||
trait OShare[F[_]] {
|
trait OShare[F[_]] {
|
||||||
|
|
||||||
def findAll(collective: Ident): F[List[RShare]]
|
def findAll(collective: Ident): F[List[RShare]]
|
||||||
@ -31,10 +35,32 @@ trait OShare[F[_]] {
|
|||||||
share: OShare.NewShare,
|
share: OShare.NewShare,
|
||||||
removePassword: Boolean
|
removePassword: Boolean
|
||||||
): F[OShare.ChangeResult]
|
): F[OShare.ChangeResult]
|
||||||
|
|
||||||
|
def verify(key: ByteVector)(id: Ident, password: Option[Password]): F[VerifyResult]
|
||||||
|
def verifyToken(key: ByteVector)(token: String): F[VerifyResult]
|
||||||
}
|
}
|
||||||
|
|
||||||
object OShare {
|
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(
|
final case class NewShare(
|
||||||
cid: Ident,
|
cid: Ident,
|
||||||
name: Option[String],
|
name: Option[String],
|
||||||
@ -55,6 +81,8 @@ object OShare {
|
|||||||
|
|
||||||
def apply[F[_]: Async](store: Store[F]): OShare[F] =
|
def apply[F[_]: Async](store: Store[F]): OShare[F] =
|
||||||
new OShare[F] {
|
new OShare[F] {
|
||||||
|
private[this] val logger = Logger.log4s[F](org.log4s.getLogger)
|
||||||
|
|
||||||
def findAll(collective: Ident): F[List[RShare]] =
|
def findAll(collective: Ident): F[List[RShare]] =
|
||||||
store.transact(RShare.findAllByCollective(collective))
|
store.transact(RShare.findAllByCollective(collective))
|
||||||
|
|
||||||
@ -112,5 +140,51 @@ object OShare {
|
|||||||
|
|
||||||
def findOne(id: Ident, collective: Ident): OptionT[F, RShare] =
|
def findOne(id: Ident, collective: Ident): OptionT[F, RShare] =
|
||||||
RShare.findOne(id, collective).mapK(store.transform)
|
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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,18 +6,29 @@
|
|||||||
|
|
||||||
package docspell.common
|
package docspell.common
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
import cats.effect.Sync
|
import cats.effect.Sync
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
import io.circe.{Decoder, Encoder}
|
import io.circe.{Decoder, Encoder}
|
||||||
|
import scodec.bits.ByteVector
|
||||||
|
|
||||||
final class Password(val pass: String) extends AnyVal {
|
final class Password(val pass: String) extends AnyVal {
|
||||||
|
|
||||||
def isEmpty: Boolean = pass.isEmpty
|
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 =
|
override def toString: String =
|
||||||
if (pass.isEmpty) "<empty>" else "***"
|
if (pass.isEmpty) "<empty>" 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 {
|
object Password {
|
||||||
|
@ -70,6 +70,9 @@ object Timestamp {
|
|||||||
def atUtc(ldt: LocalDateTime): Timestamp =
|
def atUtc(ldt: LocalDateTime): Timestamp =
|
||||||
from(ldt.atZone(UTC))
|
from(ldt.atZone(UTC))
|
||||||
|
|
||||||
|
def ofMillis(ms: Long): Timestamp =
|
||||||
|
Timestamp(Instant.ofEpochMilli(ms))
|
||||||
|
|
||||||
def daysBetween(ts0: Timestamp, ts1: Timestamp): Long =
|
def daysBetween(ts0: Timestamp, ts1: Timestamp): Long =
|
||||||
ChronoUnit.DAYS.between(ts0.toUtcDate, ts1.toUtcDate)
|
ChronoUnit.DAYS.between(ts0.toUtcDate, ts1.toUtcDate)
|
||||||
|
|
||||||
|
@ -538,6 +538,37 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/InviteResult"
|
$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:
|
/sec/auth/session:
|
||||||
post:
|
post:
|
||||||
operationId: "sec-auth-session"
|
operationId: "sec-auth-session"
|
||||||
@ -4186,6 +4217,38 @@ paths:
|
|||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
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:
|
ShareData:
|
||||||
description: |
|
description: |
|
||||||
Editable data for a share.
|
Editable data for a share.
|
||||||
|
@ -10,7 +10,7 @@ import cats.effect._
|
|||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
import fs2.Stream
|
import fs2.Stream
|
||||||
|
|
||||||
import docspell.backend.auth.AuthToken
|
import docspell.backend.auth.{AuthToken, ShareToken}
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.oidc.CodeFlowRoutes
|
import docspell.oidc.CodeFlowRoutes
|
||||||
import docspell.restserver.auth.OpenId
|
import docspell.restserver.auth.OpenId
|
||||||
@ -44,9 +44,12 @@ object RestServer {
|
|||||||
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
||||||
securedRoutes(cfg, restApp, token)
|
securedRoutes(cfg, restApp, token)
|
||||||
},
|
},
|
||||||
"/api/v1/admin" -> AdminRoutes(cfg.adminEndpoint) {
|
"/api/v1/admin" -> AdminAuth(cfg.adminEndpoint) {
|
||||||
adminRoutes(cfg, restApp)
|
adminRoutes(cfg, restApp)
|
||||||
},
|
},
|
||||||
|
"/api/v1/share" -> ShareAuth(restApp.backend.share, cfg.auth) { token =>
|
||||||
|
shareRoutes(cfg, restApp, token)
|
||||||
|
},
|
||||||
"/api/doc" -> templates.doc,
|
"/api/doc" -> templates.doc,
|
||||||
"/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]),
|
"/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]),
|
||||||
"/app" -> EnvMiddleware(templates.app),
|
"/app" -> EnvMiddleware(templates.app),
|
||||||
@ -120,7 +123,8 @@ object RestServer {
|
|||||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
||||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
||||||
"checkfile" -> CheckFileRoutes.open(restApp.backend),
|
"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] =
|
def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
|
||||||
@ -132,6 +136,15 @@ object RestServer {
|
|||||||
"attachments" -> AttachmentRoutes.admin(restApp.backend)
|
"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] = {
|
def redirectTo[F[_]: Async](path: String): HttpRoutes[F] = {
|
||||||
val dsl = new Http4sDsl[F] {}
|
val dsl = new Http4sDsl[F] {}
|
||||||
import dsl._
|
import dsl._
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
@ -10,6 +10,7 @@ import cats.data.{Kleisli, OptionT}
|
|||||||
import cats.effect._
|
import cats.effect._
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
|
import docspell.common.Password
|
||||||
import docspell.restserver.Config
|
import docspell.restserver.Config
|
||||||
import docspell.restserver.http4s.Responses
|
import docspell.restserver.http4s.Responses
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ import org.http4s.dsl.Http4sDsl
|
|||||||
import org.http4s.server._
|
import org.http4s.server._
|
||||||
import org.typelevel.ci.CIString
|
import org.typelevel.ci.CIString
|
||||||
|
|
||||||
object AdminRoutes {
|
object AdminAuth {
|
||||||
private val adminHeader = CIString("Docspell-Admin-Secret")
|
private val adminHeader = CIString("Docspell-Admin-Secret")
|
||||||
|
|
||||||
def apply[F[_]: Async](cfg: Config.AdminEndpoint)(
|
def apply[F[_]: Async](cfg: Config.AdminEndpoint)(
|
||||||
@ -55,6 +56,5 @@ object AdminRoutes {
|
|||||||
req.headers.get(adminHeader).map(_.head.value)
|
req.headers.get(adminHeader).map(_.head.value)
|
||||||
|
|
||||||
private def compareSecret(s1: String)(s2: String): Boolean =
|
private def compareSecret(s1: String)(s2: String): Boolean =
|
||||||
s1.length > 0 && s1.length == s2.length &&
|
Password(s1).compare(Password(s2))
|
||||||
s1.zip(s2).forall { case (a, b) => a == b }
|
|
||||||
}
|
}
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
@ -9,13 +9,18 @@ package docspell.restserver.routes
|
|||||||
import cats.data.OptionT
|
import cats.data.OptionT
|
||||||
import cats.effect._
|
import cats.effect._
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
import docspell.backend.BackendApp
|
import docspell.backend.BackendApp
|
||||||
import docspell.backend.auth.AuthToken
|
import docspell.backend.auth.AuthToken
|
||||||
import docspell.backend.ops.OShare
|
import docspell.backend.ops.OShare
|
||||||
|
import docspell.backend.ops.OShare.VerifyResult
|
||||||
import docspell.common.{Ident, Timestamp}
|
import docspell.common.{Ident, Timestamp}
|
||||||
import docspell.restapi.model._
|
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 docspell.store.records.RShare
|
||||||
|
|
||||||
import org.http4s.HttpRoutes
|
import org.http4s.HttpRoutes
|
||||||
import org.http4s.circe.CirceEntityDecoder._
|
import org.http4s.circe.CirceEntityDecoder._
|
||||||
import org.http4s.circe.CirceEntityEncoder._
|
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 =
|
def mkNewShare(data: ShareData, user: AuthToken): OShare.NewShare =
|
||||||
OShare.NewShare(
|
OShare.NewShare(
|
||||||
user.account.collective,
|
user.account.collective,
|
||||||
|
@ -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")
|
||||||
|
???
|
||||||
|
}
|
||||||
|
}
|
@ -101,6 +101,28 @@ object RShare {
|
|||||||
.option
|
.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]] =
|
def findAllByCollective(cid: Ident): ConnectionIO[List[RShare]] =
|
||||||
Select(select(T.all), from(T), T.cid === cid)
|
Select(select(T.all), from(T), T.cid === cid)
|
||||||
.orderBy(T.publishedAt.desc)
|
.orderBy(T.publishedAt.desc)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user