Compare commits
2 Commits
43b51d756d
...
907f76d11d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907f76d11d | ||
|
|
e1b901f01c |
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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](
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
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]
|
||||||
|
|
||||||
|
def updateTokens(sessionId: UUID, accessToken: String, refreshToken: String): IO[Int]
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
override def updateTokens(sessionId: UUID, accessToken: String, refreshToken: String): IO[Int] = {
|
||||||
|
sql"""
|
||||||
|
UPDATE user_sessions
|
||||||
|
SET access_token = $accessToken, refresh_token = $refreshToken
|
||||||
|
WHERE id = $sessionId
|
||||||
|
""".update.run.transact(transactor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
_ <- userSessionRepository.updateTokens(sessionId, accessToken, refreshToken)
|
||||||
|
// 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")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user