Adopt login process for two-factor auth

This commit is contained in:
eikek
2021-08-31 21:29:07 +02:00
parent 999c39833a
commit 1afc005a6c
14 changed files with 356 additions and 126 deletions

View File

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

View File

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

View File

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

View File

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

View File

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