This commit is contained in:
cheykrym 2025-08-06 05:15:40 +03:00
parent 43b51d756d
commit e1b901f01c
8 changed files with 163 additions and 23 deletions

View File

@ -14,6 +14,7 @@ val vaultVersion = "3.6.0"
val doobieVersion = "1.0.0-RC5" val doobieVersion = "1.0.0-RC5"
val postgresqlVersion = "42.7.4" val postgresqlVersion = "42.7.4"
val bcryptVersion = "0.10.2" val bcryptVersion = "0.10.2"
val jwtVersion = "10.0.1"
lazy val root = (project in file(".")) lazy val root = (project in file("."))
@ -48,6 +49,9 @@ lazy val root = (project in file("."))
"org.postgresql" % "postgresql" % postgresqlVersion, "org.postgresql" % "postgresql" % postgresqlVersion,
// Bcrypt // Bcrypt
"at.favre.lib" % "bcrypt" % bcryptVersion "at.favre.lib" % "bcrypt" % bcryptVersion,
// JWT
"com.github.jwt-scala" %% "jwt-circe" % jwtVersion
) )
) )

View File

@ -11,11 +11,13 @@ import sttp.tapir.json.circe.*
object AuthEndpoints { object AuthEndpoints {
val loginEndpoint: PublicEndpoint[LoginRequest, ErrorResponse, LoginResponse, Any] = val loginEndpoint: PublicEndpoint[(LoginRequest, Option[String], Option[String]), ErrorResponse, LoginResponse, Any] =
endpoint.post endpoint.post
.in("auth" / "login") .in("auth" / "login")
.tags(List("Auth")) .tags(List("Auth"))
.in(jsonBody[LoginRequest]) .in(jsonBody[LoginRequest])
.in(header[Option[String]]("X-Real-IP"))
.in(header[Option[String]]("X-User-Agent"))
.out(jsonBody[LoginResponse]) .out(jsonBody[LoginResponse])
.errorOut( .errorOut(
oneOf[ErrorResponse]( oneOf[ErrorResponse](

View File

@ -3,17 +3,32 @@ package org.yobble.scala_monolith.api.route
import cats.effect.IO import cats.effect.IO
import doobie.Transactor import doobie.Transactor
import org.yobble.scala_monolith.api.endpoint.auth.AuthEndpoints 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.endpoint.info.{ErrorsEndpoint, PingEndpoint, TestErrorEndpoint}
import org.yobble.scala_monolith.api.response.{BaseResponse, ErrorResponse} 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.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 { object AllServerEndpoints {
def all(transactor: Transactor[IO]): List[ServerEndpoint[Any, IO]] = { 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( val infoEndpoints: List[ServerEndpoint[Any, IO]] = List(
PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "pong"))), PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "pong"))),
ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "errors"))), ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "errors"))),
@ -22,12 +37,6 @@ object AllServerEndpoints {
}) })
) )
val authService = new AuthService(new UserRepositoryImpl(transactor)) authEndpoints ++ infoEndpoints
val authEndpoints = List(
AuthEndpoints.loginEndpoint.serverLogic(authService.login)
)
infoEndpoints ++ authEndpoints
} }
} }

View File

@ -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
)
}

View File

@ -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
)

View File

@ -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)
}
}

View File

@ -2,29 +2,45 @@ package org.yobble.scala_monolith.service
import at.favre.lib.crypto.bcrypt.BCrypt import at.favre.lib.crypto.bcrypt.BCrypt
import cats.effect.IO import cats.effect.IO
import cats.implicits.*
import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse, Tokens} import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse, Tokens}
import org.yobble.scala_monolith.api.response.ErrorResponse import org.yobble.scala_monolith.api.response.ErrorResponse
import org.yobble.scala_monolith.api.util.ErrorUtils 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]] = { class AuthService(
userRepository.findByLogin(request.login).map { 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) => case Some(user) =>
val passwordMatches = BCrypt.verifyer().verify(request.password.toCharArray, user.passwordHash).verified val passwordMatches = BCrypt.verifyer().verify(request.password.toCharArray, user.passwordHash).verified
if (!passwordMatches) { if (!passwordMatches) {
Left(ErrorUtils.unauthorized("Invalid login or password")) IO.pure(Left(ErrorUtils.unauthorized("Invalid login or password")))
} else if (user.isBlocked) { } else if (user.isBlocked) {
Left(ErrorUtils.forbidden("User account is disabled")) IO.pure(Left(ErrorUtils.forbidden("User account is disabled")))
} else if (user.isDeleted) { } else if (user.isDeleted) {
Left(ErrorUtils.forbidden("User account is deleted")) IO.pure(Left(ErrorUtils.forbidden("User account is deleted")))
} else { } else {
// TODO: Implement real token generation for {
Right(LoginResponse(message = Tokens(accessToken = "fake-access-token", refreshToken = "fake-refresh-token"))) 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 => case None =>
Left(ErrorUtils.unauthorized("Invalid login or password")) IO.pure(Left(ErrorUtils.unauthorized("Invalid login or password")))
} }
} }
} }

View File

@ -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)
}
}