mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 02:18:26 +00:00
Adopt login process for two-factor auth
This commit is contained in:
@ -19,6 +19,7 @@ import docspell.joexapi.client.JoexClient
|
||||
import docspell.store.Store
|
||||
import docspell.store.queue.JobQueue
|
||||
import docspell.store.usertask.UserTaskStore
|
||||
import docspell.totp.Totp
|
||||
|
||||
import emil.javamail.{JavaMailEmil, Settings}
|
||||
import org.http4s.blaze.client.BlazeClientBuilder
|
||||
@ -60,8 +61,8 @@ object BackendApp {
|
||||
for {
|
||||
utStore <- UserTaskStore(store)
|
||||
queue <- JobQueue(store)
|
||||
totpImpl <- OTotp(store)
|
||||
loginImpl <- Login[F](store)
|
||||
totpImpl <- OTotp(store, Totp.default)
|
||||
loginImpl <- Login[F](store, Totp.default)
|
||||
signupImpl <- OSignup[F](store)
|
||||
joexImpl <- OJoex(JoexClient(httpClient), store)
|
||||
collImpl <- OCollective[F](store, utStore, queue, joexImpl)
|
||||
|
@ -42,7 +42,7 @@ case class AuthToken(
|
||||
}
|
||||
|
||||
def validate(key: ByteVector, validity: Duration): Boolean =
|
||||
sigValid(key) && notExpired(validity)
|
||||
sigValid(key) && notExpired(validity) && !requireSecondFactor
|
||||
|
||||
}
|
||||
|
||||
@ -62,11 +62,15 @@ object AuthToken {
|
||||
Left("Invalid authenticator")
|
||||
}
|
||||
|
||||
def user[F[_]: Sync](accountId: AccountId, key: ByteVector): F[AuthToken] =
|
||||
def user[F[_]: Sync](
|
||||
accountId: AccountId,
|
||||
requireSecondFactor: Boolean,
|
||||
key: ByteVector
|
||||
): F[AuthToken] =
|
||||
for {
|
||||
salt <- Common.genSaltString[F]
|
||||
millis = Instant.now.toEpochMilli
|
||||
cd = AuthToken(millis, accountId, false, salt, "")
|
||||
cd = AuthToken(millis, accountId, requireSecondFactor, salt, "")
|
||||
sig = TokenUtil.sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
package docspell.backend.auth
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.data.{EitherT, OptionT}
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
@ -15,6 +15,7 @@ import docspell.common._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QLogin
|
||||
import docspell.store.records._
|
||||
import docspell.totp.{OnetimePassword, Totp}
|
||||
|
||||
import org.log4s.getLogger
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
@ -26,6 +27,8 @@ trait Login[F[_]] {
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
||||
|
||||
def loginSecondFactor(config: Config)(sf: SecondFactor): F[Result]
|
||||
|
||||
def loginRememberMe(config: Config)(token: String): F[Result]
|
||||
|
||||
def loginSessionOrRememberMe(
|
||||
@ -54,6 +57,12 @@ object Login {
|
||||
else copy(pass = "***")
|
||||
}
|
||||
|
||||
final case class SecondFactor(
|
||||
token: AuthToken,
|
||||
rememberMe: Boolean,
|
||||
otp: OnetimePassword
|
||||
)
|
||||
|
||||
sealed trait Result {
|
||||
def toEither: Either[String, AuthToken]
|
||||
}
|
||||
@ -79,7 +88,7 @@ object Login {
|
||||
def invalidFactor: Result = InvalidFactor
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](store: Store[F]): Resource[F, Login[F]] =
|
||||
def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, Login[F]] =
|
||||
Resource.pure[F, Login[F]](new Login[F] {
|
||||
|
||||
private val logF = Logger.log4s(logger)
|
||||
@ -94,8 +103,8 @@ object Login {
|
||||
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]
|
||||
case Left(err) =>
|
||||
logF.debug(s"Invalid session token: $err") *> Result.invalidAuth.pure[F]
|
||||
}
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result] =
|
||||
@ -103,10 +112,13 @@ object Login {
|
||||
case Right(acc) =>
|
||||
val okResult =
|
||||
for {
|
||||
_ <- store.transact(RUser.updateLogin(acc))
|
||||
token <- AuthToken.user(acc, config.serverSecret)
|
||||
require2FA <- store.transact(RTotp.isEnabled(acc))
|
||||
_ <-
|
||||
if (require2FA) ().pure[F]
|
||||
else store.transact(RUser.updateLogin(acc))
|
||||
token <- AuthToken.user(acc, require2FA, config.serverSecret)
|
||||
rem <- OptionT
|
||||
.whenF(up.rememberMe && config.rememberMe.enabled)(
|
||||
.whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)(
|
||||
insertRememberToken(store, acc, config)
|
||||
)
|
||||
.value
|
||||
@ -123,11 +135,54 @@ object Login {
|
||||
Result.invalidAuth.pure[F]
|
||||
}
|
||||
|
||||
def loginSecondFactor(config: Config)(sf: SecondFactor): F[Result] = {
|
||||
val okResult: F[Result] =
|
||||
for {
|
||||
_ <- store.transact(RUser.updateLogin(sf.token.account))
|
||||
newToken <- AuthToken.user(sf.token.account, false, config.serverSecret)
|
||||
rem <- OptionT
|
||||
.whenF(sf.rememberMe && config.rememberMe.enabled)(
|
||||
insertRememberToken(store, sf.token.account, config)
|
||||
)
|
||||
.value
|
||||
} yield Result.ok(newToken, rem)
|
||||
|
||||
val validateToken: EitherT[F, Result, Unit] = for {
|
||||
_ <- EitherT
|
||||
.cond[F](sf.token.sigValid(config.serverSecret), (), Result.invalidAuth)
|
||||
.leftSemiflatTap(_ =>
|
||||
logF.warn("OTP authentication token signature invalid!")
|
||||
)
|
||||
_ <- EitherT
|
||||
.cond[F](sf.token.notExpired(config.sessionValid), (), Result.invalidTime)
|
||||
.leftSemiflatTap(_ => logF.info("OTP Token expired."))
|
||||
_ <- EitherT
|
||||
.cond[F](sf.token.requireSecondFactor, (), Result.invalidAuth)
|
||||
.leftSemiflatTap(_ =>
|
||||
logF.warn("OTP received for token that is not allowed for 2FA!")
|
||||
)
|
||||
} yield ()
|
||||
|
||||
(for {
|
||||
_ <- validateToken
|
||||
key <- EitherT.fromOptionF(
|
||||
store.transact(RTotp.findEnabledByLogin(sf.token.account, true)),
|
||||
Result.invalidAuth
|
||||
)
|
||||
now <- EitherT.right[Result](Timestamp.current[F])
|
||||
_ <- EitherT.cond[F](
|
||||
totp.checkPassword(key.secret, sf.otp, now.value),
|
||||
(),
|
||||
Result.invalidAuth
|
||||
)
|
||||
} yield ()).swap.getOrElseF(okResult)
|
||||
}
|
||||
|
||||
def loginRememberMe(config: Config)(token: String): F[Result] = {
|
||||
def okResult(acc: AccountId) =
|
||||
for {
|
||||
_ <- store.transact(RUser.updateLogin(acc))
|
||||
token <- AuthToken.user(acc, config.serverSecret)
|
||||
token <- AuthToken.user(acc, false, config.serverSecret)
|
||||
} yield Result.ok(token, None)
|
||||
|
||||
def doLogin(rid: Ident) =
|
||||
|
@ -24,7 +24,8 @@ private[auth] object TokenUtil {
|
||||
}
|
||||
|
||||
def sign(cd: AuthToken, key: ByteVector): String = {
|
||||
val raw = cd.nowMillis.toString + cd.account.asString + cd.salt
|
||||
val raw =
|
||||
cd.nowMillis.toString + cd.account.asString + cd.requireSecondFactor + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
|
@ -74,10 +74,9 @@ object OTotp {
|
||||
case object Failed extends ConfirmResult
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](store: Store[F]): Resource[F, OTotp[F]] =
|
||||
def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, OTotp[F]] =
|
||||
Resource.pure[F, OTotp[F]](new OTotp[F] {
|
||||
val totp = Totp.default
|
||||
val log = Logger.log4s[F](logger)
|
||||
val log = Logger.log4s[F](logger)
|
||||
|
||||
def initialize(accountId: AccountId): F[InitResult] =
|
||||
for {
|
||||
|
Reference in New Issue
Block a user