init
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { authMiddleware } from "../middlewares/auth";
|
||||
import {
|
||||
loginService,
|
||||
registerService,
|
||||
sessionService,
|
||||
} from "../services/auth";
|
||||
import type { LoginData, RegisterData } from "../services/auth/types";
|
||||
|
||||
export const authController = new Elysia({ prefix: "/auth" })
|
||||
// POST /login
|
||||
.post("/login", async ({ body, status, request }) => {
|
||||
const { email, password } = body as LoginData;
|
||||
|
||||
// Получить метаданные запроса
|
||||
const metadata = {
|
||||
userAgent: request.headers.get("User-Agent") || null,
|
||||
ipAddress:
|
||||
request.headers.get("X-Forwarded-For") ||
|
||||
request.headers.get("X-Real-IP") ||
|
||||
null,
|
||||
};
|
||||
|
||||
const result = await loginService.login(email, password, metadata);
|
||||
|
||||
if (!result) {
|
||||
return status(401, "Invalid email or password");
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
// POST /register (публичная регистрация)
|
||||
.post("/register", async ({ body, status }) => {
|
||||
const result = await registerService.register(body as RegisterData);
|
||||
|
||||
if (!result) {
|
||||
return status(409, "User with this email already exists");
|
||||
}
|
||||
|
||||
return { user: result };
|
||||
})
|
||||
// Защищенные роуты (требуют authMiddleware + проверка ролей через БД)
|
||||
.use(authMiddleware)
|
||||
// GET /me
|
||||
.get("/me", async ({ currentUser }) => {
|
||||
return { user: currentUser };
|
||||
})
|
||||
// POST /logout
|
||||
.post("/logout", async ({ authSession }) => {
|
||||
await sessionService.revoke(authSession.id);
|
||||
return { message: "Logged out successfully" };
|
||||
})
|
||||
// POST /register-user (регистрация администратором)
|
||||
// Доступ проверяется через БД (таблица protected_routes)
|
||||
.post("/register-user", async ({ body, status, currentUser }) => {
|
||||
const result = await registerService.register(
|
||||
body as RegisterData,
|
||||
currentUser.role
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return status(409, "User with this email already exists");
|
||||
}
|
||||
|
||||
return { user: result };
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema/index";
|
||||
|
||||
const db = drizzle(process.env.DATABASE_URL!, { schema });
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
timestamp,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
|
||||
export const apps = pgTable("apps", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name").notNull(), // Имя приложения (например, "minecraft")
|
||||
title: varchar("title").notNull(), // Название приложения (например, "Майнкрафт")
|
||||
gpuLimitMb: integer("gpu_limit_mb"), // Лимит GPU в мегабайтах (только для stream серверов)
|
||||
psVersion: integer("ps_version"), // Версия Pixel Streaming (например, "1")
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertAppSchema = createInsertSchema(apps);
|
||||
export const selectAppSchema = createSelectSchema(apps);
|
||||
|
||||
// Relations
|
||||
export const appsRelations = relations(apps, ({ many }) => ({
|
||||
serverSessions: many(serverSessions),
|
||||
}));
|
||||
|
||||
// Type exports
|
||||
export type App = typeof apps.$inferSelect;
|
||||
export type NewApp = typeof apps.$inferInsert;
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
timestamp,
|
||||
text,
|
||||
inet,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { users } from "./users";
|
||||
|
||||
// Храним пользовательские сессии. accessToken хранится как bcrypt-хэш
|
||||
export const authSessions = pgTable("auth_sessions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
// bcrypt-хэш токена доступа. Длина 60-72 символа, но оставим запас
|
||||
accessTokenHash: varchar("access_token_hash", { length: 72 }).notNull(),
|
||||
// Доп. метаданные устройства/браузера
|
||||
userAgent: text("user_agent"),
|
||||
ipAddress: inet("ip_address"),
|
||||
// Срок действия и отзыв
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const authSessionsRelations = relations(authSessions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [authSessions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertAuthSessionSchema = createInsertSchema(authSessions);
|
||||
export const selectAuthSessionSchema = createSelectSchema(authSessions);
|
||||
|
||||
// Type exports
|
||||
export type AuthSession = typeof authSessions.$inferSelect;
|
||||
export type NewAuthSession = typeof authSessions.$inferInsert;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { pgEnum } from "drizzle-orm/pg-core";
|
||||
|
||||
// Enum для ролей пользователей
|
||||
export const roleNameEnum = pgEnum("role_name", [
|
||||
"admin",
|
||||
"director",
|
||||
"manager",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Export all schemas
|
||||
export * from "./enums";
|
||||
export * from "./streamServers";
|
||||
export * from "./localServers";
|
||||
export * from "./apps";
|
||||
export * from "./users";
|
||||
export * from "./serverSessions";
|
||||
export * from "./authSessions";
|
||||
export * from "./protectedRoutes";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { pgTable, uuid, varchar } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
|
||||
export const localServers = pgTable("local_servers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars
|
||||
hostname: varchar("hostname", { length: 255 }).notNull(),
|
||||
location: varchar("location", { length: 10 }).notNull(), // ru1, uae1
|
||||
type: varchar("type", { length: 10 }).notNull(), // demo, prod
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertLocalServerSchema = createInsertSchema(localServers);
|
||||
export const selectLocalServerSchema = createSelectSchema(localServers);
|
||||
|
||||
// Relations
|
||||
export const localServersRelations = relations(localServers, ({ many }) => ({
|
||||
serverSessions: many(serverSessions, {
|
||||
relationName: "session_local_server",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Type exports
|
||||
export type LocalServer = typeof localServers.$inferSelect;
|
||||
export type NewLocalServer = typeof localServers.$inferInsert;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { pgTable, varchar, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { roleNameEnum } from "./enums";
|
||||
|
||||
export const protectedRoutes = pgTable("protected_routes", {
|
||||
path: varchar("path", { length: 255 }).primaryKey(), // /auth/register-user
|
||||
methods: varchar("methods", { length: 50 })
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]), // Массив: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
roles: roleNameEnum("roles")
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]), // Массив: ["admin", "director", "manager"]
|
||||
description: text("description"), // Описание маршрута
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertProtectedRouteSchema = createInsertSchema(protectedRoutes);
|
||||
export const selectProtectedRouteSchema = createSelectSchema(protectedRoutes);
|
||||
|
||||
// Type exports
|
||||
export type ProtectedRoute = typeof protectedRoutes.$inferSelect;
|
||||
export type NewProtectedRoute = typeof protectedRoutes.$inferInsert;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { pgTable, uuid, integer, timestamp, pgEnum } from "drizzle-orm/pg-core";
|
||||
import { streamServers } from "./streamServers";
|
||||
import { localServers } from "./localServers";
|
||||
import { apps } from "./apps";
|
||||
import { users } from "./users";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
|
||||
// Enums
|
||||
export const sessionModeEnum = pgEnum("session_mode", ["stream", "local"]);
|
||||
|
||||
export const serverSessions = pgTable("server_sessions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
serverId: uuid("server_id").notNull(),
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
startAt: timestamp("start_at").defaultNow().notNull(),
|
||||
endAt: timestamp("end_at"), // Default 30 minutes from start_at
|
||||
appPid: integer("app_pid"),
|
||||
cirrusPid: integer("cirrus_pid"),
|
||||
mode: sessionModeEnum("mode").notNull(), // stream, local
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const serverSessionsRelations = relations(serverSessions, ({ one }) => ({
|
||||
app: one(apps, {
|
||||
fields: [serverSessions.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [serverSessions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
// Полиморфная реляция для serverId
|
||||
// В зависимости от mode, serverId может ссылаться на stream_servers или local_servers
|
||||
streamServer: one(streamServers, {
|
||||
fields: [serverSessions.serverId],
|
||||
references: [streamServers.id],
|
||||
relationName: "session_stream_server",
|
||||
}),
|
||||
localServer: one(localServers, {
|
||||
fields: [serverSessions.serverId],
|
||||
references: [localServers.id],
|
||||
relationName: "session_local_server",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertServerSessionSchema = createInsertSchema(serverSessions);
|
||||
export const selectServerSessionSchema = createSelectSchema(serverSessions);
|
||||
|
||||
// Type exports
|
||||
export type ServerSession = typeof serverSessions.$inferSelect;
|
||||
export type NewServerSession = typeof serverSessions.$inferInsert;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { pgTable, uuid, varchar, pgEnum } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
|
||||
// Enums
|
||||
export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]);
|
||||
export const serverTypeEnum = pgEnum("server_type", ["demo", "prod"]);
|
||||
|
||||
export const streamServers = pgTable("stream_servers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars
|
||||
hostname: varchar("hostname", { length: 255 }).notNull(),
|
||||
location: serverLocationEnum("location").notNull(),
|
||||
type: serverTypeEnum("type").notNull(),
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertStreamServerSchema = createInsertSchema(streamServers);
|
||||
export const selectStreamServerSchema = createSelectSchema(streamServers);
|
||||
|
||||
// Relations
|
||||
export const streamServersRelations = relations(streamServers, ({ many }) => ({
|
||||
serverSessions: many(serverSessions, {
|
||||
relationName: "session_stream_server",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Type exports
|
||||
export type StreamServer = typeof streamServers.$inferSelect;
|
||||
export type NewStreamServer = typeof streamServers.$inferInsert;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { roleNameEnum } from "./enums";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
import { authSessions } from "./authSessions";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
password: varchar("password", { length: 255 }).notNull(), // scrypt hash
|
||||
fullName: varchar("full_name", { length: 255 }).notNull(), // ФИО
|
||||
role: roleNameEnum("role").notNull().default("manager"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertUserSchema = createInsertSchema(users);
|
||||
export const selectUserSchema = createSelectSchema(users);
|
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
serverSessions: many(serverSessions),
|
||||
authSessions: many(authSessions),
|
||||
}));
|
||||
|
||||
// Type exports
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Elysia } from "elysia";
|
||||
import cors from "@elysiajs/cors";
|
||||
import { authController } from "./controllers/auth";
|
||||
|
||||
const app = new Elysia();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: true,
|
||||
// credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(authController);
|
||||
|
||||
app.listen(3000);
|
||||
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
|
||||
);
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { AuthSession, authSessions } from "../db/schema/authSessions";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import db from "../db";
|
||||
import { jwtVerify } from "jose";
|
||||
import { userService } from "../services/auth/user";
|
||||
import { protectedRoutes } from "../db/schema";
|
||||
import { RoleName } from "../services/auth";
|
||||
|
||||
// JWT секрет (должен совпадать с session.service.ts)
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
|
||||
export const authMiddleware = new Elysia().derive(
|
||||
{ as: "scoped" },
|
||||
async ({ request, status }) => {
|
||||
const { headers } = request;
|
||||
const authHeader = headers.get("Authorization");
|
||||
|
||||
// 1. Проверить наличие заголовка Authorization
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return status(401, "Missing or invalid authorization header");
|
||||
}
|
||||
|
||||
const accessToken = authHeader.split(" ")[1];
|
||||
|
||||
if (!accessToken) {
|
||||
return status(401, "Missing access token");
|
||||
}
|
||||
|
||||
// 2. Верифицировать JWT (проверка подписи и срока действия)
|
||||
let sessionId: string;
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<{ id: string }>(
|
||||
accessToken,
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
sessionId = payload.id;
|
||||
|
||||
if (!sessionId) {
|
||||
return status(401, "Invalid token payload");
|
||||
}
|
||||
} catch (err) {
|
||||
return status(401, "Invalid or expired token");
|
||||
}
|
||||
|
||||
// 3. Получить сессию из БД и проверить её валидность
|
||||
let authSession: AuthSession;
|
||||
|
||||
try {
|
||||
authSession = (
|
||||
await db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(authSessions.id, sessionId),
|
||||
isNull(authSessions.revokedAt) // Сессия не отозвана
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
if (!authSession) {
|
||||
return status(401, "Session not found or revoked");
|
||||
}
|
||||
} catch (err) {
|
||||
// Ошибка БД - 500, а не 401
|
||||
console.error("Database error in auth middleware:", err);
|
||||
return status(500, "Internal server error");
|
||||
}
|
||||
|
||||
// 4. Проверить срок действия сессии
|
||||
if (authSession.expiresAt && authSession.expiresAt < new Date()) {
|
||||
return status(401, "Session expired");
|
||||
}
|
||||
|
||||
// 5. Верифицировать bcrypt hash токена
|
||||
try {
|
||||
const verified = await Bun.password.verify(
|
||||
accessToken,
|
||||
authSession.accessTokenHash
|
||||
);
|
||||
|
||||
if (!verified) {
|
||||
return status(401, "Invalid token hash");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Token verification error:", err);
|
||||
return status(500, "Internal server error");
|
||||
}
|
||||
|
||||
// 6. Получить пользователя
|
||||
const user = await userService.findById(authSession.userId);
|
||||
|
||||
if (!user) {
|
||||
return status(401, "User not found");
|
||||
}
|
||||
|
||||
// 7. Проверить доступ к маршруту на основе ролей (если маршрут защищен)
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// Получить маршрут из БД
|
||||
const route = await db
|
||||
.select({
|
||||
methods: protectedRoutes.methods,
|
||||
roles: protectedRoutes.roles,
|
||||
})
|
||||
.from(protectedRoutes)
|
||||
.where(eq(protectedRoutes.path, path))
|
||||
.limit(1);
|
||||
|
||||
// Если маршрут защищен, проверить права доступа
|
||||
if (route.length > 0) {
|
||||
const allowedMethods = route[0].methods;
|
||||
const allowedRoles = route[0].roles;
|
||||
|
||||
// Проверить, что метод входит в список разрешенных
|
||||
if (allowedMethods.includes(method)) {
|
||||
// Проверить, есть ли роль пользователя среди разрешенных
|
||||
if (!allowedRoles.includes(user.role as RoleName)) {
|
||||
return status(403, "Access denied: insufficient permissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Всё ОК - вернуть сессию и санитизированного пользователя (без пароля)
|
||||
return {
|
||||
authSession,
|
||||
currentUser: userService.sanitize(user),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
// Экспорт всех auth сервисов и типов
|
||||
export * from "./types";
|
||||
export * from "./user";
|
||||
export * from "./session";
|
||||
export * from "./login";
|
||||
export * from "./register";
|
||||
@@ -0,0 +1,51 @@
|
||||
import { userService } from "./user";
|
||||
import { sessionService } from "./session";
|
||||
import type { LoginResult, SessionMetadata } from "./types";
|
||||
|
||||
/**
|
||||
* Сервис авторизации
|
||||
*/
|
||||
export const loginService = {
|
||||
/**
|
||||
* Авторизация пользователя
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
metadata: SessionMetadata
|
||||
): Promise<LoginResult | null> {
|
||||
// Найти пользователя по email
|
||||
const user = await userService.findByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Проверить пароль
|
||||
const isPasswordValid = await userService.verifyPassword(
|
||||
password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Создать новую сессию
|
||||
const { sessionId, accessToken } = await sessionService.create(user.id, metadata);
|
||||
|
||||
// Вычислить дату истечения токена
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
return {
|
||||
user: userService.sanitize(user),
|
||||
session: {
|
||||
id: sessionId,
|
||||
token: accessToken,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { userService } from "./user";
|
||||
import type { RegisterData, UserResponse } from "./types";
|
||||
|
||||
// Роль по умолчанию для новых пользователей
|
||||
const DEFAULT_ROLE_NAME = "manager" as const;
|
||||
|
||||
/**
|
||||
* Сервис регистрации
|
||||
*/
|
||||
export const registerService = {
|
||||
/**
|
||||
* Регистрация нового пользователя
|
||||
* @param data - данные для регистрации
|
||||
* @param callerRole - роль вызывающего пользователя (если авторизован)
|
||||
*/
|
||||
async register(
|
||||
data: RegisterData,
|
||||
callerRole?: string
|
||||
): Promise<UserResponse | null> {
|
||||
// Проверить, существует ли пользователь
|
||||
const existingUser = await userService.findByEmail(data.email);
|
||||
|
||||
if (existingUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Захешировать пароль
|
||||
const hashedPassword = await userService.hashPassword(data.password);
|
||||
|
||||
// Определить роль для нового пользователя
|
||||
// Только администраторы могут указывать кастомную роль
|
||||
const role =
|
||||
callerRole === "admin" && data.role
|
||||
? data.role
|
||||
: DEFAULT_ROLE_NAME;
|
||||
|
||||
// Создать пользователя
|
||||
const newUser = await userService.create({
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
fullName: data.fullName,
|
||||
role,
|
||||
});
|
||||
|
||||
return userService.sanitize(newUser);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { SignJWT } from "jose";
|
||||
import db from "../../db";
|
||||
import { authSessions } from "../../db/schema/authSessions";
|
||||
import type { SessionMetadata } from "./types";
|
||||
|
||||
// JWT секрет (лучше вынести в .env)
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || "your-secret-key-change-this"
|
||||
);
|
||||
|
||||
const TOKEN_EXPIRATION_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Сервис для работы с сессиями
|
||||
*/
|
||||
export const sessionService = {
|
||||
/**
|
||||
* Создать новую сессию
|
||||
*/
|
||||
async create(
|
||||
userId: string,
|
||||
metadata: SessionMetadata
|
||||
): Promise<{ sessionId: string; accessToken: string }> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
// Создать JWT токен с id сессии
|
||||
const accessToken = await new SignJWT({ id: sessionId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${TOKEN_EXPIRATION_DAYS}d`)
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
// Захешировать токен через bcrypt для хранения в БД
|
||||
const accessTokenHash = await Bun.password.hash(accessToken, {
|
||||
algorithm: "bcrypt",
|
||||
});
|
||||
|
||||
// Вычислить дату истечения
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + TOKEN_EXPIRATION_DAYS);
|
||||
|
||||
// Сохранить сессию в БД
|
||||
await db.insert(authSessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
accessTokenHash,
|
||||
userAgent: metadata.userAgent,
|
||||
ipAddress: metadata.ipAddress,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { sessionId, accessToken };
|
||||
},
|
||||
|
||||
/**
|
||||
* Отозвать сессию (logout)
|
||||
*/
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
await db
|
||||
.update(authSessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(authSessions.id, sessionId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить сессию по ID
|
||||
*/
|
||||
async findById(sessionId: string) {
|
||||
const session = (
|
||||
await db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(eq(authSessions.id, sessionId))
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
return session || null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// Типы для auth сервисов
|
||||
|
||||
export type RoleName = "admin" | "director" | "manager";
|
||||
|
||||
export type UserResponse = {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: RoleName;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type LoginResult = {
|
||||
user: UserResponse;
|
||||
session: {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RegisterData = {
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
role?: RoleName; // Опциональное поле, по умолчанию используется "manager"
|
||||
};
|
||||
|
||||
export type LoginData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type SessionMetadata = {
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import db from "../../db";
|
||||
import { users } from "../../db/schema/users";
|
||||
import type { User } from "../../db/schema/users";
|
||||
import type { RoleName, UserResponse } from "./types";
|
||||
|
||||
/**
|
||||
* Сервис для работы с пользователями
|
||||
*/
|
||||
export const userService = {
|
||||
/**
|
||||
* Получить пользователя по email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const user = (
|
||||
await db.select().from(users).where(eq(users.email, email)).limit(1)
|
||||
)[0];
|
||||
|
||||
return user || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить пользователя по ID
|
||||
*/
|
||||
async findById(userId: string): Promise<User | null> {
|
||||
const user = (
|
||||
await db.select().from(users).where(eq(users.id, userId)).limit(1)
|
||||
)[0];
|
||||
|
||||
return user || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Создать нового пользователя
|
||||
*/
|
||||
async create(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
role: RoleName;
|
||||
}): Promise<User> {
|
||||
const newUser = (
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
fullName: data.fullName,
|
||||
role: data.role,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
return newUser;
|
||||
},
|
||||
|
||||
/**
|
||||
* Проверить пароль пользователя
|
||||
*/
|
||||
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return await Bun.password.verify(password, hash);
|
||||
},
|
||||
|
||||
/**
|
||||
* Захешировать пароль
|
||||
*/
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, { algorithm: "bcrypt" });
|
||||
},
|
||||
|
||||
/**
|
||||
* Убрать пароль из объекта пользователя
|
||||
*/
|
||||
sanitize(user: User): UserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user