Merge pull request #495 from eikek/remember-me

Remember me
This commit is contained in:
mergify[bot] 2020-12-04 21:16:20 +00:00 committed by GitHub
commit 5eaa8e9ac6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 520 additions and 68 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,23 +24,23 @@ 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)
} }
def validate(key: ByteVector, validity: Duration): Boolean = def validate(key: ByteVector, validity: Duration): Boolean =
sigValid(key) && notExpired(validity) sigValid(key) && notExpired(validity)
} }
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 +53,14 @@ 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 = { def update[F[_]: Sync](token: AuthToken, key: ByteVector): F[AuthToken] =
val raw = cd.millis.toString + cd.account.asString + cd.salt for {
val mac = Mac.getInstance("HmacSHA1") now <- Timestamp.current[F]
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) salt <- Common.genSaltString[F]
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 data = AuthToken(now.toMillis, token.account, salt, "")
} sig = TokenUtil.sign(data, key)
} yield data.copy(sig = sig)
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

@ -1,5 +1,6 @@
package docspell.backend.auth package docspell.backend.auth
import cats.data.OptionT
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
@ -7,9 +8,9 @@ import docspell.backend.auth.Login._
import docspell.common._ import docspell.common._
import docspell.store.Store import docspell.store.Store
import docspell.store.queries.QLogin import docspell.store.queries.QLogin
import docspell.store.records.RUser import docspell.store.records._
import org.log4s._ import org.log4s.getLogger
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import scodec.bits.ByteVector import scodec.bits.ByteVector
@ -19,14 +20,29 @@ trait Login[F[_]] {
def loginUserPass(config: Config)(up: UserPass): F[Result] def loginUserPass(config: Config)(up: UserPass): F[Result]
def loginRememberMe(config: Config)(token: String): F[Result]
def loginSessionOrRememberMe(
config: Config
)(sessionKey: String, rememberId: Option[String]): F[Result]
def removeRememberToken(token: String): F[Int]
} }
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 = "***")
@ -36,7 +52,8 @@ object Login {
def toEither: Either[String, AuthToken] def toEither: Either[String, AuthToken]
} }
object Result { object Result {
case class Ok(session: AuthToken) extends Result { case class Ok(session: AuthToken, rememberToken: Option[RememberToken])
extends Result {
val toEither = Right(session) val toEither = Right(session)
} }
case object InvalidAuth extends Result { case object InvalidAuth extends Result {
@ -46,20 +63,25 @@ object Login {
val toEither = Left("Authentication failed.") val toEither = Left("Authentication failed.")
} }
def ok(session: AuthToken): Result = Ok(session) def ok(session: AuthToken, remember: Option[RememberToken]): Result =
def invalidAuth: Result = InvalidAuth Ok(session, remember)
def invalidTime: Result = InvalidTime def invalidAuth: Result = InvalidAuth
def invalidTime: Result = InvalidTime
} }
def apply[F[_]: Effect](store: Store[F]): Resource[F, Login[F]] = def apply[F[_]: Effect](store: Store[F]): Resource[F, Login[F]] =
Resource.pure[F, Login[F]](new Login[F] { Resource.pure[F, Login[F]](new Login[F] {
private val logF = Logger.log4s(logger)
def loginSession(config: Config)(sessionKey: String): F[Result] = def loginSession(config: Config)(sessionKey: String): F[Result] =
AuthToken.fromString(sessionKey) match { AuthToken.fromString(sessionKey) match {
case Right(at) => case Right(at) =>
if (at.sigInvalid(config.serverSecret)) Result.invalidAuth.pure[F] if (at.sigInvalid(config.serverSecret))
else if (at.isExpired(config.sessionValid)) Result.invalidTime.pure[F] logF.warn("Cookie signature invalid!") *> Result.invalidAuth.pure[F]
else Result.ok(at).pure[F] else if (at.isExpired(config.sessionValid))
logF.debug("Auth Cookie expired") *> Result.invalidTime.pure[F]
else Result.ok(at, None).pure[F]
case Left(_) => case Left(_) =>
Result.invalidAuth.pure[F] Result.invalidAuth.pure[F]
} }
@ -68,8 +90,15 @@ object Login {
AccountId.parse(up.user) match { AccountId.parse(up.user) match {
case Right(acc) => case Right(acc) =>
val okResult = val okResult =
store.transact(RUser.updateLogin(acc)) *> for {
AuthToken.user(acc, config.serverSecret).map(Result.ok) _ <- store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, config.serverSecret)
rem <- OptionT
.whenF(up.rememberMe && config.rememberMe.enabled)(
insertRememberToken(store, acc, config)
)
.value
} yield Result.ok(token, rem)
for { for {
data <- store.transact(QLogin.findUser(acc)) data <- store.transact(QLogin.findUser(acc))
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data")) _ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
@ -78,15 +107,98 @@ object Login {
else Result.invalidAuth.pure[F] else Result.invalidAuth.pure[F]
} yield res } yield res
case Left(_) => case Left(_) =>
Result.invalidAuth.pure[F] logF.info(s"User authentication failed for: ${up.hidePass}") *>
Result.invalidAuth.pure[F]
} }
def loginRememberMe(config: Config)(token: String): F[Result] = {
def okResult(acc: AccountId) =
for {
_ <- store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, config.serverSecret)
} yield Result.ok(token, None)
def doLogin(rid: Ident) =
(for {
now <- OptionT.liftF(Timestamp.current[F])
minTime = now - config.rememberMe.valid
data <- OptionT(store.transact(QLogin.findByRememberMe(rid, minTime).value))
_ <- OptionT.liftF(
logF.warn(s"Account lookup via remember me: $data")
)
res <- OptionT.liftF(
if (checkNoPassword(data)) okResult(data.account)
else Result.invalidAuth.pure[F]
)
} yield res).getOrElseF(
logF.info("RememberMe not found in database.") *> Result.invalidAuth.pure[F]
)
if (config.rememberMe.disabled)
logF.info(
"Remember me auth tried, but disabled in config."
) *> Result.invalidAuth.pure[F]
else
RememberToken.fromString(token) match {
case Right(rt) =>
if (rt.sigInvalid(config.serverSecret))
logF.warn(
s"RememberMe cookie signature invalid ($rt)!"
) *> Result.invalidAuth
.pure[F]
else if (rt.isExpired(config.rememberMe.valid))
logF.info(s"RememberMe cookie expired ($rt).") *> Result.invalidTime
.pure[F]
else doLogin(rt.rememberId)
case Left(err) =>
logF.info(s"RememberMe cookie was invalid: $err") *> Result.invalidAuth
.pure[F]
}
}
def loginSessionOrRememberMe(
config: Config
)(sessionKey: String, rememberToken: Option[String]): F[Result] =
loginSession(config)(sessionKey).flatMap {
case success @ Result.Ok(_, _) => (success: Result).pure[F]
case fail =>
rememberToken match {
case Some(token) =>
loginRememberMe(config)(token)
case None =>
fail.pure[F]
}
}
def removeRememberToken(token: String): F[Int] =
RememberToken.fromString(token) match {
case Right(rt) =>
store.transact(RRememberMe.delete(rt.rememberId))
case Left(_) =>
0.pure[F]
}
private def insertRememberToken(
store: Store[F],
acc: AccountId,
config: Config
): F[RememberToken] =
for {
rme <- RRememberMe.generate[F](acc)
_ <- store.transact(RRememberMe.insert(rme))
token <- RememberToken.user(rme.id, config.serverSecret)
} yield token
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,40 @@
package docspell.backend.auth
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import cats.implicits._
import scodec.bits._
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.fromBase64(s).flatMap(_.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

@ -142,6 +142,17 @@ docspell.joex {
older-than = "30 days" older-than = "30 days"
} }
# This task removes expired remember-me tokens. The timespan
# should be greater than the `valid` time in the restserver
# config.
cleanup-remember-me = {
# Whether the job is enabled.
enabled = true
# The minimum age of tokens to be deleted.
older-than = "30 days"
}
# Jobs store their log output in the database. Normally this data # Jobs store their log output in the database. Normally this data
# is only interesting for some period of time. The processing logs # is only interesting for some period of time. The processing logs
# of old files can be removed eventually. # of old files can be removed eventually.

View File

@ -0,0 +1,25 @@
package docspell.joex.hk
import cats.effect._
import cats.implicits._
import docspell.common._
import docspell.joex.scheduler.Task
import docspell.store.records._
object CleanupRememberMeTask {
def apply[F[_]: Sync](cfg: HouseKeepingConfig.CleanupRememberMe): Task[F, Unit, Unit] =
Task { ctx =>
if (cfg.enabled)
for {
now <- Timestamp.current[F]
ts = now - cfg.olderThan
_ <- ctx.logger.info(s"Cleanup remember-me tokens older than $ts")
n <- ctx.store.transact(RRememberMe.deleteOlderThan(ts))
_ <- ctx.logger.info(s"Removed $n tokens")
} yield ()
else
ctx.logger.info("CleanupRememberMe task is disabled in the configuration")
}
}

View File

@ -8,7 +8,8 @@ import com.github.eikek.calev.CalEvent
case class HouseKeepingConfig( case class HouseKeepingConfig(
schedule: CalEvent, schedule: CalEvent,
cleanupInvites: CleanupInvites, cleanupInvites: CleanupInvites,
cleanupJobs: CleanupJobs cleanupJobs: CleanupJobs,
cleanupRememberMe: CleanupRememberMe
) )
object HouseKeepingConfig { object HouseKeepingConfig {
@ -17,4 +18,6 @@ object HouseKeepingConfig {
case class CleanupJobs(enabled: Boolean, olderThan: Duration, deleteBatch: Int) case class CleanupJobs(enabled: Boolean, olderThan: Duration, deleteBatch: Int)
case class CleanupRememberMe(enabled: Boolean, olderThan: Duration)
} }

View File

@ -19,6 +19,7 @@ object HouseKeepingTask {
Task Task
.log[F, Unit](_.info(s"Running house-keeping task now")) .log[F, Unit](_.info(s"Running house-keeping task now"))
.flatMap(_ => CleanupInvitesTask(cfg.houseKeeping.cleanupInvites)) .flatMap(_ => CleanupInvitesTask(cfg.houseKeeping.cleanupInvites))
.flatMap(_ => CleanupRememberMeTask(cfg.houseKeeping.cleanupRememberMe))
.flatMap(_ => CleanupJobsTask(cfg.houseKeeping.cleanupJobs)) .flatMap(_ => CleanupJobsTask(cfg.houseKeeping.cleanupJobs))
def onCancel[F[_]: Sync]: Task[F, Unit, Unit] = def onCancel[F[_]: Sync]: Task[F, Unit, Unit] =

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 = "30 days"
}
} }
# This endpoint allows to upload files to any collective. The # This endpoint allows to upload files to any collective. The

View File

@ -64,7 +64,7 @@ object RestServer {
token: AuthToken token: AuthToken
): HttpRoutes[F] = ): HttpRoutes[F] =
Router( Router(
"auth" -> LoginRoutes.session(restApp.backend.login, cfg), "auth" -> LoginRoutes.session(restApp.backend.login, cfg, token),
"tag" -> TagRoutes(restApp.backend, token), "tag" -> TagRoutes(restApp.backend, token),
"equipment" -> EquipmentRoutes(restApp.backend, token), "equipment" -> EquipmentRoutes(restApp.backend, token),
"organization" -> OrganizationRoutes(restApp.backend, token), "organization" -> OrganizationRoutes(restApp.backend, token),

View File

@ -23,6 +23,10 @@ case class CookieData(auth: AuthToken) {
secure = sec secure = sec
) )
} }
def addCookie[F[_]](baseUrl: LenientUri)(resp: Response[F]): Response[F] =
resp.addCookie(asCookie(baseUrl))
} }
object CookieData { object CookieData {
val cookieName = "docspell_auth" val cookieName = "docspell_auth"

View File

@ -0,0 +1,51 @@
package docspell.restserver.auth
import docspell.backend.auth._
import docspell.common._
import org.http4s._
case class RememberCookieData(token: RememberToken) {
def asString: String = token.asString
def asCookie(config: Login.RememberMe, baseUrl: LenientUri): ResponseCookie = {
val sec = baseUrl.scheme.exists(_.endsWith("s"))
val path = baseUrl.path / "api" / "v1"
ResponseCookie(
name = RememberCookieData.cookieName,
content = asString,
domain = None,
path = Some(path.asString),
httpOnly = true,
secure = sec,
maxAge = Some(config.valid.seconds)
)
}
def addCookie[F[_]](cfg: Login.RememberMe, baseUrl: LenientUri)(
resp: Response[F]
): Response[F] =
resp.addCookie(asCookie(cfg, baseUrl))
}
object RememberCookieData {
val cookieName = "docspell_remember"
def fromCookie[F[_]](req: Request[F]): Option[String] =
for {
header <- headers.Cookie.from(req.headers)
cookie <- header.values.toList.find(_.name == cookieName)
} yield cookie.content
def delete(baseUrl: LenientUri): ResponseCookie =
ResponseCookie(
cookieName,
"",
domain = None,
path = Some(baseUrl.path / "api" / "v1").map(_.asString),
httpOnly = true,
secure = baseUrl.scheme.exists(_.endsWith("s")),
maxAge = Some(-1)
)
}

View File

@ -15,11 +15,19 @@ import org.http4s.server._
object Authenticate { object Authenticate {
def authenticateRequest[F[_]: Effect]( def authenticateRequest[F[_]: Effect](
auth: String => F[Login.Result] auth: (String, Option[String]) => F[Login.Result]
)(req: Request[F]): F[Login.Result] = )(req: Request[F]): F[Login.Result] =
CookieData.authenticator(req) match { CookieData.authenticator(req) match {
case Right(str) => auth(str) case Right(str) =>
case Left(_) => Login.Result.invalidAuth.pure[F] val rememberMe = RememberCookieData.fromCookie(req)
auth(str, rememberMe)
case Left(_) =>
RememberCookieData.fromCookie(req) match {
case Some(rc) =>
auth("", rc.some)
case None =>
Login.Result.invalidAuth.pure[F]
}
} }
def of[F[_]: Effect](S: Login[F], cfg: Login.Config)( def of[F[_]: Effect](S: Login[F], cfg: Login.Config)(
@ -28,7 +36,7 @@ object Authenticate {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {} val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._ import dsl._
val authUser = getUser[F](S.loginSession(cfg)) val authUser = getUser[F](S.loginSessionOrRememberMe(cfg))
val onFailure: AuthedRoutes[String, F] = val onFailure: AuthedRoutes[String, F] =
Kleisli(req => OptionT.liftF(Forbidden(req.context))) Kleisli(req => OptionT.liftF(Forbidden(req.context)))
@ -45,7 +53,7 @@ object Authenticate {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {} val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._ import dsl._
val authUser = getUser[F](S.loginSession(cfg)) val authUser = getUser[F](S.loginSessionOrRememberMe(cfg))
val onFailure: AuthedRoutes[String, F] = val onFailure: AuthedRoutes[String, F] =
Kleisli(req => OptionT.liftF(Forbidden(req.context))) Kleisli(req => OptionT.liftF(Forbidden(req.context)))
@ -57,7 +65,7 @@ object Authenticate {
} }
private def getUser[F[_]: Effect]( private def getUser[F[_]: Effect](
auth: String => F[Login.Result] auth: (String, Option[String]) => F[Login.Result]
): Kleisli[F, Request[F], Either[String, AuthToken]] = ): Kleisli[F, Request[F], Either[String, AuthToken]] =
Kleisli(r => authenticateRequest(auth)(r).map(_.toEither)) Kleisli(r => authenticateRequest(auth)(r).map(_.toEither))
} }

View File

@ -23,25 +23,33 @@ 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
} }
} }
def session[F[_]: Effect](S: Login[F], cfg: Config): HttpRoutes[F] = { def session[F[_]: Effect](S: Login[F], cfg: Config, token: AuthToken): HttpRoutes[F] = {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {} val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._ import dsl._
HttpRoutes.of[F] { HttpRoutes.of[F] {
case req @ POST -> Root / "session" => case req @ POST -> Root / "session" =>
Authenticate AuthToken
.authenticateRequest(S.loginSession(cfg.auth))(req) .update(token, cfg.auth.serverSecret)
.map(newToken => Login.Result.ok(newToken, None))
.flatMap(res => makeResponse(dsl, cfg, req, res, "")) .flatMap(res => makeResponse(dsl, cfg, req, res, ""))
case req @ POST -> Root / "logout" => case req @ POST -> Root / "logout" =>
Ok().map(_.addCookie(CookieData.deleteCookie(getBaseUrl(cfg, req)))) for {
_ <- RememberCookieData.fromCookie(req).traverse(S.removeRememberToken)
res <- Ok()
} yield res
.removeCookie(CookieData.deleteCookie(getBaseUrl(cfg, req)))
.removeCookie(RememberCookieData.delete(getBaseUrl(cfg, req)))
} }
} }
@ -57,9 +65,10 @@ object LoginRoutes {
): F[Response[F]] = { ): F[Response[F]] = {
import dsl._ import dsl._
res match { res match {
case Login.Result.Ok(token) => case Login.Result.Ok(token, remember) =>
val cd = CookieData(token)
val rem = remember.map(RememberCookieData.apply)
for { for {
cd <- AuthToken.user(token.account, cfg.auth.serverSecret).map(CookieData.apply)
resp <- Ok( resp <- Ok(
AuthResult( AuthResult(
token.account.collective.id, token.account.collective.id,
@ -69,7 +78,13 @@ object LoginRoutes {
Some(cd.asString), Some(cd.asString),
cfg.auth.sessionValid.millis cfg.auth.sessionValid.millis
) )
).map(_.addCookie(cd.asCookie(getBaseUrl(cfg, req)))) ).map(cd.addCookie(getBaseUrl(cfg, req)))
.map(resp =>
rem
.map(_.addCookie(cfg.auth.rememberMe, getBaseUrl(cfg, req))(resp))
.getOrElse(resp)
)
} yield resp } yield resp
case _ => case _ =>
Ok(AuthResult("", account, false, "Login failed.", None, 0L)) Ok(AuthResult("", account, false, "Login failed.", None, 0L))

View File

@ -0,0 +1,9 @@
CREATE TABLE "rememberme" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"login" varchar(254) not null,
"created" timestamp not null,
"uses" int not null,
FOREIGN KEY ("cid") REFERENCES "user_"("cid"),
FOREIGN KEY ("login") REFERENCES "user_"("login")
);

View File

@ -0,0 +1,9 @@
CREATE TABLE `rememberme` (
`id` varchar(254) not null primary key,
`cid` varchar(254) not null,
`login` varchar(254) not null,
`created` timestamp not null,
`uses` int not null,
FOREIGN KEY (`cid`) REFERENCES `user_`(`cid`),
FOREIGN KEY (`login`) REFERENCES `user_`(`login`)
);

View File

@ -0,0 +1,8 @@
CREATE TABLE "rememberme" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"login" varchar(254) not null,
"created" timestamp not null,
"uses" int not null,
FOREIGN KEY ("cid","login") REFERENCES "user_"("cid","login")
);

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,69 @@
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, uses: Int) {}
object RRememberMe {
val table = fr"rememberme"
object Columns {
val id = Column("id")
val cid = Column("cid")
val username = Column("login")
val created = Column("created")
val uses = Column("uses")
val all = List(id, cid, username, created, uses)
}
import Columns._
def generate[F[_]: Sync](account: AccountId): F[RRememberMe] =
for {
c <- Timestamp.current[F]
i <- Ident.randomId[F]
} yield RRememberMe(i, account, c, 0)
def insert(v: RRememberMe): ConnectionIO[Int] =
insertRow(
table,
all,
fr"${v.id},${v.accountId.collective},${v.accountId.user},${v.created},${v.uses}"
).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 incrementUse(rid: Ident): ConnectionIO[Int] =
updateRow(table, id.is(rid), uses.increment(1)).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
_ <- incrementUse(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"