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