feat: IAM API интеграция + обновление ТЗ + тесты

- src/auth.js: fetchIamUser(), switchProfile(), userFromPayload(iamData)
- src/config.js: IAM_API_URL из env
- src/routes/oidc.js: обогащение сессии через IAM (с fallback на JWT)
- ui/routes/auth.js: обогащение при токен-логине
- ui/routes/entries.js: switchTo через POST /switch-profile
- src/api/routes/entries.js: resolveCompany через profiles[]
- views/index.ejs: переключатель компаний с названиями из profiles
- .env.example: IAM_API_URL
- docs: обновлены ТЗ-реализация.md, ТЗ-плюс.md, добавлен iam-integration.md
- tests: api-crud.sh (14 тестов CRUD + валидации)
- .gitignore: исключены .env.test, DEPLOY-TESTING.md
This commit is contained in:
2026-06-04 13:53:44 +03:00
parent 878e46287b
commit a8a07fe019
15 changed files with 1404 additions and 164 deletions
+11 -4
View File
@@ -49,15 +49,22 @@ function createEntriesRouter({ q }) {
if (!c) throw Object.assign(new Error('Company not found'), { status: 404 });
return c;
}
// Мульти-компания: client_id разрешён только если есть в allClientIds пользователя
// Мульти-компания: client_id разрешён только если есть в профилях (IAM) или allClientIds (JWT fallback)
const requestedId = (req.query.client_id || '').trim();
const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId))
const allowedIds = req.user.profiles && req.user.profiles.length
? req.user.profiles.map(p => p.client_id)
: (req.user.allClientIds || []);
const effectiveClientId = (requestedId && allowedIds.includes(requestedId))
? requestedId
: req.user.clientId;
// companyName: для переключённой компании — clientId как имя по умолчанию
const effectiveName = effectiveClientId === req.user.clientId
// companyName: для переключённой компании — находим в profiles или используем clientId
let effectiveName = effectiveClientId === req.user.clientId
? req.user.companyName
: effectiveClientId;
if (req.user.profiles) {
const p = req.user.profiles.find(p => p.client_id === effectiveClientId);
if (p) effectiveName = p.company_name;
}
return q.getOrCreateCompany(effectiveClientId, effectiveName, req.user.email);
}
+109 -3
View File
@@ -25,6 +25,7 @@ const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const { IAM_API_URL } = require('./config');
// ── Конфигурация из окружения ────────────────────────────────────────────────
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
@@ -186,6 +187,7 @@ async function exchangeCode(code) {
return {
user: userFromPayload(payload),
accessToken: data.access_token,
idToken: data.id_token || null,
refreshToken: data.refresh_token || null,
};
@@ -274,8 +276,112 @@ function verifyAnyToken(token, devMode) {
throw lastErr || new Error('Token verification failed: no verifier available');
}
function userFromPayload(payload) {
// ── Компании ──────────────────────────────────────────────────────────
// ── IAM API — получение профилей пользователя ─────────────────────────────────
/**
* Выполняет HTTP-запрос к IAM API и возвращает обогащённые данные пользователя.
* @param {string} token — access_token от IAM
* @returns {object} { email, clientId, allClientIds, activeProfileId, companyId, companyName, isAdmin, fio, profiles, raw }
*/
async function fetchIamUser(token) {
const raw = await new Promise((resolve, reject) => {
const url = new URL(IAM_API_URL + '/api/v1/auth/user');
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'GET',
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
}, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`IAM API returned ${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('IAM API timeout')); });
req.end();
});
const profiles = raw.profiles || [];
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
const ui = raw.userInfo || {};
return {
email: ui.email || '',
clientId: ui.clientID || activeProfile.client_id || '',
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
activeProfileId: activeProfile.id || null,
companyId: ui.companyId || '',
companyName: ui.company || activeProfile.company_name || '',
isAdmin: !!ui.isAdmin,
fio: ui.fio || null,
profiles, // полный массив для UI
raw, // сырой ответ (для отладки)
};
}
/**
* Переключает активный профиль пользователя через IAM API.
* @param {string} token — access_token от IAM
* @param {number} profileId — profiles[].id нового активного профиля
* @returns {object} { success, active_profile }
*/
async function switchProfile(token, profileId) {
const body = JSON.stringify({ profile_id: profileId });
const raw = await new Promise((resolve, reject) => {
const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile');
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
}, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`IAM switch-profile failed: ${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('IAM switch-profile timeout')); });
req.write(body);
req.end();
});
return raw;
}
function userFromPayload(payload, iamData) {
// ── Если есть данные из IAM API — используем их как основной источник ────
if (iamData) {
return {
email: iamData.email,
clientId: iamData.clientId,
allClientIds: iamData.allClientIds,
activeClientId: iamData.clientId, // активная по IAM
activeProfileId: iamData.activeProfileId,
companyId: iamData.companyId,
companyName: iamData.companyName,
isAdmin: iamData.isAdmin,
fio: iamData.fio,
profiles: iamData.profiles,
};
}
// ── Без IAM — fallback на JWT (mock-режим) ──────────────────────────────
let allClientIds = [];
// 1. auth-api: ClientID = "WZ03709" или "WZ03709, WZ54321"
@@ -402,4 +508,4 @@ function safeReturn(target) {
return '/';
}
module.exports = { initAuth, requireAdmin, safeReturn };
module.exports = { initAuth, requireAdmin, safeReturn, fetchIamUser, switchProfile };
+6 -1
View File
@@ -10,6 +10,11 @@
'use strict';
// ── IAM API ───────────────────────────────────────────────────────────────────
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
// и переключения активной компании (POST /api/v1/user/switch-profile).
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
// ── Мок-пользователи (только dev/staging) ─────────────────────────────────────
// В продакшене этот список не используется — вход через Keycloak.
// clientId соответствует WZ-номеру компании в Nubes.
@@ -89,4 +94,4 @@ function backUrl(isAdmin, companyId, extra = {}) {
// Передаётся в шаблоны через app.locals чтобы отображаться в UI.
const { version: APP_VERSION } = require('../package.json');
module.exports = { MOCK_USERS, backUrl, APP_VERSION };
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL };
+39 -18
View File
@@ -43,30 +43,51 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
}
try {
// Обмен code на токен (через Keycloak или API Gateway)
// Обмен code на токен (через IAM / Keycloak)
const tokenData = await auth.exchangeCode(code);
const accessToken = tokenData.access_token;
const accessToken = tokenData.accessToken;
// Верификация токена (проверка подписи через JWKS)
// userFromPayload уже встроена в exchangeCode или делается здесь
if (!accessToken) throw new Error('No access_token in response');
// Базовый user из JWT (fallback)
const payload = jwt.decode(accessToken);
if (!payload) throw new Error('Failed to decode token');
// Определяем компании пользователя
const rawClientId = payload.ClientID || payload.client_id || '';
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
const activeClientId = allClientIds[0] || rawClientId;
req.session.token = accessToken;
req.session.user = {
clientId: rawClientId,
allClientIds,
activeClientId,
email: payload.email || payload.login || '',
companyId: payload.company_id || payload.companyId || null,
companyName: payload.company_name || payload.companyName || activeClientId,
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
};
// Пытаемся обогатить через IAM API
try {
const { fetchIamUser } = require('../auth');
const iamData = await fetchIamUser(accessToken);
req.session.user = {
email: iamData.email,
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,
};
console.log('[oidc] IAM enrichment OK:', iamData.email, iamData.clientId, 'profiles:', iamData.allClientIds.length);
} catch (iamErr) {
// IAM недоступен — используем fallback из JWT
console.warn('[oidc] IAM enrichment failed, using JWT fallback:', iamErr.message);
const rawClientId = payload.ClientID || payload.client_id || '';
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
const activeClientId = allClientIds[0] || rawClientId || 'UNKNOWN';
req.session.user = {
clientId: activeClientId,
allClientIds,
activeClientId,
email: payload.email || payload.login || '',
companyId: payload.company_id || null,
companyName: payload.company_name || activeClientId,
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
};
}
const returnTo = req.session.oidcReturnTo || '/';
delete req.session.oidcReturnTo;