From 1afc005a6c29e1fa078d052a399c02d959fa607d Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Tue, 31 Aug 2021 21:29:07 +0200
Subject: [PATCH] Adopt login process for two-factor auth

---
 .../scala/docspell/backend/BackendApp.scala   |   5 +-
 .../docspell/backend/auth/AuthToken.scala     |  10 +-
 .../scala/docspell/backend/auth/Login.scala   |  71 ++++-
 .../docspell/backend/auth/TokenUtil.scala     |   3 +-
 .../scala/docspell/backend/ops/OTotp.scala    |   5 +-
 .../src/main/resources/docspell-openapi.yml   |  43 +++
 .../restserver/routes/LoginRoutes.scala       |  34 ++-
 .../scala/docspell/store/records/RTotp.scala  |  10 +
 modules/webapp/src/main/elm/Api.elm           |  11 +
 .../src/main/elm/Messages/Page/Login.elm      |   3 +
 .../main/elm/Messages/Page/UserSettings.elm   |   2 +-
 .../webapp/src/main/elm/Page/Login/Data.elm   |  14 +-
 .../webapp/src/main/elm/Page/Login/Update.elm |  21 +-
 .../webapp/src/main/elm/Page/Login/View2.elm  | 250 +++++++++++-------
 14 files changed, 356 insertions(+), 126 deletions(-)

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