v2: детальное логирование postForm + exchangeCode

This commit is contained in:
2026-06-12 16:26:17 +04:00
parent 3e4c5cb1af
commit 8f8093fa29
2 changed files with 34 additions and 280 deletions
+1 -1
View File
@@ -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": {
+33 -279
View File
@@ -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 };