Authorize share access

This commit is contained in:
eikek 2021-10-03 23:56:59 +02:00
parent 97922340d9
commit f4596db63d
13 changed files with 457 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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._

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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