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

View File

@ -42,7 +42,7 @@ case class AuthToken(
} }
def validate(key: ByteVector, validity: Duration): Boolean = 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") 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 { for {
salt <- Common.genSaltString[F] salt <- Common.genSaltString[F]
millis = Instant.now.toEpochMilli millis = Instant.now.toEpochMilli
cd = AuthToken(millis, accountId, false, salt, "") cd = AuthToken(millis, accountId, requireSecondFactor, salt, "")
sig = TokenUtil.sign(cd, key) sig = TokenUtil.sign(cd, key)
} yield cd.copy(sig = sig) } yield cd.copy(sig = sig)

View File

@ -6,7 +6,7 @@
package docspell.backend.auth package docspell.backend.auth
import cats.data.OptionT import cats.data.{EitherT, OptionT}
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
@ -15,6 +15,7 @@ import docspell.common._
import docspell.store.Store import docspell.store.Store
import docspell.store.queries.QLogin import docspell.store.queries.QLogin
import docspell.store.records._ import docspell.store.records._
import docspell.totp.{OnetimePassword, Totp}
import org.log4s.getLogger import org.log4s.getLogger
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
@ -26,6 +27,8 @@ trait Login[F[_]] {
def loginUserPass(config: Config)(up: UserPass): F[Result] 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 loginRememberMe(config: Config)(token: String): F[Result]
def loginSessionOrRememberMe( def loginSessionOrRememberMe(
@ -54,6 +57,12 @@ object Login {
else copy(pass = "***") else copy(pass = "***")
} }
final case class SecondFactor(
token: AuthToken,
rememberMe: Boolean,
otp: OnetimePassword
)
sealed trait Result { sealed trait Result {
def toEither: Either[String, AuthToken] def toEither: Either[String, AuthToken]
} }
@ -79,7 +88,7 @@ object Login {
def invalidFactor: Result = InvalidFactor 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] { Resource.pure[F, Login[F]](new Login[F] {
private val logF = Logger.log4s(logger) private val logF = Logger.log4s(logger)
@ -94,8 +103,8 @@ object Login {
else if (at.requireSecondFactor) else if (at.requireSecondFactor)
logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F] logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F]
else Result.ok(at, None).pure[F] else Result.ok(at, None).pure[F]
case Left(_) => case Left(err) =>
Result.invalidAuth.pure[F] logF.debug(s"Invalid session token: $err") *> Result.invalidAuth.pure[F]
} }
def loginUserPass(config: Config)(up: UserPass): F[Result] = def loginUserPass(config: Config)(up: UserPass): F[Result] =
@ -103,10 +112,13 @@ object Login {
case Right(acc) => case Right(acc) =>
val okResult = val okResult =
for { for {
_ <- store.transact(RUser.updateLogin(acc)) require2FA <- store.transact(RTotp.isEnabled(acc))
token <- AuthToken.user(acc, config.serverSecret) _ <-
if (require2FA) ().pure[F]
else store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, require2FA, config.serverSecret)
rem <- OptionT rem <- OptionT
.whenF(up.rememberMe && config.rememberMe.enabled)( .whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)(
insertRememberToken(store, acc, config) insertRememberToken(store, acc, config)
) )
.value .value
@ -123,11 +135,54 @@ object Login {
Result.invalidAuth.pure[F] 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 loginRememberMe(config: Config)(token: String): F[Result] = {
def okResult(acc: AccountId) = def okResult(acc: AccountId) =
for { for {
_ <- store.transact(RUser.updateLogin(acc)) _ <- store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, config.serverSecret) token <- AuthToken.user(acc, false, config.serverSecret)
} yield Result.ok(token, None) } yield Result.ok(token, None)
def doLogin(rid: Ident) = def doLogin(rid: Ident) =

View File

@ -24,7 +24,8 @@ private[auth] object TokenUtil {
} }
def sign(cd: AuthToken, key: ByteVector): String = { 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") val mac = Mac.getInstance("HmacSHA1")
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64

View File

@ -74,10 +74,9 @@ object OTotp {
case object Failed extends ConfirmResult 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] { Resource.pure[F, OTotp[F]](new OTotp[F] {
val totp = Totp.default val log = Logger.log4s[F](logger)
val log = Logger.log4s[F](logger)
def initialize(accountId: AccountId): F[InitResult] = def initialize(accountId: AccountId): F[InitResult] =
for { for {

View File

@ -54,6 +54,9 @@ paths:
If successful, an authentication token is returned that can be If successful, an authentication token is returned that can be
used for subsequent calls to protected routes. 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: requestBody:
content: content:
application/json: application/json:
@ -66,6 +69,31 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/AuthResult" $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}: /open/checkfile/{id}/{checksum}:
get: get:
operationId: "open-checkfile-checksum-by-id" operationId: "open-checkfile-checksum-by-id"
@ -3994,6 +4022,21 @@ paths:
components: components:
schemas: 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: OtpState:
description: | description: |
The state for OTP for an account The state for OTP for an account

View File

@ -15,6 +15,7 @@ import docspell.restapi.model._
import docspell.restserver._ import docspell.restserver._
import docspell.restserver.auth._ import docspell.restserver.auth._
import docspell.restserver.http4s.ClientRequestInfo import docspell.restserver.http4s.ClientRequestInfo
import docspell.totp.OnetimePassword
import org.http4s._ import org.http4s._
import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityDecoder._
@ -27,14 +28,31 @@ object LoginRoutes {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {} val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._ import dsl._
HttpRoutes.of[F] { case req @ POST -> Root / "login" => HttpRoutes.of[F] {
for { case req @ POST -> Root / "two-factor" =>
up <- req.as[UserPass] for {
res <- S.loginUserPass(cfg.auth)( sf <- req.as[SecondFactor]
Login.UserPass(up.account, up.password, up.rememberMe.getOrElse(false)) tokenParsed = AuthToken.fromString(sf.token)
) resp <- tokenParsed match {
resp <- makeResponse(dsl, cfg, req, res, up.account) case Right(token) =>
} yield resp 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
} }
} }

View File

@ -70,6 +70,16 @@ object RTotp {
} }
} yield n } 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( def findEnabledByLogin(
accountId: AccountId, accountId: AccountId,
enabled: Boolean enabled: Boolean

View File

@ -141,6 +141,7 @@ module Api exposing
, startReIndex , startReIndex
, submitNotifyDueItems , submitNotifyDueItems
, toggleTags , toggleTags
, twoFactor
, unconfirmMultiple , unconfirmMultiple
, updateNotifyDueItems , updateNotifyDueItems
, updateScanMailbox , updateScanMailbox
@ -209,6 +210,7 @@ import Api.Model.Registration exposing (Registration)
import Api.Model.ScanMailboxSettings exposing (ScanMailboxSettings) import Api.Model.ScanMailboxSettings exposing (ScanMailboxSettings)
import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList) import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList)
import Api.Model.SearchStats exposing (SearchStats) import Api.Model.SearchStats exposing (SearchStats)
import Api.Model.SecondFactor exposing (SecondFactor)
import Api.Model.SentMails exposing (SentMails) import Api.Model.SentMails exposing (SentMails)
import Api.Model.SimpleMail exposing (SimpleMail) import Api.Model.SimpleMail exposing (SimpleMail)
import Api.Model.SourceAndTags exposing (SourceAndTags) 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 -> (Result Http.Error () -> msg) -> Cmd msg
logout flags receive = logout flags receive =
Http2.authPost Http2.authPost

View File

@ -28,6 +28,7 @@ type alias Texts =
, loginSuccessful : String , loginSuccessful : String
, noAccount : String , noAccount : String
, signupLink : String , signupLink : String
, otpCode : String
} }
@ -45,6 +46,7 @@ gb =
, loginSuccessful = "Login successful" , loginSuccessful = "Login successful"
, noAccount = "No account?" , noAccount = "No account?"
, signupLink = "Sign up!" , signupLink = "Sign up!"
, otpCode = "Authentication code"
} }
@ -62,4 +64,5 @@ de =
, loginSuccessful = "Anmeldung erfolgreich" , loginSuccessful = "Anmeldung erfolgreich"
, noAccount = "Kein Konto?" , noAccount = "Kein Konto?"
, signupLink = "Hier registrieren!" , 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 ist es gut, die Kriterien so zu gestalten, dass die
gleichen E-Mails möglichst nicht noch einmal eingelesen gleichen E-Mails möglichst nicht noch einmal eingelesen
werden.""" werden."""
, otpMenu = "Zwei Faktor Auth" , otpMenu = "Zwei-Faktor-Authentifizierung"
} }

View File

@ -6,7 +6,8 @@
module Page.Login.Data exposing module Page.Login.Data exposing
( FormState(..) ( AuthStep(..)
, FormState(..)
, Model , Model
, Msg(..) , Msg(..)
, emptyModel , emptyModel
@ -20,8 +21,10 @@ import Page exposing (Page(..))
type alias Model = type alias Model =
{ username : String { username : String
, password : String , password : String
, otp : String
, rememberMe : Bool , rememberMe : Bool
, formState : FormState , formState : FormState
, authStep : AuthStep
} }
@ -32,12 +35,19 @@ type FormState
| FormInitial | FormInitial
type AuthStep
= StepLogin
| StepOtp AuthResult
emptyModel : Model emptyModel : Model
emptyModel = emptyModel =
{ username = "" { username = ""
, password = "" , password = ""
, otp = ""
, rememberMe = False , rememberMe = False
, formState = FormInitial , formState = FormInitial
, authStep = StepLogin
} }
@ -47,3 +57,5 @@ type Msg
| ToggleRememberMe | ToggleRememberMe
| Authenticate | Authenticate
| AuthResp (Result Http.Error AuthResult) | AuthResp (Result Http.Error AuthResult)
| SetOtp String
| AuthOtp AuthResult

View File

@ -24,6 +24,9 @@ update referrer flags msg model =
SetPassword str -> SetPassword str ->
( { model | password = str }, Cmd.none, Nothing ) ( { model | password = str }, Cmd.none, Nothing )
SetOtp str ->
( { model | otp = str }, Cmd.none, Nothing )
ToggleRememberMe -> ToggleRememberMe ->
( { model | rememberMe = not model.rememberMe }, Cmd.none, Nothing ) ( { model | rememberMe = not model.rememberMe }, Cmd.none, Nothing )
@ -37,17 +40,33 @@ update referrer flags msg model =
in in
( model, Api.login flags userPass AuthResp, Nothing ) ( 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) -> AuthResp (Ok lr) ->
let let
gotoRef = gotoRef =
Maybe.withDefault HomePage referrer |> Page.goto Maybe.withDefault HomePage referrer |> Page.goto
in in
if lr.success then if lr.success && not lr.requireSecondFactor then
( { model | formState = AuthSuccess lr, password = "" } ( { model | formState = AuthSuccess lr, password = "" }
, Cmd.batch [ setAccount lr, gotoRef ] , Cmd.batch [ setAccount lr, gotoRef ]
, Just lr , Just lr
) )
else if lr.success && lr.requireSecondFactor then
( { model | formState = FormInitial, authStep = StepOtp lr, password = "" }
, Cmd.none
, Nothing
)
else else
( { model | formState = AuthFailed lr, password = "" } ( { model | formState = AuthFailed lr, password = "" }
, Ports.removeAccount () , Ports.removeAccount ()

View File

@ -7,6 +7,7 @@
module Page.Login.View2 exposing (viewContent, viewSidebar) module Page.Login.View2 exposing (viewContent, viewSidebar)
import Api.Model.AuthResult exposing (AuthResult)
import Api.Model.VersionInfo exposing (VersionInfo) import Api.Model.VersionInfo exposing (VersionInfo)
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.UiSettings exposing (UiSettings) 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" ] , div [ class "font-medium self-center text-xl sm:text-2xl" ]
[ text texts.loginToDocspell [ text texts.loginToDocspell
] ]
, Html.form , case model.authStep of
[ action "#" StepOtp token ->
, onSubmit Authenticate otpForm texts flags model token
, autocomplete False
] StepLogin ->
[ div [ class "flex flex-col mt-6" ] loginForm texts flags model
[ 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
]
]
]
] ]
, a , a
[ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90" [ 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 -> Html Msg
resultMessage texts model = resultMessage texts model =
case model.formState of case model.formState of