Авторизация: форма входа, cookie-сессия, защита /api/* (кроме health/login/me/logout)
This commit is contained in:
144
server.js
144
server.js
@@ -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 = "Внутренняя ошибка сервера.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user