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 =
sigValid(key) && notExpired(validity)
}
object AuthToken {
@ -55,5 +56,11 @@ object AuthToken {
sig = TokenUtil.sign(cd, key)
} 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
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import cats.data.OptionT
import docspell.backend.auth.Login._
import docspell.common._
import docspell.store.Store
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 scodec.bits.ByteVector
@ -20,11 +20,13 @@ trait Login[F[_]] {
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(
config: Config
)(sessionKey: String, rememberId: Option[Ident]): F[Result]
)(sessionKey: String, rememberId: Option[String]): F[Result]
def removeRememberToken(token: String): F[Int]
}
object Login {
@ -50,7 +52,8 @@ object Login {
def toEither: Either[String, AuthToken]
}
object Result {
case class Ok(session: AuthToken) extends Result {
case class Ok(session: AuthToken, rememberToken: Option[RememberToken])
extends Result {
val toEither = Right(session)
}
case object InvalidAuth extends Result {
@ -60,20 +63,25 @@ object Login {
val toEither = Left("Authentication failed.")
}
def ok(session: AuthToken): Result = Ok(session)
def invalidAuth: Result = InvalidAuth
def invalidTime: Result = InvalidTime
def ok(session: AuthToken, remember: Option[RememberToken]): Result =
Ok(session, remember)
def invalidAuth: Result = InvalidAuth
def invalidTime: Result = InvalidTime
}
def apply[F[_]: Effect](store: Store[F]): Resource[F, Login[F]] =
Resource.pure[F, Login[F]](new Login[F] {
private val logF = Logger.log4s(logger)
def loginSession(config: Config)(sessionKey: String): F[Result] =
AuthToken.fromString(sessionKey) match {
case Right(at) =>
if (at.sigInvalid(config.serverSecret)) Result.invalidAuth.pure[F]
else if (at.isExpired(config.sessionValid)) Result.invalidTime.pure[F]
else Result.ok(at).pure[F]
if (at.sigInvalid(config.serverSecret))
logF.warn("Cookie signature invalid!") *> Result.invalidAuth.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(_) =>
Result.invalidAuth.pure[F]
}
@ -82,8 +90,15 @@ object Login {
AccountId.parse(up.user) match {
case Right(acc) =>
val okResult =
store.transact(RUser.updateLogin(acc)) *>
AuthToken.user(acc, config.serverSecret).map(Result.ok)
for {
_ <- 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 {
data <- store.transact(QLogin.findUser(acc))
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
@ -92,44 +107,88 @@ object Login {
else Result.invalidAuth.pure[F]
} yield res
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) =
store.transact(RUser.updateLogin(acc)) *>
AuthToken.user(acc, config.serverSecret).map(Result.ok)
for {
_ <- store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, config.serverSecret)
} yield Result.ok(token, None)
if (config.rememberMe.disabled) Result.invalidAuth.pure[F]
else
def doLogin(rid: Ident) =
(for {
now <- OptionT.liftF(Timestamp.current[F])
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(
Sync[F].delay(logger.info(s"Account lookup via remember me: $data"))
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).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(
config: Config
)(sessionKey: String, rememberId: Option[Ident]): F[Result] =
)(sessionKey: String, rememberToken: Option[String]): F[Result] =
loginSession(config)(sessionKey).flatMap {
case success @ Result.Ok(_) => (success: Result).pure[F]
case success @ Result.Ok(_, _) => (success: Result).pure[F]
case fail =>
rememberId match {
case Some(rid) =>
loginRememberMe(config)(rid)
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 = {
val passOk = BCrypt.checkpw(given, data.password.pass)
checkNoPassword(data) && passOk

View File

@ -1,11 +1,12 @@
package docspell.backend.auth
import scodec.bits._
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
@ -27,7 +28,7 @@ private[auth] object TokenUtil {
ByteVector.view(s.getBytes(utf8)).toBase64
def b64dec(s: String): Option[String] =
ByteVector.fromValidBase64(s).decodeUtf8.toOption
ByteVector.fromBase64(s).flatMap(_.decodeUtf8.toOption)
def asInt(s: String): Option[Long] =
Either.catchNonFatal(s.toLong).toOption

View File

@ -57,7 +57,7 @@ docspell.server {
remember-me {
enabled = true
# 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
): HttpRoutes[F] =
Router(
"auth" -> LoginRoutes.session(restApp.backend.login, cfg),
"auth" -> LoginRoutes.session(restApp.backend.login, cfg, token),
"tag" -> TagRoutes(restApp.backend, token),
"equipment" -> EquipmentRoutes(restApp.backend, token),
"organization" -> OrganizationRoutes(restApp.backend, token),

View File

@ -23,6 +23,10 @@ case class CookieData(auth: AuthToken) {
secure = sec
)
}
def addCookie[F[_]](baseUrl: LenientUri)(resp: Response[F]): Response[F] =
resp.addCookie(asCookie(baseUrl))
}
object CookieData {
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 {
def authenticateRequest[F[_]: Effect](
auth: String => F[Login.Result]
auth: (String, Option[String]) => F[Login.Result]
)(req: Request[F]): F[Login.Result] =
CookieData.authenticator(req) match {
case Right(str) => auth(str)
case Left(_) => Login.Result.invalidAuth.pure[F]
case Right(str) =>
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)(
@ -28,7 +36,7 @@ object Authenticate {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
val authUser = getUser[F](S.loginSession(cfg))
val authUser = getUser[F](S.loginSessionOrRememberMe(cfg))
val onFailure: AuthedRoutes[String, F] =
Kleisli(req => OptionT.liftF(Forbidden(req.context)))
@ -45,7 +53,7 @@ object Authenticate {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
val authUser = getUser[F](S.loginSession(cfg))
val authUser = getUser[F](S.loginSessionOrRememberMe(cfg))
val onFailure: AuthedRoutes[String, F] =
Kleisli(req => OptionT.liftF(Forbidden(req.context)))
@ -57,7 +65,7 @@ object Authenticate {
}
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(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] {}
import dsl._
HttpRoutes.of[F] {
case req @ POST -> Root / "session" =>
Authenticate
.authenticateRequest(S.loginSession(cfg.auth))(req)
AuthToken
.update(token, cfg.auth.serverSecret)
.map(newToken => Login.Result.ok(newToken, None))
.flatMap(res => makeResponse(dsl, cfg, req, res, ""))
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]] = {
import dsl._
res match {
case Login.Result.Ok(token) =>
case Login.Result.Ok(token, remember) =>
val cd = CookieData(token)
val rem = remember.map(RememberCookieData.apply)
for {
cd <- AuthToken.user(token.account, cfg.auth.serverSecret).map(CookieData.apply)
resp <- Ok(
AuthResult(
token.account.collective.id,
@ -71,7 +78,13 @@ object LoginRoutes {
Some(cd.asString),
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
case _ =>
Ok(AuthResult("", account, false, "Login failed.", None, 0L))

View File

@ -1,6 +1,9 @@
CREATE TABLE "rememberme" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"username" varchar(254) not null,
"created" timestamp 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

@ -1,6 +1,9 @@
CREATE TABLE `rememberme` (
`id` varchar(254) not null primary key,
`cid` varchar(254) not null,
`username` varchar(254) not null,
`created` timestamp 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

@ -1,6 +1,8 @@
CREATE TABLE "rememberme" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"username" varchar(254) not null,
"created" timestamp 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

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