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:
+148
-56
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user