mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 10:28:27 +00:00
Initial impl for totp
This commit is contained in:
@ -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](
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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]
|
||||
|
152
modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
Normal file
152
modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
Normal 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
|
||||
})
|
||||
|
||||
}
|
Reference in New Issue
Block a user