Initial impl for totp

This commit is contained in:
eikek
2021-08-30 16:15:13 +02:00
parent 2b46cc7970
commit 309a52393a
17 changed files with 568 additions and 20 deletions

View File

@ -46,6 +46,7 @@ trait BackendApp[F[_]] {
def customFields: OCustomFields[F]
def simpleSearch: OSimpleSearch[F]
def clientSettings: OClientSettings[F]
def totp: OTotp[F]
}
object BackendApp {
@ -59,6 +60,7 @@ object BackendApp {
for {
utStore <- UserTaskStore(store)
queue <- JobQueue(store)
totpImpl <- OTotp(store)
loginImpl <- Login[F](store)
signupImpl <- OSignup[F](store)
joexImpl <- OJoex(JoexClient(httpClient), store)
@ -103,6 +105,7 @@ object BackendApp {
val customFields = customFieldsImpl
val simpleSearch = simpleSearchImpl
val clientSettings = clientSettingsImpl
val totp = totpImpl
}
def apply[F[_]: Async](

View File

@ -16,8 +16,15 @@ import docspell.common._
import scodec.bits.ByteVector
case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: String) {
def asString = s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$salt-$sig"
case class AuthToken(
nowMillis: Long,
account: AccountId,
requireSecondFactor: Boolean,
salt: String,
sig: String
) {
def asString =
s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$requireSecondFactor-$salt-$sig"
def sigValid(key: ByteVector): Boolean = {
val newSig = TokenUtil.sign(this, key)
@ -42,13 +49,14 @@ case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: Str
object AuthToken {
def fromString(s: String): Either[String, AuthToken] =
s.split("\\-", 4) match {
case Array(ms, as, salt, sig) =>
s.split("\\-", 5) match {
case Array(ms, as, fa, salt, sig) =>
for {
millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data")
acc <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data")
accId <- AccountId.parse(acc)
} yield AuthToken(millis, accId, salt, sig)
twofac <- Right[String, Boolean](java.lang.Boolean.parseBoolean(fa))
} yield AuthToken(millis, accId, twofac, salt, sig)
case _ =>
Left("Invalid authenticator")
@ -58,7 +66,7 @@ object AuthToken {
for {
salt <- Common.genSaltString[F]
millis = Instant.now.toEpochMilli
cd = AuthToken(millis, accountId, salt, "")
cd = AuthToken(millis, accountId, false, salt, "")
sig = TokenUtil.sign(cd, key)
} yield cd.copy(sig = sig)
@ -66,7 +74,7 @@ object AuthToken {
for {
now <- Timestamp.current[F]
salt <- Common.genSaltString[F]
data = AuthToken(now.toMillis, token.account, salt, "")
data = AuthToken(now.toMillis, token.account, token.requireSecondFactor, salt, "")
sig = TokenUtil.sign(data, key)
} yield data.copy(sig = sig)
}

View File

@ -68,11 +68,15 @@ object Login {
case object InvalidTime extends Result {
val toEither = Left("Authentication failed.")
}
case object InvalidFactor extends Result {
val toEither = Left("Authentication requires second factor.")
}
def ok(session: AuthToken, remember: Option[RememberToken]): Result =
Ok(session, remember)
def invalidAuth: Result = InvalidAuth
def invalidTime: Result = InvalidTime
def invalidAuth: Result = InvalidAuth
def invalidTime: Result = InvalidTime
def invalidFactor: Result = InvalidFactor
}
def apply[F[_]: Async](store: Store[F]): Resource[F, Login[F]] =
@ -87,6 +91,8 @@ object Login {
logF.warn("Cookie signature invalid!") *> Result.invalidAuth.pure[F]
else if (at.isExpired(config.sessionValid))
logF.debug("Auth Cookie expired") *> Result.invalidTime.pure[F]
else if (at.requireSecondFactor)
logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F]
else Result.ok(at, None).pure[F]
case Left(_) =>
Result.invalidAuth.pure[F]
@ -136,7 +142,7 @@ object Login {
if (checkNoPassword(data))
logF.info("RememberMe auth successful") *> okResult(data.account)
else
logF.warn("RememberMe auth not successfull") *> Result.invalidAuth.pure[F]
logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F]
)
} yield res).getOrElseF(
logF.info("RememberMe not found in database.") *> Result.invalidAuth.pure[F]

View File

@ -0,0 +1,152 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.backend.ops
import cats.effect._
import cats.implicits._
import docspell.backend.ops.OTotp.{ConfirmResult, InitResult, OtpState}
import docspell.common._
import docspell.store.records.{RTotp, RUser}
import docspell.store.{AddResult, Store, UpdateResult}
import docspell.totp.{Key, OnetimePassword, Totp}
import org.log4s.getLogger
trait OTotp[F[_]] {
def state(accountId: AccountId): F[OtpState]
def initialize(accountId: AccountId): F[InitResult]
def confirmInit(accountId: AccountId, otp: OnetimePassword): F[ConfirmResult]
def disable(accountId: AccountId): F[UpdateResult]
}
object OTotp {
private[this] val logger = getLogger
sealed trait OtpState {
def isEnabled: Boolean
def isDisabled = !isEnabled
def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A
}
object OtpState {
final case class Enabled(created: Timestamp) extends OtpState {
val isEnabled = true
def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A =
fe(this)
}
case object Disabled extends OtpState {
val isEnabled = false
def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A =
fd(this)
}
}
sealed trait InitResult
object InitResult {
final case class Success(accountId: AccountId, key: Key) extends InitResult {
def authenticatorUrl(issuer: String): LenientUri =
LenientUri.unsafe(
s"otpauth://totp/$issuer:${accountId.asString}?secret=${key.data.toBase32}&issuer=$issuer"
)
}
case object AlreadyExists extends InitResult
case object NotFound extends InitResult
final case class Failed(ex: Throwable) extends InitResult
def success(accountId: AccountId, key: Key): InitResult =
Success(accountId, key)
def alreadyExists: InitResult = AlreadyExists
def failed(ex: Throwable): InitResult = Failed(ex)
}
sealed trait ConfirmResult
object ConfirmResult {
case object Success extends ConfirmResult
case object Failed extends ConfirmResult
}
def apply[F[_]: Async](store: Store[F]): Resource[F, OTotp[F]] =
Resource.pure[F, OTotp[F]](new OTotp[F] {
val totp = Totp.default
val log = Logger.log4s[F](logger)
def initialize(accountId: AccountId): F[InitResult] =
for {
_ <- log.info(s"Initializing TOTP for account ${accountId.asString}")
userId <- store.transact(RUser.findIdByAccount(accountId))
result <- userId match {
case Some(uid) =>
for {
record <- RTotp.generate[F](uid, totp.settings.mac)
un <- store.transact(RTotp.updateDisabled(record))
an <-
if (un != 0)
AddResult.entityExists("Entity exists, but update was ok").pure[F]
else store.add(RTotp.insert(record), RTotp.existsByLogin(accountId))
innerResult <-
if (un != 0) InitResult.success(accountId, record.secret).pure[F]
else
an match {
case AddResult.EntityExists(msg) =>
log.warn(
s"A totp record already exists for account '${accountId.asString}': $msg!"
) *>
InitResult.alreadyExists.pure[F]
case AddResult.Failure(ex) =>
log.warn(
s"Failed to setup totp record for '${accountId.asString}': ${ex.getMessage}"
) *>
InitResult.failed(ex).pure[F]
case AddResult.Success =>
InitResult.success(accountId, record.secret).pure[F]
}
} yield innerResult
case None =>
log.warn(s"No user found for account: ${accountId.asString}!") *>
InitResult.NotFound.pure[F]
}
} yield result
def confirmInit(accountId: AccountId, otp: OnetimePassword): F[ConfirmResult] =
for {
_ <- log.info(s"Confirm TOTP setup for account ${accountId.asString}")
key <- store.transact(RTotp.findEnabledByLogin(accountId, false))
now <- Timestamp.current[F]
res <- key match {
case None =>
ConfirmResult.Failed.pure[F]
case Some(r) =>
val check = totp.checkPassword(r.secret, otp, now.value)
if (check)
store
.transact(RTotp.setEnabled(accountId, true))
.map(_ => ConfirmResult.Success)
else ConfirmResult.Failed.pure[F]
}
} yield res
def disable(accountId: AccountId): F[UpdateResult] =
UpdateResult.fromUpdate(store.transact(RTotp.setEnabled(accountId, false)))
def state(accountId: AccountId): F[OtpState] =
for {
record <- store.transact(RTotp.findEnabledByLogin(accountId, true))
result = record match {
case Some(r) =>
OtpState.Enabled(r.created)
case None =>
OtpState.Disabled
}
} yield result
})
}