From e1b901f01c35ceeddbdd6b5c1a15e936190b8456 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Wed, 6 Aug 2025 05:15:40 +0300 Subject: [PATCH] add jwt --- build.sbt | 6 ++- .../api/endpoint/auth/AuthEndpoints.scala | 4 +- .../api/route/AllServerEndpoints.scala | 31 +++++++++------ .../scala_monolith/config/JwtConfig.scala | 20 ++++++++++ .../scala_monolith/models/UserSession.scala | 16 ++++++++ .../repository/UserSessionRepository.scala | 34 ++++++++++++++++ .../scala_monolith/service/AuthService.scala | 36 ++++++++++++----- .../scala_monolith/service/JwtService.scala | 39 +++++++++++++++++++ 8 files changed, 163 insertions(+), 23 deletions(-) create mode 100644 src/main/scala/org/yobble/scala_monolith/config/JwtConfig.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/models/UserSession.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/repository/UserSessionRepository.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/service/JwtService.scala diff --git a/build.sbt b/build.sbt index 75e496e..e6b8d3e 100644 --- a/build.sbt +++ b/build.sbt @@ -14,6 +14,7 @@ val vaultVersion = "3.6.0" val doobieVersion = "1.0.0-RC5" val postgresqlVersion = "42.7.4" val bcryptVersion = "0.10.2" +val jwtVersion = "10.0.1" lazy val root = (project in file(".")) @@ -48,6 +49,9 @@ lazy val root = (project in file(".")) "org.postgresql" % "postgresql" % postgresqlVersion, // Bcrypt - "at.favre.lib" % "bcrypt" % bcryptVersion + "at.favre.lib" % "bcrypt" % bcryptVersion, + + // JWT + "com.github.jwt-scala" %% "jwt-circe" % jwtVersion ) ) diff --git a/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala b/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala index 37dee02..01562f2 100644 --- a/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala +++ b/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala @@ -11,11 +11,13 @@ import sttp.tapir.json.circe.* object AuthEndpoints { - val loginEndpoint: PublicEndpoint[LoginRequest, ErrorResponse, LoginResponse, Any] = + val loginEndpoint: PublicEndpoint[(LoginRequest, Option[String], Option[String]), ErrorResponse, LoginResponse, Any] = endpoint.post .in("auth" / "login") .tags(List("Auth")) .in(jsonBody[LoginRequest]) + .in(header[Option[String]]("X-Real-IP")) + .in(header[Option[String]]("X-User-Agent")) .out(jsonBody[LoginResponse]) .errorOut( oneOf[ErrorResponse]( diff --git a/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala b/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala index ef44d2d..bbdd65b 100644 --- a/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala +++ b/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala @@ -3,17 +3,32 @@ package org.yobble.scala_monolith.api.route import cats.effect.IO import doobie.Transactor import org.yobble.scala_monolith.api.endpoint.auth.AuthEndpoints -import sttp.tapir.server.ServerEndpoint import org.yobble.scala_monolith.api.endpoint.info.{ErrorsEndpoint, PingEndpoint, TestErrorEndpoint} import org.yobble.scala_monolith.api.response.{BaseResponse, ErrorResponse} -import org.yobble.scala_monolith.repository.UserRepositoryImpl -import org.yobble.scala_monolith.service.AuthService -import sttp.model.StatusCode import org.yobble.scala_monolith.api.util.errorByStatus +import org.yobble.scala_monolith.config.JwtConfig +import org.yobble.scala_monolith.repository.{UserRepositoryImpl, UserSessionRepositoryImpl} +import org.yobble.scala_monolith.service.{AuthService, JwtService} +import sttp.model.StatusCode +import sttp.tapir.server.ServerEndpoint object AllServerEndpoints { def all(transactor: Transactor[IO]): List[ServerEndpoint[Any, IO]] = { + // === Repositories === + val userRepository = new UserRepositoryImpl(transactor) + val userSessionRepository = new UserSessionRepositoryImpl(transactor) + + // === Services === + val jwtService = new JwtService(JwtConfig.default) + val authService = new AuthService(userRepository, userSessionRepository, jwtService) + + val authEndpoints = List( + AuthEndpoints.loginEndpoint.serverLogic { case (loginRequest, ipAddress, userAgent) => + authService.login(loginRequest, ipAddress, userAgent) + } + ) + val infoEndpoints: List[ServerEndpoint[Any, IO]] = List( PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "pong"))), ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "errors"))), @@ -22,12 +37,6 @@ object AllServerEndpoints { }) ) - val authService = new AuthService(new UserRepositoryImpl(transactor)) - - val authEndpoints = List( - AuthEndpoints.loginEndpoint.serverLogic(authService.login) - ) - - infoEndpoints ++ authEndpoints + authEndpoints ++ infoEndpoints } } diff --git a/src/main/scala/org/yobble/scala_monolith/config/JwtConfig.scala b/src/main/scala/org/yobble/scala_monolith/config/JwtConfig.scala new file mode 100644 index 0000000..e0d9b66 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/config/JwtConfig.scala @@ -0,0 +1,20 @@ +package org.yobble.scala_monolith.config + +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.duration._ + +case class JwtConfig( + secret: String, + issuer: String, + accessTokenExpiration: FiniteDuration, + refreshTokenExpiration: FiniteDuration +) + +object JwtConfig { + def default: JwtConfig = JwtConfig( + secret = "your-super-secret-key-that-is-long-and-secure", // TODO: Load from a secure source + issuer = "yobble-inc", + accessTokenExpiration = 30.minutes, + refreshTokenExpiration = 30.days + ) +} diff --git a/src/main/scala/org/yobble/scala_monolith/models/UserSession.scala b/src/main/scala/org/yobble/scala_monolith/models/UserSession.scala new file mode 100644 index 0000000..04b59de --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/models/UserSession.scala @@ -0,0 +1,16 @@ +package org.yobble.scala_monolith.models + +import java.time.Instant +import java.util.UUID + +case class UserSession( + id: UUID, + userId: UUID, + accessToken: String, + refreshToken: String, + ipAddress: Option[String], + userAgent: Option[String], + isActive: Boolean, + createdAt: Instant, + lastRefreshAt: Instant +) diff --git a/src/main/scala/org/yobble/scala_monolith/repository/UserSessionRepository.scala b/src/main/scala/org/yobble/scala_monolith/repository/UserSessionRepository.scala new file mode 100644 index 0000000..38e1c22 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/repository/UserSessionRepository.scala @@ -0,0 +1,34 @@ +package org.yobble.scala_monolith.repository + +import cats.effect.IO +import doobie.util.transactor.Transactor +import doobie.implicits.* +import doobie.postgres.implicits.* +import org.yobble.scala_monolith.models.UserSession + +import java.util.UUID + +trait UserSessionRepository { + def insert( + userId: UUID, + accessToken: String, + refreshToken: String, + ipAddress: Option[String], + userAgent: Option[String] + ): IO[UUID] +} + +class UserSessionRepositoryImpl(transactor: Transactor[IO]) extends UserSessionRepository { + override def insert( + userId: UUID, + accessToken: String, + refreshToken: String, + ipAddress: Option[String], + userAgent: Option[String] + ): IO[UUID] = { + sql""" + INSERT INTO user_sessions (user_id, access_token, refresh_token, ip_address, user_agent) + VALUES ($userId, $accessToken, $refreshToken, $ipAddress, $userAgent) + """.update.withUniqueGeneratedKeys[UUID]("id").transact(transactor) + } +} diff --git a/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala b/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala index 2fb235a..d9a79ca 100644 --- a/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala +++ b/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala @@ -2,29 +2,45 @@ package org.yobble.scala_monolith.service import at.favre.lib.crypto.bcrypt.BCrypt import cats.effect.IO +import cats.implicits.* import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse, Tokens} import org.yobble.scala_monolith.api.response.ErrorResponse import org.yobble.scala_monolith.api.util.ErrorUtils -import org.yobble.scala_monolith.repository.UserRepository +import org.yobble.scala_monolith.repository.{UserRepository, UserSessionRepository} -class AuthService(userRepository: UserRepository) { +import java.util.UUID - def login(request: LoginRequest): IO[Either[ErrorResponse, LoginResponse]] = { - userRepository.findByLogin(request.login).map { +class AuthService( + userRepository: UserRepository, + userSessionRepository: UserSessionRepository, + jwtService: JwtService +) { + + def login( + request: LoginRequest, + ipAddress: Option[String], + userAgent: Option[String] + ): IO[Either[ErrorResponse, LoginResponse]] = { + userRepository.findByLogin(request.login).flatMap { case Some(user) => val passwordMatches = BCrypt.verifyer().verify(request.password.toCharArray, user.passwordHash).verified if (!passwordMatches) { - Left(ErrorUtils.unauthorized("Invalid login or password")) + IO.pure(Left(ErrorUtils.unauthorized("Invalid login or password"))) } else if (user.isBlocked) { - Left(ErrorUtils.forbidden("User account is disabled")) + IO.pure(Left(ErrorUtils.forbidden("User account is disabled"))) } else if (user.isDeleted) { - Left(ErrorUtils.forbidden("User account is deleted")) + IO.pure(Left(ErrorUtils.forbidden("User account is deleted"))) } else { - // TODO: Implement real token generation - Right(LoginResponse(message = Tokens(accessToken = "fake-access-token", refreshToken = "fake-refresh-token"))) + for { + sessionId <- userSessionRepository.insert(user.id, "temp", "temp", ipAddress, userAgent) + tokens <- jwtService.createTokens(user.id, sessionId) + (accessToken, refreshToken) = tokens + // TODO: Update tokens in user_sessions table after creation + // TODO: Save session to Redis + } yield Right(LoginResponse(message = Tokens(accessToken = accessToken, refreshToken = refreshToken))) } case None => - Left(ErrorUtils.unauthorized("Invalid login or password")) + IO.pure(Left(ErrorUtils.unauthorized("Invalid login or password"))) } } } diff --git a/src/main/scala/org/yobble/scala_monolith/service/JwtService.scala b/src/main/scala/org/yobble/scala_monolith/service/JwtService.scala new file mode 100644 index 0000000..d0378d0 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/service/JwtService.scala @@ -0,0 +1,39 @@ +package org.yobble.scala_monolith.service + +import cats.effect.IO +import org.yobble.scala_monolith.config.JwtConfig +import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim} + +import java.time.Clock +import java.util.UUID + +class JwtService(config: JwtConfig) { + private implicit val clock: Clock = Clock.systemUTC() + + private val algorithm = JwtAlgorithm.HS256 + + def createTokens(userId: UUID, sessionId: UUID): IO[(String, String)] = IO { + val accessToken = createJwt( + userId, + sessionId, + config.accessTokenExpiration.toSeconds + ) + val refreshToken = createJwt( + userId, + sessionId, + config.refreshTokenExpiration.toSeconds + ) + (accessToken, refreshToken) + } + + private def createJwt(userId: UUID, sessionId: UUID, expirationSeconds: Long): String = { + val claim = JwtClaim() + .by(config.issuer) + .to(userId.toString) + .issuedNow + .expiresIn(expirationSeconds) + .withId(sessionId.toString) // Using session ID as JTI + + JwtCirce.encode(claim, config.secret, algorithm) + } +}