diff --git a/docs/history/2026-06-12.md b/docs/history/2026-06-12.md new file mode 100644 index 0000000..7caf58e --- /dev/null +++ b/docs/history/2026-06-12.md @@ -0,0 +1,61 @@ +# Сессия 2026-06-12 + +## Архитектурный анализ + +**Вердикт: модульный монолит (modular monolith)** + +Не чистый монолит, не микросервисы. Осмысленная архитектура с DI и разделением на слои. + +### Слои + +``` +UI (EJS) → HTTP → API (JSON) → Queries → PostgreSQL + ↑ + DI через фабрики: + createApiRouter({ auth, q }) +``` + +UI не ходит в БД напрямую — всегда через `ui/api-client.js` → HTTP → `/api/v1/*`. Разделение по протоколу. + +### Степень связанности + +| Модуль | Связанность | Почему | +|--------|-------------|--------| +| `validators.js` | Ноль | Только `net`, чистые функции | +| `config.js` | Ноль | Чистые данные | +| `api-client.js` | Ноль | Только `http`/`https` | +| `bearerAuth.js` | Ноль | Standalone middleware | +| `queries.js` → `db.js` | Tight | Data-access — иначе быть не может | +| `server.js` → всё | Tight | Оркестратор — нормально | + +### Что хорошо + +- DI-паттерн: роутеры получают зависимости через фабрики +- Нет циклических зависимостей — граф чистый DAG +- Большинство модулей тестируемы изолированно +- Лёгкий вынос API в микросервис: поменять `API_BASE` в `api-client.js` + +### Что не очень + +- `server.js` знает про все модули (15 импортов) — но для монолита норма +- `src/auth.js` делает и JWT, и IAM, и bearer — можно разделить +- Дублирование `buildSessionUser` в `oidc.js` и `auth.js` + +### Граф зависимостей + +``` +server.js +├── src/db.js → pg +├── src/middleware/session.js → express-session, connect-pg-simple +├── src/middleware/csp.js +├── src/middleware/rateLimit.js +├── src/auth.js → src/config.js +├── src/queries.js → src/db.js, src/validators.js +├── src/validators.js → net +├── src/config.js (ноль зависимостей) +├── src/api/index.js → src/api/routes/* → src/validators.js +│ └── src/api/middleware/bearerAuth.js (ноль зависимостей) +├── ui/index.js → ui/routes/* → ui/api-client.js (только http/https) +│ └── ui/routes/auth.js → src/auth.js (только safeReturn) +└── src/routes/oidc.js +``` diff --git a/package.json b/package.json index 1c1db20..15a84d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.57", + "version": "0.5.58", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/server.js b/server.js index 96419d1..3264bc7 100644 --- a/server.js +++ b/server.js @@ -135,6 +135,9 @@ async function start() { // ── UI-слой: SSR через EJS, данные из /api/v1/* ─────────────────────────── // Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы. + // ── V2 — тестовый роутер (Keycloak → IAM → вывод) ───────────────── + const { createV2Router } = require('./v2/server'); + app.use('/v2', createV2Router()); // Хранит Bearer token в сессии, рендерит те же views/*.ejs. app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter })); diff --git a/v2/server.js b/v2/server.js new file mode 100644 index 0000000..1dc0495 --- /dev/null +++ b/v2/server.js @@ -0,0 +1,116 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// V2 — тестовый роутер (монтируется в server.js как /v2) +// Только: вход через Keycloak → вывод ответа IAM на экран +// ═══════════════════════════════════════════════════════════════════════════════ + +'use strict'; + +const express = require('express'); +const config = require('./src/config'); +const auth = require('./src/auth'); + +const oidcConfig = { + baseUrl: config.kcBaseUrl, + clientId: config.kcClientId, + clientSecret: config.kcClientSecret, + redirectUri: config.appUrl.replace(/\/$/, '') + '/v2/callback', +}; + +/** + * createV2Router — создаёт роутер для монтирования в основной server.js. + * Сессия используется общая, но ключи — с префиксом v2_ чтобы не пересекаться. + */ +function createV2Router() { + const router = express.Router(); + + // ── GET /v2/ — главная (вывод IAM-данных) ─────────────────────────────── + router.get('/', (req, res) => { + if (!req.session.v2_user) { + return res.redirect('/v2/login'); + } + + const u = req.session.v2_user; + res.send(` + +
Email: ${u.email}
+clientId: ${u.clientId} (активная компания)
+Компания: ${u.companyName}
+isAdmin: ${u.isAdmin}
+ФИО: ${u.fio || '—'}
+Компаний: ${u.allClientIds ? u.allClientIds.length : 0} — ${(u.allClientIds || []).join(', ')}
+${u.isImpersonated ? `⚠️ Имперсонация: ${u.originalUserEmail} → ${u.email}
` : ''} +${JSON.stringify(u, null, 2)}
+
+
+ `);
+ });
+
+ // ── GET /v2/login — редирект на Keycloak ────────────────────────────────
+ router.get('/login', (req, res) => {
+ if (req.session.v2_user) return res.redirect('/v2/');
+
+ const state = auth.generateState();
+ req.session.v2_oidcState = state;
+
+ const url = auth.buildAuthUrl({ ...oidcConfig, state });
+ console.log('[v2] → Keycloak');
+ res.redirect(url);
+ });
+
+ // ── GET /v2/callback — Keycloak возвращает пользователя ──────────────────
+ router.get('/callback', async (req, res) => {
+ const { code, state } = req.query;
+
+ if (!state || state !== req.session.v2_oidcState) {
+ return res.status(403).send('Invalid state — possible CSRF attack');
+ }
+ delete req.session.v2_oidcState;
+
+ if (!code) {
+ return res.status(400).send('Missing authorization code');
+ }
+
+ try {
+ console.log('[v2] Exchanging code...');
+ const result = await auth.login(code, oidcConfig, config.iamUrl);
+
+ req.session.v2_user = result.sessionUser;
+ req.session.v2_token = result.token;
+ req.session.v2_idToken = result.idToken;
+
+ console.log('[v2] ✅ Login OK:', result.sessionUser.email, result.sessionUser.clientId);
+
+ res.redirect('/v2/');
+ } catch (err) {
+ console.error('[v2] ❌ Login failed:', err.message);
+ res.status(500).send(`
+ ${err.message}\n\n${err.stack || ''}
+
+ `);
+ }
+ });
+
+ // ── GET /v2/logout ───────────────────────────────────────────────────────
+ router.get('/logout', (req, res) => {
+ delete req.session.v2_user;
+ delete req.session.v2_token;
+ delete req.session.v2_idToken;
+ res.redirect('/v2/');
+ });
+
+ return router;
+}
+
+module.exports = { createV2Router };
diff --git a/v2/src/auth/index.js b/v2/src/auth/index.js
new file mode 100644
index 0000000..3718f05
--- /dev/null
+++ b/v2/src/auth/index.js
@@ -0,0 +1,392 @@
+// ═══════════════════════════════════════════════════════════════════════════════
+// Модуль аутентификации
+// ═══════════════════════════════════════════════════════════════════════════════
+//
+// Два источника данных:
+// 1. Keycloak OIDC — токены (access_token, id_token)
+// 2. IAM API — профиль пользователя (компании, роли, имперсонация)
+//
+// После вызова login() ни Keycloak, ни IAM больше не нужны.
+// Всё что требуется для сессии — в возвращаемом объекте sessionUser.
+//
+// Экспорт:
+// login(code, oidcConfig, iamUrl) → { sessionUser, token, idToken }
+// — полный цикл: обмен code → токены → IAM → готовый объект.
+// Вызывается ОДИН раз при логине.
+//
+// buildAuthUrl(config) → string
+// — URL для редиректа пользователя на Keycloak.
+//
+// exchangeCode(code, config) → { accessToken, idToken, ... }
+// — обмен authorization_code на токены (обратный вызов Keycloak).
+//
+// fetchIamUser(token, iamUrl) → { email, clientId, profiles, ... }
+// — запрос профиля пользователя у IAM.
+//
+// generateState() → string
+// — случайная строка для OAuth state (CSRF-защита).
+// ═══════════════════════════════════════════════════════════════════════════════
+
+const crypto = require('crypto');
+const https = require('https');
+const http = require('http');
+const jwt = require('jsonwebtoken');
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// 1. Keycloak OIDC — получение токенов
+// ═══════════════════════════════════════════════════════════════════════════════
+
+/**
+ * buildAuthUrl — строит URL для редиректа пользователя на Keycloak.
+ *
+ * Это ПЕРВЫЙ шаг OIDC-потока:
+ * 1. Генерируем state (через generateState)
+ * 2. Сохраняем state в сессии (для проверки в callback)
+ * 3. Редиректим пользователя на этот URL
+ *
+ * Keycloak показывает форму входа, после успешного входа редиректит
+ * обратно на redirectUri с параметрами ?code=...&state=...
+ *
+ * @param {object} config
+ * .baseUrl — URL Keycloak realm, например "https://keycloak.nubes.ru/realms/cloud"
+ * .clientId — client_id приложения в Keycloak
+ * .redirectUri — callback URL нашего приложения (должен совпадать с настройками Keycloak)
+ * .state — случайная строка для CSRF-защиты (результат generateState())
+ * @returns {string} — полный URL для редиректа
+ *
+ * Пример возврата:
+ * "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth
+ * ?client_id=ipwhitelist&redirect_uri=https://app.ru/callback&response_type=code
+ * &scope=openid+profile+email&state=a1b2c3..."
+ */
+function buildAuthUrl(config) {
+ const { baseUrl, clientId, redirectUri, state } = config;
+ const authUrl = new URL(baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/auth');
+ authUrl.searchParams.set('client_id', clientId);
+ authUrl.searchParams.set('redirect_uri', redirectUri);
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('scope', 'openid profile email');
+ authUrl.searchParams.set('state', state);
+ return authUrl.toString();
+}
+
+/**
+ * exchangeCode — обменивает authorization_code на токены.
+ *
+ * Это ВТОРОЙ шаг OIDC-потока:
+ * Keycloak редиректит пользователя обратно с ?code=...
+ * Мы отправляем этот code на token endpoint и получаем токены.
+ *
+ * Запрос: POST /protocol/openid-connect/token
+ * Content-Type: application/x-www-form-urlencoded
+ * grant_type=authorization_code&code=...&client_id=...&client_secret=...&redirect_uri=...
+ *
+ * @param {string} code — authorization_code из query-параметра callback-URL
+ * @param {object} config
+ * .baseUrl — URL Keycloak realm
+ * .clientId — client_id приложения
+ * .clientSecret — client_secret приложения (секретный!)
+ * .redirectUri — тот же redirect_uri что и в buildAuthUrl
+ * @returns {object} — ответ Keycloak:
+ * .accessToken — JWT для доступа к API (передаём в IAM)
+ * .idToken — JWT с информацией о пользователе (опционально)
+ * .refreshToken — для обновления (не используется)
+ * .expiresIn — срок действия access_token
+ * @throws {Error} — если Keycloak вернул не 200 (неверный code, secret, redirect_uri)
+ */
+async function exchangeCode(code, config) {
+ const { baseUrl, clientId, clientSecret, redirectUri } = config;
+ const tokenUrl = baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/token';
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uri: redirectUri,
+ });
+
+ return postForm(tokenUrl, body);
+}
+
+/**
+ * generateState — генерирует случайную строку для OAuth state.
+ *
+ * Зачем: защита от CSRF в OAuth-потоке.
+ * 1. Генерируем → сохраняем в сессию
+ * 2. Передаём в buildAuthUrl
+ * 3. Keycloak возвращает state в callback
+ * 4. Сверяем: req.query.state === req.session.oidcState
+ *
+ * @returns {string} — 32 hex-символа (16 байт)
+ */
+function generateState() {
+ return crypto.randomBytes(16).toString('hex');
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// 2. IAM API — профиль пользователя
+// ═══════════════════════════════════════════════════════════════════════════════
+
+/**
+ * fetchIamUser — запрашивает профиль пользователя у IAM.
+ *
+ * Это ТРЕТИЙ шаг аутентификации:
+ * Имея access_token от Keycloak, запрашиваем /api/v1/auth/user
+ * IAM возвращает: профили, роли, имперсонацию.
+ *
+ * Запрос: GET {iamUrl}/api/v1/auth/user
+ * Authorization: Bearer {access_token}
+ * Accept: application/json
+ *
+ * Ответ IAM (JSON):
+ * userInfo: { email, clientID, companyId, company, isAdmin, fio }
+ * profiles[]: [{ id, client_id, company_name, is_active_profile }]
+ * impersonation: { is_impersonated, type, originalUserEmail, ... }
+ *
+ * Все поля плоские — не нужно лазить в userInfo.email, просто email.
+ *
+ * @param {string} token — access_token от Keycloak (из exchangeCode)
+ * @param {string} iamUrl — IAM_API_URL, например "https://auth-api-dev.ngcloud.ru"
+ * @returns {object} — плоский объект со всеми полями:
+ * .email — email пользователя
+ * .clientId — W-номер активной компании (WZ01112)
+ * .companyId — внутренний ID IAM (не W-формат, почти не используется)
+ * .companyName — название активной компании
+ * .isAdmin — флаг администратора (из userInfo.isAdmin)
+ * .fio — полное имя
+ * .profiles — массив всех профилей (все компании пользователя)
+ * .allClientIds — все W-номера (собираем из profiles[].client_id)
+ * .activeProfileId — id активного профиля
+ * .isImpersonated — активна ли имперсонация
+ * .impersonationType — тип имперсонации
+ * .originalUserEmail — кто реально (если имперсонация)
+ * .originalUserFullName — ФИО реального
+ * .originalUserCompany — компания реального
+ * .impersonatedCompanyId — в чью компанию вошли
+ * .raw — сырой JSON ответ IAM (для отладки)
+ * @throws {Error} — если IAM недоступен, вернул не 200, или ответ не JSON
+ */
+async function fetchIamUser(token, iamUrl) {
+ const url = new URL(iamUrl.replace(/\/$/, '') + '/api/v1/auth/user');
+
+ const data = await httpGet(url, token);
+
+ const raw = JSON.parse(data);
+ const profiles = raw.profiles || [];
+ const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
+ const ui = raw.userInfo || {};
+ const imp = raw.impersonation || {};
+
+ return {
+ // ── из userInfo ──
+ email: ui.email || '',
+ clientId: ui.clientID || activeProfile.client_id || '', // W-номер — активная компания
+ companyId: ui.companyId || '', // внутр. ID IAM (не W-формат)
+ companyName: ui.company || activeProfile.company_name || '',
+ isAdmin: !!ui.isAdmin,
+ fio: ui.fio || null,
+
+ // ── из profiles ──
+ profiles, // все компании (массив)
+ allClientIds: profiles.map(p => p.client_id).filter(Boolean), // все W-номера (плоский массив)
+ activeProfileId: activeProfile.id || null,
+
+ // ── из impersonation ──
+ isImpersonated: !!(imp.is_impersonated),
+ impersonationType: imp.type || null,
+ originalUserEmail: imp.originalUserEmail || '',
+ originalUserFullName: imp.originalUserFullName || '',
+ originalUserCompany: imp.originalUserCompany || '',
+ impersonatedCompanyId: imp.impersonatedCompanyId || '',
+
+ // ── сырой ответ (для отладки) ──
+ raw,
+ };
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// 3. Оркестратор — единая точка входа
+// ═══════════════════════════════════════════════════════════════════════════════
+
+/**
+ * login — полный цикл аутентификации.
+ *
+ * Выполняет последовательно:
+ * 1. exchangeCode(code, oidcConfig) → accessToken
+ * 2. fetchIamUser(accessToken, iamUrl) → профиль пользователя
+ * 3. Собирает готовый sessionUser для записи в req.session.user
+ *
+ * После этого вызова:
+ * — НЕ НУЖЕН Keycloak (токен получен)
+ * — НЕ НУЖЕН IAM (профиль получен, switchProfile не нужен — компании переключаем в сессии)
+ *
+ * Вызывается ОДИН раз — в callback-роуте OIDC (/callback).
+ *
+ * @param {string} code — authorization_code из query-параметра callback-URL
+ * @param {object} oidcConfig
+ * .baseUrl — URL Keycloak realm
+ * .clientId — client_id приложения
+ * .clientSecret — client_secret приложения
+ * .redirectUri — callback URL приложения
+ * @param {string} iamUrl — IAM_API_URL
+ * @returns {object}
+ * .sessionUser — готовый объект для req.session.user:
+ * { email, clientId, allClientIds, activeClientId, companyName,
+ * isAdmin, fio, profiles, isImpersonated, originalUserEmail, ... }
+ * .token — access_token (JWT для API-запросов)
+ * .idToken — id_token (опционально)
+ * @throws {Error} — на любом этапе (Keycloak не ответил, IAM недоступен, ...)
+ */
+async function login(code, oidcConfig, iamUrl) {
+ // 1. Обмен code на токены Keycloak
+ const tokenData = await exchangeCode(code, oidcConfig);
+ const accessToken = tokenData.accessToken;
+ if (!accessToken) throw new Error('No access_token in Keycloak response');
+
+ // 2. Профиль пользователя из IAM
+ const iamData = await fetchIamUser(accessToken, iamUrl);
+
+ // 3. Собираем sessionUser — всё что нужно для сессии
+ const sessionUser = {
+ email: iamData.email,
+ clientId: iamData.clientId, // W-номер активной компании
+ allClientIds: iamData.allClientIds, // все W-номера пользователя
+ activeClientId: iamData.clientId, // активная компания (можно менять в сессии)
+ activeProfileId: iamData.activeProfileId,
+ companyId: iamData.companyId,
+ companyName: iamData.companyName,
+ isAdmin: iamData.isAdmin,
+ fio: iamData.fio,
+ profiles: iamData.profiles, // все профили (для переключения компаний)
+
+ // имперсонация
+ isImpersonated: iamData.isImpersonated,
+ impersonationType: iamData.impersonationType,
+ originalUserEmail: iamData.originalUserEmail,
+ originalUserFullName: iamData.originalUserFullName,
+ originalUserCompany: iamData.originalUserCompany,
+ };
+
+ return {
+ sessionUser,
+ token: accessToken,
+ idToken: tokenData.idToken || null,
+ };
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// 4. HTTP-хелперы (внутренние, не экспортируются)
+// ═══════════════════════════════════════════════════════════════════════════════
+
+/**
+ * httpGet — GET-запрос с Bearer-токеном.
+ *
+ * Особенности:
+ * — Только JSON-ответы (Accept: application/json)
+ * — Таймаут 10 секунд
+ * — Лимит ответа 100 KB (защита от переполнения памяти)
+ * — Авто-выбор http/https по URL
+ *
+ * @param {URL} url — parsed URL (new URL(...))
+ * @param {string} token — Bearer-токен
+ * @returns {string} — тело ответа
+ * @throws {Error} — HTTP не 200, таймаут, или ответ > 100 KB
+ */
+function httpGet(url, token) {
+ const lib = url.protocol === 'https:' ? https : http;
+ return new Promise((resolve, reject) => {
+ const req = lib.request({
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: url.pathname + url.search,
+ method: 'GET',
+ headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
+ }, (res) => {
+ let data = '';
+ res.on('data', c => {
+ data += c;
+ if (data.length > 100_000) {
+ req.destroy();
+ reject(new Error('Response too large (>100KB)'));
+ }
+ });
+ res.on('end', () => {
+ if (res.statusCode !== 200) {
+ return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
+ }
+ resolve(data);
+ });
+ });
+ req.on('error', reject);
+ req.setTimeout(10000, () => {
+ req.destroy();
+ reject(new Error('HTTP request timeout (10s)'));
+ });
+ req.end();
+ });
+}
+
+/**
+ * postForm — POST-запрос с form-urlencoded телом.
+ *
+ * Используется для Keycloak token endpoint.
+ *
+ * Особенности:
+ * — Content-Type: application/x-www-form-urlencoded
+ * — Accept: application/json (ожидаем JSON в ответе)
+ * — Таймаут 10 секунд
+ *
+ * @param {string} urlStr — полный URL
+ * @param {URLSearchParams} body — параметры формы
+ * @returns {object} — распарсенный JSON-ответ
+ * @throws {Error} — HTTP не 200, таймаут, или ответ не JSON
+ */
+function postForm(urlStr, body) {
+ const url = new URL(urlStr);
+ const lib = url.protocol === 'https:' ? https : http;
+ const postData = body.toString();
+ return new Promise((resolve, reject) => {
+ const req = lib.request({
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: url.pathname + url.search,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Content-Length': Buffer.byteLength(postData),
+ 'Accept': 'application/json',
+ },
+ }, (res) => {
+ let data = '';
+ res.on('data', c => data += c);
+ res.on('end', () => {
+ if (res.statusCode !== 200) {
+ return reject(new Error(`POST ${res.statusCode}: ${data.slice(0, 200)}`));
+ }
+ try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
+ });
+ });
+ req.on('error', reject);
+ req.setTimeout(10000, () => {
+ req.destroy();
+ reject(new Error('POST request timeout (10s)'));
+ });
+ req.write(postData);
+ req.end();
+ });
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// Экспорт
+// ═══════════════════════════════════════════════════════════════════════════════
+
+module.exports = {
+ // основной — один вызов на сессию
+ login,
+
+ // атомарные (для гибкости / тестов)
+ buildAuthUrl,
+ exchangeCode,
+ fetchIamUser,
+ generateState,
+};
diff --git a/v2/src/config/index.js b/v2/src/config/index.js
new file mode 100644
index 0000000..4167f90
--- /dev/null
+++ b/v2/src/config/index.js
@@ -0,0 +1,35 @@
+// ═══════════════════════════════════════════════════════════════════════════════
+// Конфигурация V2
+//
+// Приоритет: V2_* → основные → умолчания (локальный KC на ВМ)
+// ═══════════════════════════════════════════════════════════════════════════════
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '..', '.env') });
+
+module.exports = {
+ // Keycloak OIDC
+ kcBaseUrl: process.env.V2_KC_BASE_URL
+ || process.env.KC_BASE_URL
+ || 'https://italo.kube5s.ru/realms/ipwhitelist',
+
+ kcClientId: process.env.V2_KC_CLIENT_ID
+ || process.env.KC_CLIENT_ID
+ || 'ipwhitelist',
+
+ kcClientSecret: process.env.V2_KC_CLIENT_SECRET
+ || process.env.KC_CLIENT_SECRET
+ || '',
+
+ // IAM API
+ iamUrl: process.env.V2_IAM_API_URL
+ || process.env.IAM_API_URL
+ || 'https://auth-api-dev.ngcloud.ru',
+
+ // Наше приложение (НЕ Keycloak!)
+ appUrl: process.env.V2_APP_URL
+ || process.env.APP_URL
+ || 'https://italo.kube5s.ru',
+
+ // Админ
+ adminClientId: process.env.ADMIN_CLIENT_ID || 'WZ01112',
+};