- [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)
224 lines
9.9 KiB
JavaScript
224 lines
9.9 KiB
JavaScript
/**
|
||
* 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 };
|