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 ce92e248..05524902 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/AuthToken.scala @@ -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) } 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 623f183e..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,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 diff --git a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala index e1b3be0b..66343d69 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala @@ -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 diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index d71ef97d..244852de 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -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" } } 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 5408c619..9beaf4ce 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/LoginRoutes.scala @@ -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)) 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 index 13776080..45bb52bf 100644 --- 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 @@ -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") ); 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 index 910c0e2c..bc15f1db 100644 --- 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 @@ -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`) ); 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 index 13776080..f52f38c1 100644 --- 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 @@ -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") ); diff --git a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala index 38ce2ea0..36d0b4f9 100644 --- a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala +++ b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala @@ -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 }