add auth
This commit is contained in:
parent
2afe7e619d
commit
7762e1de89
11
build.sbt
11
build.sbt
@ -11,6 +11,9 @@ val openapiVersion = "0.11.10"
|
||||
val log4catsVersion = "2.6.0"
|
||||
val logbackVersion = "1.4.11"
|
||||
val vaultVersion = "3.6.0"
|
||||
val doobieVersion = "1.0.0-RC5"
|
||||
val postgresqlVersion = "42.7.4"
|
||||
|
||||
|
||||
lazy val root = (project in file("."))
|
||||
.settings(
|
||||
@ -35,6 +38,12 @@ lazy val root = (project in file("."))
|
||||
"org.typelevel" %% "log4cats-slf4j" % log4catsVersion,
|
||||
"ch.qos.logback" % "logback-classic" % logbackVersion,
|
||||
|
||||
"org.typelevel" %% "vault" % vaultVersion
|
||||
"org.typelevel" %% "vault" % vaultVersion,
|
||||
|
||||
// Database
|
||||
"org.tpolecat" %% "doobie-core" % doobieVersion,
|
||||
"org.tpolecat" %% "doobie-hikari" % doobieVersion,
|
||||
"org.tpolecat" %% "doobie-postgres" % doobieVersion,
|
||||
"org.postgresql" % "postgresql" % postgresqlVersion
|
||||
)
|
||||
)
|
||||
|
||||
139
src/main/resources/db/migration/V1__Initial_schema.sql
Normal file
139
src/main/resources/db/migration/V1__Initial_schema.sql
Normal file
@ -0,0 +1,139 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS
|
||||
$$
|
||||
BEGIN
|
||||
NEW.updated_at := NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Таблица привилегий (например: "Администратор", "Оператор склада", "Менеджер заказов")
|
||||
CREATE TABLE user_privileges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE, -- internal code (e.g., 'admin', 'warehouse_operator')
|
||||
name TEXT NOT NULL, -- display name
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Таблица прав (перечень отдельных действий — CRUD по сущностям)
|
||||
CREATE TABLE user_permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE, -- internal code (e.g., 'product.view', 'order.update')
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Связь привилегий и разрешений (многие ко многим)
|
||||
CREATE TABLE user_privilege_permissions (
|
||||
privilege_id INTEGER REFERENCES user_privileges(id) ON DELETE CASCADE,
|
||||
permission_id INTEGER REFERENCES user_permissions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (privilege_id, permission_id)
|
||||
);
|
||||
|
||||
-- Таблица юзеров
|
||||
-- CREATE TABLE user_employees (
|
||||
-- id SERIAL PRIMARY KEY,
|
||||
-- full_name TEXT,
|
||||
-- login TEXT UNIQUE,
|
||||
-- password_hash TEXT NOT NULL,
|
||||
-- is_active BOOLEAN DEFAULT FALSE,
|
||||
-- privilege_id INTEGER REFERENCES user_privileges(id),
|
||||
-- created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
-- );
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
full_name TEXT,
|
||||
login TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
|
||||
is_blocked BOOLEAN DEFAULT FALSE,
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
privilege_id INTEGER REFERENCES user_privileges(id),
|
||||
|
||||
-- Баланс
|
||||
--btc BIGINT DEFAULT 0,
|
||||
--btc_balance NUMERIC(20, 8) DEFAULT 0.0,
|
||||
|
||||
-- Конфиденциальность
|
||||
is_searchable BOOLEAN DEFAULT TRUE,
|
||||
allow_message_forwarding BOOLEAN DEFAULT TRUE,
|
||||
allow_messages_from_non_contacts BOOLEAN DEFAULT TRUE,
|
||||
show_profile_photo_to_non_contacts BOOLEAN DEFAULT TRUE,
|
||||
last_seen_visibility SMALLINT DEFAULT 0 CHECK (last_seen_visibility IN (0, 1, 2)),
|
||||
last_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Биография
|
||||
bio TEXT,
|
||||
show_bio_to_non_contacts BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Сторисы
|
||||
show_stories_to_non_contacts BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Разрешения на чаты
|
||||
allow_server_chats BOOLEAN DEFAULT TRUE,
|
||||
public_invite_permission SMALLINT DEFAULT 0 CHECK (public_invite_permission IN (0, 1, 2)),
|
||||
group_invite_permission SMALLINT DEFAULT 0 CHECK (group_invite_permission IN (0, 1, 2)),
|
||||
--chat_invite_permission SMALLINT DEFAULT 0 CHECK (chat_invite_permission IN (0, 1, 2)),
|
||||
|
||||
-- Звонки
|
||||
call_permission SMALLINT DEFAULT 0 CHECK (call_permission IN (0, 1, 2)),
|
||||
|
||||
-- Автоудаление аккаунта
|
||||
auto_delete_after_days INTEGER CHECK (auto_delete_after_days IS NULL OR auto_delete_after_days > 0),
|
||||
|
||||
-- Автоудаление сообщений
|
||||
force_auto_delete_messages_in_private BOOLEAN DEFAULT FALSE,
|
||||
max_message_auto_delete_seconds INTEGER CHECK (max_message_auto_delete_seconds IS NULL OR max_message_auto_delete_seconds >= 0),
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- =======================
|
||||
-- Таблица сессий
|
||||
-- =======================
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
access_token TEXT NOT NULL, -- JWT
|
||||
refresh_token TEXT NOT NULL, -- JWT
|
||||
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
last_refresh_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- =======================
|
||||
-- Функция автообновления last_refresh_at при смене токенов
|
||||
-- =======================
|
||||
CREATE OR REPLACE FUNCTION update_last_refresh_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.access_token IS DISTINCT FROM OLD.access_token THEN
|
||||
NEW.last_refresh_at := CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- =======================
|
||||
-- Триггер на обновление
|
||||
-- =======================
|
||||
CREATE TRIGGER trg_update_last_refresh_at
|
||||
BEFORE UPDATE ON user_sessions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_last_refresh_at();
|
||||
@ -1,16 +1,24 @@
|
||||
package org.yobble.scala_monolith
|
||||
|
||||
import cats.effect.{ExitCode, IO, IOApp}
|
||||
import cats.effect.{ExitCode, IO, IOApp, Resource}
|
||||
import org.http4s.ember.server.EmberServerBuilder
|
||||
import org.http4s.server.Router
|
||||
import com.comcast.ip4s._
|
||||
import org.yobble.scala_monolith.api.route.Routes
|
||||
import org.yobble.scala_monolith.config.{Database, DatabaseConfig}
|
||||
import org.yobble.scala_monolith.middleware.GlobalErrorHandler
|
||||
import cats.implicits._
|
||||
import doobie.hikari.HikariTransactor
|
||||
|
||||
object Main extends IOApp {
|
||||
override def run(args: List[String]): IO[ExitCode] = {
|
||||
val httpApp = Router("/" -> Routes.all).orNotFound
|
||||
val resources: Resource[IO, HikariTransactor[IO]] = for {
|
||||
dbConfig <- Resource.eval(DatabaseConfig.load[IO])
|
||||
transactor <- Database.transactor[IO](dbConfig)
|
||||
} yield transactor
|
||||
|
||||
resources.use { transactor =>
|
||||
val httpApp = Router("/" -> Routes.all(transactor)).orNotFound
|
||||
val httpAppWithMiddleware = GlobalErrorHandler.withGlobalErrorHandler(httpApp)
|
||||
|
||||
val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080)
|
||||
@ -22,13 +30,12 @@ object Main extends IOApp {
|
||||
.withHttpApp(httpAppWithMiddleware)
|
||||
.build
|
||||
.use { server =>
|
||||
// Здесь вручную выводим "localhost" в лог, хотя слушаем "0.0.0.0"
|
||||
for {
|
||||
_ <- IO.println(s"Server running at http://localhost:$port")
|
||||
_ <- IO.println(s"Swagger UI available at http://localhost:$port/docs")
|
||||
_ <- IO.never
|
||||
} yield ()
|
||||
}
|
||||
.as(ExitCode.Success)
|
||||
}.as(ExitCode.Success)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package org.yobble.scala_monolith.api.dto
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
case class LoginRequest(login: String, password: String)
|
||||
|
||||
object LoginRequest {
|
||||
implicit val decoder: Decoder[LoginRequest] = deriveDecoder[LoginRequest]
|
||||
implicit val encoder: Encoder[LoginRequest] = deriveEncoder[LoginRequest]
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package org.yobble.scala_monolith.api.dto
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
case class LoginResponse(accessToken: String, refreshToken: String)
|
||||
|
||||
object LoginResponse {
|
||||
implicit val decoder: Decoder[LoginResponse] = deriveDecoder[LoginResponse]
|
||||
implicit val encoder: Encoder[LoginResponse] = deriveEncoder[LoginResponse]
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.yobble.scala_monolith.api.endpoint.auth
|
||||
|
||||
import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse}
|
||||
import sttp.tapir._
|
||||
import sttp.tapir.generic.auto._
|
||||
import sttp.tapir.json.circe._
|
||||
|
||||
object AuthEndpoints {
|
||||
|
||||
val loginEndpoint: PublicEndpoint[LoginRequest, String, LoginResponse, Any] =
|
||||
endpoint.post
|
||||
.in("auth" / "login")
|
||||
.in(jsonBody[LoginRequest])
|
||||
.out(jsonBody[LoginResponse])
|
||||
.errorOut(stringBody)
|
||||
}
|
||||
@ -1,14 +1,29 @@
|
||||
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.PingEndpoint
|
||||
import org.yobble.scala_monolith.api.endpoint.ErrorsEndpoint
|
||||
import org.yobble.scala_monolith.api.response.BaseResponse
|
||||
import org.yobble.scala_monolith.repository.UserRepositoryImpl
|
||||
import org.yobble.scala_monolith.service.AuthService
|
||||
|
||||
object AllServerEndpoints {
|
||||
val all: List[ServerEndpoint[Any, IO]] = List(
|
||||
def all(transactor: Transactor[IO]): List[ServerEndpoint[Any, IO]] = {
|
||||
|
||||
val authService = new AuthService(new UserRepositoryImpl(transactor))
|
||||
|
||||
val authEndpoints = List(
|
||||
AuthEndpoints.loginEndpoint.serverLogic(authService.login)
|
||||
)
|
||||
|
||||
val otherEndpoints = List(
|
||||
PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "pong"))),
|
||||
ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "errors")))
|
||||
)
|
||||
|
||||
authEndpoints ++ otherEndpoints
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package org.yobble.scala_monolith.api.route
|
||||
|
||||
import cats.effect.IO
|
||||
import doobie.Transactor
|
||||
import org.http4s.HttpRoutes
|
||||
import sttp.tapir.server.http4s.Http4sServerInterpreter
|
||||
import sttp.tapir.swagger.bundle.SwaggerInterpreter
|
||||
@ -8,11 +9,12 @@ import org.yobble.scala_monolith.middleware.RealIpAndUserAgentMiddleware
|
||||
import cats.syntax.semigroupk._
|
||||
|
||||
object Routes {
|
||||
private val allServerEndpoints = AllServerEndpoints.all
|
||||
def all(transactor: Transactor[IO]): HttpRoutes[IO] = {
|
||||
val allServerEndpoints = AllServerEndpoints.all(transactor)
|
||||
|
||||
private val httpRoutes = Http4sServerInterpreter[IO]().toRoutes(allServerEndpoints)
|
||||
val httpRoutes = Http4sServerInterpreter[IO]().toRoutes(allServerEndpoints)
|
||||
|
||||
private val docsRoutes = Http4sServerInterpreter[IO]().toRoutes(
|
||||
val docsRoutes = Http4sServerInterpreter[IO]().toRoutes(
|
||||
SwaggerInterpreter().fromServerEndpoints[IO](
|
||||
allServerEndpoints,
|
||||
"scala_monolith API",
|
||||
@ -20,5 +22,6 @@ object Routes {
|
||||
)
|
||||
)
|
||||
|
||||
val all: HttpRoutes[IO] = RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes)
|
||||
RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package org.yobble.scala_monolith.config
|
||||
|
||||
import cats.effect.{Async, Resource}
|
||||
import doobie.hikari.HikariTransactor
|
||||
import org.yobble.scala_monolith.config.DatabaseConfig
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
object Database {
|
||||
|
||||
def transactor[F[_]: Async](config: DatabaseConfig): Resource[F, HikariTransactor[F]] = {
|
||||
for {
|
||||
ec <- Resource.eval(Async[F].executionContext)
|
||||
transactor <- HikariTransactor.newHikariTransactor[F](
|
||||
driverClassName = "org.postgresql.Driver",
|
||||
url = config.url,
|
||||
user = config.user,
|
||||
pass = config.password,
|
||||
connectEC = ec
|
||||
)
|
||||
} yield transactor
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package org.yobble.scala_monolith.config
|
||||
|
||||
import cats.effect.Sync
|
||||
|
||||
case class DatabaseConfig(
|
||||
url: String,
|
||||
user: String,
|
||||
password: String
|
||||
)
|
||||
|
||||
object DatabaseConfig {
|
||||
def load[F[_]: Sync]: F[DatabaseConfig] = {
|
||||
Sync[F].delay {
|
||||
// TODO: Load from a proper config file (e.g., application.conf)
|
||||
DatabaseConfig(
|
||||
url = "jdbc:postgresql://localhost:5101/yobble_db",
|
||||
user = "yobble_app_user",
|
||||
password = "strong_password_here"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/main/scala/org/yobble/scala_monolith/models/User.scala
Normal file
11
src/main/scala/org/yobble/scala_monolith/models/User.scala
Normal file
@ -0,0 +1,11 @@
|
||||
package org.yobble.scala_monolith.models
|
||||
|
||||
import doobie.Read
|
||||
import doobie.util.meta.Meta
|
||||
import java.util.UUID
|
||||
|
||||
case class User(id: UUID, login: String, passwordHash: String) derives Read
|
||||
|
||||
object User {
|
||||
implicit val uuidMeta: Meta[UUID] = Meta[String].timap(UUID.fromString)(_.toString)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package org.yobble.scala_monolith.repository
|
||||
|
||||
import cats.effect.IO
|
||||
import doobie.implicits._
|
||||
import doobie.postgres.implicits._
|
||||
import doobie.Transactor
|
||||
import org.yobble.scala_monolith.models.User
|
||||
|
||||
trait UserRepository {
|
||||
def findByLogin(login: String): IO[Option[User]]
|
||||
}
|
||||
|
||||
class UserRepositoryImpl(transactor: Transactor[IO]) extends UserRepository {
|
||||
override def findByLogin(login: String): IO[Option[User]] = {
|
||||
sql"SELECT id, login, password_hash as passwordHash FROM users WHERE login = $login"
|
||||
.query[User]
|
||||
.option
|
||||
.transact(transactor)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package org.yobble.scala_monolith.service
|
||||
|
||||
import cats.effect.IO
|
||||
import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse}
|
||||
import org.yobble.scala_monolith.repository.UserRepository
|
||||
|
||||
class AuthService(userRepository: UserRepository) {
|
||||
|
||||
def login(request: LoginRequest): IO[Either[String, LoginResponse]] = {
|
||||
userRepository.findByLogin(request.login).map {
|
||||
case Some(user) if user.passwordHash == request.password =>
|
||||
// TODO: Implement proper password hashing (e.g., with bcrypt)
|
||||
// TODO: Implement real token generation
|
||||
Right(LoginResponse(accessToken = "fake-access-token", refreshToken = "fake-refresh-token"))
|
||||
case _ =>
|
||||
Left("Invalid login or password")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user