Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
Binary file removed client/images/android-chrome-192x192.png
Binary file not shown.
Binary file removed client/images/android-chrome-512x512.png
Binary file not shown.
Binary file removed client/images/apple-touch-icon.png
Binary file not shown.
Binary file removed client/images/favicon-16x16.png
Binary file not shown.
Binary file removed client/images/favicon-32x32.png
Binary file not shown.
Binary file removed client/images/favicon.ico
Binary file not shown.
Binary file added client/public/images/android-chrome-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/images/android-chrome-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/images/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
Binary file added client/public/images/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/images/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/images/favicon.ico
Binary file not shown.
1 change: 1 addition & 0 deletions client/public/site.webmanifest
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 0 additions & 1 deletion client/site.webmanifest

This file was deleted.

2 changes: 1 addition & 1 deletion scripts/local-docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions server/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ google {
api-key = ""
api-key = ${?GOOGLE_API_KEY}
}

jwt {
secret = ${JWT_SECRET}
}
18 changes: 9 additions & 9 deletions server/src/main/scala/ru/trett/rss/server/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -116,15 +116,15 @@ object Server extends IOApp:
corsPolicy(
jarRoutes <+>
routes(
sessionManager,
jwtManager,
channelService,
userService,
feedService,
appConfig.oauth,
authFilter,
client,
summarizeService,
new LogoutController[IO](sessionManager)
new LogoutController[IO]
)
)
exitCode <- UpdateTask(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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:

private val OAuthBaseUrl =
Uri.unsafeFromString("https://accounts.google.com/o/oauth2/v2/auth")

def routes(
sessionManager: SessionManager[IO],
jwtManager: JwtManager,
oauthConfig: OAuthConfig,
userService: UserService,
client: Client[IO]
Expand Down Expand Up @@ -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
)
)
)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}