diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 55d7bf81..730bcb3f 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -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) diff --git a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala index 531a6616..7faa5f85 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala @@ -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) diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala index 2fda1faf..721c362c 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala @@ -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) = diff --git a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala index 4ced29a7..f585c2df 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala @@ -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 diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala b/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala index 634579a4..99c18cbe 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OTotp.scala @@ -74,10 +74,9 @@ object OTotp { case object Failed extends ConfirmResult } - def apply[F[_]: Async](store: Store[F]): Resource[F, OTotp[F]] = + def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, OTotp[F]] = Resource.pure[F, OTotp[F]](new OTotp[F] { - val totp = Totp.default - val log = Logger.log4s[F](logger) + val log = Logger.log4s[F](logger) def initialize(accountId: AccountId): F[InitResult] = for { diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 5475bf80..9e12d895 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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 diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala index 3067e912..947ac709 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala @@ -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 } } diff --git a/modules/store/src/main/scala/docspell/store/records/RTotp.scala b/modules/store/src/main/scala/docspell/store/records/RTotp.scala index 38612012..03c07fa9 100644 --- a/modules/store/src/main/scala/docspell/store/records/RTotp.scala +++ b/modules/store/src/main/scala/docspell/store/records/RTotp.scala @@ -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 diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 18ed9cbd..c9486ec4 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Messages/Page/Login.elm b/modules/webapp/src/main/elm/Messages/Page/Login.elm index a9b0bdb7..10f8c4bf 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Login.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Login.elm @@ -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" } diff --git a/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm b/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm index 2089c17a..dd4724e9 100644 --- a/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm +++ b/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm @@ -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" } diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm index 1d8ffd79..33bd3913 100644 --- a/modules/webapp/src/main/elm/Page/Login/Data.elm +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm index 56882fcd..6f3c82e6 100644 --- a/modules/webapp/src/main/elm/Page/Login/Update.elm +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -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 () diff --git a/modules/webapp/src/main/elm/Page/Login/View2.elm b/modules/webapp/src/main/elm/Page/Login/View2.elm index 9ac3c8db..c5e06a2b 100644 --- a/modules/webapp/src/main/elm/Page/Login/View2.elm +++ b/modules/webapp/src/main/elm/Page/Login/View2.elm @@ -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