From 8f8093fa29c0bb08daec9cd7e3f786bc4d0eff51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 12 Jun 2026 16:26:17 +0400 Subject: [PATCH] =?UTF-8?q?v2:=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20postForm=20+=20exchangeCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- v2/src/auth/index.js | 312 +++++-------------------------------------- 2 files changed, 34 insertions(+), 280 deletions(-) diff --git a/package.json b/package.json index 4288f43..619dc31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.62", + "version": "0.5.63", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v2/src/auth/index.js b/v2/src/auth/index.js index c996138..4a6a5c7 100644 --- a/v2/src/auth/index.js +++ b/v2/src/auth/index.js @@ -1,64 +1,11 @@ // ═══════════════════════════════════════════════════════════════════════════════ -// Модуль аутентификации -// ═══════════════════════════════════════════════════════════════════════════════ -// -// Два источника данных: -// 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-защита). +// Модуль аутентификации V2 // ═══════════════════════════════════════════════════════════════════════════════ 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'); @@ -70,30 +17,6 @@ function buildAuthUrl(config) { 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'; @@ -105,75 +28,24 @@ async function exchangeCode(code, config) { redirect_uri: redirectUri, }); + console.log('[v2:auth] exchangeCode →', tokenUrl); + console.log('[v2:auth] body:', body.toString()); + const result = await postForm(tokenUrl, body); - console.log("[v2:auth] exchangeCode result keys:", Object.keys(result)); - if (result.error) console.log("[v2:auth] KC error:", JSON.stringify(result).slice(0, 300)); + console.log('[v2:auth] response keys:', Object.keys(result)); + if (!result.access_token) { + console.log('[v2:auth] NO ACCESS_TOKEN, full response:', JSON.stringify(result).slice(0, 500)); + } return result; } -/** - * 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] || {}; @@ -181,88 +53,43 @@ async function fetchIamUser(token, iamUrl) { const imp = raw.impersonation || {}; return { - // ── из userInfo ── email: ui.email || '', - clientId: ui.clientID || activeProfile.client_id || '', // W-номер — активная компания - companyId: ui.companyId || '', // внутр. ID IAM (не W-формат) + clientId: ui.clientID || activeProfile.client_id || '', + companyId: ui.companyId || '', 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-номера (плоский массив) + profiles, + allClientIds: profiles.map(p => p.client_id).filter(Boolean), 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'); + const accessToken = tokenData.access_token; + if (!accessToken) throw new Error('No access_token in Keycloak response: ' + JSON.stringify(tokenData).slice(0, 300)); - // 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, // активная компания (можно менять в сессии) + clientId: iamData.clientId, + allClientIds: iamData.allClientIds, + activeClientId: iamData.clientId, activeProfileId: iamData.activeProfileId, companyId: iamData.companyId, companyName: iamData.companyName, isAdmin: iamData.isAdmin, fio: iamData.fio, - profiles: iamData.profiles, // все профили (для переключения компаний) - - // имперсонация + profiles: iamData.profiles, isImpersonated: iamData.isImpersonated, impersonationType: iamData.impersonationType, originalUserEmail: iamData.originalUserEmail, @@ -270,126 +97,53 @@ async function login(code, oidcConfig, iamUrl) { originalUserCompany: iamData.originalUserCompany, }; - return { - sessionUser, - token: accessToken, - idToken: tokenData.idToken || null, - }; + return { sessionUser, token: accessToken, idToken: tokenData.id_token || 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' }, + 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('data', c => { data += c; if (data.length > 100_000) { req.destroy(); reject(new Error('Response too large')); } }); res.on('end', () => { - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); - } + 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.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); 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', - }, + 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)}`)); - } + console.log('[v2:auth] postForm status:', res.statusCode, 'body:', data.slice(0, 300)); + 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.on('error', (e) => { console.log('[v2:auth] postForm ERROR:', e.message); reject(e); }); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); req.write(postData); req.end(); }); } -// ═══════════════════════════════════════════════════════════════════════════════ -// Экспорт -// ═══════════════════════════════════════════════════════════════════════════════ - -module.exports = { - // основной — один вызов на сессию - login, - - // атомарные (для гибкости / тестов) - buildAuthUrl, - exchangeCode, - fetchIamUser, - generateState, -}; +module.exports = { login, buildAuthUrl, exchangeCode, fetchIamUser, generateState };