Prepare remember-me authentication variant

This commit is contained in:
Eike Kettner 2020-12-03 19:45:06 +01:00
parent 07f3e08f35
commit c10c1fad72
15 changed files with 294 additions and 40 deletions

View File

@ -1,24 +1,21 @@
package docspell.backend.auth package docspell.backend.auth
import java.time.Instant import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.backend.Common import docspell.backend.Common
import docspell.backend.auth.AuthToken._
import docspell.common._ import docspell.common._
import scodec.bits.ByteVector import scodec.bits.ByteVector
case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String) { case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: String) {
def asString = s"$millis-${b64enc(account.asString)}-$salt-$sig" def asString = s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$salt-$sig"
def sigValid(key: ByteVector): Boolean = { def sigValid(key: ByteVector): Boolean = {
val newSig = AuthToken.sign(this, key) val newSig = TokenUtil.sign(this, key)
AuthToken.constTimeEq(sig, newSig) TokenUtil.constTimeEq(sig, newSig)
} }
def sigInvalid(key: ByteVector): Boolean = def sigInvalid(key: ByteVector): Boolean =
!sigValid(key) !sigValid(key)
@ -27,7 +24,7 @@ case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String
!isExpired(validity) !isExpired(validity)
def isExpired(validity: Duration): Boolean = { 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) Instant.now.isAfter(ends)
} }
@ -36,14 +33,13 @@ case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String
} }
object AuthToken { object AuthToken {
private val utf8 = java.nio.charset.StandardCharsets.UTF_8
def fromString(s: String): Either[String, AuthToken] = def fromString(s: String): Either[String, AuthToken] =
s.split("\\-", 4) match { s.split("\\-", 4) match {
case Array(ms, as, salt, sig) => case Array(ms, as, salt, sig) =>
for { for {
millis <- asInt(ms).toRight("Cannot read authenticator data") millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data")
acc <- b64dec(as).toRight("Cannot read authenticator data") acc <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data")
accId <- AccountId.parse(acc) accId <- AccountId.parse(acc)
} yield AuthToken(millis, accId, salt, sig) } yield AuthToken(millis, accId, salt, sig)
@ -56,27 +52,8 @@ object AuthToken {
salt <- Common.genSaltString[F] salt <- Common.genSaltString[F]
millis = Instant.now.toEpochMilli millis = Instant.now.toEpochMilli
cd = AuthToken(millis, accountId, salt, "") cd = AuthToken(millis, accountId, salt, "")
sig = sign(cd, key) sig = TokenUtil.sign(cd, key)
} yield cd.copy(sig = sig) } 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
} }

View File

@ -2,6 +2,7 @@ package docspell.backend.auth
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
import cats.data.OptionT
import docspell.backend.auth.Login._ import docspell.backend.auth.Login._
import docspell.common._ import docspell.common._
@ -19,14 +20,27 @@ trait Login[F[_]] {
def loginUserPass(config: Config)(up: UserPass): F[Result] 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 { object Login {
private[this] val logger = getLogger 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 = def hidePass: UserPass =
if (pass.isEmpty) copy(pass = "<none>") if (pass.isEmpty) copy(pass = "<none>")
else copy(pass = "***") else copy(pass = "***")
@ -81,12 +95,51 @@ object Login {
Result.invalidAuth.pure[F] 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 = { 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 || val collOk = data.collectiveState == CollectiveState.Active ||
data.collectiveState == CollectiveState.ReadOnly data.collectiveState == CollectiveState.ReadOnly
val userOk = data.userState == UserState.Active val userOk = data.userState == UserState.Active
val passOk = BCrypt.checkpw(given, data.password.pass) collOk && userOk
collOk && userOk && passOk
} }
}) })
} }

View File

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

View File

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

View File

@ -5255,6 +5255,8 @@ components:
type: string type: string
password: password:
type: string type: string
rememberMe:
type: boolean
AuthResult: AuthResult:
description: | description: |
The response to a authentication request. The response to a authentication request.

View File

@ -53,6 +53,12 @@ docspell.server {
# How long an authentication token is valid. The web application # How long an authentication token is valid. The web application
# will get a new one periodically. # will get a new one periodically.
session-valid = "5 minutes" 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 # This endpoint allows to upload files to any collective. The

View File

@ -23,8 +23,10 @@ object LoginRoutes {
HttpRoutes.of[F] { case req @ POST -> Root / "login" => HttpRoutes.of[F] { case req @ POST -> Root / "login" =>
for { for {
up <- req.as[UserPass] up <- req.as[UserPass]
res <- S.loginUserPass(cfg.auth)(Login.UserPass(up.account, up.password)) res <- S.loginUserPass(cfg.auth)(
Login.UserPass(up.account, up.password, up.rememberMe.getOrElse(false))
)
resp <- makeResponse(dsl, cfg, req, res, up.account) resp <- makeResponse(dsl, cfg, req, res, up.account)
} yield resp } yield resp
} }

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
package docspell.store.queries package docspell.store.queries
import cats.data.OptionT
import docspell.common._ import docspell.common._
import docspell.store.impl.Implicits._ import docspell.store.impl.Implicits._
import docspell.store.records.RCollective.{Columns => CC} import docspell.store.records.RCollective.{Columns => CC}
import docspell.store.records.RUser.{Columns => UC} import docspell.store.records.RUser.{Columns => UC}
import docspell.store.records.{RCollective, RUser} import docspell.store.records.{RCollective, RRememberMe, RUser}
import doobie._ import doobie._
import doobie.implicits._ import doobie.implicits._
@ -37,4 +39,13 @@ object QLogin {
logger.trace(s"SQL : $sql") logger.trace(s"SQL : $sql")
sql.query[Data].option 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
} }

View File

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

View File

@ -12,6 +12,7 @@ import Page exposing (Page(..))
type alias Model = type alias Model =
{ username : String { username : String
, password : String , password : String
, rememberMe : Bool
, result : Maybe AuthResult , result : Maybe AuthResult
} }
@ -20,6 +21,7 @@ emptyModel : Model
emptyModel = emptyModel =
{ username = "" { username = ""
, password = "" , password = ""
, rememberMe = False
, result = Nothing , result = Nothing
} }
@ -27,5 +29,6 @@ emptyModel =
type Msg type Msg
= SetUsername String = SetUsername String
| SetPassword String | SetPassword String
| ToggleRememberMe
| Authenticate | Authenticate
| AuthResp (Result Http.Error AuthResult) | AuthResp (Result Http.Error AuthResult)

View File

@ -19,8 +19,18 @@ update referrer flags msg model =
SetPassword str -> SetPassword str ->
( { model | password = str }, Cmd.none, Nothing ) ( { model | password = str }, Cmd.none, Nothing )
ToggleRememberMe ->
( { model | rememberMe = not model.rememberMe }, Cmd.none, Nothing )
Authenticate -> 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) -> AuthResp (Ok lr) ->
let let

View File

@ -3,7 +3,7 @@ module Page.Login.View exposing (view)
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onInput, onSubmit) import Html.Events exposing (onCheck, onInput, onSubmit)
import Page exposing (Page(..)) import Page exposing (Page(..))
import Page.Login.Data exposing (..) import Page.Login.Data exposing (..)
@ -59,6 +59,19 @@ view flags model =
, i [ class "lock icon" ] [] , i [ class "lock icon" ] []
] ]
] ]
, div [ class "field" ]
[ div [ class "ui checkbox" ]
[ input
[ type_ "checkbox"
, onCheck (\_ -> ToggleRememberMe)
, checked model.rememberMe
]
[]
, label []
[ text "Remember Me"
]
]
]
, button , button
[ class "ui primary fluid button" [ class "ui primary fluid button"
, type_ "submit" , type_ "submit"