mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-04 18:39:33 +00:00
Initial impl for totp
This commit is contained in:
parent
2b46cc7970
commit
309a52393a
@ -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")))
|
||||
})
|
||||
)
|
||||
|
||||
@ -371,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"))
|
||||
@ -496,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"))
|
||||
|
@ -46,6 +46,7 @@ trait BackendApp[F[_]] {
|
||||
def customFields: OCustomFields[F]
|
||||
def simpleSearch: OSimpleSearch[F]
|
||||
def clientSettings: OClientSettings[F]
|
||||
def totp: OTotp[F]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
@ -59,6 +60,7 @@ object BackendApp {
|
||||
for {
|
||||
utStore <- UserTaskStore(store)
|
||||
queue <- JobQueue(store)
|
||||
totpImpl <- OTotp(store)
|
||||
loginImpl <- Login[F](store)
|
||||
signupImpl <- OSignup[F](store)
|
||||
joexImpl <- OJoex(JoexClient(httpClient), store)
|
||||
@ -103,6 +105,7 @@ object BackendApp {
|
||||
val customFields = customFieldsImpl
|
||||
val simpleSearch = simpleSearchImpl
|
||||
val clientSettings = clientSettingsImpl
|
||||
val totp = totpImpl
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
|
@ -16,8 +16,15 @@ import docspell.common._
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: String) {
|
||||
def asString = s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$salt-$sig"
|
||||
case class AuthToken(
|
||||
nowMillis: Long,
|
||||
account: AccountId,
|
||||
requireSecondFactor: Boolean,
|
||||
salt: String,
|
||||
sig: String
|
||||
) {
|
||||
def asString =
|
||||
s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$requireSecondFactor-$salt-$sig"
|
||||
|
||||
def sigValid(key: ByteVector): Boolean = {
|
||||
val newSig = TokenUtil.sign(this, key)
|
||||
@ -42,13 +49,14 @@ case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: Str
|
||||
object AuthToken {
|
||||
|
||||
def fromString(s: String): Either[String, AuthToken] =
|
||||
s.split("\\-", 4) match {
|
||||
case Array(ms, as, salt, sig) =>
|
||||
s.split("\\-", 5) match {
|
||||
case Array(ms, as, fa, salt, sig) =>
|
||||
for {
|
||||
millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data")
|
||||
acc <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data")
|
||||
accId <- AccountId.parse(acc)
|
||||
} yield AuthToken(millis, accId, salt, sig)
|
||||
twofac <- Right[String, Boolean](java.lang.Boolean.parseBoolean(fa))
|
||||
} yield AuthToken(millis, accId, twofac, salt, sig)
|
||||
|
||||
case _ =>
|
||||
Left("Invalid authenticator")
|
||||
@ -58,7 +66,7 @@ object AuthToken {
|
||||
for {
|
||||
salt <- Common.genSaltString[F]
|
||||
millis = Instant.now.toEpochMilli
|
||||
cd = AuthToken(millis, accountId, salt, "")
|
||||
cd = AuthToken(millis, accountId, false, salt, "")
|
||||
sig = TokenUtil.sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
|
||||
@ -66,7 +74,7 @@ object AuthToken {
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
salt <- Common.genSaltString[F]
|
||||
data = AuthToken(now.toMillis, token.account, salt, "")
|
||||
data = AuthToken(now.toMillis, token.account, token.requireSecondFactor, salt, "")
|
||||
sig = TokenUtil.sign(data, key)
|
||||
} yield data.copy(sig = sig)
|
||||
}
|
||||
|
@ -68,11 +68,15 @@ object Login {
|
||||
case object InvalidTime extends Result {
|
||||
val toEither = Left("Authentication failed.")
|
||||
}
|
||||
case object InvalidFactor extends Result {
|
||||
val toEither = Left("Authentication requires second factor.")
|
||||
}
|
||||
|
||||
def ok(session: AuthToken, remember: Option[RememberToken]): Result =
|
||||
Ok(session, remember)
|
||||
def invalidAuth: Result = InvalidAuth
|
||||
def invalidTime: Result = InvalidTime
|
||||
def invalidAuth: Result = InvalidAuth
|
||||
def invalidTime: Result = InvalidTime
|
||||
def invalidFactor: Result = InvalidFactor
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](store: Store[F]): Resource[F, Login[F]] =
|
||||
@ -87,6 +91,8 @@ object Login {
|
||||
logF.warn("Cookie signature invalid!") *> Result.invalidAuth.pure[F]
|
||||
else if (at.isExpired(config.sessionValid))
|
||||
logF.debug("Auth Cookie expired") *> Result.invalidTime.pure[F]
|
||||
else if (at.requireSecondFactor)
|
||||
logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F]
|
||||
else Result.ok(at, None).pure[F]
|
||||
case Left(_) =>
|
||||
Result.invalidAuth.pure[F]
|
||||
@ -136,7 +142,7 @@ object Login {
|
||||
if (checkNoPassword(data))
|
||||
logF.info("RememberMe auth successful") *> okResult(data.account)
|
||||
else
|
||||
logF.warn("RememberMe auth not successfull") *> Result.invalidAuth.pure[F]
|
||||
logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F]
|
||||
)
|
||||
} yield res).getOrElseF(
|
||||
logF.info("RememberMe not found in database.") *> Result.invalidAuth.pure[F]
|
||||
|
152
modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
Normal file
152
modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala
Normal file
@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops.OTotp.{ConfirmResult, InitResult, OtpState}
|
||||
import docspell.common._
|
||||
import docspell.store.records.{RTotp, RUser}
|
||||
import docspell.store.{AddResult, Store, UpdateResult}
|
||||
import docspell.totp.{Key, OnetimePassword, Totp}
|
||||
|
||||
import org.log4s.getLogger
|
||||
|
||||
trait OTotp[F[_]] {
|
||||
|
||||
def state(accountId: AccountId): F[OtpState]
|
||||
|
||||
def initialize(accountId: AccountId): F[InitResult]
|
||||
|
||||
def confirmInit(accountId: AccountId, otp: OnetimePassword): F[ConfirmResult]
|
||||
|
||||
def disable(accountId: AccountId): F[UpdateResult]
|
||||
}
|
||||
|
||||
object OTotp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
sealed trait OtpState {
|
||||
def isEnabled: Boolean
|
||||
def isDisabled = !isEnabled
|
||||
def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A
|
||||
}
|
||||
object OtpState {
|
||||
final case class Enabled(created: Timestamp) extends OtpState {
|
||||
val isEnabled = true
|
||||
def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A =
|
||||
fe(this)
|
||||
}
|
||||
case object Disabled extends OtpState {
|
||||
val isEnabled = false
|
||||
def fold[A](fe: OtpState.Enabled => A, fd: OtpState.Disabled.type => A): A =
|
||||
fd(this)
|
||||
}
|
||||
}
|
||||
|
||||
sealed trait InitResult
|
||||
object InitResult {
|
||||
final case class Success(accountId: AccountId, key: Key) extends InitResult {
|
||||
def authenticatorUrl(issuer: String): LenientUri =
|
||||
LenientUri.unsafe(
|
||||
s"otpauth://totp/$issuer:${accountId.asString}?secret=${key.data.toBase32}&issuer=$issuer"
|
||||
)
|
||||
}
|
||||
case object AlreadyExists extends InitResult
|
||||
case object NotFound extends InitResult
|
||||
final case class Failed(ex: Throwable) extends InitResult
|
||||
|
||||
def success(accountId: AccountId, key: Key): InitResult =
|
||||
Success(accountId, key)
|
||||
|
||||
def alreadyExists: InitResult = AlreadyExists
|
||||
|
||||
def failed(ex: Throwable): InitResult = Failed(ex)
|
||||
}
|
||||
|
||||
sealed trait ConfirmResult
|
||||
object ConfirmResult {
|
||||
case object Success extends ConfirmResult
|
||||
case object Failed extends ConfirmResult
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](store: Store[F]): Resource[F, OTotp[F]] =
|
||||
Resource.pure[F, OTotp[F]](new OTotp[F] {
|
||||
val totp = Totp.default
|
||||
val log = Logger.log4s[F](logger)
|
||||
|
||||
def initialize(accountId: AccountId): F[InitResult] =
|
||||
for {
|
||||
_ <- log.info(s"Initializing TOTP for account ${accountId.asString}")
|
||||
userId <- store.transact(RUser.findIdByAccount(accountId))
|
||||
result <- userId match {
|
||||
case Some(uid) =>
|
||||
for {
|
||||
record <- RTotp.generate[F](uid, totp.settings.mac)
|
||||
un <- store.transact(RTotp.updateDisabled(record))
|
||||
an <-
|
||||
if (un != 0)
|
||||
AddResult.entityExists("Entity exists, but update was ok").pure[F]
|
||||
else store.add(RTotp.insert(record), RTotp.existsByLogin(accountId))
|
||||
innerResult <-
|
||||
if (un != 0) InitResult.success(accountId, record.secret).pure[F]
|
||||
else
|
||||
an match {
|
||||
case AddResult.EntityExists(msg) =>
|
||||
log.warn(
|
||||
s"A totp record already exists for account '${accountId.asString}': $msg!"
|
||||
) *>
|
||||
InitResult.alreadyExists.pure[F]
|
||||
case AddResult.Failure(ex) =>
|
||||
log.warn(
|
||||
s"Failed to setup totp record for '${accountId.asString}': ${ex.getMessage}"
|
||||
) *>
|
||||
InitResult.failed(ex).pure[F]
|
||||
case AddResult.Success =>
|
||||
InitResult.success(accountId, record.secret).pure[F]
|
||||
}
|
||||
} yield innerResult
|
||||
case None =>
|
||||
log.warn(s"No user found for account: ${accountId.asString}!") *>
|
||||
InitResult.NotFound.pure[F]
|
||||
}
|
||||
} yield result
|
||||
|
||||
def confirmInit(accountId: AccountId, otp: OnetimePassword): F[ConfirmResult] =
|
||||
for {
|
||||
_ <- log.info(s"Confirm TOTP setup for account ${accountId.asString}")
|
||||
key <- store.transact(RTotp.findEnabledByLogin(accountId, false))
|
||||
now <- Timestamp.current[F]
|
||||
res <- key match {
|
||||
case None =>
|
||||
ConfirmResult.Failed.pure[F]
|
||||
case Some(r) =>
|
||||
val check = totp.checkPassword(r.secret, otp, now.value)
|
||||
if (check)
|
||||
store
|
||||
.transact(RTotp.setEnabled(accountId, true))
|
||||
.map(_ => ConfirmResult.Success)
|
||||
else ConfirmResult.Failed.pure[F]
|
||||
}
|
||||
} yield res
|
||||
|
||||
def disable(accountId: AccountId): F[UpdateResult] =
|
||||
UpdateResult.fromUpdate(store.transact(RTotp.setEnabled(accountId, false)))
|
||||
|
||||
def state(accountId: AccountId): F[OtpState] =
|
||||
for {
|
||||
record <- store.transact(RTotp.findEnabledByLogin(accountId, true))
|
||||
result = record match {
|
||||
case Some(r) =>
|
||||
OtpState.Enabled(r.created)
|
||||
case None =>
|
||||
OtpState.Disabled
|
||||
}
|
||||
} yield result
|
||||
})
|
||||
|
||||
}
|
@ -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)))
|
||||
}
|
||||
|
@ -1275,6 +1275,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 +1449,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 +3994,49 @@ paths:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
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 +6040,7 @@ components:
|
||||
required:
|
||||
- collective
|
||||
- user
|
||||
- requireSecondFactor
|
||||
- success
|
||||
- message
|
||||
- validMs
|
||||
@ -5910,6 +6063,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)
|
||||
|
@ -82,7 +82,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 +94,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 {
|
||||
|
@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 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) =
|
||||
|
@ -48,7 +48,7 @@ class TotpTest extends FunSuite {
|
||||
}
|
||||
|
||||
test("check password 15s later") {
|
||||
assert(totp.checkPassword(key, OnetimePassword("410352"),time.plusSeconds(15)))
|
||||
assert(totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(15)))
|
||||
}
|
||||
|
||||
test("check password 29s later") {
|
||||
@ -56,6 +56,6 @@ class TotpTest extends FunSuite {
|
||||
}
|
||||
|
||||
test("check password 31s later (too late)") {
|
||||
assert(!totp.checkPassword(key, OnetimePassword("410352"),time.plusSeconds(31)))
|
||||
assert(!totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(31)))
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user