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:
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user