v2: откат auth/config/server к 5c09844 — чистый модуль аутентификации
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.5.71",
|
"version": "0.5.72",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-10
@@ -8,7 +8,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const config = require('./src/config');
|
const config = require('./src/config');
|
||||||
const auth = require('./src/auth');
|
const auth = require('./src/auth');
|
||||||
const crud = require('./src/crud');
|
|
||||||
|
|
||||||
const oidcConfig = {
|
const oidcConfig = {
|
||||||
baseUrl: config.kcBaseUrl,
|
baseUrl: config.kcBaseUrl,
|
||||||
@@ -22,11 +21,6 @@ const oidcConfig = {
|
|||||||
* Сессия используется общая, но ключи — с префиксом v2_ чтобы не пересекаться.
|
* Сессия используется общая, но ключи — с префиксом v2_ чтобы не пересекаться.
|
||||||
*/
|
*/
|
||||||
function createV2Router() {
|
function createV2Router() {
|
||||||
// Автосоздание таблиц при первом обращении
|
|
||||||
const { pool } = require('./src/db');
|
|
||||||
const { ensureSchema } = require('./src/db/schema');
|
|
||||||
ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message));
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── GET /v2/ — главная (вывод IAM-данных) ───────────────────────────────
|
// ── GET /v2/ — главная (вывод IAM-данных) ───────────────────────────────
|
||||||
@@ -46,7 +40,7 @@ function createV2Router() {
|
|||||||
.logout { color: #ef5350; }
|
.logout { color: #ef5350; }
|
||||||
.label { color: #888; }
|
.label { color: #888; }
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<h2>✅ Вход выполнен <small style="color:#888">v${config.version}</small></h2>
|
<h2>✅ Вход выполнен</h2>
|
||||||
<p><span class="label">Email:</span> ${u.email}</p>
|
<p><span class="label">Email:</span> ${u.email}</p>
|
||||||
<p><span class="label">clientId:</span> ${u.clientId} <span class="label">(активная компания)</span></p>
|
<p><span class="label">clientId:</span> ${u.clientId} <span class="label">(активная компания)</span></p>
|
||||||
<p><span class="label">Компания:</span> ${u.companyName}</p>
|
<p><span class="label">Компания:</span> ${u.companyName}</p>
|
||||||
@@ -108,9 +102,6 @@ ${u.isImpersonated ? `<p style="background:#ffc107;color:#000;padding:5px;">⚠
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── CRUD (/v2/app) ────────────────────────────────────────────
|
|
||||||
router.use('/app', crud.createCrudRouter());
|
|
||||||
|
|
||||||
// ── GET /v2/logout ───────────────────────────────────────────────────────
|
// ── GET /v2/logout ───────────────────────────────────────────────────────
|
||||||
router.get('/logout', (req, res) => {
|
router.get('/logout', (req, res) => {
|
||||||
delete req.session.v2_user;
|
delete req.session.v2_user;
|
||||||
|
|||||||
+278
-22
@@ -1,8 +1,64 @@
|
|||||||
// Модуль аутентификации V2
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Модуль аутентификации
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Два источника данных:
|
||||||
|
// 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 crypto = require('crypto');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
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) {
|
function buildAuthUrl(config) {
|
||||||
const { baseUrl, clientId, redirectUri, state } = config;
|
const { baseUrl, clientId, redirectUri, state } = config;
|
||||||
const authUrl = new URL(baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/auth');
|
const authUrl = new URL(baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/auth');
|
||||||
@@ -14,6 +70,30 @@ function buildAuthUrl(config) {
|
|||||||
return authUrl.toString();
|
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) {
|
async function exchangeCode(code, config) {
|
||||||
const { baseUrl, clientId, clientSecret, redirectUri } = config;
|
const { baseUrl, clientId, clientSecret, redirectUri } = config;
|
||||||
const tokenUrl = baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/token';
|
const tokenUrl = baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/token';
|
||||||
@@ -24,16 +104,73 @@ async function exchangeCode(code, config) {
|
|||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
return postForm(tokenUrl, body);
|
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() {
|
function generateState() {
|
||||||
return crypto.randomBytes(16).toString('hex');
|
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) {
|
async function fetchIamUser(token, iamUrl) {
|
||||||
const url = new URL(iamUrl.replace(/\/$/, '') + '/api/v1/auth/user');
|
const url = new URL(iamUrl.replace(/\/$/, '') + '/api/v1/auth/user');
|
||||||
|
|
||||||
const data = await httpGet(url, token);
|
const data = await httpGet(url, token);
|
||||||
|
|
||||||
const raw = JSON.parse(data);
|
const raw = JSON.parse(data);
|
||||||
const profiles = raw.profiles || [];
|
const profiles = raw.profiles || [];
|
||||||
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
|
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
|
||||||
@@ -41,43 +178,88 @@ async function fetchIamUser(token, iamUrl) {
|
|||||||
const imp = raw.impersonation || {};
|
const imp = raw.impersonation || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// ── из userInfo ──
|
||||||
email: ui.email || '',
|
email: ui.email || '',
|
||||||
clientId: ui.clientID || activeProfile.client_id || '',
|
clientId: ui.clientID || activeProfile.client_id || '', // W-номер — активная компания
|
||||||
companyId: ui.companyId || '',
|
companyId: ui.companyId || '', // внутр. ID IAM (не W-формат)
|
||||||
companyName: ui.company || activeProfile.company_name || '',
|
companyName: ui.company || activeProfile.company_name || '',
|
||||||
isAdmin: !!ui.isAdmin,
|
isAdmin: !!ui.isAdmin,
|
||||||
fio: ui.fio || null,
|
fio: ui.fio || null,
|
||||||
profiles,
|
|
||||||
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
|
// ── из profiles ──
|
||||||
|
profiles, // все компании (массив)
|
||||||
|
allClientIds: profiles.map(p => p.client_id).filter(Boolean), // все W-номера (плоский массив)
|
||||||
activeProfileId: activeProfile.id || null,
|
activeProfileId: activeProfile.id || null,
|
||||||
|
|
||||||
|
// ── из impersonation ──
|
||||||
isImpersonated: !!(imp.is_impersonated),
|
isImpersonated: !!(imp.is_impersonated),
|
||||||
impersonationType: imp.type || null,
|
impersonationType: imp.type || null,
|
||||||
originalUserEmail: imp.originalUserEmail || '',
|
originalUserEmail: imp.originalUserEmail || '',
|
||||||
originalUserFullName: imp.originalUserFullName || '',
|
originalUserFullName: imp.originalUserFullName || '',
|
||||||
originalUserCompany: imp.originalUserCompany || '',
|
originalUserCompany: imp.originalUserCompany || '',
|
||||||
impersonatedCompanyId: imp.impersonatedCompanyId || '',
|
impersonatedCompanyId: imp.impersonatedCompanyId || '',
|
||||||
|
|
||||||
|
// ── сырой ответ (для отладки) ──
|
||||||
raw,
|
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) {
|
async function login(code, oidcConfig, iamUrl) {
|
||||||
|
// 1. Обмен code на токены Keycloak
|
||||||
const tokenData = await exchangeCode(code, oidcConfig);
|
const tokenData = await exchangeCode(code, oidcConfig);
|
||||||
const accessToken = tokenData.access_token;
|
const accessToken = tokenData.accessToken;
|
||||||
if (!accessToken) throw new Error('No access_token in Keycloak response');
|
if (!accessToken) throw new Error('No access_token in Keycloak response');
|
||||||
|
|
||||||
|
// 2. Профиль пользователя из IAM
|
||||||
const iamData = await fetchIamUser(accessToken, iamUrl);
|
const iamData = await fetchIamUser(accessToken, iamUrl);
|
||||||
|
|
||||||
|
// 3. Собираем sessionUser — всё что нужно для сессии
|
||||||
const sessionUser = {
|
const sessionUser = {
|
||||||
email: iamData.email,
|
email: iamData.email,
|
||||||
clientId: iamData.clientId,
|
clientId: iamData.clientId, // W-номер активной компании
|
||||||
allClientIds: iamData.allClientIds,
|
allClientIds: iamData.allClientIds, // все W-номера пользователя
|
||||||
activeClientId: iamData.clientId,
|
activeClientId: iamData.clientId, // активная компания (можно менять в сессии)
|
||||||
activeProfileId: iamData.activeProfileId,
|
activeProfileId: iamData.activeProfileId,
|
||||||
companyId: iamData.companyId,
|
companyId: iamData.companyId,
|
||||||
companyName: iamData.companyName,
|
companyName: iamData.companyName,
|
||||||
isAdmin: iamData.isAdmin,
|
isAdmin: iamData.isAdmin,
|
||||||
fio: iamData.fio,
|
fio: iamData.fio,
|
||||||
profiles: iamData.profiles,
|
profiles: iamData.profiles, // все профили (для переключения компаний)
|
||||||
|
|
||||||
|
// имперсонация
|
||||||
isImpersonated: iamData.isImpersonated,
|
isImpersonated: iamData.isImpersonated,
|
||||||
impersonationType: iamData.impersonationType,
|
impersonationType: iamData.impersonationType,
|
||||||
originalUserEmail: iamData.originalUserEmail,
|
originalUserEmail: iamData.originalUserEmail,
|
||||||
@@ -85,52 +267,126 @@ async function login(code, oidcConfig, iamUrl) {
|
|||||||
originalUserCompany: iamData.originalUserCompany,
|
originalUserCompany: iamData.originalUserCompany,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { sessionUser, token: accessToken, idToken: tokenData.id_token || null };
|
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) {
|
function httpGet(url, token) {
|
||||||
const lib = url.protocol === 'https:' ? https : http;
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = lib.request({
|
const req = lib.request({
|
||||||
hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
hostname: url.hostname,
|
||||||
path: url.pathname + url.search, method: 'GET',
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: 'GET',
|
||||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||||
}, (res) => {
|
}, (res) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', c => { data += c; if (data.length > 100_000) { req.destroy(); reject(new Error('Too large')); } });
|
res.on('data', c => {
|
||||||
|
data += c;
|
||||||
|
if (data.length > 100_000) {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Response too large (>100KB)'));
|
||||||
|
}
|
||||||
|
});
|
||||||
res.on('end', () => {
|
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);
|
resolve(data);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('HTTP request timeout (10s)'));
|
||||||
|
});
|
||||||
req.end();
|
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) {
|
function postForm(urlStr, body) {
|
||||||
const url = new URL(urlStr);
|
const url = new URL(urlStr);
|
||||||
const lib = url.protocol === 'https:' ? https : http;
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
const postData = body.toString();
|
const postData = body.toString();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = lib.request({
|
const req = lib.request({
|
||||||
hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
hostname: url.hostname,
|
||||||
path: url.pathname + url.search, method: 'POST',
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData), 'Accept': 'application/json' },
|
path: url.pathname + url.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Content-Length': Buffer.byteLength(postData),
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
}, (res) => {
|
}, (res) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', c => data += c);
|
res.on('data', c => data += c);
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
if (res.statusCode !== 200) return reject(new Error('POST ' + res.statusCode + ': ' + data.slice(0, 200)));
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`POST ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||||
|
}
|
||||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('POST request timeout (10s)'));
|
||||||
|
});
|
||||||
req.write(postData);
|
req.write(postData);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { login, buildAuthUrl, exchangeCode, fetchIamUser, generateState };
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Экспорт
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// основной — один вызов на сессию
|
||||||
|
login,
|
||||||
|
|
||||||
|
// атомарные (для гибкости / тестов)
|
||||||
|
buildAuthUrl,
|
||||||
|
exchangeCode,
|
||||||
|
fetchIamUser,
|
||||||
|
generateState,
|
||||||
|
};
|
||||||
|
|||||||
+24
-26
@@ -1,37 +1,35 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// Конфигурация V2
|
// Конфигурация V2
|
||||||
|
//
|
||||||
|
// Приоритет: V2_* → основные → умолчания (локальный KC на ВМ)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
//
|
|
||||||
// ⚠️ КОСТЫЛЬ — TEST_TOKEN (testToken):
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '..', '.env') });
|
||||||
// Временный токен WZ01112 / ntazetdinov@nubes.ru, в обход Keycloak.
|
|
||||||
// Используется для теста CRUD с реальными данными IAM (пока нет production-KC).
|
|
||||||
//
|
|
||||||
// КАК УБРАТЬ: задать V2_TEST_TOKEN='' на платформе → login() вернётся
|
|
||||||
// к нормальному OIDC-потоку через Keycloak.
|
|
||||||
//
|
|
||||||
// ПРИ ПЕРЕХОДЕ НА PRODUCTION KEYCLOAK:
|
|
||||||
// 1. Добавить redirect URI в keycloak.nubes.ru/realms/cloud
|
|
||||||
// 2. Задать V2_KC_* env на платформе
|
|
||||||
// 3. V2_TEST_TOKEN='' (отключить костыль)
|
|
||||||
// 4. Редеплой
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
version: '0.5.71',
|
// Keycloak OIDC
|
||||||
|
kcBaseUrl: process.env.V2_KC_BASE_URL
|
||||||
|
|| process.env.KC_BASE_URL
|
||||||
|
|| 'https://italo.kube5s.ru/realms/ipwhitelist',
|
||||||
|
|
||||||
// ⚠️ КОСТЫЛЬ — убрать при production-KC
|
kcClientId: process.env.V2_KC_CLIENT_ID
|
||||||
testToken: process.env.V2_TEST_TOKEN !== ''
|
|| process.env.KC_CLIENT_ID
|
||||||
? (process.env.V2_TEST_TOKEN || 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc5NjExODA2NywiaWF0IjoxNzgwNTY2MDY3LCJqdGkiOiJjMzJlNWIwMy05YjBlLTRjNGEtYTY0MS1lOTE0YWQ4MWRiMDQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAzNzA5IiwiY29tcGFueV9pZCI6IjAxOWNjMjRhLTcyN2UtNzQwZi1iNDA3LWJlYzc5ZGFiNDE2MiIsImNvbXBhbnlfbmFtZSI6Im5hZWVsX3Rlc3QiLCJ0b2tlbl90eXBlIjoidGVjaCIsImlkcF91c3JfdWlkIjoiMDE5Y2MyNjgtNmM2YS03ODFlLTg2MTMtNGJlZDRlYzdjZDIwIiwibG9naW4iOiJ0YXpldGRpbm92bkBnbWFpbC5jb20iLCJmaXJzdG5hbWUiOiLQndCw0LjQu9GMIiwibWlkZGxlbmFtZSI6ItCk0LDRgNC40YLQvtCy0LjRhyIsImxhc3RuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRkaW5vdm5AZ21haWwuY29tIn0.Pc0NJsauTQQApbPgDjFZd9phceMvfN4usaa8Rw9wGeMUvKR6EOAC6F_9in6zhcgK0zFsdbpsSzeGaNLoJNpadbshAWmGKVxprbMZbCuuxvDFzraIHTw0okgvh-4XPN8NhzqI0taTWjN9Wdl5hWBpHBQFpgRlf9u-jMwqmSQQ15wZHiOc_x1Xh_IKhtBGC4duYlvnzVzzWgRVvCNzs73dnPhCAKNn17epZujq0QHlzkkg2WeCo24iiiKrIpssW6YUiM9aKezwbYxGvZZOePX-RjcapBKFjNmpjI2UjEOMroQUtS7ei1cgU9VN5_QjmGKmSSXF2JBtcSGb52qAhYDwQQ')
|
|| 'ipwhitelist',
|
||||||
: '',
|
|
||||||
|
|
||||||
// Keycloak — тестовый (ВМ)
|
kcClientSecret: process.env.V2_KC_CLIENT_SECRET
|
||||||
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru/realms/ipwhitelist',
|
|| process.env.KC_CLIENT_SECRET
|
||||||
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist',
|
|| '',
|
||||||
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '40bpIZvumq6jttIB5QtTLcl9AJvAHxlw',
|
|
||||||
|
|
||||||
// IAM API
|
// IAM API
|
||||||
iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru',
|
iamUrl: process.env.V2_IAM_API_URL
|
||||||
|
|| process.env.IAM_API_URL
|
||||||
|
|| 'https://auth-api-dev.ngcloud.ru',
|
||||||
|
|
||||||
// URL приложения
|
// Наше приложение (НЕ Keycloak!)
|
||||||
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
appUrl: process.env.V2_APP_URL
|
||||||
|
|| process.env.APP_URL
|
||||||
|
|| 'https://italo.kube5s.ru',
|
||||||
|
|
||||||
|
// Админ
|
||||||
|
adminClientId: process.env.ADMIN_CLIENT_ID || 'WZ01112',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user