diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61d2cad..40b7c49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,6 +42,10 @@ jobs: run: | gcloud auth configure-docker ${GAR_LOCATION}-docker.pkg.dev + - name: Set short git commit SHA + id: vars + run: echo "DOCKER_TAG=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + - name: Build and push image run: sbt pushImage env: diff --git a/build.sbt b/build.sbt index 011c9bb..0f5aeee 100644 --- a/build.sbt +++ b/build.sbt @@ -74,6 +74,7 @@ lazy val server = project organization := organizationName, scalaVersion := scala3Version, name := "server", + Docker / version := sys.env.get("DOCKER_TAG").getOrElse("local"), dockerBaseImage := "eclipse-temurin:21-jre-jammy", dockerRepository := sys.env.get("REGISTRY"), dockerExposedPorts := Seq(8080), @@ -120,6 +121,7 @@ lazy val server = project ).map(_ % doobieVersion), libraryDependencies += "org.jsoup" % "jsoup" % "1.21.2", libraryDependencies += "com.github.blemale" %% "scaffeine" % "5.3.0", + libraryDependencies += "com.github.jwt-scala" %% "jwt-circe" % "10.0.1", libraryDependencies += "io.circe" %% "circe-fs2" % "0.14.1", libraryDependencies += "org.flywaydb" % "flyway-database-postgresql" % "11.17.2" % "runtime", libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test, diff --git a/client/images/android-chrome-192x192.png b/client/images/android-chrome-192x192.png deleted file mode 100644 index 3e30045..0000000 Binary files a/client/images/android-chrome-192x192.png and /dev/null differ diff --git a/client/images/android-chrome-512x512.png b/client/images/android-chrome-512x512.png deleted file mode 100644 index cedf3f1..0000000 Binary files a/client/images/android-chrome-512x512.png and /dev/null differ diff --git a/client/images/apple-touch-icon.png b/client/images/apple-touch-icon.png deleted file mode 100644 index 537f58b..0000000 Binary files a/client/images/apple-touch-icon.png and /dev/null differ diff --git a/client/images/favicon-16x16.png b/client/images/favicon-16x16.png deleted file mode 100644 index 3366967..0000000 Binary files a/client/images/favicon-16x16.png and /dev/null differ diff --git a/client/images/favicon-32x32.png b/client/images/favicon-32x32.png deleted file mode 100644 index ab3ac50..0000000 Binary files a/client/images/favicon-32x32.png and /dev/null differ diff --git a/client/images/favicon.ico b/client/images/favicon.ico deleted file mode 100644 index 9b34d16..0000000 Binary files a/client/images/favicon.ico and /dev/null differ diff --git a/client/public/images/android-chrome-192x192.png b/client/public/images/android-chrome-192x192.png new file mode 100644 index 0000000..083f26e Binary files /dev/null and b/client/public/images/android-chrome-192x192.png differ diff --git a/client/public/images/android-chrome-512x512.png b/client/public/images/android-chrome-512x512.png new file mode 100644 index 0000000..37f2838 Binary files /dev/null and b/client/public/images/android-chrome-512x512.png differ diff --git a/client/public/images/apple-touch-icon.png b/client/public/images/apple-touch-icon.png new file mode 100644 index 0000000..7289669 Binary files /dev/null and b/client/public/images/apple-touch-icon.png differ diff --git a/client/images/background.webp b/client/public/images/background.webp similarity index 100% rename from client/images/background.webp rename to client/public/images/background.webp diff --git a/client/public/images/favicon-16x16.png b/client/public/images/favicon-16x16.png new file mode 100644 index 0000000..e32cf3e Binary files /dev/null and b/client/public/images/favicon-16x16.png differ diff --git a/client/public/images/favicon-32x32.png b/client/public/images/favicon-32x32.png new file mode 100644 index 0000000..94d855b Binary files /dev/null and b/client/public/images/favicon-32x32.png differ diff --git a/client/public/images/favicon.ico b/client/public/images/favicon.ico new file mode 100644 index 0000000..1e9f735 Binary files /dev/null and b/client/public/images/favicon.ico differ diff --git a/client/public/site.webmanifest b/client/public/site.webmanifest new file mode 100644 index 0000000..bd8bf04 --- /dev/null +++ b/client/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"RSS Reader","short_name":"RSS","icons":[{"src":"/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/client/site.webmanifest b/client/site.webmanifest deleted file mode 100644 index 7fd5804..0000000 --- a/client/site.webmanifest +++ /dev/null @@ -1 +0,0 @@ -{"name":"RSS Reader","short_name":"RSS","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index 5bd2bc7..55ae3ee 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -24,7 +24,7 @@ services: - host.docker.internal:host-gateway server: - image: server:2.4.3 + image: server:local container_name: rss_server restart: always depends_on: diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf index 76fc97d..28c540e 100644 --- a/server/src/main/resources/application.conf +++ b/server/src/main/resources/application.conf @@ -32,3 +32,7 @@ google { api-key = "" api-key = ${?GOOGLE_API_KEY} } + +jwt { + secret = ${JWT_SECRET} +} diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index 7c653cb..12c7430 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -31,7 +31,7 @@ import org.typelevel.otel4s.oteljava.OtelJava import pureconfig.ConfigSource import scala.concurrent.duration.* import ru.trett.rss.server.authorization.AuthFilter -import ru.trett.rss.server.authorization.SessionManager +import ru.trett.rss.server.authorization.JwtManager import ru.trett.rss.server.config.AppConfig import ru.trett.rss.server.config.CorsConfig import ru.trett.rss.server.config.DbConfig @@ -95,7 +95,7 @@ object Server extends IOApp: for { _ <- FlywayMigration.migrate(appConfig.db) corsPolicy = createCorsPolicy(appConfig.cors) - sessionManager <- SessionManager[IO] + jwtManager = JwtManager(appConfig.jwt.secret) channelRepository = ChannelRepository(xa) feedRepository = FeedRepository(xa) feedService = FeedService(feedRepository) @@ -116,7 +116,7 @@ object Server extends IOApp: corsPolicy( jarRoutes <+> routes( - sessionManager, + jwtManager, channelService, userService, feedService, @@ -124,7 +124,7 @@ object Server extends IOApp: authFilter, client, summarizeService, - new LogoutController[IO](sessionManager) + new LogoutController[IO] ) ) exitCode <- UpdateTask( @@ -185,7 +185,7 @@ object Server extends IOApp: .withMaxAge(config.maxAge) private def routes( - sessionManager: SessionManager[IO], + jwtManager: JwtManager, channelService: ChannelService, userService: UserService, feedService: FeedService, @@ -195,8 +195,8 @@ object Server extends IOApp: summarizeService: SummarizeService, logoutController: LogoutController[IO] ): HttpRoutes[IO] = - unprotectedRoutes(sessionManager, oauthConfig, userService, client) <+> - authFilter.middleware(sessionManager, userService)( + unprotectedRoutes(jwtManager, oauthConfig, userService, client) <+> + authFilter.middleware(jwtManager, userService)( authedRoutes( channelService, userService, @@ -213,12 +213,12 @@ object Server extends IOApp: } private def unprotectedRoutes( - sessionManager: SessionManager[IO], + jwtManager: JwtManager, oauthConfig: OAuthConfig, userService: UserService, client: Client[IO] ): HttpRoutes[IO] = - LoginController.routes(sessionManager, oauthConfig, userService, client) <+> indexRoute + LoginController.routes(jwtManager, oauthConfig, userService, client) <+> indexRoute private def authedRoutes( channelService: ChannelService, diff --git a/server/src/main/scala/ru/trett/rss/server/authorization/AuthFilter.scala b/server/src/main/scala/ru/trett/rss/server/authorization/AuthFilter.scala index fefe8f2..fcb044d 100644 --- a/server/src/main/scala/ru/trett/rss/server/authorization/AuthFilter.scala +++ b/server/src/main/scala/ru/trett/rss/server/authorization/AuthFilter.scala @@ -6,34 +6,36 @@ import cats.syntax.all.* import com.github.blemale.scaffeine.{Cache, Scaffeine} import org.http4s.* import org.http4s.server.* +import org.typelevel.log4cats.LoggerFactory import ru.trett.rss.server.models.User import ru.trett.rss.server.services.UserService import scala.concurrent.duration.* -class AuthFilter[F[_]: Sync: LiftIO] private (cache: Cache[String, User]): +class AuthFilter[F[_]: Sync: LiftIO: LoggerFactory] private (cache: Cache[String, User]): - def middleware( - sessionManager: SessionManager[F], - userService: UserService - ): AuthMiddleware[F, User] = - AuthMiddleware(authUser(sessionManager, userService)) + private val logger = LoggerFactory[F].getLogger + + def middleware(jwtManager: JwtManager, userService: UserService): AuthMiddleware[F, User] = + AuthMiddleware(authUser(jwtManager, userService)) def updateCache(user: User): F[Unit] = Sync[F].delay(cache.put(user.email, user)) private def authUser( - sessionManager: SessionManager[F], + jwtManager: JwtManager, userService: UserService ): Kleisli[[A] =>> OptionT[F, A], Request[F], User] = Kleisli { req => req.cookies.find(_.name == "sessionId") match { case Some(sessionId) => - OptionT - .some(sessionId.content) - .flatMapF(sessionManager.getSession) - .flatMap(sessionData => + jwtManager.verifyToken(sessionId.content) match { + case Right(sessionData) => OptionT(getUser(sessionData.userEmail, userService)) - ) + case Left(e) => + OptionT + .liftF(logger.error(e)("Failed to verify token")) + .flatMap(_ => OptionT.none[F, User]) + } case None => OptionT.none[F, User] } } @@ -52,7 +54,7 @@ class AuthFilter[F[_]: Sync: LiftIO] private (cache: Cache[String, User]): } object AuthFilter: - def apply[F[_]: Sync: LiftIO]: F[AuthFilter[F]] = + def apply[F[_]: Sync: LiftIO: LoggerFactory]: F[AuthFilter[F]] = val cache: Cache[String, User] = Scaffeine() .maximumSize(100) .expireAfterWrite(1.hour) diff --git a/server/src/main/scala/ru/trett/rss/server/authorization/JwtManager.scala b/server/src/main/scala/ru/trett/rss/server/authorization/JwtManager.scala new file mode 100644 index 0000000..020bb8a --- /dev/null +++ b/server/src/main/scala/ru/trett/rss/server/authorization/JwtManager.scala @@ -0,0 +1,25 @@ +package ru.trett.rss.server.authorization + +import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim} +import io.circe.syntax.* +import io.circe.generic.auto.* +import java.time.Clock +import scala.concurrent.duration.* + +case class SessionData(userEmail: String) + +class JwtManager(secret: String): + private val algorithm = JwtAlgorithm.HS256 + private val clock: Clock = Clock.systemUTC() + + def createToken(data: SessionData): String = + val claim = + JwtClaim(data.asJson.noSpaces) + .issuedNow(clock) + .expiresIn(1.day.toSeconds)(clock) // 1 day + JwtCirce.encode(claim, secret, algorithm) + + def verifyToken(token: String): Either[Throwable, SessionData] = + JwtCirce.decodeJson(token, secret, Seq(algorithm)).toEither.flatMap { json => + json.as[SessionData] + } diff --git a/server/src/main/scala/ru/trett/rss/server/authorization/SessionManager.scala b/server/src/main/scala/ru/trett/rss/server/authorization/SessionManager.scala deleted file mode 100644 index 67ab831..0000000 --- a/server/src/main/scala/ru/trett/rss/server/authorization/SessionManager.scala +++ /dev/null @@ -1,32 +0,0 @@ -package ru.trett.rss.server.authorization - -import cats.effect.* -import cats.effect.std.UUIDGen -import cats.syntax.all.* -import com.github.blemale.scaffeine.{Cache, Scaffeine} - -import scala.concurrent.duration.DurationInt - -case class SessionData(userEmail: String) - -class SessionManager[F[_]: Sync] private (cache: Cache[String, SessionData]): - - def createSession(data: SessionData): F[String] = - for { - sessionId <- UUIDGen.randomString[F] - _ <- Sync[F].delay(cache.put(sessionId, data)) - } yield sessionId - - def getSession(sessionId: String): F[Option[SessionData]] = - Sync[F].delay(cache.getIfPresent(sessionId)) - - def deleteSession(sessionId: String): F[Unit] = - Sync[F].delay(cache.invalidate(sessionId)) - -object SessionManager: - def apply[F[_]: Sync]: F[SessionManager[F]] = - val cache: Cache[String, SessionData] = Scaffeine() - .maximumSize(500) - .expireAfterWrite(1.days) - .build[String, SessionData]() - new SessionManager(cache).pure[F] diff --git a/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala b/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala index 569b1f8..91e5054 100644 --- a/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala +++ b/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala @@ -9,7 +9,8 @@ case class AppConfig( db: DbConfig, oauth: OAuthConfig, cors: CorsConfig, - google: GoogleConfig + google: GoogleConfig, + jwt: JwtConfig ) derives ConfigReader case class ServerConfig(port: Int, host: String = "0.0.0.0") derives ConfigReader @@ -24,3 +25,5 @@ case class CorsConfig(allowedOrigin: String, allowCredentials: Boolean, maxAge: derives ConfigReader case class GoogleConfig(apiKey: String) derives ConfigReader + +case class JwtConfig(secret: String) derives ConfigReader diff --git a/server/src/main/scala/ru/trett/rss/server/controllers/LoginController.scala b/server/src/main/scala/ru/trett/rss/server/controllers/LoginController.scala index 13b53a2..ad64357 100644 --- a/server/src/main/scala/ru/trett/rss/server/controllers/LoginController.scala +++ b/server/src/main/scala/ru/trett/rss/server/controllers/LoginController.scala @@ -12,6 +12,7 @@ import org.http4s.implicits.* import ru.trett.rss.server.authorization.* import ru.trett.rss.server.config.OAuthConfig import ru.trett.rss.server.services.UserService +import scala.concurrent.duration.* object LoginController: @@ -19,7 +20,7 @@ object LoginController: Uri.unsafeFromString("https://accounts.google.com/o/oauth2/v2/auth") def routes( - sessionManager: SessionManager[IO], + jwtManager: JwtManager, oauthConfig: OAuthConfig, userService: UserService, client: Client[IO] @@ -59,16 +60,17 @@ object LoginController: ) userInfo <- getUserInfo(client, token.access_token) sessionData = SessionData(userInfo.email) - sessionId <- sessionManager.createSession(sessionData) + jwtToken = jwtManager.createToken(sessionData) response <- SeeOther(Location(uri"/")) .map( _.addCookie( ResponseCookie( "sessionId", - sessionId, + jwtToken, + path = Some("/"), httpOnly = true, secure = true, - maxAge = Some(60 * 60 * 24) // 1 day + maxAge = Some(1.day.toSeconds) // 1 day ) ) ) @@ -90,13 +92,6 @@ object LoginController: case Left(_) => BadRequest("Failed to create user") } } yield response - - case req @ POST -> Root / "logout" => - req.cookies.find(_.name == "sessionId") match { - case Some(cookie) => - sessionManager.deleteSession(cookie.content) *> Ok("Logged out") - case None => BadRequest("No session to log out from") - } } private def getToken( diff --git a/server/src/main/scala/ru/trett/rss/server/controllers/LogoutController.scala b/server/src/main/scala/ru/trett/rss/server/controllers/LogoutController.scala index 1492446..4f2678e 100644 --- a/server/src/main/scala/ru/trett/rss/server/controllers/LogoutController.scala +++ b/server/src/main/scala/ru/trett/rss/server/controllers/LogoutController.scala @@ -2,31 +2,32 @@ package ru.trett.rss.server.controllers import cats.effect.Async import org.http4s.dsl.Http4sDsl -import ru.trett.rss.server.authorization.SessionManager -import cats.implicits._ +import cats.implicits.* import org.http4s.AuthedRoutes import org.typelevel.log4cats.LoggerFactory import ru.trett.rss.server.models.User +import org.http4s.ResponseCookie -class LogoutController[F[_]: Async: LoggerFactory](sessionManager: SessionManager[F]) - extends Http4sDsl[F] { +class LogoutController[F[_]: Async: LoggerFactory] extends Http4sDsl[F] { private val logger = LoggerFactory[F].getLogger val routes: AuthedRoutes[User, F] = AuthedRoutes.of { case authReq @ POST -> Root / "api" / "logout" as user => - authReq.req.cookies.find(_.name == "sessionId") match { - case Some(cookie) => - for { - _ <- logger.info(s"Deleting session for user: ${user.email}") - _ <- sessionManager.deleteSession(cookie.content) - res <- Ok() - } yield res - case None => - logger.warn( - s"Logout request without session cookie for user: ${user.email}" - ) >> Ok() - } + for { + _ <- logger.info(s"Logging out user: ${user.email}") + res <- Ok().map( + _.addCookie( + ResponseCookie( + "sessionId", + "", + path = Some("/"), + maxAge = Some(-1), + httpOnly = true, + secure = true + ) + ) + ) + } yield res } - }