mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-05 10:59:33 +00:00
Merge pull request #1044 from eikek/feature/762-2fa
Add two-factor authentication using TOTP
This commit is contained in:
commit
b2589b9104
25
build.sbt
25
build.sbt
@ -250,6 +250,10 @@ val openapiScalaSettings = Seq(
|
||||
field =>
|
||||
field
|
||||
.copy(typeDef = TypeDef("Duration", Imports("docspell.common.Duration")))
|
||||
case "uri" =>
|
||||
field =>
|
||||
field
|
||||
.copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri")))
|
||||
})
|
||||
)
|
||||
|
||||
@ -335,6 +339,20 @@ val query =
|
||||
Dependencies.scalaJsStubs
|
||||
)
|
||||
|
||||
val totp = project
|
||||
.in(file("modules/totp"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
.settings(sharedSettings)
|
||||
.settings(testSettingsMUnit)
|
||||
.settings(
|
||||
name := "docspell-totp",
|
||||
libraryDependencies ++=
|
||||
Dependencies.javaOtp ++
|
||||
Dependencies.scodecBits ++
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.circe
|
||||
)
|
||||
|
||||
val store = project
|
||||
.in(file("modules/store"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
@ -357,7 +375,7 @@ val store = project
|
||||
libraryDependencies ++=
|
||||
Dependencies.testContainer.map(_ % Test)
|
||||
)
|
||||
.dependsOn(common, query.jvm)
|
||||
.dependsOn(common, query.jvm, totp)
|
||||
|
||||
val extract = project
|
||||
.in(file("modules/extract"))
|
||||
@ -482,7 +500,7 @@ val backend = project
|
||||
Dependencies.http4sClient ++
|
||||
Dependencies.emil
|
||||
)
|
||||
.dependsOn(store, joexapi, ftsclient)
|
||||
.dependsOn(store, joexapi, ftsclient, totp)
|
||||
|
||||
val webapp = project
|
||||
.in(file("modules/webapp"))
|
||||
@ -676,7 +694,8 @@ val root = project
|
||||
restapi,
|
||||
restserver,
|
||||
query.jvm,
|
||||
query.js
|
||||
query.js,
|
||||
totp
|
||||
)
|
||||
|
||||
// --- Helpers
|
||||
|
@ -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
|
||||
@ -46,6 +47,7 @@ trait BackendApp[F[_]] {
|
||||
def customFields: OCustomFields[F]
|
||||
def simpleSearch: OSimpleSearch[F]
|
||||
def clientSettings: OClientSettings[F]
|
||||
def totp: OTotp[F]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
@ -59,7 +61,8 @@ object BackendApp {
|
||||
for {
|
||||
utStore <- UserTaskStore(store)
|
||||
queue <- JobQueue(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)
|
||||
@ -103,6 +106,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)
|
||||
@ -35,30 +42,35 @@ case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: Str
|
||||
}
|
||||
|
||||
def validate(key: ByteVector, validity: Duration): Boolean =
|
||||
sigValid(key) && notExpired(validity)
|
||||
sigValid(key) && notExpired(validity) && !requireSecondFactor
|
||||
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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, salt, "")
|
||||
cd = AuthToken(millis, accountId, requireSecondFactor, salt, "")
|
||||
sig = TokenUtil.sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
|
||||
@ -66,7 +78,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)
|
||||
}
|
||||
|
@ -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]
|
||||
}
|
||||
@ -68,14 +77,18 @@ 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]] =
|
||||
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)
|
||||
@ -87,9 +100,11 @@ 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]
|
||||
case Left(err) =>
|
||||
logF.debug(s"Invalid session token: $err") *> Result.invalidAuth.pure[F]
|
||||
}
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result] =
|
||||
@ -97,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
|
||||
@ -117,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) =
|
||||
@ -136,7 +197,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]
|
||||
|
@ -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
|
||||
|
151
modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
Normal file
151
modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
Normal file
@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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], totp: Totp): Resource[F, OTotp[F]] =
|
||||
Resource.pure[F, OTotp[F]](new OTotp[F] {
|
||||
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
|
||||
})
|
||||
|
||||
}
|
@ -9,13 +9,13 @@ package docspell.common
|
||||
import io.circe._
|
||||
|
||||
case class AccountId(collective: Ident, user: Ident) {
|
||||
|
||||
def asString =
|
||||
s"${collective.id}/${user.id}"
|
||||
if (collective == user) user.id
|
||||
else s"${collective.id}/${user.id}"
|
||||
}
|
||||
|
||||
object AccountId {
|
||||
private[this] val sepearatorChars: String = "/\\:"
|
||||
private[this] val separatorChars: String = "/\\:"
|
||||
|
||||
def parse(str: String): Either[String, AccountId] = {
|
||||
val input = str.replaceAll("\\s+", "").trim
|
||||
@ -36,7 +36,7 @@ object AccountId {
|
||||
invalid
|
||||
}
|
||||
|
||||
val separated = sepearatorChars.foldRight(invalid)((c, v) => v.orElse(parse0(c)))
|
||||
val separated = separatorChars.foldRight(invalid)((c, v) => v.orElse(parse0(c)))
|
||||
|
||||
separated.orElse(Ident.fromString(str).map(id => AccountId(id, id)))
|
||||
}
|
||||
|
@ -54,6 +54,9 @@ paths:
|
||||
|
||||
If successful, an authentication token is returned that can be
|
||||
used for subsequent calls to protected routes.
|
||||
|
||||
If the account has two-factor auth enabled, the returned token
|
||||
must be used to supply the second factor.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@ -66,6 +69,31 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthResult"
|
||||
/open/auth/two-factor:
|
||||
post:
|
||||
operationId: "open-auth-two-factor"
|
||||
tags: [ Authentication ]
|
||||
summary: Provide the second factor to finalize authentication
|
||||
description: |
|
||||
After a login with account name and password, a second factor
|
||||
must be supplied (only for accounts that enabled it) in order
|
||||
to complete login.
|
||||
|
||||
If the code is correct, a new token is returned that can be
|
||||
used for subsequent calls to protected routes.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecondFactor"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthResult"
|
||||
|
||||
/open/checkfile/{id}/{checksum}:
|
||||
get:
|
||||
operationId: "open-checkfile-checksum-by-id"
|
||||
@ -1275,6 +1303,91 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/user/otp/state:
|
||||
get:
|
||||
operationId: "sec-user-otp-state"
|
||||
tags: [ Collective ]
|
||||
summary: Gets the otp state for the current user.
|
||||
description: |
|
||||
Returns whether the current account as OTP enabled or not.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OtpState"
|
||||
|
||||
/sec/user/otp/init:
|
||||
post:
|
||||
operationId: "sec-user-otp-init"
|
||||
tags: [ Collective, Authentication ]
|
||||
summary: Initialize two factor auth via OTP
|
||||
description: |
|
||||
Requests to enable two factor authentication for this user. A
|
||||
secret key is generated and returned. The client is expected
|
||||
to insert it into some OTP application. Currently, only time
|
||||
based OTP is supported.
|
||||
|
||||
The request body is empty.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OtpResult"
|
||||
|
||||
/sec/user/otp/confirm:
|
||||
post:
|
||||
operationId: "sec-user-otp-confirm"
|
||||
tags: [ Collective, Authentication ]
|
||||
summary: Confirms two factor authentication
|
||||
description: |
|
||||
Confirms using two factor authentication by sending a one time
|
||||
password. If the password is correct, this enables two factor
|
||||
authentication for the current user.
|
||||
|
||||
If there exists no unapproved otp request or the password is
|
||||
not correct, an error is returned. If 2fa is already enabled
|
||||
for this account, success is returned.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OtpConfirm"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/user/otp/disable:
|
||||
post:
|
||||
operationId: "sec-user-otp-disable"
|
||||
tags: [ Collective, Authentication ]
|
||||
summary: Disables two factor authentication.
|
||||
description: |
|
||||
Disables two factor authentication for the current user. If
|
||||
the user has no two factor authentication enabled, this
|
||||
returns success, too.
|
||||
|
||||
After this completes successfully, two factor auth can be
|
||||
enabled again by initializing it anew.
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/clientSettings/{clientId}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/clientId"
|
||||
@ -1364,6 +1477,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResetPasswordResult"
|
||||
/admin/user/resetOTP:
|
||||
post:
|
||||
operationId: "admin-user-reset-otp"
|
||||
tags: [ Collective, Admin ]
|
||||
summary: Disables OTP two factor auth for the given user.
|
||||
description: |
|
||||
Removes the OTP setup for the given user account. The account
|
||||
can login afterwards with a correct password. A second factor
|
||||
is not required. Two factor auth can be setup again for this
|
||||
account.
|
||||
security:
|
||||
- adminHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResetPassword"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/admin/attachments/generatePreviews:
|
||||
post:
|
||||
@ -3885,6 +4022,64 @@ paths:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
SecondFactor:
|
||||
description: |
|
||||
Provide a second factor for login.
|
||||
required:
|
||||
- token
|
||||
- otp
|
||||
- rememberMe
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
otp:
|
||||
type: string
|
||||
format: password
|
||||
rememberMe:
|
||||
type: boolean
|
||||
OtpState:
|
||||
description: |
|
||||
The state for OTP for an account
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
created:
|
||||
type: integer
|
||||
format: date-time
|
||||
OtpResult:
|
||||
description: |
|
||||
The result from initializing OTP. It contains the shared
|
||||
secret.
|
||||
required:
|
||||
- authenticatorUrl
|
||||
- secret
|
||||
- authType
|
||||
- issuer
|
||||
properties:
|
||||
authenticatorUrl:
|
||||
type: string
|
||||
format: uri
|
||||
secret:
|
||||
type: string
|
||||
authType:
|
||||
type: string
|
||||
enum:
|
||||
- totp
|
||||
issuer:
|
||||
type: string
|
||||
|
||||
OtpConfirm:
|
||||
description: |
|
||||
Transports a one time password.
|
||||
required:
|
||||
- otp
|
||||
properties:
|
||||
otp:
|
||||
type: string
|
||||
format: password
|
||||
|
||||
ResetPassword:
|
||||
description: |
|
||||
The account to reset the password.
|
||||
@ -5888,6 +6083,7 @@ components:
|
||||
required:
|
||||
- collective
|
||||
- user
|
||||
- requireSecondFactor
|
||||
- success
|
||||
- message
|
||||
- validMs
|
||||
@ -5910,6 +6106,8 @@ components:
|
||||
How long the token is valid in ms.
|
||||
type: integer
|
||||
format: int64
|
||||
requireSecondFactor:
|
||||
type: boolean
|
||||
VersionInfo:
|
||||
description: |
|
||||
Information about the software.
|
||||
|
@ -76,6 +76,7 @@ object RestServer {
|
||||
"organization" -> OrganizationRoutes(restApp.backend, token),
|
||||
"person" -> PersonRoutes(restApp.backend, token),
|
||||
"source" -> SourceRoutes(restApp.backend, token),
|
||||
"user/otp" -> TotpRoutes(restApp.backend, cfg, token),
|
||||
"user" -> UserRoutes(restApp.backend, token),
|
||||
"collective" -> CollectiveRoutes(restApp.backend, token),
|
||||
"queue" -> JobQueueRoutes(restApp.backend, token),
|
||||
@ -109,6 +110,7 @@ object RestServer {
|
||||
def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
|
||||
Router(
|
||||
"fts" -> FullTextIndexRoutes.admin(cfg, restApp.backend),
|
||||
"user/otp" -> TotpRoutes.admin(restApp.backend),
|
||||
"user" -> UserRoutes.admin(restApp.backend),
|
||||
"info" -> InfoRoutes.admin(cfg),
|
||||
"attachments" -> AttachmentRoutes.admin(restApp.backend)
|
||||
|
@ -15,6 +15,7 @@ import docspell.restapi.model._
|
||||
import docspell.restserver._
|
||||
import docspell.restserver.auth._
|
||||
import docspell.restserver.http4s.ClientRequestInfo
|
||||
import docspell.totp.OnetimePassword
|
||||
|
||||
import org.http4s._
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
@ -27,14 +28,31 @@ object LoginRoutes {
|
||||
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of[F] { case req @ POST -> Root / "login" =>
|
||||
for {
|
||||
up <- req.as[UserPass]
|
||||
res <- S.loginUserPass(cfg.auth)(
|
||||
Login.UserPass(up.account, up.password, up.rememberMe.getOrElse(false))
|
||||
)
|
||||
resp <- makeResponse(dsl, cfg, req, res, up.account)
|
||||
} yield resp
|
||||
HttpRoutes.of[F] {
|
||||
case req @ POST -> Root / "two-factor" =>
|
||||
for {
|
||||
sf <- req.as[SecondFactor]
|
||||
tokenParsed = AuthToken.fromString(sf.token)
|
||||
resp <- tokenParsed match {
|
||||
case Right(token) =>
|
||||
S.loginSecondFactor(cfg.auth)(
|
||||
Login.SecondFactor(token, sf.rememberMe, OnetimePassword(sf.otp.pass))
|
||||
).flatMap(result =>
|
||||
makeResponse(dsl, cfg, req, result, token.account.asString)
|
||||
)
|
||||
case Left(err) =>
|
||||
BadRequest(BasicResult(false, s"Invalid authentication token: $err"))
|
||||
}
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "login" =>
|
||||
for {
|
||||
up <- req.as[UserPass]
|
||||
res <- S.loginUserPass(cfg.auth)(
|
||||
Login.UserPass(up.account, up.password, up.rememberMe.getOrElse(false))
|
||||
)
|
||||
resp <- makeResponse(dsl, cfg, req, res, up.account)
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +100,8 @@ object LoginRoutes {
|
||||
true,
|
||||
"Login successful",
|
||||
Some(cd.asString),
|
||||
cfg.auth.sessionValid.millis
|
||||
cfg.auth.sessionValid.millis,
|
||||
token.requireSecondFactor
|
||||
)
|
||||
).map(cd.addCookie(getBaseUrl(cfg, req)))
|
||||
.map(resp =>
|
||||
@ -93,7 +112,7 @@ object LoginRoutes {
|
||||
|
||||
} yield resp
|
||||
case _ =>
|
||||
Ok(AuthResult("", account, false, "Login failed.", None, 0L))
|
||||
Ok(AuthResult("", account, false, "Login failed.", None, 0L, false))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OTotp
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.totp.OnetimePassword
|
||||
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
|
||||
object TotpRoutes {
|
||||
def apply[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
cfg: Config,
|
||||
user: AuthToken
|
||||
): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case GET -> Root / "state" =>
|
||||
for {
|
||||
result <- backend.totp.state(user.account)
|
||||
resp <- Ok(
|
||||
result.fold(en => OtpState(true, en.created.some), _ => OtpState(false, None))
|
||||
)
|
||||
} yield resp
|
||||
case POST -> Root / "init" =>
|
||||
for {
|
||||
result <- backend.totp.initialize(user.account)
|
||||
resp <- result match {
|
||||
case OTotp.InitResult.AlreadyExists =>
|
||||
UnprocessableEntity(BasicResult(false, "A totp setup already exists!"))
|
||||
case OTotp.InitResult.NotFound =>
|
||||
NotFound(BasicResult(false, "User not found"))
|
||||
case OTotp.InitResult.Failed(ex) =>
|
||||
InternalServerError(BasicResult(false, ex.getMessage))
|
||||
case s @ OTotp.InitResult.Success(_, key) =>
|
||||
val issuer = cfg.appName
|
||||
val uri = s.authenticatorUrl(issuer)
|
||||
Ok(OtpResult(uri, key.data.toBase32, "totp", issuer))
|
||||
}
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "confirm" =>
|
||||
for {
|
||||
data <- req.as[OtpConfirm]
|
||||
result <- backend.totp.confirmInit(user.account, OnetimePassword(data.otp.pass))
|
||||
resp <- result match {
|
||||
case OTotp.ConfirmResult.Success =>
|
||||
Ok(BasicResult(true, "TOTP setup successful."))
|
||||
case OTotp.ConfirmResult.Failed =>
|
||||
Ok(BasicResult(false, "TOTP setup failed!"))
|
||||
}
|
||||
} yield resp
|
||||
|
||||
case POST -> Root / "disable" =>
|
||||
for {
|
||||
result <- backend.totp.disable(user.account)
|
||||
resp <- Ok(Conversions.basicResult(result, "TOTP setup disabled."))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def admin[F[_]: Async](backend: BackendApp[F]): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of { case req @ POST -> Root / "resetOTP" =>
|
||||
for {
|
||||
data <- req.as[ResetPassword]
|
||||
result <- backend.totp.disable(data.account)
|
||||
resp <- Ok(Conversions.basicResult(result, "TOTP setup disabled."))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
}
|
@ -39,6 +39,10 @@
|
||||
<script type="application/javascript">
|
||||
var storedAccount = localStorage.getItem('account');
|
||||
var account = storedAccount ? JSON.parse(storedAccount) : null;
|
||||
if (account && !account.hasOwnProperty("requireSecondFactor")) {
|
||||
// this is required for transitioning; elm fails to parse the account
|
||||
account["requireSecondFactor"] = false;
|
||||
}
|
||||
var elmFlags = {
|
||||
"account": account,
|
||||
"config": {{{flagsJson}}}
|
||||
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE "totp" (
|
||||
"user_id" varchar(254) not null primary key,
|
||||
"enabled" boolean not null,
|
||||
"secret" varchar(254) not null,
|
||||
"created" timestamp not null,
|
||||
FOREIGN KEY ("user_id") REFERENCES "user_"("uid") ON DELETE CASCADE
|
||||
);
|
@ -42,6 +42,7 @@ object AddResult {
|
||||
def withMsg(msg: String): EntityExists =
|
||||
EntityExists(msg)
|
||||
}
|
||||
def entityExists(msg: String): AddResult = EntityExists(msg)
|
||||
|
||||
case class Failure(ex: Throwable) extends AddResult {
|
||||
def toEither = Left(ex)
|
||||
|
@ -11,6 +11,7 @@ import java.time.{Instant, LocalDate}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.totp.Key
|
||||
|
||||
import com.github.eikek.calev.CalEvent
|
||||
import doobie._
|
||||
@ -125,6 +126,9 @@ trait DoobieMeta extends EmilDoobieMeta {
|
||||
|
||||
implicit val metaJsonString: Meta[Json] =
|
||||
Meta[String].timap(DoobieMeta.parseJsonUnsafe)(_.noSpaces)
|
||||
|
||||
implicit val metaKey: Meta[Key] =
|
||||
Meta[String].timap(Key.unsafeFromString)(_.asString)
|
||||
}
|
||||
|
||||
object DoobieMeta extends DoobieMeta {
|
||||
|
109
modules/store/src/main/scala/docspell/store/records/RTotp.scala
Normal file
109
modules/store/src/main/scala/docspell/store/records/RTotp.scala
Normal file
@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.store.records
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.store.qb.DSL._
|
||||
import docspell.store.qb._
|
||||
import docspell.totp.{Key, Mac}
|
||||
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
|
||||
final case class RTotp(
|
||||
userId: Ident,
|
||||
enabled: Boolean,
|
||||
secret: Key,
|
||||
created: Timestamp
|
||||
) {}
|
||||
|
||||
object RTotp {
|
||||
final case class Table(alias: Option[String]) extends TableDef {
|
||||
val tableName = "totp"
|
||||
|
||||
val userId = Column[Ident]("user_id", this)
|
||||
val enabled = Column[Boolean]("enabled", this)
|
||||
val secret = Column[Key]("secret", this)
|
||||
val created = Column[Timestamp]("created", this)
|
||||
|
||||
val all = Nel.of(userId, enabled, secret, created)
|
||||
}
|
||||
val T = Table(None)
|
||||
def as(alias: String): Table =
|
||||
Table(Some(alias))
|
||||
|
||||
def generate[F[_]: Sync](userId: Ident, mac: Mac): F[RTotp] =
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
key <- Key.generate[F](mac)
|
||||
} yield RTotp(userId, false, key, now)
|
||||
|
||||
def insert(r: RTotp): ConnectionIO[Int] =
|
||||
DML.insert(T, T.all, sql"${r.userId},${r.enabled},${r.secret},${r.created}")
|
||||
|
||||
def updateDisabled(r: RTotp): ConnectionIO[Int] =
|
||||
DML.update(
|
||||
T,
|
||||
T.enabled === false && T.userId === r.userId,
|
||||
DML.set(
|
||||
T.secret.setTo(r.secret),
|
||||
T.created.setTo(r.created)
|
||||
)
|
||||
)
|
||||
|
||||
def setEnabled(account: AccountId, enabled: Boolean): ConnectionIO[Int] =
|
||||
for {
|
||||
userId <- RUser.findIdByAccount(account)
|
||||
n <- userId match {
|
||||
case Some(id) =>
|
||||
DML.update(T, T.userId === id, DML.set(T.enabled.setTo(enabled)))
|
||||
case None =>
|
||||
0.pure[ConnectionIO]
|
||||
}
|
||||
} yield n
|
||||
|
||||
def isEnabled(accountId: AccountId): ConnectionIO[Boolean] = {
|
||||
val t = RTotp.as("t")
|
||||
val u = RUser.as("u")
|
||||
Select(
|
||||
select(count(t.userId)),
|
||||
from(t).innerJoin(u, t.userId === u.uid),
|
||||
u.login === accountId.user && u.cid === accountId.collective && t.enabled === true
|
||||
).build.query[Int].unique.map(_ > 0)
|
||||
}
|
||||
|
||||
def findEnabledByLogin(
|
||||
accountId: AccountId,
|
||||
enabled: Boolean
|
||||
): ConnectionIO[Option[RTotp]] = {
|
||||
val t = RTotp.as("t")
|
||||
val u = RUser.as("u")
|
||||
Select(
|
||||
select(t.all),
|
||||
from(t).innerJoin(u, t.userId === u.uid),
|
||||
u.login === accountId.user && u.cid === accountId.collective && t.enabled === enabled
|
||||
).build.query[RTotp].option
|
||||
}
|
||||
|
||||
def existsByLogin(accountId: AccountId): ConnectionIO[Boolean] = {
|
||||
val t = RTotp.as("t")
|
||||
val u = RUser.as("u")
|
||||
Select(
|
||||
select(count(t.userId)),
|
||||
from(t).innerJoin(u, t.userId === u.uid),
|
||||
u.login === accountId.user && u.cid === accountId.collective
|
||||
).build
|
||||
.query[Int]
|
||||
.unique
|
||||
.map(_ > 0)
|
||||
}
|
||||
|
||||
}
|
@ -55,6 +55,8 @@ object RUser {
|
||||
)
|
||||
}
|
||||
|
||||
val T = Table(None)
|
||||
|
||||
def as(alias: String): Table =
|
||||
Table(Some(alias))
|
||||
|
||||
@ -105,6 +107,15 @@ object RUser {
|
||||
sql.query[RUser].to[Vector]
|
||||
}
|
||||
|
||||
def findIdByAccount(accountId: AccountId): ConnectionIO[Option[Ident]] =
|
||||
run(
|
||||
select(T.uid),
|
||||
from(T),
|
||||
T.login === accountId.user && T.cid === accountId.collective
|
||||
)
|
||||
.query[Ident]
|
||||
.option
|
||||
|
||||
def updateLogin(accountId: AccountId): ConnectionIO[Int] = {
|
||||
val t = Table(None)
|
||||
def stmt(now: Timestamp) =
|
||||
|
79
modules/totp/src/main/scala/docspell/totp/Key.scala
Normal file
79
modules/totp/src/main/scala/docspell/totp/Key.scala
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import java.security.{Key => JKey}
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import io.circe.generic.semiauto
|
||||
import io.circe.{Decoder, Encoder}
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
final case class Key(data: ByteVector, mac: Mac) {
|
||||
def toJavaKey: JKey =
|
||||
new SecretKeySpec(data.toArray, mac.identifier)
|
||||
|
||||
/** Renders the mac and data into one string which can be consumed by `fromString` */
|
||||
def asString: String =
|
||||
s"${mac.identifier}::${data.toBase32}"
|
||||
}
|
||||
|
||||
object Key {
|
||||
|
||||
def fromSecretKey(sk: JKey): Either[String, Key] =
|
||||
for {
|
||||
mac <- Mac.fromString(sk.getAlgorithm)
|
||||
key = Key(ByteVector.view(sk.getEncoded), mac)
|
||||
} yield key
|
||||
|
||||
def generate[F[_]: Sync](mac: Mac): F[Key] = Sync[F].delay {
|
||||
val jkey = generateJavaKey(mac)
|
||||
Key(ByteVector.view(jkey.getEncoded), mac)
|
||||
}
|
||||
|
||||
def fromString(str: String): Either[String, Key] = {
|
||||
val (macStr, dataStr) = str.span(_ != ':')
|
||||
if (dataStr.isEmpty) Left(s"No separator found in key string: $str")
|
||||
else
|
||||
for {
|
||||
mac <- Mac.fromString(macStr)
|
||||
data <- ByteVector.fromBase32Descriptive(dataStr.dropWhile(_ == ':'))
|
||||
} yield Key(data, mac)
|
||||
}
|
||||
|
||||
def unsafeFromString(str: String): Key =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
private[totp] def generateJavaKey(mac: Mac): JKey = {
|
||||
val keyGen = KeyGenerator.getInstance(mac.identifier)
|
||||
keyGen.init(mac.keyLengthBits)
|
||||
keyGen.generateKey()
|
||||
}
|
||||
|
||||
implicit val jsonEncoder: Encoder[Key] =
|
||||
Codec.jsonEncoder
|
||||
|
||||
implicit val jsonDecoder: Decoder[Key] =
|
||||
Codec.jsonDecoder
|
||||
|
||||
private object Codec {
|
||||
implicit val byteVectorEncoder: Encoder[ByteVector] =
|
||||
Encoder.encodeString.contramap(_.toBase32)
|
||||
|
||||
implicit val byteVectorDecoder: Decoder[ByteVector] =
|
||||
Decoder.decodeString.emap(s => ByteVector.fromBase32Descriptive(s))
|
||||
|
||||
val jsonEncoder: Encoder[Key] =
|
||||
semiauto.deriveEncoder[Key]
|
||||
|
||||
val jsonDecoder: Decoder[Key] =
|
||||
semiauto.deriveDecoder[Key]
|
||||
}
|
||||
}
|
51
modules/totp/src/main/scala/docspell/totp/Mac.scala
Normal file
51
modules/totp/src/main/scala/docspell/totp/Mac.scala
Normal file
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait Mac {
|
||||
def identifier: String
|
||||
def keyLengthBits: Int
|
||||
}
|
||||
object Mac {
|
||||
case object Sha1 extends Mac {
|
||||
val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA1
|
||||
val keyLengthBits = 160
|
||||
}
|
||||
case object Sha256 extends Mac {
|
||||
val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256
|
||||
val keyLengthBits = 256
|
||||
}
|
||||
case object Sha512 extends Mac {
|
||||
val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA512
|
||||
val keyLengthBits = 512
|
||||
}
|
||||
|
||||
val all: NonEmptyList[Mac] =
|
||||
NonEmptyList.of(Sha1, Sha256, Sha512)
|
||||
|
||||
def fromString(str: String): Either[String, Mac] =
|
||||
str.toLowerCase match {
|
||||
case "hmacsha1" => Right(Sha1)
|
||||
case "hmacsha256" => Right(Sha256)
|
||||
case "hmacsha512" => Right(Sha512)
|
||||
case _ => Left(s"Unknown mac name: $str")
|
||||
}
|
||||
|
||||
def unsafeFromString(str: String): Mac =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Mac] =
|
||||
Encoder.encodeString.contramap(_.identifier)
|
||||
|
||||
implicit val jsonDecoder: Decoder[Mac] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
final class OnetimePassword(val pass: String) extends AnyVal {
|
||||
override def toString: String = "***"
|
||||
}
|
||||
|
||||
object OnetimePassword {
|
||||
|
||||
def apply(pass: String): OnetimePassword =
|
||||
new OnetimePassword(pass)
|
||||
|
||||
def unapply(op: OnetimePassword): Option[String] =
|
||||
Some(op.pass)
|
||||
|
||||
implicit val jsonEncoder: Encoder[OnetimePassword] =
|
||||
Encoder.encodeString.contramap(_.pass)
|
||||
|
||||
implicit val jsonDecoder: Decoder[OnetimePassword] =
|
||||
Decoder.decodeString.map(OnetimePassword.apply)
|
||||
}
|
43
modules/totp/src/main/scala/docspell/totp/PassLength.scala
Normal file
43
modules/totp/src/main/scala/docspell/totp/PassLength.scala
Normal file
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait PassLength {
|
||||
def toInt: Int
|
||||
}
|
||||
|
||||
object PassLength {
|
||||
case object Chars6 extends PassLength {
|
||||
val toInt = 6
|
||||
}
|
||||
case object Chars8 extends PassLength {
|
||||
val toInt = 8
|
||||
}
|
||||
|
||||
val all: NonEmptyList[PassLength] =
|
||||
NonEmptyList.of(Chars6, Chars8)
|
||||
|
||||
def fromInt(n: Int): Either[String, PassLength] =
|
||||
n match {
|
||||
case 6 => Right(Chars6)
|
||||
case 8 => Right(Chars8)
|
||||
case _ => Left(s"Invalid length: $n! Must be either 6 or 8")
|
||||
}
|
||||
|
||||
def unsafeFromInt(n: Int): PassLength =
|
||||
fromInt(n).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonEncoder: Encoder[PassLength] =
|
||||
Encoder.encodeInt.contramap(_.toInt)
|
||||
|
||||
implicit val jsonDecoder: Decoder[PassLength] =
|
||||
Decoder.decodeInt.emap(fromInt)
|
||||
}
|
42
modules/totp/src/main/scala/docspell/totp/Settings.scala
Normal file
42
modules/totp/src/main/scala/docspell/totp/Settings.scala
Normal file
@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import io.circe.generic.semiauto
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
case class Settings(mac: Mac, passLength: PassLength, duration: FiniteDuration)
|
||||
|
||||
object Settings {
|
||||
val default =
|
||||
Settings(Mac.Sha1, PassLength.Chars6, 30.seconds)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Settings] =
|
||||
Codec.jsonEncoder
|
||||
|
||||
implicit val jsonDecoder: Decoder[Settings] =
|
||||
Codec.jsonDecoder
|
||||
|
||||
private object Codec {
|
||||
|
||||
implicit val durationEncoder: Encoder[FiniteDuration] =
|
||||
Encoder.encodeLong.contramap(_.toSeconds)
|
||||
|
||||
implicit val durationDecoder: Decoder[FiniteDuration] =
|
||||
Decoder.decodeLong.map(secs => FiniteDuration(secs, TimeUnit.SECONDS))
|
||||
|
||||
val jsonEncoder: Encoder[Settings] =
|
||||
semiauto.deriveEncoder[Settings]
|
||||
|
||||
val jsonDecoder: Decoder[Settings] =
|
||||
semiauto.deriveDecoder[Settings]
|
||||
}
|
||||
}
|
66
modules/totp/src/main/scala/docspell/totp/Totp.scala
Normal file
66
modules/totp/src/main/scala/docspell/totp/Totp.scala
Normal file
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
import fs2.Stream
|
||||
|
||||
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator
|
||||
|
||||
/** Generator for time based one time passwords. */
|
||||
trait Totp {
|
||||
|
||||
/** The settings used to generate passwords. */
|
||||
def settings: Settings
|
||||
|
||||
/** Generate the password for the given key and time. */
|
||||
def generate(key: Key, time: Instant): OnetimePassword
|
||||
|
||||
/** Generate a stream of passwords using the given key and starting at the given time.
|
||||
*/
|
||||
def generateStream[F[_]](key: Key, time: Instant): Stream[F, OnetimePassword]
|
||||
|
||||
/** Checks whether the given password matches using the current time. */
|
||||
def checkPassword(key: Key, otp: OnetimePassword, time: Instant): Boolean
|
||||
}
|
||||
|
||||
object Totp {
|
||||
|
||||
val default: Totp =
|
||||
Totp(Settings.default)
|
||||
|
||||
def apply(setts: Settings): Totp =
|
||||
new Totp {
|
||||
|
||||
val settings = setts
|
||||
private val generator = makeGenerator(setts)
|
||||
|
||||
def generate(key: Key, time: Instant): OnetimePassword =
|
||||
OnetimePassword(generator.generateOneTimePasswordString(key.toJavaKey, time))
|
||||
|
||||
def generateStream[F[_]](key: Key, time: Instant): Stream[F, OnetimePassword] =
|
||||
Stream.emit(generate(key, time)) ++ generateStream(
|
||||
key,
|
||||
time.plus(generator.getTimeStep)
|
||||
)
|
||||
|
||||
def checkPassword(key: Key, given: OnetimePassword, time: Instant): Boolean = {
|
||||
val pass = generate(key, time)
|
||||
pass == given
|
||||
}
|
||||
}
|
||||
|
||||
private def makeGenerator(settings: Settings): TimeBasedOneTimePasswordGenerator = {
|
||||
val duration = java.time.Duration.ofNanos(settings.duration.toNanos)
|
||||
new TimeBasedOneTimePasswordGenerator(
|
||||
duration,
|
||||
settings.passLength.toInt,
|
||||
settings.mac.identifier
|
||||
)
|
||||
}
|
||||
}
|
38
modules/totp/src/test/scala/docspell/totp/KeyTest.scala
Normal file
38
modules/totp/src/test/scala/docspell/totp/KeyTest.scala
Normal file
@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import cats.effect._
|
||||
import cats.effect.unsafe.implicits._
|
||||
|
||||
import docspell.totp.{Key, Mac}
|
||||
|
||||
import io.circe.syntax._
|
||||
import munit._
|
||||
|
||||
class KeyTest extends FunSuite {
|
||||
|
||||
test("generate and read in key") {
|
||||
val jkey = Key.generateJavaKey(Mac.Sha1)
|
||||
val key = Key.fromSecretKey(jkey).fold(sys.error, identity)
|
||||
assertEquals(jkey, key.toJavaKey)
|
||||
}
|
||||
|
||||
test("generate key") {
|
||||
for (mac <- Mac.all.toList) {
|
||||
val key = Key.generate[IO](mac).unsafeRunSync()
|
||||
assertEquals(key.data.length.toInt * 8, key.mac.keyLengthBits)
|
||||
}
|
||||
}
|
||||
|
||||
test("encode/decode json") {
|
||||
val key = Key.generate[IO](Mac.Sha1).unsafeRunSync()
|
||||
val keyJson = key.asJson
|
||||
val newKey = keyJson.as[Key].fold(throw _, identity)
|
||||
assertEquals(key, newKey)
|
||||
}
|
||||
}
|
61
modules/totp/src/test/scala/docspell/totp/TotpTest.scala
Normal file
61
modules/totp/src/test/scala/docspell/totp/TotpTest.scala
Normal file
@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.totp
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import cats.Id
|
||||
import cats.effect._
|
||||
import cats.effect.unsafe.implicits._
|
||||
|
||||
import munit._
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
class TotpTest extends FunSuite {
|
||||
|
||||
val totp = Totp.default
|
||||
val key = Key(ByteVector.fromValidBase64("GGFWIWYnHB8F5Dp87iS2HP86k4A="), Mac.Sha1)
|
||||
val time = Instant.parse("2021-08-29T18:42:00Z")
|
||||
|
||||
test("generate password") {
|
||||
val otp = totp.generate(key, time)
|
||||
assertEquals("410352", otp.pass)
|
||||
}
|
||||
|
||||
test("generate stream") {
|
||||
val otp3 = totp.generateStream[Id](key, time).take(3).compile.toList
|
||||
assertEquals(otp3.map(_.pass), List("410352", "557347", "512023"))
|
||||
}
|
||||
|
||||
for {
|
||||
mac <- Mac.all.toList
|
||||
plen <- PassLength.all.toList
|
||||
} test(s"generate ${mac.identifier} with ${plen.toInt} characters") {
|
||||
val key = Key.generate[IO](mac).unsafeRunSync()
|
||||
val totp = Totp(Settings(mac, plen, 30.seconds))
|
||||
val otp = totp.generate(key, time)
|
||||
assertEquals(otp.pass.length, plen.toInt)
|
||||
}
|
||||
|
||||
test("check password at same time") {
|
||||
assert(totp.checkPassword(key, OnetimePassword("410352"), time))
|
||||
}
|
||||
|
||||
test("check password 15s later") {
|
||||
assert(totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(15)))
|
||||
}
|
||||
|
||||
test("check password 29s later") {
|
||||
assert(totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(29)))
|
||||
}
|
||||
|
||||
test("check password 31s later (too late)") {
|
||||
assert(!totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(31)))
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ module Api exposing
|
||||
, changePassword
|
||||
, checkCalEvent
|
||||
, confirmMultiple
|
||||
, confirmOtp
|
||||
, createImapSettings
|
||||
, createMailSettings
|
||||
, createNewFolder
|
||||
@ -42,6 +43,7 @@ module Api exposing
|
||||
, deleteSource
|
||||
, deleteTag
|
||||
, deleteUser
|
||||
, disableOtp
|
||||
, fileURL
|
||||
, getAttachmentMeta
|
||||
, getClientSettings
|
||||
@ -63,6 +65,7 @@ module Api exposing
|
||||
, getOrgFull
|
||||
, getOrgLight
|
||||
, getOrganizations
|
||||
, getOtpState
|
||||
, getPersonFull
|
||||
, getPersons
|
||||
, getPersonsLight
|
||||
@ -72,6 +75,7 @@ module Api exposing
|
||||
, getTagCloud
|
||||
, getTags
|
||||
, getUsers
|
||||
, initOtp
|
||||
, itemBasePreviewURL
|
||||
, itemDetail
|
||||
, itemIndexSearch
|
||||
@ -137,6 +141,7 @@ module Api exposing
|
||||
, startReIndex
|
||||
, submitNotifyDueItems
|
||||
, toggleTags
|
||||
, twoFactor
|
||||
, unconfirmMultiple
|
||||
, updateNotifyDueItems
|
||||
, updateScanMailbox
|
||||
@ -194,6 +199,9 @@ import Api.Model.OptionalId exposing (OptionalId)
|
||||
import Api.Model.OptionalText exposing (OptionalText)
|
||||
import Api.Model.Organization exposing (Organization)
|
||||
import Api.Model.OrganizationList exposing (OrganizationList)
|
||||
import Api.Model.OtpConfirm exposing (OtpConfirm)
|
||||
import Api.Model.OtpResult exposing (OtpResult)
|
||||
import Api.Model.OtpState exposing (OtpState)
|
||||
import Api.Model.PasswordChange exposing (PasswordChange)
|
||||
import Api.Model.Person exposing (Person)
|
||||
import Api.Model.PersonList exposing (PersonList)
|
||||
@ -202,6 +210,7 @@ import Api.Model.Registration exposing (Registration)
|
||||
import Api.Model.ScanMailboxSettings exposing (ScanMailboxSettings)
|
||||
import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList)
|
||||
import Api.Model.SearchStats exposing (SearchStats)
|
||||
import Api.Model.SecondFactor exposing (SecondFactor)
|
||||
import Api.Model.SentMails exposing (SentMails)
|
||||
import Api.Model.SimpleMail exposing (SimpleMail)
|
||||
import Api.Model.SourceAndTags exposing (SourceAndTags)
|
||||
@ -935,6 +944,15 @@ login flags up receive =
|
||||
}
|
||||
|
||||
|
||||
twoFactor : Flags -> SecondFactor -> (Result Http.Error AuthResult -> msg) -> Cmd msg
|
||||
twoFactor flags sf receive =
|
||||
Http.post
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/open/auth/two-factor"
|
||||
, body = Http.jsonBody (Api.Model.SecondFactor.encode sf)
|
||||
, expect = Http.expectJson receive Api.Model.AuthResult.decoder
|
||||
}
|
||||
|
||||
|
||||
logout : Flags -> (Result Http.Error () -> msg) -> Cmd msg
|
||||
logout flags receive =
|
||||
Http2.authPost
|
||||
@ -2128,6 +2146,49 @@ saveClientSettings flags settings receive =
|
||||
|
||||
|
||||
|
||||
--- OTP
|
||||
|
||||
|
||||
getOtpState : Flags -> (Result Http.Error OtpState -> msg) -> Cmd msg
|
||||
getOtpState flags receive =
|
||||
Http2.authGet
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/user/otp/state"
|
||||
, account = getAccount flags
|
||||
, expect = Http.expectJson receive Api.Model.OtpState.decoder
|
||||
}
|
||||
|
||||
|
||||
initOtp : Flags -> (Result Http.Error OtpResult -> msg) -> Cmd msg
|
||||
initOtp flags receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/user/otp/init"
|
||||
, account = getAccount flags
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson receive Api.Model.OtpResult.decoder
|
||||
}
|
||||
|
||||
|
||||
confirmOtp : Flags -> OtpConfirm -> (Result Http.Error BasicResult -> msg) -> Cmd msg
|
||||
confirmOtp flags confirm receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/user/otp/confirm"
|
||||
, account = getAccount flags
|
||||
, body = Http.jsonBody (Api.Model.OtpConfirm.encode confirm)
|
||||
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
|
||||
}
|
||||
|
||||
|
||||
disableOtp : Flags -> (Result Http.Error BasicResult -> msg) -> Cmd msg
|
||||
disableOtp flags receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/user/otp/disable"
|
||||
, account = getAccount flags
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
|
||||
}
|
||||
|
||||
|
||||
|
||||
--- Helper
|
||||
|
||||
|
||||
|
430
modules/webapp/src/main/elm/Comp/OtpSetup.elm
Normal file
430
modules/webapp/src/main/elm/Comp/OtpSetup.elm
Normal file
@ -0,0 +1,430 @@
|
||||
{-
|
||||
Copyright 2020 Docspell Contributors
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-}
|
||||
|
||||
|
||||
module Comp.OtpSetup exposing (Model, Msg, init, update, view)
|
||||
|
||||
import Api
|
||||
import Api.Model.BasicResult exposing (BasicResult)
|
||||
import Api.Model.OtpConfirm exposing (OtpConfirm)
|
||||
import Api.Model.OtpResult exposing (OtpResult)
|
||||
import Api.Model.OtpState exposing (OtpState)
|
||||
import Comp.Basic as B
|
||||
import Comp.PasswordInput
|
||||
import Data.Flags exposing (Flags)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onClick, onInput, onSubmit)
|
||||
import Http
|
||||
import Markdown
|
||||
import Messages.Comp.OtpSetup exposing (Texts)
|
||||
import QRCode
|
||||
import Styles as S
|
||||
|
||||
|
||||
type Model
|
||||
= InitialModel
|
||||
| StateError Http.Error
|
||||
| InitError Http.Error
|
||||
| DisableError Http.Error
|
||||
| ConfirmError Http.Error
|
||||
| StateEnabled EnabledModel
|
||||
| StateDisabled DisabledModel
|
||||
| SetupSuccessful
|
||||
|
||||
|
||||
type alias DisabledModel =
|
||||
{ loading : Bool
|
||||
, result : Maybe OtpResult
|
||||
, secretModel : Comp.PasswordInput.Model
|
||||
, confirmCode : String
|
||||
, confirmError : Bool
|
||||
}
|
||||
|
||||
|
||||
initDisabledModel : DisabledModel
|
||||
initDisabledModel =
|
||||
{ loading = False
|
||||
, result = Nothing
|
||||
, secretModel = Comp.PasswordInput.init
|
||||
, confirmCode = ""
|
||||
, confirmError = False
|
||||
}
|
||||
|
||||
|
||||
type alias EnabledModel =
|
||||
{ created : Int
|
||||
, loading : Bool
|
||||
, confirmText : String
|
||||
, confirmTextWrong : Bool
|
||||
}
|
||||
|
||||
|
||||
initEnabledModel : Int -> EnabledModel
|
||||
initEnabledModel created =
|
||||
{ created = created
|
||||
, loading = False
|
||||
, confirmText = ""
|
||||
, confirmTextWrong = False
|
||||
}
|
||||
|
||||
|
||||
emptyModel : Model
|
||||
emptyModel =
|
||||
InitialModel
|
||||
|
||||
|
||||
type Msg
|
||||
= GetStateResp (Result Http.Error OtpState)
|
||||
| Initialize
|
||||
| InitResp (Result Http.Error OtpResult)
|
||||
| SetConfirmCode String
|
||||
| SecretMsg Comp.PasswordInput.Msg
|
||||
| Confirm
|
||||
| ConfirmResp (Result Http.Error BasicResult)
|
||||
| SetDisableConfirmText String
|
||||
| Disable
|
||||
| DisableResp (Result Http.Error BasicResult)
|
||||
|
||||
|
||||
init : Flags -> ( Model, Cmd Msg )
|
||||
init flags =
|
||||
( emptyModel, Api.getOtpState flags GetStateResp )
|
||||
|
||||
|
||||
|
||||
--- Update
|
||||
|
||||
|
||||
update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
|
||||
update flags msg model =
|
||||
case msg of
|
||||
GetStateResp (Ok state) ->
|
||||
if state.enabled then
|
||||
( StateEnabled <| initEnabledModel (Maybe.withDefault 0 state.created), Cmd.none )
|
||||
|
||||
else
|
||||
( StateDisabled initDisabledModel, Cmd.none )
|
||||
|
||||
GetStateResp (Err err) ->
|
||||
( StateError err, Cmd.none )
|
||||
|
||||
Initialize ->
|
||||
case model of
|
||||
StateDisabled _ ->
|
||||
( StateDisabled { initDisabledModel | loading = True }
|
||||
, Api.initOtp flags InitResp
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
InitResp (Ok r) ->
|
||||
case model of
|
||||
StateDisabled m ->
|
||||
( StateDisabled { m | result = Just r, loading = False }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
InitResp (Err err) ->
|
||||
( InitError err, Cmd.none )
|
||||
|
||||
SetConfirmCode str ->
|
||||
case model of
|
||||
StateDisabled m ->
|
||||
( StateDisabled { m | confirmCode = str }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SecretMsg lm ->
|
||||
case model of
|
||||
StateDisabled m ->
|
||||
let
|
||||
( pm, _ ) =
|
||||
Comp.PasswordInput.update lm m.secretModel
|
||||
in
|
||||
( StateDisabled { m | secretModel = pm }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Confirm ->
|
||||
case model of
|
||||
StateDisabled m ->
|
||||
( StateDisabled { m | loading = True }
|
||||
, Api.confirmOtp flags (OtpConfirm m.confirmCode) ConfirmResp
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ConfirmResp (Ok result) ->
|
||||
case model of
|
||||
StateDisabled m ->
|
||||
if result.success then
|
||||
( SetupSuccessful, Cmd.none )
|
||||
|
||||
else
|
||||
( StateDisabled { m | confirmError = True, loading = False }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ConfirmResp (Err err) ->
|
||||
( ConfirmError err, Cmd.none )
|
||||
|
||||
SetDisableConfirmText str ->
|
||||
case model of
|
||||
StateEnabled m ->
|
||||
( StateEnabled { m | confirmText = str }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Disable ->
|
||||
case model of
|
||||
StateEnabled m ->
|
||||
if String.toLower m.confirmText == "ok" then
|
||||
( StateEnabled { m | confirmTextWrong = False, loading = True }
|
||||
, Api.disableOtp flags DisableResp
|
||||
)
|
||||
|
||||
else
|
||||
( StateEnabled { m | confirmTextWrong = True }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
DisableResp (Ok result) ->
|
||||
if result.success then
|
||||
init flags
|
||||
|
||||
else
|
||||
( model, Cmd.none )
|
||||
|
||||
DisableResp (Err err) ->
|
||||
( DisableError err, Cmd.none )
|
||||
|
||||
|
||||
|
||||
--- View
|
||||
|
||||
|
||||
view : Texts -> Model -> Html Msg
|
||||
view texts model =
|
||||
case model of
|
||||
InitialModel ->
|
||||
div [] []
|
||||
|
||||
StateError err ->
|
||||
viewHttpError texts texts.stateErrorInfoText err
|
||||
|
||||
InitError err ->
|
||||
viewHttpError texts texts.initErrorInfo err
|
||||
|
||||
ConfirmError err ->
|
||||
viewHttpError texts texts.confirmErrorInfo err
|
||||
|
||||
DisableError err ->
|
||||
viewHttpError texts texts.disableErrorInfo err
|
||||
|
||||
SetupSuccessful ->
|
||||
viewSetupSuccessful texts
|
||||
|
||||
StateEnabled m ->
|
||||
viewEnabled texts m
|
||||
|
||||
StateDisabled m ->
|
||||
viewDisabled texts m
|
||||
|
||||
|
||||
viewEnabled : Texts -> EnabledModel -> Html Msg
|
||||
viewEnabled texts model =
|
||||
div []
|
||||
[ h2 [ class S.header2 ]
|
||||
[ text texts.twoFaActiveSince
|
||||
, text <| texts.formatDateShort model.created
|
||||
]
|
||||
, p []
|
||||
[ text texts.revert2FAText
|
||||
]
|
||||
, div [ class "flex flex-col items-center mt-6" ]
|
||||
[ div [ class "flex flex-row max-w-md" ]
|
||||
[ input
|
||||
[ type_ "text"
|
||||
, value model.confirmText
|
||||
, onInput SetDisableConfirmText
|
||||
, class S.textInput
|
||||
, class "rounded-r-none"
|
||||
]
|
||||
[]
|
||||
, B.genericButton
|
||||
{ label = texts.disableButton
|
||||
, icon =
|
||||
if model.loading then
|
||||
"fa fa-circle-notch animate-spin"
|
||||
|
||||
else
|
||||
"fa fa-exclamation-circle"
|
||||
, handler = onClick Disable
|
||||
, disabled = model.loading
|
||||
, attrs = [ href "#" ]
|
||||
, baseStyle = S.primaryButtonPlain ++ " rounded-r"
|
||||
, activeStyle = S.primaryButtonHover
|
||||
}
|
||||
]
|
||||
, div
|
||||
[ class S.errorMessage
|
||||
, class "my-2"
|
||||
, classList [ ( "hidden", not model.confirmTextWrong ) ]
|
||||
]
|
||||
[ text texts.disableConfirmErrorMsg
|
||||
]
|
||||
, Markdown.toHtml [ class "mt-2" ] texts.disableConfirmBoxInfo
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewDisabled : Texts -> DisabledModel -> Html Msg
|
||||
viewDisabled texts model =
|
||||
div []
|
||||
[ h2 [ class S.header2 ]
|
||||
[ text texts.setupTwoFactorAuth
|
||||
]
|
||||
, p []
|
||||
[ text texts.setupTwoFactorAuthInfo
|
||||
]
|
||||
, case model.result of
|
||||
Nothing ->
|
||||
div [ class "flex flex-row items-center justify-center my-6 px-2" ]
|
||||
[ B.primaryButton
|
||||
{ label = texts.activateButton
|
||||
, icon =
|
||||
if model.loading then
|
||||
"fa fa-circle-notch animate-spin"
|
||||
|
||||
else
|
||||
"fa fa-key"
|
||||
, disabled = model.loading
|
||||
, handler = onClick Initialize
|
||||
, attrs = [ href "#" ]
|
||||
}
|
||||
]
|
||||
|
||||
Just data ->
|
||||
div [ class "flex flex-col mt-6" ]
|
||||
[ div [ class "flex flex-col items-center justify-center" ]
|
||||
[ div
|
||||
[ class S.border
|
||||
, class S.qrCode
|
||||
]
|
||||
[ qrCodeView texts data.authenticatorUrl
|
||||
]
|
||||
, div [ class "mt-4" ]
|
||||
[ p []
|
||||
[ text texts.scanQRCode
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col items-center justify-center mt-4" ]
|
||||
[ Html.form [ class "flex flex-row relative", onSubmit Confirm ]
|
||||
[ input
|
||||
[ type_ "text"
|
||||
, name "confirm-setup"
|
||||
, autocomplete False
|
||||
, onInput SetConfirmCode
|
||||
, value model.confirmCode
|
||||
, autofocus True
|
||||
, class "pl-2 pr-10 py-2 rounded-lg max-w-xs text-center font-mono "
|
||||
, class S.textInput
|
||||
, if model.confirmError then
|
||||
class S.inputErrorBorder
|
||||
|
||||
else
|
||||
class ""
|
||||
, placeholder "123456"
|
||||
]
|
||||
[]
|
||||
, a
|
||||
[ class S.inputLeftIconLink
|
||||
, href "#"
|
||||
, onClick Confirm
|
||||
]
|
||||
[ if model.loading then
|
||||
i [ class "fa fa-circle-notch animate-spin" ] []
|
||||
|
||||
else
|
||||
i [ class "fa fa-check" ] []
|
||||
]
|
||||
]
|
||||
, div
|
||||
[ classList [ ( "hidden", not model.confirmError ) ]
|
||||
, class S.errorMessage
|
||||
, class "mt-2"
|
||||
]
|
||||
[ text texts.setupCodeInvalid ]
|
||||
, div [ class "mt-6" ]
|
||||
[ p [] [ text texts.ifNotQRCode ]
|
||||
, div [ class "max-w-md mx-auto mt-4" ]
|
||||
[ Html.map SecretMsg
|
||||
(Comp.PasswordInput.view2
|
||||
{ placeholder = "" }
|
||||
(Just data.secret)
|
||||
False
|
||||
model.secretModel
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
qrCodeView : Texts -> String -> Html msg
|
||||
qrCodeView texts message =
|
||||
QRCode.encode message
|
||||
|> Result.map QRCode.toSvg
|
||||
|> Result.withDefault
|
||||
(Html.text texts.errorGeneratingQR)
|
||||
|
||||
|
||||
viewHttpError : Texts -> String -> Http.Error -> Html Msg
|
||||
viewHttpError texts descr err =
|
||||
div [ class S.errorMessage ]
|
||||
[ h2 [ class S.header2 ]
|
||||
[ text texts.errorTitle
|
||||
]
|
||||
, p []
|
||||
[ text descr
|
||||
, text " "
|
||||
, text <| texts.httpError err
|
||||
]
|
||||
, p []
|
||||
[ text texts.reloadToTryAgain
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewSetupSuccessful : Texts -> Html msg
|
||||
viewSetupSuccessful texts =
|
||||
div [ class "flex flex-col" ]
|
||||
[ div
|
||||
[ class S.successMessage
|
||||
, class "text-lg"
|
||||
]
|
||||
[ h2
|
||||
[ class "text-2xl font-medium tracking-wide"
|
||||
]
|
||||
[ i [ class "fa fa-check mr-2" ] []
|
||||
, text texts.twoFactorNowActive
|
||||
]
|
||||
]
|
||||
, div [ class "mt-4" ]
|
||||
[ text texts.revertInfo
|
||||
]
|
||||
]
|
100
modules/webapp/src/main/elm/Messages/Comp/OtpSetup.elm
Normal file
100
modules/webapp/src/main/elm/Messages/Comp/OtpSetup.elm
Normal file
@ -0,0 +1,100 @@
|
||||
{-
|
||||
Copyright 2020 Docspell Contributors
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-}
|
||||
|
||||
|
||||
module Messages.Comp.OtpSetup exposing
|
||||
( Texts
|
||||
, de
|
||||
, gb
|
||||
)
|
||||
|
||||
import Http
|
||||
import Messages.Comp.HttpError
|
||||
import Messages.DateFormat
|
||||
import Messages.UiLanguage
|
||||
|
||||
|
||||
type alias Texts =
|
||||
{ httpError : Http.Error -> String
|
||||
, formatDateShort : Int -> String
|
||||
, errorTitle : String
|
||||
, stateErrorInfoText : String
|
||||
, errorGeneratingQR : String
|
||||
, initErrorInfo : String
|
||||
, confirmErrorInfo : String
|
||||
, disableErrorInfo : String
|
||||
, twoFaActiveSince : String
|
||||
, revert2FAText : String
|
||||
, disableButton : String
|
||||
, disableConfirmErrorMsg : String
|
||||
, disableConfirmBoxInfo : String
|
||||
, setupTwoFactorAuth : String
|
||||
, setupTwoFactorAuthInfo : String
|
||||
, activateButton : String
|
||||
, setupConfirmLabel : String
|
||||
, scanQRCode : String
|
||||
, setupCodeInvalid : String
|
||||
, ifNotQRCode : String
|
||||
, reloadToTryAgain : String
|
||||
, twoFactorNowActive : String
|
||||
, revertInfo : String
|
||||
}
|
||||
|
||||
|
||||
gb : Texts
|
||||
gb =
|
||||
{ httpError = Messages.Comp.HttpError.gb
|
||||
, formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.English
|
||||
, errorTitle = "Error"
|
||||
, stateErrorInfoText = "There was a problem determining the current state of your two factor authentication scheme:"
|
||||
, errorGeneratingQR = "Error generating QR Code"
|
||||
, initErrorInfo = "There was an error when initializing two-factor authentication."
|
||||
, confirmErrorInfo = "There was an error when confirming the setup!"
|
||||
, disableErrorInfo = "There was an error when disabling 2FA!"
|
||||
, twoFaActiveSince = "Two Factor Authentication is active since "
|
||||
, revert2FAText = "If you really want to revert back to password-only authentication, you can do this here. You can run the setup any time to enable the second factor again."
|
||||
, disableButton = "Disable 2FA"
|
||||
, disableConfirmErrorMsg = "Please type OK if you really want to disable this!"
|
||||
, disableConfirmBoxInfo = "Type `OK` into the text box and click the button to disable 2FA."
|
||||
, setupTwoFactorAuth = "Setup Two Factor Authentication"
|
||||
, setupTwoFactorAuthInfo = "You can setup a second factor for authentication using a one-time password. When clicking the button a secret is generated that you can load into an app on your mobile device. The app then provides a 6 digit code that you need to pass in the field in order to confirm and finalize the setup."
|
||||
, activateButton = "Activate two-factor authentication"
|
||||
, setupConfirmLabel = "Confirm"
|
||||
, scanQRCode = "Scan this QR code with your device and enter the 6 digit code:"
|
||||
, setupCodeInvalid = "The confirmation code was invalid!"
|
||||
, ifNotQRCode = "If you cannot use the qr code, enter this secret:"
|
||||
, reloadToTryAgain = "If you want to try again, reload the page."
|
||||
, twoFactorNowActive = "Two Factor Authentication is now active!"
|
||||
, revertInfo = "You can revert back to password-only auth any time (reload this page)."
|
||||
}
|
||||
|
||||
|
||||
de : Texts
|
||||
de =
|
||||
{ httpError = Messages.Comp.HttpError.de
|
||||
, formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.German
|
||||
, errorTitle = "Fehler"
|
||||
, stateErrorInfoText = "Es gab ein Problem, den Status der Zwei-Faktor-Authentifizierung zu ermittlen:"
|
||||
, errorGeneratingQR = "Fehler beim Generieren des QR-Code"
|
||||
, initErrorInfo = "Es gab ein Problem beim Initialisieren der Zwei-Faktor-Authentifizierung."
|
||||
, confirmErrorInfo = "Es gab ein Problem bei der Verifizierung!"
|
||||
, disableErrorInfo = "Es gab ein Problem die Zwei-Faktor-Authentifizierung zu entfernen."
|
||||
, twoFaActiveSince = "Die Zwei-Faktor-Authentifizierung ist aktiv seit "
|
||||
, revert2FAText = "Die Zwei-Faktor-Authentifizierung kann hier wieder deaktiviert werden. Danach kann die Einrichtung wieder von neuem gestartet werden, um 2FA wieder zu aktivieren."
|
||||
, disableButton = "Deaktiviere 2FA"
|
||||
, disableConfirmErrorMsg = "Bitte tippe OK ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
|
||||
, disableConfirmBoxInfo = "Tippe `OK` in das Feld und klicke, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
|
||||
, setupTwoFactorAuth = "Zwei-Faktor-Authentifizierung einrichten"
|
||||
, setupTwoFactorAuthInfo = "Ein zweiter Faktor zur Authentifizierung mittels eines Einmalkennworts kann eingerichtet werden. Beim Klicken des Button wird ein Schlüssel generiert, der an eine Authentifizierungs-App eines mobilen Gerätes übetragen werden kann. Danach präsentiert die App ein 6-stelliges Kennwort, welches zur Bestätigung und zum Abschluss angegeben werden muss."
|
||||
, activateButton = "Zwei-Faktor-Authentifizierung aktivieren"
|
||||
, setupConfirmLabel = "Bestätigung"
|
||||
, scanQRCode = "Scanne den QR Code mit der Authentifizierungs-App und gebe den 6-stelligen Code ein:"
|
||||
, setupCodeInvalid = "Der Code war ungültig!"
|
||||
, ifNotQRCode = "Wenn der QR-Code nicht möglich ist, kann der Schlüssel manuell eingegeben werden:"
|
||||
, reloadToTryAgain = "Um es noch einmal zu versuchen, bitte die Seite neu laden."
|
||||
, twoFactorNowActive = "Die Zwei-Faktor-Authentifizierung ist nun aktiv!"
|
||||
, revertInfo = "Es kann jederzeit zur normalen Passwort-Authentifizierung zurück gegangen werden (dazu Seite neu laden)."
|
||||
}
|
@ -28,6 +28,7 @@ type alias Texts =
|
||||
, loginSuccessful : String
|
||||
, noAccount : String
|
||||
, signupLink : String
|
||||
, otpCode : String
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +46,7 @@ gb =
|
||||
, loginSuccessful = "Login successful"
|
||||
, noAccount = "No account?"
|
||||
, signupLink = "Sign up!"
|
||||
, otpCode = "Authentication code"
|
||||
}
|
||||
|
||||
|
||||
@ -62,4 +64,5 @@ de =
|
||||
, loginSuccessful = "Anmeldung erfolgreich"
|
||||
, noAccount = "Kein Konto?"
|
||||
, signupLink = "Hier registrieren!"
|
||||
, otpCode = "Authentifizierungscode"
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import Messages.Comp.ChangePasswordForm
|
||||
import Messages.Comp.EmailSettingsManage
|
||||
import Messages.Comp.ImapSettingsManage
|
||||
import Messages.Comp.NotificationManage
|
||||
import Messages.Comp.OtpSetup
|
||||
import Messages.Comp.ScanMailboxManage
|
||||
import Messages.Comp.UiSettingsManage
|
||||
|
||||
@ -26,6 +27,7 @@ type alias Texts =
|
||||
, imapSettingsManage : Messages.Comp.ImapSettingsManage.Texts
|
||||
, notificationManage : Messages.Comp.NotificationManage.Texts
|
||||
, scanMailboxManage : Messages.Comp.ScanMailboxManage.Texts
|
||||
, otpSetup : Messages.Comp.OtpSetup.Texts
|
||||
, userSettings : String
|
||||
, uiSettings : String
|
||||
, notifications : String
|
||||
@ -38,6 +40,7 @@ type alias Texts =
|
||||
, notificationRemindDaysInfo : String
|
||||
, scanMailboxInfo1 : String
|
||||
, scanMailboxInfo2 : String
|
||||
, otpMenu : String
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +52,7 @@ gb =
|
||||
, imapSettingsManage = Messages.Comp.ImapSettingsManage.gb
|
||||
, notificationManage = Messages.Comp.NotificationManage.gb
|
||||
, scanMailboxManage = Messages.Comp.ScanMailboxManage.gb
|
||||
, otpSetup = Messages.Comp.OtpSetup.gb
|
||||
, userSettings = "User Settings"
|
||||
, uiSettings = "UI Settings"
|
||||
, notifications = "Notifications"
|
||||
@ -80,6 +84,7 @@ gb =
|
||||
or to just leave it there. In the latter case you should
|
||||
adjust the schedule to avoid reading over the same mails
|
||||
again."""
|
||||
, otpMenu = "Two Factor"
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +96,7 @@ de =
|
||||
, imapSettingsManage = Messages.Comp.ImapSettingsManage.de
|
||||
, notificationManage = Messages.Comp.NotificationManage.de
|
||||
, scanMailboxManage = Messages.Comp.ScanMailboxManage.de
|
||||
, otpSetup = Messages.Comp.OtpSetup.de
|
||||
, userSettings = "Benutzereinstellung"
|
||||
, uiSettings = "Oberfläche"
|
||||
, notifications = "Benachrichtigungen"
|
||||
@ -122,4 +128,5 @@ E-Mail-Einstellungen (IMAP) notwendig."""
|
||||
ist es gut, die Kriterien so zu gestalten, dass die
|
||||
gleichen E-Mails möglichst nicht noch einmal eingelesen
|
||||
werden."""
|
||||
, otpMenu = "Zwei-Faktor-Authentifizierung"
|
||||
}
|
||||
|
@ -6,7 +6,8 @@
|
||||
|
||||
|
||||
module Page.Login.Data exposing
|
||||
( FormState(..)
|
||||
( AuthStep(..)
|
||||
, FormState(..)
|
||||
, Model
|
||||
, Msg(..)
|
||||
, emptyModel
|
||||
@ -20,8 +21,10 @@ import Page exposing (Page(..))
|
||||
type alias Model =
|
||||
{ username : String
|
||||
, password : String
|
||||
, otp : String
|
||||
, rememberMe : Bool
|
||||
, formState : FormState
|
||||
, authStep : AuthStep
|
||||
}
|
||||
|
||||
|
||||
@ -32,12 +35,19 @@ type FormState
|
||||
| FormInitial
|
||||
|
||||
|
||||
type AuthStep
|
||||
= StepLogin
|
||||
| StepOtp AuthResult
|
||||
|
||||
|
||||
emptyModel : Model
|
||||
emptyModel =
|
||||
{ username = ""
|
||||
, password = ""
|
||||
, otp = ""
|
||||
, rememberMe = False
|
||||
, formState = FormInitial
|
||||
, authStep = StepLogin
|
||||
}
|
||||
|
||||
|
||||
@ -47,3 +57,5 @@ type Msg
|
||||
| ToggleRememberMe
|
||||
| Authenticate
|
||||
| AuthResp (Result Http.Error AuthResult)
|
||||
| SetOtp String
|
||||
| AuthOtp AuthResult
|
||||
|
@ -24,6 +24,9 @@ update referrer flags msg model =
|
||||
SetPassword str ->
|
||||
( { model | password = str }, Cmd.none, Nothing )
|
||||
|
||||
SetOtp str ->
|
||||
( { model | otp = str }, Cmd.none, Nothing )
|
||||
|
||||
ToggleRememberMe ->
|
||||
( { model | rememberMe = not model.rememberMe }, Cmd.none, Nothing )
|
||||
|
||||
@ -37,17 +40,33 @@ update referrer flags msg model =
|
||||
in
|
||||
( model, Api.login flags userPass AuthResp, Nothing )
|
||||
|
||||
AuthOtp acc ->
|
||||
let
|
||||
sf =
|
||||
{ rememberMe = model.rememberMe
|
||||
, token = Maybe.withDefault "" acc.token
|
||||
, otp = model.otp
|
||||
}
|
||||
in
|
||||
( model, Api.twoFactor flags sf AuthResp, Nothing )
|
||||
|
||||
AuthResp (Ok lr) ->
|
||||
let
|
||||
gotoRef =
|
||||
Maybe.withDefault HomePage referrer |> Page.goto
|
||||
in
|
||||
if lr.success then
|
||||
if lr.success && not lr.requireSecondFactor then
|
||||
( { model | formState = AuthSuccess lr, password = "" }
|
||||
, Cmd.batch [ setAccount lr, gotoRef ]
|
||||
, Just lr
|
||||
)
|
||||
|
||||
else if lr.success && lr.requireSecondFactor then
|
||||
( { model | formState = FormInitial, authStep = StepOtp lr, password = "" }
|
||||
, Cmd.none
|
||||
, Nothing
|
||||
)
|
||||
|
||||
else
|
||||
( { model | formState = AuthFailed lr, password = "" }
|
||||
, Ports.removeAccount ()
|
||||
|
@ -7,6 +7,7 @@
|
||||
|
||||
module Page.Login.View2 exposing (viewContent, viewSidebar)
|
||||
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Api.Model.VersionInfo exposing (VersionInfo)
|
||||
import Data.Flags exposing (Flags)
|
||||
import Data.UiSettings exposing (UiSettings)
|
||||
@ -46,104 +47,12 @@ viewContent texts flags versionInfo _ model =
|
||||
, div [ class "font-medium self-center text-xl sm:text-2xl" ]
|
||||
[ text texts.loginToDocspell
|
||||
]
|
||||
, Html.form
|
||||
[ action "#"
|
||||
, onSubmit Authenticate
|
||||
, autocomplete False
|
||||
]
|
||||
[ div [ class "flex flex-col mt-6" ]
|
||||
[ label
|
||||
[ for "username"
|
||||
, class S.inputLabel
|
||||
]
|
||||
[ text texts.username
|
||||
]
|
||||
, div [ class "relative" ]
|
||||
[ div [ class S.inputIcon ]
|
||||
[ i [ class "fa fa-user" ] []
|
||||
]
|
||||
, input
|
||||
[ type_ "text"
|
||||
, name "username"
|
||||
, autocomplete False
|
||||
, onInput SetUsername
|
||||
, value model.username
|
||||
, autofocus True
|
||||
, class ("pl-10 pr-4 py-2 rounded-lg" ++ S.textInput)
|
||||
, placeholder texts.collectiveSlashLogin
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ label
|
||||
[ for "password"
|
||||
, class S.inputLabel
|
||||
]
|
||||
[ text texts.password
|
||||
]
|
||||
, div [ class "relative" ]
|
||||
[ div [ class S.inputIcon ]
|
||||
[ i [ class "fa fa-lock" ] []
|
||||
]
|
||||
, input
|
||||
[ type_ "password"
|
||||
, name "password"
|
||||
, autocomplete False
|
||||
, onInput SetPassword
|
||||
, value model.password
|
||||
, class ("pl-10 pr-4 py-2 rounded-lg" ++ S.textInput)
|
||||
, placeholder texts.password
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ label
|
||||
[ class "inline-flex items-center"
|
||||
, for "rememberme"
|
||||
]
|
||||
[ input
|
||||
[ id "rememberme"
|
||||
, type_ "checkbox"
|
||||
, onCheck (\_ -> ToggleRememberMe)
|
||||
, checked model.rememberMe
|
||||
, name "rememberme"
|
||||
, class S.checkboxInput
|
||||
]
|
||||
[]
|
||||
, span
|
||||
[ class "mb-1 ml-2 text-xs sm:text-sm tracking-wide my-1"
|
||||
]
|
||||
[ text texts.rememberMe
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ button
|
||||
[ type_ "submit"
|
||||
, class S.primaryButton
|
||||
]
|
||||
[ text texts.loginButton
|
||||
]
|
||||
]
|
||||
, resultMessage texts model
|
||||
, div
|
||||
[ class "flex justify-end text-sm pt-4"
|
||||
, classList [ ( "hidden", flags.config.signupMode == "closed" ) ]
|
||||
]
|
||||
[ span []
|
||||
[ text texts.noAccount
|
||||
]
|
||||
, a
|
||||
[ Page.href RegisterPage
|
||||
, class ("ml-2" ++ S.link)
|
||||
]
|
||||
[ i [ class "fa fa-user-plus mr-1" ] []
|
||||
, text texts.signupLink
|
||||
]
|
||||
]
|
||||
]
|
||||
, case model.authStep of
|
||||
StepOtp token ->
|
||||
otpForm texts flags model token
|
||||
|
||||
StepLogin ->
|
||||
loginForm texts flags model
|
||||
]
|
||||
, a
|
||||
[ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90"
|
||||
@ -163,6 +72,151 @@ viewContent texts flags versionInfo _ model =
|
||||
]
|
||||
|
||||
|
||||
otpForm : Texts -> Flags -> Model -> AuthResult -> Html Msg
|
||||
otpForm texts flags model acc =
|
||||
Html.form
|
||||
[ action "#"
|
||||
, onSubmit (AuthOtp acc)
|
||||
, autocomplete False
|
||||
]
|
||||
[ div [ class "flex flex-col mt-6" ]
|
||||
[ label
|
||||
[ for "otp"
|
||||
, class S.inputLabel
|
||||
]
|
||||
[ text texts.otpCode
|
||||
]
|
||||
, div [ class "relative" ]
|
||||
[ div [ class S.inputIcon ]
|
||||
[ i [ class "fa fa-key" ] []
|
||||
]
|
||||
, input
|
||||
[ type_ "text"
|
||||
, name "otp"
|
||||
, autocomplete False
|
||||
, onInput SetOtp
|
||||
, value model.otp
|
||||
, autofocus True
|
||||
, class ("pl-10 pr-4 py-2 rounded-lg" ++ S.textInput)
|
||||
, placeholder "123456"
|
||||
]
|
||||
[]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ button
|
||||
[ type_ "submit"
|
||||
, class S.primaryButton
|
||||
]
|
||||
[ text texts.loginButton
|
||||
]
|
||||
]
|
||||
, resultMessage texts model
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
loginForm : Texts -> Flags -> Model -> Html Msg
|
||||
loginForm texts flags model =
|
||||
Html.form
|
||||
[ action "#"
|
||||
, onSubmit Authenticate
|
||||
, autocomplete False
|
||||
]
|
||||
[ div [ class "flex flex-col mt-6" ]
|
||||
[ label
|
||||
[ for "username"
|
||||
, class S.inputLabel
|
||||
]
|
||||
[ text texts.username
|
||||
]
|
||||
, div [ class "relative" ]
|
||||
[ div [ class S.inputIcon ]
|
||||
[ i [ class "fa fa-user" ] []
|
||||
]
|
||||
, input
|
||||
[ type_ "text"
|
||||
, name "username"
|
||||
, autocomplete False
|
||||
, onInput SetUsername
|
||||
, value model.username
|
||||
, autofocus True
|
||||
, class ("pl-10 pr-4 py-2 rounded-lg" ++ S.textInput)
|
||||
, placeholder texts.collectiveSlashLogin
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ label
|
||||
[ for "password"
|
||||
, class S.inputLabel
|
||||
]
|
||||
[ text texts.password
|
||||
]
|
||||
, div [ class "relative" ]
|
||||
[ div [ class S.inputIcon ]
|
||||
[ i [ class "fa fa-lock" ] []
|
||||
]
|
||||
, input
|
||||
[ type_ "password"
|
||||
, name "password"
|
||||
, autocomplete False
|
||||
, onInput SetPassword
|
||||
, value model.password
|
||||
, class ("pl-10 pr-4 py-2 rounded-lg" ++ S.textInput)
|
||||
, placeholder texts.password
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ label
|
||||
[ class "inline-flex items-center"
|
||||
, for "rememberme"
|
||||
]
|
||||
[ input
|
||||
[ id "rememberme"
|
||||
, type_ "checkbox"
|
||||
, onCheck (\_ -> ToggleRememberMe)
|
||||
, checked model.rememberMe
|
||||
, name "rememberme"
|
||||
, class S.checkboxInput
|
||||
]
|
||||
[]
|
||||
, span
|
||||
[ class "mb-1 ml-2 text-xs sm:text-sm tracking-wide my-1"
|
||||
]
|
||||
[ text texts.rememberMe
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "flex flex-col my-3" ]
|
||||
[ button
|
||||
[ type_ "submit"
|
||||
, class S.primaryButton
|
||||
]
|
||||
[ text texts.loginButton
|
||||
]
|
||||
]
|
||||
, resultMessage texts model
|
||||
, div
|
||||
[ class "flex justify-end text-sm pt-4"
|
||||
, classList [ ( "hidden", flags.config.signupMode == "closed" ) ]
|
||||
]
|
||||
[ span []
|
||||
[ text texts.noAccount
|
||||
]
|
||||
, a
|
||||
[ Page.href RegisterPage
|
||||
, class ("ml-2" ++ S.link)
|
||||
]
|
||||
[ i [ class "fa fa-user-plus mr-1" ] []
|
||||
, text texts.signupLink
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
resultMessage : Texts -> Model -> Html Msg
|
||||
resultMessage texts model =
|
||||
case model.formState of
|
||||
|
@ -16,6 +16,7 @@ import Comp.ChangePasswordForm
|
||||
import Comp.EmailSettingsManage
|
||||
import Comp.ImapSettingsManage
|
||||
import Comp.NotificationManage
|
||||
import Comp.OtpSetup
|
||||
import Comp.ScanMailboxManage
|
||||
import Comp.UiSettingsManage
|
||||
import Data.Flags exposing (Flags)
|
||||
@ -30,6 +31,7 @@ type alias Model =
|
||||
, notificationModel : Comp.NotificationManage.Model
|
||||
, scanMailboxModel : Comp.ScanMailboxManage.Model
|
||||
, uiSettingsModel : Comp.UiSettingsManage.Model
|
||||
, otpSetupModel : Comp.OtpSetup.Model
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +40,9 @@ init flags settings =
|
||||
let
|
||||
( um, uc ) =
|
||||
Comp.UiSettingsManage.init flags settings
|
||||
|
||||
( otpm, otpc ) =
|
||||
Comp.OtpSetup.init flags
|
||||
in
|
||||
( { currentTab = Just UiSettingsTab
|
||||
, changePassModel = Comp.ChangePasswordForm.emptyModel
|
||||
@ -46,8 +51,12 @@ init flags settings =
|
||||
, notificationModel = Tuple.first (Comp.NotificationManage.init flags)
|
||||
, scanMailboxModel = Tuple.first (Comp.ScanMailboxManage.init flags)
|
||||
, uiSettingsModel = um
|
||||
, otpSetupModel = otpm
|
||||
}
|
||||
, Cmd.map UiSettingsMsg uc
|
||||
, Cmd.batch
|
||||
[ Cmd.map UiSettingsMsg uc
|
||||
, Cmd.map OtpSetupMsg otpc
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@ -58,6 +67,7 @@ type Tab
|
||||
| NotificationTab
|
||||
| ScanMailboxTab
|
||||
| UiSettingsTab
|
||||
| OtpTab
|
||||
|
||||
|
||||
type Msg
|
||||
@ -68,5 +78,6 @@ type Msg
|
||||
| ImapSettingsMsg Comp.ImapSettingsManage.Msg
|
||||
| ScanMailboxMsg Comp.ScanMailboxManage.Msg
|
||||
| UiSettingsMsg Comp.UiSettingsManage.Msg
|
||||
| OtpSetupMsg Comp.OtpSetup.Msg
|
||||
| UpdateSettings
|
||||
| ReceiveBrowserSettings StoredUiSettings
|
||||
|
@ -11,6 +11,7 @@ import Comp.ChangePasswordForm
|
||||
import Comp.EmailSettingsManage
|
||||
import Comp.ImapSettingsManage
|
||||
import Comp.NotificationManage
|
||||
import Comp.OtpSetup
|
||||
import Comp.ScanMailboxManage
|
||||
import Comp.UiSettingsManage
|
||||
import Data.Flags exposing (Flags)
|
||||
@ -79,6 +80,9 @@ update flags settings msg model =
|
||||
UiSettingsTab ->
|
||||
UpdateResult m Cmd.none Sub.none Nothing
|
||||
|
||||
OtpTab ->
|
||||
UpdateResult m Cmd.none Sub.none Nothing
|
||||
|
||||
ChangePassMsg m ->
|
||||
let
|
||||
( m2, c2 ) =
|
||||
@ -145,6 +149,17 @@ update flags settings msg model =
|
||||
, newSettings = res.newSettings
|
||||
}
|
||||
|
||||
OtpSetupMsg lm ->
|
||||
let
|
||||
( otpm, otpc ) =
|
||||
Comp.OtpSetup.update flags lm model.otpSetupModel
|
||||
in
|
||||
{ model = { model | otpSetupModel = otpm }
|
||||
, cmd = Cmd.map OtpSetupMsg otpc
|
||||
, sub = Sub.none
|
||||
, newSettings = Nothing
|
||||
}
|
||||
|
||||
UpdateSettings ->
|
||||
update flags
|
||||
settings
|
||||
|
@ -11,6 +11,7 @@ import Comp.ChangePasswordForm
|
||||
import Comp.EmailSettingsManage
|
||||
import Comp.ImapSettingsManage
|
||||
import Comp.NotificationManage
|
||||
import Comp.OtpSetup
|
||||
import Comp.ScanMailboxManage
|
||||
import Comp.UiSettingsManage
|
||||
import Data.Flags exposing (Flags)
|
||||
@ -104,6 +105,17 @@ viewSidebar texts visible _ _ model =
|
||||
[ class "ml-3" ]
|
||||
[ text texts.changePassword ]
|
||||
]
|
||||
, a
|
||||
[ href "#"
|
||||
, onClick (SetTab OtpTab)
|
||||
, menuEntryActive model OtpTab
|
||||
, class S.sidebarLink
|
||||
]
|
||||
[ i [ class "fa fa-key" ] []
|
||||
, span
|
||||
[ class "ml-3" ]
|
||||
[ text texts.otpMenu ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@ -133,6 +145,9 @@ viewContent texts flags settings model =
|
||||
Just UiSettingsTab ->
|
||||
viewUiSettings texts flags settings model
|
||||
|
||||
Just OtpTab ->
|
||||
viewOtpSetup texts settings model
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
)
|
||||
@ -151,6 +166,25 @@ menuEntryActive model tab =
|
||||
class ""
|
||||
|
||||
|
||||
viewOtpSetup : Texts -> UiSettings -> Model -> List (Html Msg)
|
||||
viewOtpSetup texts _ model =
|
||||
[ h2
|
||||
[ class S.header1
|
||||
, class "inline-flex items-center"
|
||||
]
|
||||
[ i [ class "fa fa-key" ] []
|
||||
, div [ class "ml-3" ]
|
||||
[ text texts.otpMenu
|
||||
]
|
||||
]
|
||||
, Html.map OtpSetupMsg
|
||||
(Comp.OtpSetup.view
|
||||
texts.otpSetup
|
||||
model.otpSetupModel
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
viewChangePassword : Texts -> Model -> List (Html Msg)
|
||||
viewChangePassword texts model =
|
||||
[ h2
|
||||
|
@ -21,6 +21,7 @@ object Dependencies {
|
||||
val H2Version = "1.4.200"
|
||||
val Http4sVersion = "0.23.1"
|
||||
val Icu4jVersion = "69.1"
|
||||
val javaOtpVersion = "0.3.0"
|
||||
val JsoupVersion = "1.14.2"
|
||||
val KindProjectorVersion = "0.10.3"
|
||||
val KittensVersion = "2.3.2"
|
||||
@ -36,6 +37,7 @@ object Dependencies {
|
||||
val PostgresVersion = "42.2.23"
|
||||
val PureConfigVersion = "0.16.0"
|
||||
val ScalaJavaTimeVersion = "2.3.0"
|
||||
val ScodecBitsVersion = "1.1.27"
|
||||
val Slf4jVersion = "1.7.32"
|
||||
val StanfordNlpVersion = "4.2.2"
|
||||
val TikaVersion = "2.1.0"
|
||||
@ -46,6 +48,14 @@ object Dependencies {
|
||||
val JQueryVersion = "3.5.1"
|
||||
val ViewerJSVersion = "0.5.9"
|
||||
|
||||
val scodecBits = Seq(
|
||||
"org.scodec" %% "scodec-bits" % ScodecBitsVersion
|
||||
)
|
||||
|
||||
val javaOtp = Seq(
|
||||
"com.eatthepath" % "java-otp" % "0.3.0"
|
||||
)
|
||||
|
||||
val testContainer = Seq(
|
||||
"com.dimafeng" %% "testcontainers-scala-munit" % TestContainerVersion,
|
||||
"com.dimafeng" %% "testcontainers-scala-mariadb" % TestContainerVersion,
|
||||
|
Loading…
x
Reference in New Issue
Block a user