/** * 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 };