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
This commit is contained in:
@@ -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
|
||||
```
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
+116
@@ -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(`
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>V2 — IAM Response</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #111; color: #eee; padding: 20px; }
|
||||
pre { background: #1a1a2e; padding: 15px; border-radius: 8px; overflow-x: auto; }
|
||||
h2 { color: #4fc3f7; }
|
||||
.logout { color: #ef5350; }
|
||||
.label { color: #888; }
|
||||
</style></head><body>
|
||||
<h2>✅ Вход выполнен</h2>
|
||||
<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">Компания:</span> ${u.companyName}</p>
|
||||
<p><span class="label">isAdmin:</span> ${u.isAdmin}</p>
|
||||
<p><span class="label">ФИО:</span> ${u.fio || '—'}</p>
|
||||
<p><span class="label">Компаний:</span> ${u.allClientIds ? u.allClientIds.length : 0} — ${(u.allClientIds || []).join(', ')}</p>
|
||||
${u.isImpersonated ? `<p style="background:#ffc107;color:#000;padding:5px;">⚠️ Имперсонация: ${u.originalUserEmail} → ${u.email}</p>` : ''}
|
||||
<hr>
|
||||
<h3>session.v2_user (полный объект)</h3>
|
||||
<pre>${JSON.stringify(u, null, 2)}</pre>
|
||||
<p><a class="logout" href="/v2/logout">Выйти</a></p>
|
||||
</body></html>
|
||||
`);
|
||||
});
|
||||
|
||||
// ── 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(`
|
||||
<h2>Ошибка входа</h2>
|
||||
<pre>${err.message}\n\n${err.stack || ''}</pre>
|
||||
<p><a href="/v2/login">Попробовать снова</a></p>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user