feat: Authorization Code Flow (KK) + session middleware + dev-login

- src/auth.js: OIDC mode (KC_CLIENT_ID/KC_CLIENT_SECRET), session-based
  middleware, legacy jwt-cookie migration, httpGet/httpPost без внешних зав.
- src/routes/auth.js: GET /callback (OIDC), GET+POST /dev-login (backdoor),
  session-based login/logout для обоих режимов
- server.js: express-session (8h, httpOnly, sameSite lax), trust proxy в prod
- views/dev-login.ejs: форма с пресетами MOCK_USERS + произвольные поля
- views/login.ejs: ссылка на /dev-login если devLoginEnabled
- tests: +5 тестов (session middleware), 50 PASS / 0 FAIL

Env-переменные:
  KC_CLIENT_ID, KC_CLIENT_SECRET, KC_BASE_URL, APP_URL  — OIDC режим
  SESSION_SECRET                                         — секрет сессии
  DEV_MODE=true                                          — /dev-login без пароля
  DEV_SECRET=xyz                                         — /dev-login с ?key=xyz
This commit is contained in:
2026-05-30 14:37:29 +03:00
parent 876e03b78b
commit 86ca944c52
8 changed files with 624 additions and 169 deletions
+148 -56
View File
@@ -1,19 +1,20 @@
/**
* src/routes/auth.js — маршруты аутентификации (mock-режим).
* src/routes/auth.js — маршруты аутентификации.
*
* Маршруты:
* GET /login — страница выбора пользователя (мок-форма)
* POST /login — обработка выбора: выпускает JWT, кладёт в cookie
* GET /logout — очищает cookie, возвращает на /login
* OIDC-режим (KC_CLIENT_ID + KC_CLIENT_SECRET заданы в окружении):
* GET /login → генерирует state, сохраняет в сессии, редиректит на Keycloak
* GET /callback → проверяет state, обменивает code→токен, кладёт user в сессию
* GET /logout → уничтожает сессию + редирект на KK logout
*
* Зависимости (приходят через deps):
* auth — объект из initAuth(): { issue, middleware, jwksHandler }
* doubleCsrfProtection — middleware проверки CSRF-токена
* generateCsrfToken — функция генерации токена для передачи в шаблон
* MOCK_USERS — список мок-пользователей (из src/config.js)
* Mock-режим (нет OIDC credentials):
* GET /login → рендерит views/login.ejs — список MOCK_USERS
* POST /login → CSRF → сохраняет выбранного user в сессию → /
* GET /logout → уничтожает сессию → /login
*
* В продакшене этот файл заменяется на редирект к Keycloak (OIDC Authorization Code Flow).
* Маршруты регистрируются ДО auth.middleware — т.е. доступны без авторизации.
* 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}
@@ -21,70 +22,161 @@
'use strict';
const crypto = require('crypto');
const { Router } = require('express');
function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }) {
const router = Router();
// GET /login — отдаём форму с выпадающим списком мок-пользователей.
// Генерируем CSRF-токен здесь, чтобы он попал в скрытое поле формы.
// Без этого POST /login будет отклонён doubleCsrfProtection.
// ── GET /login ─────────────────────────────────────────────────────────────
router.get('/login', (req, res) => {
// generateCsrfToken одновременно: устанавливает CSRF cookie и возвращает токен-строку
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, // ошибка от предыдущего неудачного логина
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('Пользователь не найден'));
}
req.session.user = {
email: user.email,
clientId: user.clientId,
companyId: user.companyId,
companyName: user.companyName,
isAdmin: user.role === 'admin',
};
res.redirect(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);
req.session.user = user;
req.session.idToken = idToken;
req.session.refreshToken = refreshToken;
const returnTo = req.session.returnTo || '/';
delete req.session.returnTo;
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 /login — обработка формы входа.
// doubleCsrfProtection идёт первым: если CSRF-токен невалиден — отдаёт 403
// ещё до того как мы начнём работать с req.body.
router.post('/login', doubleCsrfProtection, async (req, res) => {
// Ищем выбранного пользователя по id ('admin', 'test', 'client2')
const user = MOCK_USERS.find(u => u.id === req.body.user);
// ── POST /dev-login ────────────────────────────────────────────────────────
router.post('/dev-login', doubleCsrfProtection, (req, res) => {
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
if (!user) {
// Неизвестный id — скорее всего попытка подделки запроса
return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) {
return res.status(403).send('403 Forbidden');
}
try {
// Выпускаем JWT с clientId, companyId, companyName, email.
// В продакшене этот токен приходит от Keycloak — здесь мы его имитируем.
const token = await auth.issue({
clientId: user.clientId,
companyId: user.companyId,
companyName: user.companyName,
email: user.email,
});
let user;
// Кладём токен в httpOnly cookie — JS на странице его не видит (защита от XSS).
// sameSite: lax — cookie отправляется при навигации, но не при кросс-сайтовых запросах.
res.cookie('jwt', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // только HTTPS в проде
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24 часа в миллисекундах
});
// returnTo — параметр от auth.middleware: куда перенаправить после логина.
// Если не задан — на главную.
res.redirect(req.query.returnTo || '/');
} catch (e) {
console.error('Login error:', e);
res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена'));
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.user = user;
res.redirect('/');
});
// GET /logout — удаляем cookie и отправляем на /login.
// Простой GET (а не POST) допустим для мок-режима.
// В продакшене логаут должен быть POST с CSRF-токеном + редирект к Keycloak.
// ── GET /logout ────────────────────────────────────────────────────────────
router.get('/logout', (req, res) => {
res.clearCookie('jwt');
res.redirect('/login');
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;