Adopt login process for two-factor auth

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

View File

@ -19,6 +19,7 @@ import docspell.joexapi.client.JoexClient
import docspell.store.Store
import docspell.store.queue.JobQueue
import docspell.store.usertask.UserTaskStore
import docspell.totp.Totp
import emil.javamail.{JavaMailEmil, Settings}
import org.http4s.blaze.client.BlazeClientBuilder
@ -60,8 +61,8 @@ object BackendApp {
for {
utStore <- UserTaskStore(store)
queue <- JobQueue(store)
totpImpl <- OTotp(store)
loginImpl <- Login[F](store)
totpImpl <- OTotp(store, Totp.default)
loginImpl <- Login[F](store, Totp.default)
signupImpl <- OSignup[F](store)
joexImpl <- OJoex(JoexClient(httpClient), store)
collImpl <- OCollective[F](store, utStore, queue, joexImpl)

View File

@ -42,7 +42,7 @@ case class AuthToken(
}
def validate(key: ByteVector, validity: Duration): Boolean =
sigValid(key) && notExpired(validity)
sigValid(key) && notExpired(validity) && !requireSecondFactor
}
@ -62,11 +62,15 @@ object AuthToken {
Left("Invalid authenticator")
}
def user[F[_]: Sync](accountId: AccountId, key: ByteVector): F[AuthToken] =
def user[F[_]: Sync](
accountId: AccountId,
requireSecondFactor: Boolean,
key: ByteVector
): F[AuthToken] =
for {
salt <- Common.genSaltString[F]
millis = Instant.now.toEpochMilli
cd = AuthToken(millis, accountId, false, salt, "")
cd = AuthToken(millis, accountId, requireSecondFactor, salt, "")
sig = TokenUtil.sign(cd, key)
} yield cd.copy(sig = sig)

View File

@ -6,7 +6,7 @@
package docspell.backend.auth
import cats.data.OptionT
import cats.data.{EitherT, OptionT}
import cats.effect._
import cats.implicits._
@ -15,6 +15,7 @@ import docspell.common._
import docspell.store.Store
import docspell.store.queries.QLogin
import docspell.store.records._
import docspell.totp.{OnetimePassword, Totp}
import org.log4s.getLogger
import org.mindrot.jbcrypt.BCrypt
@ -26,6 +27,8 @@ trait Login[F[_]] {
def loginUserPass(config: Config)(up: UserPass): F[Result]
def loginSecondFactor(config: Config)(sf: SecondFactor): F[Result]
def loginRememberMe(config: Config)(token: String): F[Result]
def loginSessionOrRememberMe(
@ -54,6 +57,12 @@ object Login {
else copy(pass = "***")
}
final case class SecondFactor(
token: AuthToken,
rememberMe: Boolean,
otp: OnetimePassword
)
sealed trait Result {
def toEither: Either[String, AuthToken]
}
@ -79,7 +88,7 @@ object Login {
def invalidFactor: Result = InvalidFactor
}
def apply[F[_]: Async](store: Store[F]): Resource[F, Login[F]] =
def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, Login[F]] =
Resource.pure[F, Login[F]](new Login[F] {
private val logF = Logger.log4s(logger)
@ -94,8 +103,8 @@ object Login {
else if (at.requireSecondFactor)
logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F]
else Result.ok(at, None).pure[F]
case Left(_) =>
Result.invalidAuth.pure[F]
case Left(err) =>
logF.debug(s"Invalid session token: $err") *> Result.invalidAuth.pure[F]
}
def loginUserPass(config: Config)(up: UserPass): F[Result] =
@ -103,10 +112,13 @@ object Login {
case Right(acc) =>
val okResult =
for {
_ <- store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, config.serverSecret)
require2FA <- store.transact(RTotp.isEnabled(acc))
_ <-
if (require2FA) ().pure[F]
else store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, require2FA, config.serverSecret)
rem <- OptionT
.whenF(up.rememberMe && config.rememberMe.enabled)(
.whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)(
insertRememberToken(store, acc, config)
)
.value
@ -123,11 +135,54 @@ object Login {
Result.invalidAuth.pure[F]
}
def loginSecondFactor(config: Config)(sf: SecondFactor): F[Result] = {
val okResult: F[Result] =
for {
_ <- store.transact(RUser.updateLogin(sf.token.account))
newToken <- AuthToken.user(sf.token.account, false, config.serverSecret)
rem <- OptionT
.whenF(sf.rememberMe && config.rememberMe.enabled)(
insertRememberToken(store, sf.token.account, config)
)
.value
} yield Result.ok(newToken, rem)
val validateToken: EitherT[F, Result, Unit] = for {
_ <- EitherT
.cond[F](sf.token.sigValid(config.serverSecret), (), Result.invalidAuth)
.leftSemiflatTap(_ =>
logF.warn("OTP authentication token signature invalid!")
)
_ <- EitherT
.cond[F](sf.token.notExpired(config.sessionValid), (), Result.invalidTime)
.leftSemiflatTap(_ => logF.info("OTP Token expired."))
_ <- EitherT
.cond[F](sf.token.requireSecondFactor, (), Result.invalidAuth)
.leftSemiflatTap(_ =>
logF.warn("OTP received for token that is not allowed for 2FA!")
)
} yield ()
(for {
_ <- validateToken
key <- EitherT.fromOptionF(
store.transact(RTotp.findEnabledByLogin(sf.token.account, true)),
Result.invalidAuth
)
now <- EitherT.right[Result](Timestamp.current[F])
_ <- EitherT.cond[F](
totp.checkPassword(key.secret, sf.otp, now.value),
(),
Result.invalidAuth
)
} yield ()).swap.getOrElseF(okResult)
}
def loginRememberMe(config: Config)(token: String): F[Result] = {
def okResult(acc: AccountId) =
for {
_ <- store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, config.serverSecret)
token <- AuthToken.user(acc, false, config.serverSecret)
} yield Result.ok(token, None)
def doLogin(rid: Ident) =

View File

@ -24,7 +24,8 @@ private[auth] object TokenUtil {
}
def sign(cd: AuthToken, key: ByteVector): String = {
val raw = cd.nowMillis.toString + cd.account.asString + cd.salt
val raw =
cd.nowMillis.toString + cd.account.asString + cd.requireSecondFactor + cd.salt
val mac = Mac.getInstance("HmacSHA1")
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64

View File

@ -74,9 +74,8 @@ object OTotp {
case object Failed extends ConfirmResult
}
def apply[F[_]: Async](store: Store[F]): Resource[F, OTotp[F]] =
def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, OTotp[F]] =
Resource.pure[F, OTotp[F]](new OTotp[F] {
val totp = Totp.default
val log = Logger.log4s[F](logger)
def initialize(accountId: AccountId): F[InitResult] =

View File

@ -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"
@ -3994,6 +4022,21 @@ 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

View File

@ -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,7 +28,24 @@ object LoginRoutes {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of[F] { case req @ POST -> Root / "login" =>
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)(

View File

@ -70,6 +70,16 @@ object RTotp {
}
} 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

View File

@ -141,6 +141,7 @@ module Api exposing
, startReIndex
, submitNotifyDueItems
, toggleTags
, twoFactor
, unconfirmMultiple
, updateNotifyDueItems
, updateScanMailbox
@ -209,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)
@ -942,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

View File

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

View File

@ -128,5 +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 Auth"
, otpMenu = "Zwei-Faktor-Authentifizierung"
}

View File

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

View File

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

View File

@ -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,7 +47,77 @@ viewContent texts flags versionInfo _ model =
, div [ class "font-medium self-center text-xl sm:text-2xl" ]
[ text texts.loginToDocspell
]
, Html.form
, 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"
, href "https://docspell.org"
, target "_new"
]
[ img
[ src (flags.config.docspellAssetPath ++ "/img/logo-mc-96.png")
, class "w-3 h-3 mr-1"
]
[]
, span []
[ text "Docspell "
, text versionInfo.version
]
]
]
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
@ -144,23 +215,6 @@ viewContent texts flags versionInfo _ model =
]
]
]
]
, a
[ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90"
, href "https://docspell.org"
, target "_new"
]
[ img
[ src (flags.config.docspellAssetPath ++ "/img/logo-mc-96.png")
, class "w-3 h-3 mr-1"
]
[]
, span []
[ text "Docspell "
, text versionInfo.version
]
]
]
resultMessage : Texts -> Model -> Html Msg