This commit is contained in:
2025-10-03 15:43:22 +05:00
commit 531e2d2e7e
54 changed files with 2943 additions and 0 deletions
+66
View File
@@ -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 };
});
+6
View File
@@ -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;
+33
View File
@@ -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;
+49
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
import { pgEnum } from "drizzle-orm/pg-core";
// Enum для ролей пользователей
export const roleNameEnum = pgEnum("role_name", [
"admin",
"director",
"manager",
]);
+9
View File
@@ -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";
+27
View File
@@ -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;
+31
View File
@@ -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;
+60
View File
@@ -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;
+31
View File
@@ -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;
+34
View File
@@ -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;
+20
View File
@@ -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}`
);
+136
View File
@@ -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),
};
}
);
+6
View File
@@ -0,0 +1,6 @@
// Экспорт всех auth сервисов и типов
export * from "./types";
export * from "./user";
export * from "./session";
export * from "./login";
export * from "./register";
+51
View File
@@ -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(),
},
};
},
};
+48
View File
@@ -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);
},
};
+80
View File
@@ -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;
},
};
+38
View File
@@ -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;
};
+83
View File
@@ -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,
};
},
};