diff --git a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala index 2b986f88..05524902 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala @@ -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,23 +24,23 @@ 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) } def validate(key: ByteVector, validity: Duration): Boolean = sigValid(key) && notExpired(validity) + } 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 +53,14 @@ 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 - + 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) } diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala index 7d495467..9540f6e4 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala @@ -1,5 +1,6 @@ package docspell.backend.auth +import cats.data.OptionT import cats.effect._ import cats.implicits._ @@ -7,9 +8,9 @@ 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 @@ -19,14 +20,29 @@ trait Login[F[_]] { 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 { 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 = "") else copy(pass = "***") @@ -36,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 { @@ -46,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] } @@ -68,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")) @@ -78,15 +107,98 @@ 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: 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 = { + 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 } }) } diff --git a/modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala new file mode 100644 index 00000000..b40d4c00 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/auth/RememberToken.scala @@ -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) + +} diff --git a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala new file mode 100644 index 00000000..66343d69 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala @@ -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 + +} diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf index f8deb8e7..8437982d 100644 --- a/modules/joex/src/main/resources/reference.conf +++ b/modules/joex/src/main/resources/reference.conf @@ -142,6 +142,17 @@ docspell.joex { 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 # is only interesting for some period of time. The processing logs # of old files can be removed eventually. diff --git a/modules/joex/src/main/scala/docspell/joex/hk/CleanupRememberMeTask.scala b/modules/joex/src/main/scala/docspell/joex/hk/CleanupRememberMeTask.scala new file mode 100644 index 00000000..05568e8f --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/hk/CleanupRememberMeTask.scala @@ -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") + } +} diff --git a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingConfig.scala b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingConfig.scala index be0f3f5e..a76cc520 100644 --- a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingConfig.scala +++ b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingConfig.scala @@ -8,7 +8,8 @@ import com.github.eikek.calev.CalEvent case class HouseKeepingConfig( schedule: CalEvent, cleanupInvites: CleanupInvites, - cleanupJobs: CleanupJobs + cleanupJobs: CleanupJobs, + cleanupRememberMe: CleanupRememberMe ) object HouseKeepingConfig { @@ -17,4 +18,6 @@ object HouseKeepingConfig { case class CleanupJobs(enabled: Boolean, olderThan: Duration, deleteBatch: Int) + case class CleanupRememberMe(enabled: Boolean, olderThan: Duration) + } diff --git a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala index c124717f..aa5281ab 100644 --- a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala @@ -19,6 +19,7 @@ object HouseKeepingTask { Task .log[F, Unit](_.info(s"Running house-keeping task now")) .flatMap(_ => CleanupInvitesTask(cfg.houseKeeping.cleanupInvites)) + .flatMap(_ => CleanupRememberMeTask(cfg.houseKeeping.cleanupRememberMe)) .flatMap(_ => CleanupJobsTask(cfg.houseKeeping.cleanupJobs)) def onCancel[F[_]: Sync]: Task[F, Unit, Unit] = diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 70d7c979..99d6d884 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -5255,6 +5255,8 @@ components: type: string password: type: string + rememberMe: + type: boolean AuthResult: description: | The response to a authentication request. diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index d78d5233..d1c94119 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -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 = "30 days" + } } # This endpoint allows to upload files to any collective. The diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 9c23e589..d0f987b6 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -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), diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/CookieData.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/CookieData.scala index b427970f..2b744f27 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/auth/CookieData.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/CookieData.scala @@ -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" diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/RememberCookieData.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/RememberCookieData.scala new file mode 100644 index 00000000..7aaebaaa --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/RememberCookieData.scala @@ -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) + ) + +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/Authenticate.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/Authenticate.scala index d29d9ecc..5fe16414 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/Authenticate.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/Authenticate.scala @@ -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)) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala index 5b06472e..9beaf4ce 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala @@ -23,25 +23,33 @@ 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)) + up <- req.as[UserPass] + 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 } } - 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))) } } @@ -57,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, @@ -69,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)) diff --git a/modules/store/src/main/resources/db/migration/h2/V1.15.0__rememberme.sql b/modules/store/src/main/resources/db/migration/h2/V1.15.0__rememberme.sql new file mode 100644 index 00000000..45bb52bf --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.15.0__rememberme.sql @@ -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") +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql new file mode 100644 index 00000000..bc15f1db --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.15.0__rememberme.sql @@ -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`) +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql new file mode 100644 index 00000000..f52f38c1 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.15.0__rememberme.sql @@ -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") +); diff --git a/modules/store/src/main/scala/docspell/store/queries/QLogin.scala b/modules/store/src/main/scala/docspell/store/queries/QLogin.scala index da5f6637..4554772d 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QLogin.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QLogin.scala @@ -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 } diff --git a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala new file mode 100644 index 00000000..36d0b4f9 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala @@ -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 +} diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm index 8e41309f..dd4634c7 100644 --- a/modules/webapp/src/main/elm/Page/Login/Data.elm +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -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) diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm index 539d2f02..fb28ddfb 100644 --- a/modules/webapp/src/main/elm/Page/Login/Update.elm +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page/Login/View.elm b/modules/webapp/src/main/elm/Page/Login/View.elm index d5f9bc0e..1ca4a666 100644 --- a/modules/webapp/src/main/elm/Page/Login/View.elm +++ b/modules/webapp/src/main/elm/Page/Login/View.elm @@ -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"