Авторизация: форма входа, cookie-сессия, защита /api/* (кроме health/login/me/logout)

This commit is contained in:
2026-08-26 00:38:23 +07:00
parent 5dcc4ce1ad
commit d9eccfb4b7
7 changed files with 482 additions and 17 deletions

144
server.js
View File

@@ -58,6 +58,9 @@ app.use(express.static(path.join(__dirname, "public"), {
let codexEditInFlight = false;
// JSON-тело нужно для POST /api/login; multipart обрабатывает multer.
app.use(express.json({ limit: "16kb" }));
class AppError extends Error {
constructor(status, message, details = undefined) {
super(message);
@@ -67,6 +70,96 @@ class AppError extends Error {
}
}
// --- Авторизация -------------------------------------------------
const SESSION_COOKIE = "kadr_session";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 суток
const APP_USER = String(process.env.APP_USER || "").trim() || "admin";
const APP_PASSWORD =
process.env.APP_PASSWORD && String(process.env.APP_PASSWORD).length > 0
? String(process.env.APP_PASSWORD)
: crypto.randomBytes(12).toString("base64url"); // 16 символов
if (!process.env.APP_PASSWORD) {
console.log("");
console.log("========================================================");
console.log(" APP_PASSWORD не задан. Сгенерирован случайный пароль:");
console.log(` логин: ${APP_USER}`);
console.log(` пароль: ${APP_PASSWORD}`);
console.log(" Задайте переменные окружения APP_USER / APP_PASSWORD,");
console.log(" чтобы зафиксировать пароль.");
console.log("========================================================");
console.log("");
}
// token -> { user, expiresAt }
const sessions = new Map();
function constantTimeEqual(a, b) {
const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest();
const digestB = crypto.createHash("sha256").update(String(b), "utf8").digest();
return crypto.timingSafeEqual(digestA, digestB);
}
function makeSessionToken() {
return crypto.randomBytes(32).toString("hex");
}
function sessionCookieOptions() {
return {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS
// secure не задаём: приложение работает по HTTP в LAN
};
}
function readSessionToken(request) {
const header = String(request.headers.cookie || "");
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const name = part.slice(0, eq).trim();
if (name === SESSION_COOKIE) {
const value = part.slice(eq + 1).trim();
try { return decodeURIComponent(value); } catch { return value; }
}
}
return null;
}
function getSession(request) {
const token = readSessionToken(request);
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (session.expiresAt < Date.now()) {
sessions.delete(token);
return null;
}
return { token, session };
}
function requireAuth(request, response, next) {
const found = getSession(request);
if (!found) {
next(new AppError(401, "Требуется авторизация."));
return;
}
// Sliding: продлеваем сессию при каждой активности
found.session.expiresAt = Date.now() + SESSION_TTL_MS;
response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions());
next();
}
setInterval(() => {
const now = Date.now();
for (const [token, session] of sessions) {
if (session.expiresAt < now) sessions.delete(token);
}
}, 60 * 60 * 1000).unref();
function isHeic(file) {
const mimetype = String(file?.mimetype || "").toLowerCase();
const extension = path.extname(
@@ -1156,6 +1249,46 @@ app.get("/api/health", asyncRoute(async (_request, response) => {
}
}));
app.post("/api/login", asyncRoute(async (request, response) => {
const username = String(request.body?.username || "").trim();
const password = String(request.body?.password || "");
if (!username || !password) {
throw new AppError(400, "Введите логин и пароль.");
}
if (!constantTimeEqual(APP_USER, username) ||
!constantTimeEqual(APP_PASSWORD, password)) {
throw new AppError(401, "Неверный логин или пароль.");
}
const token = makeSessionToken();
sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS });
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
response.json({ ok: true, user: APP_USER });
}));
app.get("/api/me", (request, response, next) => {
const found = getSession(request);
if (!found) {
next(new AppError(401, "Требуется авторизация."));
return;
}
found.session.expiresAt = Date.now() + SESSION_TTL_MS;
response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions());
response.json({ ok: true, user: found.session.user });
});
app.post("/api/logout", (request, response) => {
const token = readSessionToken(request);
if (token) sessions.delete(token);
response.clearCookie(SESSION_COOKIE, { path: "/" });
response.json({ ok: true });
});
// Все эндпоинты ниже — только для авторизованных пользователей.
app.use("/api", requireAuth);
app.post(
"/api/preview",
upload.single("photo"),
@@ -1510,7 +1643,16 @@ app.use((error, _request, response, next) => {
}
}
if (!(error instanceof AppError) && !(error instanceof multer.MulterError)) {
if (error && error.type === "entity.parse.failed") {
status = 400;
message = "Некорректный JSON в теле запроса.";
}
if (
!(error instanceof AppError) &&
!(error instanceof multer.MulterError) &&
!(error && error.type === "entity.parse.failed")
) {
console.error(error);
message = "Внутренняя ошибка сервера.";
}