Files
ipwhitelist-app/server.js
T

337 lines
16 KiB
JavaScript

const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const helmet = require('helmet');
// rateLimit.js: mutationLimiter (POST-роуты) и exportLimiter (GET /export)
const { mutationLimiter, exportLimiter } = require('./src/middleware/rateLimit');
// csrf.js: фабрика для инициализации CSRF-защиты (double-submit cookie pattern)
const { initCsrf } = require('./src/middleware/csrf');
require('dotenv').config();
const { checkConnection } = require('./src/db');
const { initAuth, requireAdmin } = require('./src/auth');
const { aggregateCIDRs } = require('./src/validators');
const q = require('./src/queries');
// config.js: MOCK_USERS (мок-пользователи для dev) и backUrl (хелпер редиректа)
const { MOCK_USERS, backUrl } = require('./src/config');
const app = express();
const PORT = process.env.PORT || 3000;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// ── Безопасность HTTP-заголовков ───────────────────────────────────────────────
// helmet выставляет: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security,
// Referrer-Policy и пр. CSP отключён явно — EJS использует inline-стили/скрипты.
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
app.use(cookieParser());
async function start() {
const auth = await initAuth();
// ── CSRF защита ────────────────────────────────────────────────────────────
// initCsrf() читает CSRF_SECRET и NODE_ENV из process.env.
// Возвращает: doubleCsrfProtection (middleware на POST) и
// generateCsrfToken (вызов в GET-роутах для передачи токена в шаблон).
const { doubleCsrfProtection, generateCsrfToken } = initCsrf();
// ── Публичные маршруты (до auth.middleware) ──────────────────────────────────
// k8s liveness probe — простая проверка что сервер жив
app.get('/healthz', (req, res) => res.send('OK'));
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
// GET /export: агрегированный список CIDR, без авторизации.
// src/routes/export.js содержит логику и комментарии.
app.use(require('./src/routes/export').createRouter({
q,
exportLimiter,
aggregateCIDRs,
}));
// Страница входа — отдаём форму выбора мок-пользователя.
// Генерируем CSRF-токен и передаём в шаблон для скрытого поля формы.
app.get('/login', (req, res) => {
const csrfToken = generateCsrfToken(req, res);
res.render('login', { users: MOCK_USERS, error: req.query.error || null, csrfToken });
});
// Обработка логина: создаём JWT и кладём в httpOnly cookie.
// doubleCsrfProtection проверяет, что форма отправлена с нашей страницы /login.
app.post('/login', doubleCsrfProtection, async (req, res) => {
const user = MOCK_USERS.find(u => u.id === req.body.user);
if (!user) return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
try {
const token = await auth.issue({
clientId: user.clientId,
companyId: user.companyId,
companyName: user.companyName,
email: user.email,
});
res.cookie('jwt', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24 часа
});
res.redirect(req.query.returnTo || '/');
} catch (e) {
res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена'));
}
});
// Выход: очищаем cookie и возвращаем на /login
app.get('/logout', (req, res) => { res.clearCookie('jwt'); res.redirect('/login'); });
// ── Все маршруты ниже требуют валидного JWT (проверяет auth.middleware) ───────
app.use(auth.middleware);
// ── Главная страница: список белых адресов ────────────────────────────────────
app.get('/', async (req, res) => {
const { clientId, companyName, isAdmin } = req.user;
try {
if (isAdmin) {
// Администратор видит все компании с фильтром по выбранной.
// ?company=<id> — числовой PK из таблицы companies.
const companies = await q.getAllCompanies();
const selectedId = req.query.company
? parseInt(req.query.company, 10)
: (companies[0] ? companies[0].id : null);
const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null;
const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : [];
const limit = selectedCompany ? await q.getLimit(selectedCompany) : 0;
return res.render('index', {
entries, limit, used: entries.length,
user: req.user, isAdmin: true,
companies, selectedCompany,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
// CSRF-токен для форм (add, edit, delete) на странице
csrfToken: generateCsrfToken(req, res),
});
}
// Обычный пользователь: только его компания
const company = await q.getOrCreateCompany(clientId, companyName);
const limit = await q.getLimit(company);
const entries = await q.listEntries(company.id);
res.render('index', {
entries, limit, used: entries.length,
user: req.user, isAdmin: false,
companies: null, selectedCompany: company,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
// CSRF-токен для форм (add, edit, delete)
csrfToken: generateCsrfToken(req, res),
});
} catch (e) {
console.error('GET / error:', e);
res.render('index', {
entries: [], limit: 15, used: 0,
user: req.user, isAdmin,
companies: null, selectedCompany: null,
error: 'Ошибка загрузки данных: ' + e.message,
message: null, wasNormalized: false,
csrfToken: generateCsrfToken(req, res),
});
}
});
// ── Добавление записи ─────────────────────────────────────────────────────────
// mutationLimiter: не более 30 POST-запросов/мин с одного IP.
// doubleCsrfProtection: проверяет CSRF-токен из скрытого поля формы.
app.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user;
let company;
try {
if (isAdmin && req.body.company_id) {
// Администратор добавляет в выбранную компанию (скрытое поле company_id)
const id = parseInt(req.body.company_id, 10);
if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
company = await q.getCompanyById(id);
if (!company) throw new Error('Компания не найдена');
} else {
// Пользователь: только своя компания (из токена)
company = await q.getOrCreateCompany(clientId, companyName);
}
const result = await q.createEntry(company.id, value, comment, email);
const msg = result.wasNormalized
? `Адрес нормализован в ${result.entry.value_cidr}`
: 'Добавлено';
res.redirect(backUrl(isAdmin, company.id, {
message: msg,
wasNormalized: result.wasNormalized ? '1' : '0',
}));
} catch (e) {
// При ошибке возвращаемся к тому же виду что был
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
// ── Редактирование записи ─────────────────────────────────────────────────────
app.post('/edit/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user;
const entryId = parseInt(req.params.id, 10);
let company;
try {
if (isAdmin && req.body.company_id) {
const id = parseInt(req.body.company_id, 10);
if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
company = await q.getCompanyById(id);
if (!company) throw new Error('Компания не найдена');
} else {
company = await q.getOrCreateCompany(clientId, companyName);
}
const result = await q.updateEntry(entryId, company.id, value, comment, email);
const msg = result.wasNormalized
? `Адрес нормализован в ${result.entry.value_cidr}`
: 'Сохранено';
res.redirect(backUrl(isAdmin, company.id, {
message: msg,
wasNormalized: result.wasNormalized ? '1' : '0',
}));
} catch (e) {
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
// ── Удаление записи (soft delete) ────────────────────────────────────────────
app.post('/delete/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { clientId, companyName, email, isAdmin } = req.user;
const entryId = parseInt(req.params.id, 10);
let company;
try {
if (isAdmin && req.body.company_id) {
const id = parseInt(req.body.company_id, 10);
if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
company = await q.getCompanyById(id);
if (!company) throw new Error('Компания не найдена');
} else {
company = await q.getOrCreateCompany(clientId, companyName);
}
await q.deleteEntry(entryId, company.id, email);
res.redirect(backUrl(isAdmin, company.id, { message: 'Запись удалена' }));
} catch (e) {
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
// ── Журнал аудита (только admin) ──────────────────────────────────────────────
// ТЗ 4.6: «журнал доступен для просмотра только администратору»
app.get('/audit', requireAdmin, async (req, res) => {
try {
// Если передан ?company=X — показываем только эту компанию
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
const companies = await q.getAllCompanies();
const rows = await q.getAudit(companyId || null);
res.render('audit', {
user: req.user,
rows,
companies,
selectedCompanyId: companyId,
error: req.query.error || null,
});
} catch (e) {
console.error('GET /audit error:', e);
res.render('audit', {
user: req.user, rows: [], companies: [],
selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message,
});
}
});
// ── Страница управления лимитами (только admin) ───────────────────────────────
app.get('/admin', requireAdmin, async (req, res) => {
try {
const companies = await q.getAllCompanies();
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
res.render('admin', {
user: req.user,
companies,
defaultLimit,
message: req.query.message || null,
error: req.query.error || null,
// CSRF-токен для форм /admin/limit/:id на странице
csrfToken: generateCsrfToken(req, res),
});
} catch (e) {
console.error('GET /admin error:', e);
res.status(500).send('Ошибка: ' + e.message);
}
});
// ── Изменение лимита компании (только admin) ──────────────────────────────────
// ТЗ 4.5: «администратор может задать индивидуальный лимит»
app.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection, async (req, res) => {
const companyId = parseInt(req.params.companyId, 10);
if (!Number.isFinite(companyId) || companyId <= 0) {
return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании'));
}
try {
const raw = req.body.limit;
let newLimit;
if (raw === '' || raw === null || raw === undefined) {
// Пустое поле = сброс на глобальный дефолт
newLimit = null;
} else {
newLimit = parseInt(raw, 10);
if (!Number.isFinite(newLimit) || newLimit < 0) {
throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса');
}
}
await q.setLimit(companyId, newLimit);
const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`;
res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`));
} catch (e) {
res.redirect('/admin?error=' + encodeURIComponent(e.message));
}
});
// ── CSRF error handler ────────────────────────────────────────────────────────
// doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
// Ловим ДО общего обработчика — возвращаем понятное сообщение пользователю.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) {
return res.status(403).send(
'<p style="font-family:sans-serif;padding:2rem">' +
'Недействительный CSRF-токен. ' +
'<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
'</p>'
);
}
console.error('Unhandled error:', err);
res.status(500).send('Внутренняя ошибка сервера');
});
// ── Старт сервера ─────────────────────────────────────────────────────────────
checkConnection()
.then(() => console.log('DB connected'))
.catch(e => console.error('DB not ready:', e.message));
app.listen(PORT, () => console.log('Server on port ' + PORT));
}
start().catch(e => { console.error('Startup error:', e); process.exit(1); });