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

View File

@ -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 = "<none>")
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
}
})
}

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
password:
type: string
rememberMe:
type: boolean
AuthResult:
description: |
The response to a authentication request.

View File

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

View File

@ -24,7 +24,9 @@ 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))
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

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

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 =
{ 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)

View File

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

View File

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