Use remember-me cookie if present

This commit is contained in:
Eike Kettner 2020-12-04 00:51:56 +01:00
parent c10c1fad72
commit a0642905db
13 changed files with 217 additions and 59 deletions

View File

@ -30,6 +30,7 @@ case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: Str
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 {
@ -55,5 +56,11 @@ object AuthToken {
sig = TokenUtil.sign(cd, key) sig = TokenUtil.sign(cd, key)
} yield cd.copy(sig = sig) } yield cd.copy(sig = sig)
def update[F[_]: Sync](token: AuthToken, key: ByteVector): F[AuthToken] =
for {
now <- Timestamp.current[F]
salt <- Common.genSaltString[F]
data = AuthToken(now.toMillis, token.account, salt, "")
sig = TokenUtil.sign(data, key)
} yield data.copy(sig = sig)
} }

View File

@ -1,16 +1,16 @@
package docspell.backend.auth package docspell.backend.auth
import cats.data.OptionT
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._
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
@ -20,11 +20,13 @@ 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 loginRememberMe(config: Config)(token: String): F[Result]
def loginSessionOrRememberMe( def loginSessionOrRememberMe(
config: Config config: Config
)(sessionKey: String, rememberId: Option[Ident]): F[Result] )(sessionKey: String, rememberId: Option[String]): F[Result]
def removeRememberToken(token: String): F[Int]
} }
object Login { object Login {
@ -50,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 {
@ -60,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]
} }
@ -82,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"))
@ -92,44 +107,88 @@ 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: Ident): F[Result] = { def loginRememberMe(config: Config)(token: String): F[Result] = {
def okResult(acc: AccountId) = def okResult(acc: AccountId) =
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)
} yield Result.ok(token, None)
if (config.rememberMe.disabled) Result.invalidAuth.pure[F] def doLogin(rid: Ident) =
else
(for { (for {
now <- OptionT.liftF(Timestamp.current[F]) now <- OptionT.liftF(Timestamp.current[F])
minTime = now - config.rememberMe.valid minTime = now - config.rememberMe.valid
data <- OptionT(store.transact(QLogin.findByRememberMe(token, minTime).value)) data <- OptionT(store.transact(QLogin.findByRememberMe(rid, minTime).value))
_ <- OptionT.liftF( _ <- OptionT.liftF(
Sync[F].delay(logger.info(s"Account lookup via remember me: $data")) logF.warn(s"Account lookup via remember me: $data")
) )
res <- OptionT.liftF( res <- OptionT.liftF(
if (checkNoPassword(data)) okResult(data.account) if (checkNoPassword(data)) okResult(data.account)
else Result.invalidAuth.pure[F] else Result.invalidAuth.pure[F]
) )
} yield res).getOrElse(Result.invalidAuth) } 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( def loginSessionOrRememberMe(
config: Config config: Config
)(sessionKey: String, rememberId: Option[Ident]): F[Result] = )(sessionKey: String, rememberToken: Option[String]): F[Result] =
loginSession(config)(sessionKey).flatMap { loginSession(config)(sessionKey).flatMap {
case success @ Result.Ok(_) => (success: Result).pure[F] case success @ Result.Ok(_, _) => (success: Result).pure[F]
case fail => case fail =>
rememberId match { rememberToken match {
case Some(rid) => case Some(token) =>
loginRememberMe(config)(rid) loginRememberMe(config)(token)
case None => case None =>
fail.pure[F] 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) val passOk = BCrypt.checkpw(given, data.password.pass)
checkNoPassword(data) && passOk checkNoPassword(data) && passOk

View File

@ -1,11 +1,12 @@
package docspell.backend.auth package docspell.backend.auth
import scodec.bits._
import javax.crypto.Mac import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import cats.implicits._ import cats.implicits._
import scodec.bits._
private[auth] object TokenUtil { private[auth] object TokenUtil {
private val utf8 = java.nio.charset.StandardCharsets.UTF_8 private val utf8 = java.nio.charset.StandardCharsets.UTF_8
@ -27,7 +28,7 @@ private[auth] object TokenUtil {
ByteVector.view(s.getBytes(utf8)).toBase64 ByteVector.view(s.getBytes(utf8)).toBase64
def b64dec(s: String): Option[String] = def b64dec(s: String): Option[String] =
ByteVector.fromValidBase64(s).decodeUtf8.toOption ByteVector.fromBase64(s).flatMap(_.decodeUtf8.toOption)
def asInt(s: String): Option[Long] = def asInt(s: String): Option[Long] =
Either.catchNonFatal(s.toLong).toOption Either.catchNonFatal(s.toLong).toOption

View File

@ -57,7 +57,7 @@ docspell.server {
remember-me { remember-me {
enabled = true enabled = true
# How long the remember me cookie/token is valid. # How long the remember me cookie/token is valid.
valid = "3 month" valid = "21 days"
} }
} }

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

@ -32,18 +32,24 @@ object LoginRoutes {
} }
} }
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)))
} }
} }
@ -59,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,
@ -71,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

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import docspell.store.impl._
import doobie._ import doobie._
import doobie.implicits._ import doobie.implicits._
case class RRememberMe(id: Ident, accountId: AccountId, created: Timestamp) {} case class RRememberMe(id: Ident, accountId: AccountId, created: Timestamp, uses: Int) {}
object RRememberMe { object RRememberMe {
@ -19,9 +19,10 @@ object RRememberMe {
object Columns { object Columns {
val id = Column("id") val id = Column("id")
val cid = Column("cid") val cid = Column("cid")
val username = Column("username") val username = Column("login")
val created = Column("created") val created = Column("created")
val all = List(id, cid, username, created) val uses = Column("uses")
val all = List(id, cid, username, created, uses)
} }
import Columns._ import Columns._
@ -29,13 +30,13 @@ object RRememberMe {
for { for {
c <- Timestamp.current[F] c <- Timestamp.current[F]
i <- Ident.randomId[F] i <- Ident.randomId[F]
} yield RRememberMe(i, account, c) } yield RRememberMe(i, account, c, 0)
def insert(v: RRememberMe): ConnectionIO[Int] = def insert(v: RRememberMe): ConnectionIO[Int] =
insertRow( insertRow(
table, table,
all, all,
fr"${v.id},${v.accountId.collective},${v.accountId.user},${v.created}" fr"${v.id},${v.accountId.collective},${v.accountId.user},${v.created},${v.uses}"
).update.run ).update.run
def insertNew(acc: AccountId): ConnectionIO[RRememberMe] = def insertNew(acc: AccountId): ConnectionIO[RRememberMe] =
@ -47,13 +48,19 @@ object RRememberMe {
def delete(rid: Ident): ConnectionIO[Int] = def delete(rid: Ident): ConnectionIO[Int] =
deleteFrom(table, id.is(rid)).update.run deleteFrom(table, id.is(rid)).update.run
def useRememberMe(rid: Ident, minCreated: Timestamp): ConnectionIO[Option[RRememberMe]] = { 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))) val get = selectSimple(all, table, and(id.is(rid), created.isGt(minCreated)))
.query[RRememberMe] .query[RRememberMe]
.option .option
for { for {
inv <- get inv <- get
_ <- delete(rid) _ <- incrementUse(rid)
} yield inv } yield inv
} }