Files
ipwhitelist-app/v2/src/auth/index.js
T
naeel 5c09844002 v2: модуль аутентификации Keycloak + IAM, роутер /v2
- v2/src/auth/index.js — login(), fetchIamUser(), exchangeCode(), buildAuthUrl()
- v2/src/config/index.js — приоритет V2_* → KC_* → умолчания
- v2/server.js — createV2Router(), монтируется в server.js как /v2
- server.js — app.use('/v2', createV2Router()) перед UI-роутером
- bump 0.5.57 → 0.5.58
2026-06-12 15:47:32 +04:00

393 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════════════════════
// Модуль аутентификации
// ═══════════════════════════════════════════════════════════════════════════════
//
// Два источника данных:
// 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,
};