Files
ipwhitelist-app/src/routes/auth.js
T
naeel a5170a062c security: fix 7 vulnerabilities from Opus code review
- [A01] /export: mount after auth.middleware, filter by company for non-admin
- [A02] OIDC: verify issuer, audience, select key by kid; preload JWKS at startup
- [A07] session fixation: session.regenerate() in POST /login, GET /callback, POST /dev-login
- [A10] open redirect: safeReturn() helper validates returnTo (blocks //evil.com, https:// etc)
- [A05] CSRF: getSessionIdentifier uses req.sessionID instead of dead req.cookies.jwt
- [A05] fail-fast: reject default SESSION_SECRET / CSRF_SECRET in NODE_ENV=production
- [A05] DEV_MODE=true blocked in production

Tests: 68 PASS, 0 FAIL (was 50, added 18 new security-focused tests)
2026-05-30 15:25:57 +03:00

224 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* src/routes/auth.js — маршруты аутентификации.
*
* OIDC-режим (KC_CLIENT_ID + KC_CLIENT_SECRET заданы в окружении):
* GET /login → генерирует state, сохраняет в сессии, редиректит на Keycloak
* GET /callback → проверяет state, обменивает code→токен, кладёт user в сессию
* GET /logout → уничтожает сессию + редирект на KK logout
*
* Mock-режим (нет OIDC credentials):
* GET /login → рендерит views/login.ejs — список MOCK_USERS
* POST /login → CSRF → сохраняет выбранного user в сессию → /
* GET /logout → уничтожает сессию → /login
*
* Dev-backdoor (DEV_MODE=true или DEV_SECRET задан):
* GET /dev-login → рендерит views/dev-login.ejs (требует ?key если DEV_SECRET задан)
* POST /dev-login → CSRF → кладёт произвольного user в сессию → /
* Работает в ЛЮБОМ режиме (OIDC или mock) — обходит нормальный поток.
*
* @param {{ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }} deps
* @returns {import('express').Router}
*/
'use strict';
const crypto = require('crypto');
const { Router } = require('express');
/**
* Валидирует redirect-цель: разрешает только локальные пути (начинается с '/',
* не начинается с '//').
* Защита от open redirect: //evil.com и https://evil.com → '/'.
* @param {string|undefined} target
* @returns {string}
*/
function safeReturn(target) {
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
return target;
}
return '/';
}
function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }) {
const router = Router();
// ── GET /login ─────────────────────────────────────────────────────────────
router.get('/login', (req, res) => {
if (auth.isOidc) {
// OIDC: генерируем state (защита от CSRF на callback), редиректим на KK
const state = crypto.randomBytes(16).toString('hex');
req.session.oidcState = state;
req.session.returnTo = req.query.returnTo || '/';
return res.redirect(auth.buildAuthUrl(state));
}
// Mock-режим: форма выбора пользователя
const csrfToken = generateCsrfToken(req, res);
res.render('login', {
users: MOCK_USERS,
error: req.query.error || null,
csrfToken,
devLoginUrl: auth.devLoginEnabled ? '/dev-login' : null,
});
});
// ── POST /login (только mock-режим) ────────────────────────────────────────
router.post('/login', doubleCsrfProtection, (req, res) => {
if (auth.isOidc) return res.redirect('/login');
const user = MOCK_USERS.find(u => u.id === req.body.user);
if (!user) {
return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
}
const userData = {
email: user.email,
clientId: user.clientId,
companyId: user.companyId,
companyName: user.companyName,
isAdmin: user.role === 'admin',
};
// session.regenerate() меняет идентификатор сессии — защита от session fixation (A07).
// Данные пишем уже в новую сессию.
req.session.regenerate((err) => {
if (err) { console.error('[login] session.regenerate error:', err); return res.status(500).send('Session error'); }
req.session.user = userData;
req.session.save((saveErr) => {
if (saveErr) { console.error('[login] session.save error:', saveErr); return res.status(500).send('Session error'); }
res.redirect(safeReturn(req.query.returnTo));
});
});
});
// ── GET /callback (только OIDC-режим) ──────────────────────────────────────
router.get('/callback', async (req, res) => {
if (!auth.isOidc) return res.redirect('/login');
const { code, state, error } = req.query;
if (error) {
console.error('[auth] Keycloak error:', error, req.query.error_description || '');
return res.redirect('/login?error=' + encodeURIComponent('Keycloak: ' + error));
}
// Проверяем state (защита от CSRF)
if (!state || state !== req.session.oidcState) {
return res.redirect('/login?error=' + encodeURIComponent('Недействительный state'));
}
delete req.session.oidcState;
try {
const { user, idToken, refreshToken } = await auth.exchangeCode(code);
// Сохраняем returnTo до regenerate, так как regenerate сбросит сессию.
const returnTo = safeReturn(req.session.returnTo);
// session.regenerate() — защита от session fixation (A07).
req.session.regenerate((err) => {
if (err) { console.error('[callback] session.regenerate error:', err); return res.status(500).send('Session error'); }
req.session.user = user;
req.session.idToken = idToken;
req.session.refreshToken = refreshToken;
req.session.save((saveErr) => {
if (saveErr) { console.error('[callback] session.save error:', saveErr); return res.status(500).send('Session error'); }
res.redirect(returnTo);
});
});
} catch (e) {
console.error('[auth] exchangeCode error:', e.message);
res.redirect('/login?error=' + encodeURIComponent('Ошибка авторизации: ' + e.message));
}
});
// ── GET /dev-login ─────────────────────────────────────────────────────────
//
// Тестовый вход в обход OIDC/mock. Доступен если:
// • DEV_MODE=true → без пароля
// • DEV_SECRET=xyz → с ?key=xyz или session.devKey
router.get('/dev-login', (req, res) => {
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
if (auth.DEV_SECRET) {
const keyOk = req.query.key === auth.DEV_SECRET || req.session.devKey === auth.DEV_SECRET;
if (!keyOk) return res.status(403).send('403 Forbidden: неверный dev-ключ');
req.session.devKey = auth.DEV_SECRET; // кешируем в сессии: ?key= нужен только раз
}
const csrfToken = generateCsrfToken(req, res);
res.render('dev-login', {
users: MOCK_USERS,
error: req.query.error || null,
csrfToken,
});
});
// ── POST /dev-login ────────────────────────────────────────────────────────
router.post('/dev-login', doubleCsrfProtection, (req, res) => {
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) {
return res.status(403).send('403 Forbidden');
}
let user;
if (req.body.preset) {
// Пресет из MOCK_USERS
const mockUser = MOCK_USERS.find(u => u.id === req.body.preset);
if (!mockUser) {
return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
}
user = {
email: mockUser.email,
clientId: mockUser.clientId,
companyId: mockUser.companyId,
companyName: mockUser.companyName,
isAdmin: mockUser.role === 'admin',
};
} else {
// Произвольные поля
const clientId = (req.body.clientId || '').trim();
if (!clientId) {
return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
}
user = {
email: (req.body.email || '').trim() || clientId + '@dev.local',
clientId,
companyId: (req.body.companyId || '').trim() || null,
companyName: (req.body.companyName || '').trim() || clientId,
isAdmin: req.body.isAdmin === 'on',
};
}
req.session.regenerate((err) => {
if (err) { console.error('[dev-login] session.regenerate error:', err); return res.status(500).send('Session error'); }
req.session.user = user;
// Восстанавливаем devKey в новой сессии — иначе следующий запрос потребует повторного ?key=
if (auth.DEV_SECRET) req.session.devKey = auth.DEV_SECRET;
req.session.save((saveErr) => {
if (saveErr) { console.error('[dev-login] session.save error:', saveErr); return res.status(500).send('Session error'); }
res.redirect('/');
});
});
});
// ── GET /logout ────────────────────────────────────────────────────────────
router.get('/logout', (req, res) => {
const idToken = req.session.idToken || null;
const isOidc = auth.isOidc;
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.clearCookie('jwt'); // удаляем устаревший cookie если остался от старой версии
if (isOidc && idToken) {
return res.redirect(auth.buildLogoutUrl(idToken));
}
res.redirect('/login');
});
});
return router;
}
module.exports = { createRouter, safeReturn };