From 86ca944c5252f02d9464ab145669f61ff19e8559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 14:37:29 +0300 Subject: [PATCH] feat: Authorization Code Flow (KK) + session middleware + dev-login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 50 +++++++ package.json | 1 + server.js | 19 +++ src/auth.js | 326 +++++++++++++++++++++++++++++++------------- src/routes/auth.js | 204 +++++++++++++++++++-------- tests/run-tests.js | 49 ++++--- views/dev-login.ejs | 139 +++++++++++++++++++ views/login.ejs | 5 +- 8 files changed, 624 insertions(+), 169 deletions(-) create mode 100644 views/dev-login.ejs diff --git a/package-lock.json b/package-lock.json index ddfde00..a6fec17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "ejs": "^3.1.10", "express": "^4.21.1", "express-rate-limit": "^8.5.2", + "express-session": "^1.19.0", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", "pg": "^8.13.1" @@ -360,6 +361,28 @@ "express": ">= 4.11" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -743,6 +766,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -903,6 +934,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1111,6 +1150,17 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 6e7e197..95e670a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "ejs": "^3.1.10", "express": "^4.21.1", "express-rate-limit": "^8.5.2", + "express-session": "^1.19.0", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", "pg": "^8.13.1" diff --git a/server.js b/server.js index 982344c..eef630a 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,7 @@ 'use strict'; const express = require('express'); +const session = require('express-session'); const cookieParser = require('cookie-parser'); const path = require('path'); const helmet = require('helmet'); @@ -41,6 +42,9 @@ app.set('views', path.join(__dirname, 'views')); // Referrer-Policy и пр. CSP отключён явно — EJS использует inline-стили/скрипты. app.use(helmet({ contentSecurityPolicy: false })); +// За reverse-proxy (nginx/k8s ingress) — доверяем первому хопу для secure cookies. +if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1); + app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true, limit: '32kb' })); app.use(cookieParser()); @@ -48,6 +52,21 @@ app.use(cookieParser()); async function start() { const auth = await initAuth(); + // ── Сессии ──────────────────────────────────────────────────────────────── + // SESSION_SECRET из .env (обязателен в production). + // MemoryStore — только для одного инстанса; для multi-pod нужен Redis. + app.use(session({ + secret: process.env.SESSION_SECRET || 'dev-session-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', // только HTTPS в проде + sameSite: 'lax', + maxAge: 8 * 60 * 60 * 1000, // 8 часов + }, + })); + // ── CSRF защита ──────────────────────────────────────────────────────────── // initCsrf() читает CSRF_SECRET и NODE_ENV из process.env. // Возвращает: doubleCsrfProtection (middleware на POST) и diff --git a/src/auth.js b/src/auth.js index 1e2a4c0..209f82a 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,97 +1,227 @@ -const jwt = require('jsonwebtoken'); +'use strict'; + +/** + * src/auth.js — аутентификация и авторизация. + * + * Два режима работы (определяются по наличию KC_CLIENT_ID + KC_CLIENT_SECRET): + * + * OIDC-режим — Authorization Code Flow через Keycloak. + * Токен обменивается на /callback, пользователь сохраняется в сессии. + * + * Mock-режим — локальная RSA-пара, JWT выдаётся при логине. + * Пользователь выбирается на /login из MOCK_USERS и тоже хранится в сессии. + * + * Dev-backdoor (DEV_MODE=true или DEV_SECRET задан): + * /dev-login — форма выбора произвольного пользователя, в обход OIDC/mock. + * DEV_MODE=true → доступен без пароля (локальная разработка). + * DEV_SECRET=xyz → доступен с ?key=xyz (staging/prod тестирование). + * + * Экспортируемые функции: + * initAuth() → объект auth с middleware, OIDC-хелперами и флагами + * requireAdmin() → middleware для admin-only маршрутов + */ + +const jwt = require('jsonwebtoken'); const crypto = require('crypto'); +const https = require('https'); +const http = require('http'); -const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api'; -const JWKS_URL = process.env.JWKS_URL || ''; +// ── Конфигурация из окружения ──────────────────────────────────────────────── +const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api'; +const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'; -// clientId платформы, которому выдаётся роль администратора. -// Задаётся через .env, по умолчанию WZ01112 (Nubes) согласно ТЗ. -const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'; +const KC_BASE_URL = (process.env.KC_BASE_URL || 'https://keycloak.nubes.ru/realms/cloud').replace(/\/$/, ''); +const KC_CLIENT_ID = process.env.KC_CLIENT_ID || ''; +const KC_CLIENT_SECRET = process.env.KC_CLIENT_SECRET || ''; +const APP_URL = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''); -let keyPair = null; -let jwks = null; +// DEV_MODE и DEV_SECRET читаются внутри initAuth() — иначе при тестах +// значение фиксируется при первом require() и последующее присвоение process.env +// не влияет на уже загруженный модуль (кеш Node.js). +// OIDC endpoints (Keycloak realm cloud) +const OIDC = { + auth: KC_BASE_URL + '/protocol/openid-connect/auth', + token: KC_BASE_URL + '/protocol/openid-connect/token', + certs: KC_BASE_URL + '/protocol/openid-connect/certs', + logout: KC_BASE_URL + '/protocol/openid-connect/logout', +}; + +// ── Кеши (инициализируются в initAuth) ───────────────────────────────────── +let mockKeyPair = null; +let mockJwks = null; +let cachedJwks = null; // OIDC public keys + +// ── initAuth ──────────────────────────────────────────────────────────────── function initAuth() { - if (JWKS_URL) { - console.log('Auth: external JWKS ' + JWKS_URL); - return { verify: verifyJWT, middleware: createMiddleware(), jwksHandler: null, issue: null }; + // Читаем здесь — не на уровне модуля, чтобы тесты могли менять process.env + // до вызова initAuth() и видеть актуальные значения. + const devMode = process.env.DEV_MODE === 'true'; + const devSecret = process.env.DEV_SECRET || ''; + + const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET); + + if (isOidc) { + console.log('[auth] OIDC — Keycloak', KC_BASE_URL); + } else { + console.log('[auth] MOCK — local RS256 key'); + mockKeyPair = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const jwkPub = crypto.createPublicKey(mockKeyPair.publicKey).export({ format: 'jwk' }); + Object.assign(jwkPub, { alg: 'RS256', use: 'sig', kid: 'mock-1' }); + mockJwks = { keys: [jwkPub] }; } - console.log('Auth: MOCK - local RS256 key'); - keyPair = crypto.generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, - }); - - const pubKeyObj = crypto.createPublicKey(keyPair.publicKey).export({ format: 'jwk' }); - pubKeyObj.alg = 'RS256'; - pubKeyObj.use = 'sig'; - pubKeyObj.kid = 'mock-1'; - jwks = { keys: [pubKeyObj] }; + if (devMode) console.log('[auth] DEV_MODE — /dev-login доступен без пароля'); + if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом'); return { - verify: verifyJWT, - issue: issueJWT, - middleware: createMiddleware(), - jwksHandler: function(req, res) { res.json(jwks); }, + isOidc, + middleware: createMiddleware(isOidc), + // OIDC-хелперы (используются в src/routes/auth.js) + buildAuthUrl, + exchangeCode, + buildLogoutUrl, + // Mock JWT (для API-клиентов если нужен bearer-токен) + issueMockToken, + // Флаги для routes/auth.js + devLoginEnabled: devMode || !!devSecret, + DEV_SECRET: devSecret, + // JWKS endpoint (только в mock-режиме) + jwksHandler: !isOidc ? (req, res) => res.json(mockJwks) : null, }; } -function createMiddleware() { +// ── Middleware — проверяет req.session.user или Bearer-токен ───────────────── +function createMiddleware(isOidc) { return function authMiddleware(req, res, next) { - // DEV_MODE: обход авторизации для локальной разработки. - // Активен только если NODE_ENV != 'production' — в prod всегда требует токен. - // DEV_ADMIN=true позволяет тестировать admin-функционал в dev-режиме. - if (process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production') { - req.user = { - email: 'dev@test.local', - clientId: 'WZ01325', - companyId: '00000000-0000-0000-0000-000000000000', - companyName: 'DEV', - // DEV_ADMIN=true → имитировать admin-права в dev-окружении - isAdmin: process.env.DEV_ADMIN === 'true', - }; + // 1. Сессия — основной источник пользователя (OIDC и mock) + if (req.session && req.session.user) { + req.user = req.session.user; return next(); } - // Токен извлекаем из httpOnly-cookie (браузер) или Authorization header (API) - var token = (req.cookies && req.cookies.jwt) || - (req.headers.authorization || '').replace('Bearer ', ''); - - if (!token) { - if (req.method === 'GET' && req.path.indexOf('/.well-known') !== 0) { - return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl)); + // 2. Bearer-токен из заголовка (для API-клиентов без браузерной сессии) + const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); + if (bearer) { + try { + const payload = isOidc ? verifyOidcToken(bearer) : verifyMockToken(bearer); + req.user = userFromPayload(payload); + return next(); + } catch (e) { + return res.status(401).json({ error: 'Invalid token: ' + e.message }); } - return res.status(401).send('Unauthorized'); } - try { - var payload = verifyJWT(token); - req.user = { - email: payload.email || 'unknown', - clientId: payload.ClientID, - companyId: payload.company_id, - companyName: payload.company_name || payload.ClientID, - // Роль admin определяется по clientId платформы согласно ТЗ. - // В проде: можно расширить проверкой отдельного claim из Keycloak. - isAdmin: payload.ClientID === ADMIN_CLIENT_ID, - }; - next(); - } catch (e) { - res.clearCookie('jwt'); - if (req.method === 'GET' && req.path.indexOf('/.well-known') !== 0) { - return res.redirect('/login'); + // 3. Устаревший jwt-cookie — плавная миграция: переносим в сессию + if (!isOidc && req.cookies && req.cookies.jwt) { + try { + const payload = verifyMockToken(req.cookies.jwt); + req.user = userFromPayload(payload); + if (req.session) { + req.session.user = req.user; + res.clearCookie('jwt'); + } + return next(); + } catch (_) { + res.clearCookie('jwt'); } - return res.status(401).send('Unauthorized'); } + + // 4. Не аутентифицирован → редирект (GET) или 401 (остальное) + if (req.method === 'GET') { + return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl)); + } + return res.status(401).send('Unauthorized'); }; } +// ── OIDC — redirect URL для Keycloak ──────────────────────────────────────── +function buildAuthUrl(state) { + const params = new URLSearchParams({ + response_type: 'code', + client_id: KC_CLIENT_ID, + redirect_uri: APP_URL + '/callback', + scope: 'openid profile email', + state, + }); + return OIDC.auth + '?' + params; +} + +// ── OIDC — обмен authorization code → токены, возвращает { user, idToken, refreshToken } +async function exchangeCode(code) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: KC_CLIENT_ID, + client_secret: KC_CLIENT_SECRET, + redirect_uri: APP_URL + '/callback', + code, + }).toString(); + + const raw = await httpPost(OIDC.token, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); + const data = JSON.parse(raw); + if (!data.access_token) throw new Error('No access_token: ' + raw.slice(0, 200)); + + if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs)); + const payload = verifyOidcToken(data.access_token); + + return { + user: userFromPayload(payload), + idToken: data.id_token || null, + refreshToken: data.refresh_token || null, + }; +} + +// ── OIDC — URL для logout на Keycloak ─────────────────────────────────────── +function buildLogoutUrl(idToken) { + const params = new URLSearchParams({ post_logout_redirect_uri: APP_URL + '/login' }); + if (idToken) params.set('id_token_hint', idToken); + return OIDC.logout + '?' + params; +} + +// ── Mock — выпустить JWT (для API-клиентов) ────────────────────────────────── +function issueMockToken(claims) { + if (!mockKeyPair) throw new Error('Mock keypair not initialized'); + return jwt.sign( + { ClientID: claims.clientId, company_id: claims.companyId, company_name: claims.companyName, email: claims.email, login: claims.email }, + mockKeyPair.privateKey, + { algorithm: 'RS256', issuer: ISSUER, subject: claims.companyId || 'mock', expiresIn: '24h', keyid: 'mock-1' } + ); +} + +// ── Внутренние утилиты ─────────────────────────────────────────────────────── +function verifyMockToken(token) { + if (!mockKeyPair) throw new Error('Mock keypair not initialized'); + return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER }); +} + +function verifyOidcToken(token) { + if (!cachedJwks) throw new Error('JWKS not loaded yet'); + const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig'); + if (!key) throw new Error('No RSA sig key in JWKS'); + const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' }); + return jwt.verify(token, pem, { algorithms: ['RS256'] }); +} + +function userFromPayload(payload) { + const clientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN'; + return { + email: payload.email || payload.login || 'unknown', + clientId, + companyId: payload.company_id || payload.sub || null, + companyName: payload.company_name || clientId, + isAdmin: clientId === ADMIN_CLIENT_ID, + }; +} + +// ── requireAdmin middleware ────────────────────────────────────────────────── /** * Middleware-защита для admin-only маршрутов. * Использовать после app.use(auth.middleware): - * app.get('/admin', requireAdmin, handler) + * router.get('/admin', requireAdmin, handler) */ function requireAdmin(req, res, next) { if (!req.user || !req.user.isAdmin) { @@ -100,37 +230,45 @@ function requireAdmin(req, res, next) { next(); } -function verifyJWT(token) { - if (JWKS_URL) { - // TODO: реализовать верификацию через внешний JWKS-endpoint. - // Нужен URL от команды Keycloak: JWKS_URL=https://auth-api.../jwks - throw new Error('JWKS_URL verification not implemented yet'); - } - return jwt.verify(token, keyPair.publicKey, { - algorithms: ['RS256'], - issuer: ISSUER, +// ── HTTP-утилиты (без внешних зависимостей) ───────────────────────────────── +function httpGet(url) { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https') ? https : http; + const req = lib.get(url, { timeout: 5000 }, (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => res.statusCode === 200 ? resolve(data) : reject(new Error('HTTP ' + res.statusCode))); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); }); } -function issueJWT(claims) { - if (!keyPair) throw new Error('Cannot issue tokens with external JWKS'); - return jwt.sign( - { - ClientID: claims.clientId, - company_id: claims.companyId, - company_name: claims.companyName, - email: claims.email, - login: claims.email, - }, - keyPair.privateKey, - { - algorithm: 'RS256', - issuer: ISSUER, - subject: claims.companyId, - expiresIn: '24h', - keyid: 'mock-1', - } - ); +function httpPost(url, body, headers) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const lib = u.protocol === 'https:' ? https : http; + const opts = { + hostname: u.hostname, + port: u.port || (u.protocol === 'https:' ? 443 : 80), + path: u.pathname + u.search, + method: 'POST', + headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, + timeout: 5000, + }; + const req = lib.request(opts, (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) resolve(data); + else reject(new Error('HTTP ' + res.statusCode + ': ' + data.slice(0, 200))); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); + req.write(body); + req.end(); + }); } module.exports = { initAuth, requireAdmin }; diff --git a/src/routes/auth.js b/src/routes/auth.js index 7c31a06..977f23b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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; diff --git a/tests/run-tests.js b/tests/run-tests.js index 90c2611..8a28c69 100644 --- a/tests/run-tests.js +++ b/tests/run-tests.js @@ -127,40 +127,53 @@ test('overlap exact match', overlaps('192.168.0.0/24', '192.168.0.0/24'), true); test('no overlap public', overlaps('8.8.8.0/24', '9.9.9.0/24'), false); // ──────────────────────────────────────────────────────────────── -// 4. src/auth.js — DEV_MODE (без JWT, без БД) +// 4. src/auth.js — session middleware (без JWT, без БД) // ──────────────────────────────────────────────────────────────── -section('auth.js (DEV_MODE)'); +section('auth.js (session middleware)'); process.env.DEV_MODE = 'true'; -process.env.DEV_ADMIN = 'true'; process.env.ADMIN_CLIENT_ID = 'WZ01112'; const { initAuth, requireAdmin } = require('../src/auth'); try { const auth = initAuth(); - test('middleware is function', typeof auth.middleware, 'function'); - test('jwksHandler present in DEV_MODE', typeof auth.jwksHandler, 'function'); + test('middleware is function', typeof auth.middleware, 'function'); + test('jwksHandler present (mock mode)', typeof auth.jwksHandler, 'function'); + test('devLoginEnabled when DEV_MODE', auth.devLoginEnabled, true); + test('isOidc false (no KC creds)', auth.isOidc, false); - // DEV_MODE: middleware выставляет req.user без JWT - const req = { headers: {}, cookies: {} }; - const res = { redirect: () => {} }; + // ── Пользователь из session ── + const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', companyName: 'Test', email: 'test@test.local', isAdmin: false }; + const reqSess = { headers: {}, cookies: {}, session: { user: mockUser } }; let nextCalled = false; - auth.middleware(req, res, () => { nextCalled = true; }); - test('DEV_MODE: next() called', nextCalled, true); - test('DEV_MODE: req.user set', !!req.user, true); - test('DEV_MODE: req.user.isAdmin = true (DEV_ADMIN=true)', req.user.isAdmin, true); + auth.middleware(reqSess, {}, () => { nextCalled = true; }); + test('session user: next() called', nextCalled, true); + test('session user: req.user set', !!reqSess.user, true); + test('session user: clientId match', reqSess.user.clientId, 'WZ01325'); - // requireAdmin: пропускает admin - const reqAdmin = { user: { isAdmin: true } }; + // ── Admin user из session ── + const adminUser = { clientId: 'WZ01112', isAdmin: true }; + const reqAdmin = { headers: {}, cookies: {}, session: { user: adminUser } }; + let adminNext = false; + auth.middleware(reqAdmin, {}, () => { adminNext = true; }); + test('admin session: next() called', adminNext, true); + test('admin session: isAdmin=true', reqAdmin.user.isAdmin, true); + + // ── Нет аутентификации → редирект ── + let redirectUrl = null; + const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' }; + auth.middleware(reqNone, { redirect: (url) => { redirectUrl = url; } }, () => {}); + test('no auth: redirected to /login', redirectUrl !== null && redirectUrl.startsWith('/login'), true); + + // ── requireAdmin: admin проходит ── let adminPassed = false; - requireAdmin(reqAdmin, {}, () => { adminPassed = true; }); + requireAdmin({ user: { isAdmin: true } }, {}, () => { adminPassed = true; }); test('requireAdmin: admin passes', adminPassed, true); - // requireAdmin: блокирует не-admin - const reqUser = { user: { isAdmin: false } }; + // ── requireAdmin: не-admin блокируется ── let blockedStatus = null; - requireAdmin(reqUser, { + requireAdmin({ user: { isAdmin: false } }, { status: (s) => { blockedStatus = s; return { send: () => {} }; }, }, () => {}); test('requireAdmin: non-admin blocked (403)', blockedStatus, 403); diff --git a/views/dev-login.ejs b/views/dev-login.ejs new file mode 100644 index 0000000..e91cf81 --- /dev/null +++ b/views/dev-login.ejs @@ -0,0 +1,139 @@ + + + + + Dev-вход — IP WhiteList + + + + + +
+

Тестовый вход

+

Dev-backdoor — в обход Keycloak

+ +
+ ⚠ ТЕСТОВЫЙ РЕЖИМ + Этот вход создаёт произвольного пользователя без проверки пароля. + Используйте только для тестирования. +
+ + <% if (error) { %>
<%= error %>
<% } %> + +
+ + + <%# ── Пресеты ── %> +

Быстрый выбор

+
+ + +
+ +
— или задайте вручную —
+ + <%# ── Кастомные поля ── %> +

Произвольный пользователь

+ +
+ + + WZ-номер компании в Nubes (обязателен если пресет не выбран) +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+

+ ← Обычный вход +

+
+ + + + + diff --git a/views/login.ejs b/views/login.ejs index 7a07087..0aa99f3 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -73,7 +73,10 @@
В реальном продакшене здесь будет редирект на Keycloak.
- После входа JWT сохраняется в httpOnly cookie — middleware проверяет подпись RS256. + После входа пользователь сохраняется в session middleware. + <% if (devLoginUrl) { %> +
Тестовый вход (dev-backdoor) → + <% } %>