From c10c1fad724aa711ee5e261f1b1cbfce8b746a0d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 3 Dec 2020 19:45:06 +0100 Subject: [PATCH] Prepare remember-me authentication variant --- .../docspell/backend/auth/AuthToken.scala | 39 +++--------- .../scala/docspell/backend/auth/Login.scala | 61 ++++++++++++++++-- .../docspell/backend/auth/RememberToken.scala | 58 +++++++++++++++++ .../docspell/backend/auth/TokenUtil.scala | 39 ++++++++++++ .../src/main/resources/docspell-openapi.yml | 2 + .../src/main/resources/reference.conf | 6 ++ .../restserver/routes/LoginRoutes.scala | 6 +- .../db/migration/h2/V1.15.0__rememberme.sql | 6 ++ .../migration/mariadb/V1.15.0__rememberme.sql | 6 ++ .../postgresql/V1.15.0__rememberme.sql | 6 ++ .../scala/docspell/store/queries/QLogin.scala | 13 +++- .../docspell/store/records/RRememberMe.scala | 62 +++++++++++++++++++ .../webapp/src/main/elm/Page/Login/Data.elm | 3 + .../webapp/src/main/elm/Page/Login/Update.elm | 12 +++- .../webapp/src/main/elm/Page/Login/View.elm | 15 ++++- 15 files changed, 294 insertions(+), 40 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala create mode 100644 modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.15.0__rememberme.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RRememberMe.scala 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 2b986f88..ce92e248 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala @@ -1,24 +1,21 @@ package docspell.backend.auth import java.time.Instant -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec import cats.effect._ import cats.implicits._ import docspell.backend.Common -import docspell.backend.auth.AuthToken._ import docspell.common._ import scodec.bits.ByteVector -case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String) { - def asString = s"$millis-${b64enc(account.asString)}-$salt-$sig" +case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: String) { + def asString = s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$salt-$sig" def sigValid(key: ByteVector): Boolean = { - val newSig = AuthToken.sign(this, key) - AuthToken.constTimeEq(sig, newSig) + val newSig = TokenUtil.sign(this, key) + TokenUtil.constTimeEq(sig, newSig) } def sigInvalid(key: ByteVector): Boolean = !sigValid(key) @@ -27,7 +24,7 @@ case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String !isExpired(validity) def isExpired(validity: Duration): Boolean = { - val ends = Instant.ofEpochMilli(millis).plusMillis(validity.millis) + val ends = Instant.ofEpochMilli(nowMillis).plusMillis(validity.millis) Instant.now.isAfter(ends) } @@ -36,14 +33,13 @@ case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String } object AuthToken { - private val utf8 = java.nio.charset.StandardCharsets.UTF_8 def fromString(s: String): Either[String, AuthToken] = s.split("\\-", 4) match { case Array(ms, as, salt, sig) => for { - millis <- asInt(ms).toRight("Cannot read authenticator data") - acc <- b64dec(as).toRight("Cannot read authenticator data") + 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) @@ -56,27 +52,8 @@ object AuthToken { salt <- Common.genSaltString[F] millis = Instant.now.toEpochMilli cd = AuthToken(millis, accountId, salt, "") - sig = sign(cd, key) + sig = TokenUtil.sign(cd, key) } yield cd.copy(sig = sig) - private def sign(cd: AuthToken, key: ByteVector): String = { - val raw = cd.millis.toString + cd.account.asString + cd.salt - val mac = Mac.getInstance("HmacSHA1") - mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) - ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 - } - - private def b64enc(s: String): String = - ByteVector.view(s.getBytes(utf8)).toBase64 - - private def b64dec(s: String): Option[String] = - ByteVector.fromValidBase64(s).decodeUtf8.toOption - - private def asInt(s: String): Option[Long] = - Either.catchNonFatal(s.toLong).toOption - - private def constTimeEq(s1: String, s2: String): Boolean = - s1.zip(s2) - .foldLeft(true)({ case (r, (c1, c2)) => r & c1 == c2 }) & s1.length == s2.length } 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 7d495467..623f183e 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala @@ -2,6 +2,7 @@ package docspell.backend.auth import cats.effect._ import cats.implicits._ +import cats.data.OptionT import docspell.backend.auth.Login._ import docspell.common._ @@ -19,14 +20,27 @@ trait Login[F[_]] { def loginUserPass(config: Config)(up: UserPass): F[Result] + def loginRememberMe(config: Config)(token: Ident): F[Result] + + def loginSessionOrRememberMe( + config: Config + )(sessionKey: String, rememberId: Option[Ident]): F[Result] } object Login { private[this] val logger = getLogger - case class Config(serverSecret: ByteVector, sessionValid: Duration) + case class Config( + serverSecret: ByteVector, + sessionValid: Duration, + rememberMe: RememberMe + ) - case class UserPass(user: String, pass: String) { + case class RememberMe(enabled: Boolean, valid: Duration) { + val disabled = !enabled + } + + case class UserPass(user: String, pass: String, rememberMe: Boolean) { def hidePass: UserPass = if (pass.isEmpty) copy(pass = "") else copy(pass = "***") @@ -81,12 +95,51 @@ object Login { Result.invalidAuth.pure[F] } + def loginRememberMe(config: Config)(token: Ident): F[Result] = { + def okResult(acc: AccountId) = + store.transact(RUser.updateLogin(acc)) *> + AuthToken.user(acc, config.serverSecret).map(Result.ok) + + if (config.rememberMe.disabled) Result.invalidAuth.pure[F] + else + (for { + now <- OptionT.liftF(Timestamp.current[F]) + minTime = now - config.rememberMe.valid + data <- OptionT(store.transact(QLogin.findByRememberMe(token, minTime).value)) + _ <- OptionT.liftF( + Sync[F].delay(logger.info(s"Account lookup via remember me: $data")) + ) + res <- OptionT.liftF( + if (checkNoPassword(data)) okResult(data.account) + else Result.invalidAuth.pure[F] + ) + } yield res).getOrElse(Result.invalidAuth) + } + + def loginSessionOrRememberMe( + config: Config + )(sessionKey: String, rememberId: Option[Ident]): F[Result] = + loginSession(config)(sessionKey).flatMap { + case success @ Result.Ok(_) => (success: Result).pure[F] + case fail => + rememberId match { + case Some(rid) => + loginRememberMe(config)(rid) + case None => + fail.pure[F] + } + } + private def check(given: String)(data: QLogin.Data): Boolean = { + val passOk = BCrypt.checkpw(given, data.password.pass) + checkNoPassword(data) && passOk + } + + private def checkNoPassword(data: QLogin.Data): Boolean = { val collOk = data.collectiveState == CollectiveState.Active || data.collectiveState == CollectiveState.ReadOnly val userOk = data.userState == UserState.Active - val passOk = BCrypt.checkpw(given, data.password.pass) - collOk && userOk && passOk + collOk && userOk } }) } diff --git a/modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala new file mode 100644 index 00000000..b40d4c00 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala @@ -0,0 +1,58 @@ +package docspell.backend.auth + +import java.time.Instant + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.Common +import docspell.common._ + +import scodec.bits.ByteVector + +case class RememberToken(nowMillis: Long, rememberId: Ident, salt: String, sig: String) { + def asString = s"$nowMillis-${TokenUtil.b64enc(rememberId.id)}-$salt-$sig" + + def sigValid(key: ByteVector): Boolean = { + val newSig = TokenUtil.sign(this, key) + TokenUtil.constTimeEq(sig, newSig) + } + def sigInvalid(key: ByteVector): Boolean = + !sigValid(key) + + def notExpired(validity: Duration): Boolean = + !isExpired(validity) + + def isExpired(validity: Duration): Boolean = { + val ends = Instant.ofEpochMilli(nowMillis).plusMillis(validity.millis) + Instant.now.isAfter(ends) + } + + def validate(key: ByteVector, validity: Duration): Boolean = + sigValid(key) && notExpired(validity) +} + +object RememberToken { + + def fromString(s: String): Either[String, RememberToken] = + s.split("\\-", 4) match { + case Array(ms, as, salt, sig) => + for { + millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data") + rId <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data") + accId <- Ident.fromString(rId) + } yield RememberToken(millis, accId, salt, sig) + + case _ => + Left("Invalid authenticator") + } + + def user[F[_]: Sync](rememberId: Ident, key: ByteVector): F[RememberToken] = + for { + salt <- Common.genSaltString[F] + millis = Instant.now.toEpochMilli + cd = RememberToken(millis, rememberId, salt, "") + sig = TokenUtil.sign(cd, key) + } yield cd.copy(sig = sig) + +} diff --git a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala new file mode 100644 index 00000000..e1b3be0b --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala @@ -0,0 +1,39 @@ +package docspell.backend.auth + +import scodec.bits._ +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +import cats.implicits._ + +private[auth] object TokenUtil { + private val utf8 = java.nio.charset.StandardCharsets.UTF_8 + + def sign(cd: RememberToken, key: ByteVector): String = { + val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt + val mac = Mac.getInstance("HmacSHA1") + mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) + ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 + } + + def sign(cd: AuthToken, key: ByteVector): String = { + val raw = cd.nowMillis.toString + cd.account.asString + cd.salt + val mac = Mac.getInstance("HmacSHA1") + mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) + ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 + } + + def b64enc(s: String): String = + ByteVector.view(s.getBytes(utf8)).toBase64 + + def b64dec(s: String): Option[String] = + ByteVector.fromValidBase64(s).decodeUtf8.toOption + + def asInt(s: String): Option[Long] = + Either.catchNonFatal(s.toLong).toOption + + def constTimeEq(s1: String, s2: String): Boolean = + s1.zip(s2) + .foldLeft(true)({ case (r, (c1, c2)) => r & c1 == c2 }) & s1.length == s2.length + +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 70d7c979..99d6d884 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -5255,6 +5255,8 @@ components: type: string password: type: string + rememberMe: + type: boolean AuthResult: description: | The response to a authentication request. diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index d78d5233..d71ef97d 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -53,6 +53,12 @@ docspell.server { # How long an authentication token is valid. The web application # will get a new one periodically. session-valid = "5 minutes" + + remember-me { + enabled = true + # How long the remember me cookie/token is valid. + valid = "3 month" + } } # This endpoint allows to upload files to any collective. The 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 5b06472e..5408c619 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala @@ -23,8 +23,10 @@ object LoginRoutes { 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 <- 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/resources/db/migration/h2/V1.15.0__rememberme.sql b/modules/store/src/main/resources/db/migration/h2/V1.15.0__rememberme.sql new file mode 100644 index 00000000..13776080 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.15.0__rememberme.sql @@ -0,0 +1,6 @@ +CREATE TABLE "rememberme" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "username" varchar(254) not null, + "created" timestamp not null +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql new file mode 100644 index 00000000..910c0e2c --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql @@ -0,0 +1,6 @@ +CREATE TABLE `rememberme` ( + `id` varchar(254) not null primary key, + `cid` varchar(254) not null, + `username` varchar(254) not null, + `created` timestamp not null +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql new file mode 100644 index 00000000..13776080 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql @@ -0,0 +1,6 @@ +CREATE TABLE "rememberme" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "username" varchar(254) not null, + "created" timestamp not null +); diff --git a/modules/store/src/main/scala/docspell/store/queries/QLogin.scala b/modules/store/src/main/scala/docspell/store/queries/QLogin.scala index da5f6637..4554772d 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QLogin.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QLogin.scala @@ -1,10 +1,12 @@ package docspell.store.queries +import cats.data.OptionT + import docspell.common._ import docspell.store.impl.Implicits._ import docspell.store.records.RCollective.{Columns => CC} import docspell.store.records.RUser.{Columns => UC} -import docspell.store.records.{RCollective, RUser} +import docspell.store.records.{RCollective, RRememberMe, RUser} import doobie._ import doobie.implicits._ @@ -37,4 +39,13 @@ object QLogin { logger.trace(s"SQL : $sql") sql.query[Data].option } + + def findByRememberMe( + rememberId: Ident, + minCreated: Timestamp + ): OptionT[ConnectionIO, Data] = + for { + rem <- OptionT(RRememberMe.useRememberMe(rememberId, minCreated)) + acc <- OptionT(findUser(rem.accountId)) + } yield acc } diff --git a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala new file mode 100644 index 00000000..38ce2ea0 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala @@ -0,0 +1,62 @@ +package docspell.store.records + +import cats.effect.Sync +import cats.implicits._ + +import docspell.common._ +import docspell.store.impl.Implicits._ +import docspell.store.impl._ + +import doobie._ +import doobie.implicits._ + +case class RRememberMe(id: Ident, accountId: AccountId, created: Timestamp) {} + +object RRememberMe { + + val table = fr"rememberme" + + object Columns { + val id = Column("id") + val cid = Column("cid") + val username = Column("username") + val created = Column("created") + val all = List(id, cid, username, created) + } + import Columns._ + + def generate[F[_]: Sync](account: AccountId): F[RRememberMe] = + for { + c <- Timestamp.current[F] + i <- Ident.randomId[F] + } yield RRememberMe(i, account, c) + + def insert(v: RRememberMe): ConnectionIO[Int] = + insertRow( + table, + all, + fr"${v.id},${v.accountId.collective},${v.accountId.user},${v.created}" + ).update.run + + def insertNew(acc: AccountId): ConnectionIO[RRememberMe] = + generate[ConnectionIO](acc).flatMap(v => insert(v).map(_ => v)) + + def findById(rid: Ident): ConnectionIO[Option[RRememberMe]] = + selectSimple(all, table, id.is(rid)).query[RRememberMe].option + + def delete(rid: Ident): ConnectionIO[Int] = + deleteFrom(table, id.is(rid)).update.run + + def useRememberMe(rid: Ident, minCreated: Timestamp): ConnectionIO[Option[RRememberMe]] = { + val get = selectSimple(all, table, and(id.is(rid), created.isGt(minCreated))) + .query[RRememberMe] + .option + for { + inv <- get + _ <- delete(rid) + } yield inv + } + + def deleteOlderThan(ts: Timestamp): ConnectionIO[Int] = + deleteFrom(table, created.isLt(ts)).update.run +} diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm index 8e41309f..dd4634c7 100644 --- a/modules/webapp/src/main/elm/Page/Login/Data.elm +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -12,6 +12,7 @@ import Page exposing (Page(..)) type alias Model = { username : String , password : String + , rememberMe : Bool , result : Maybe AuthResult } @@ -20,6 +21,7 @@ emptyModel : Model emptyModel = { username = "" , password = "" + , rememberMe = False , result = Nothing } @@ -27,5 +29,6 @@ emptyModel = type Msg = SetUsername String | SetPassword String + | ToggleRememberMe | Authenticate | AuthResp (Result Http.Error AuthResult) diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm index 539d2f02..fb28ddfb 100644 --- a/modules/webapp/src/main/elm/Page/Login/Update.elm +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -19,8 +19,18 @@ update referrer flags msg model = SetPassword str -> ( { model | password = str }, Cmd.none, Nothing ) + ToggleRememberMe -> + ( { model | rememberMe = not model.rememberMe }, Cmd.none, Nothing ) + Authenticate -> - ( model, Api.login flags (UserPass model.username model.password) AuthResp, Nothing ) + let + userPass = + { account = model.username + , password = model.password + , rememberMe = Just model.rememberMe + } + in + ( model, Api.login flags userPass AuthResp, Nothing ) AuthResp (Ok lr) -> let diff --git a/modules/webapp/src/main/elm/Page/Login/View.elm b/modules/webapp/src/main/elm/Page/Login/View.elm index d5f9bc0e..1ca4a666 100644 --- a/modules/webapp/src/main/elm/Page/Login/View.elm +++ b/modules/webapp/src/main/elm/Page/Login/View.elm @@ -3,7 +3,7 @@ module Page.Login.View exposing (view) import Data.Flags exposing (Flags) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onInput, onSubmit) +import Html.Events exposing (onCheck, onInput, onSubmit) import Page exposing (Page(..)) import Page.Login.Data exposing (..) @@ -59,6 +59,19 @@ view flags model = , i [ class "lock icon" ] [] ] ] + , div [ class "field" ] + [ div [ class "ui checkbox" ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleRememberMe) + , checked model.rememberMe + ] + [] + , label [] + [ text "Remember Me" + ] + ] + ] , button [ class "ui primary fluid button" , type_ "submit"