- 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
20 KiB
IAM API — интеграция с ipwhitelist-app
Дата: 2026-06-04
Исследование: проверка текущей интеграции и план доработок
1. Что такое IAM API
IAM (Identity & Access Management) — единый сервис авторизации экосистемы Nubes. Через него работают Личный Кабинет (ЛК) и все смежные сервисы.
Три стенда:
| Стенд | URL |
|---|---|
| Dev | https://auth-api-dev.ngcloud.ru |
| Test | https://auth-api-test.ngcloud.ru |
| Prod | https://auth-api.ngcloud.ru |
Swagger-документация: https://auth-api-dev.ngcloud.ru/api/v1/documentation/
2. Исходные данные (Elma CRM)
Изначальный источник — CRM Elma. Две сущности:
- Контакт (contact) — человек, пользователь
- Компания (company) — организация
Отношение не 1:1. Контакт может представлять несколько компаний. Это нормальная ситуация.
В токене IAM, который пользователь получает в ЛК, зашита основная компания. Но через API IAM можно получить все доступные пользователю компании и узнать, какая из них активна в данный момент.
Смежные сервисы ходят в IAM API для проверки, что услуга выпускается для правильной компании.
3. Ключевые эндпоинты IAM API
3.1. GET /api/v1/auth/user — информация о пользователе
Назначение: получение полной информации о текущем пользователе, включая список всех доступных профилей (компаний) и флаг активного профиля.
Авторизация: Authorization: Bearer <token>
Реальный ответ (тестовый пользователь tazetdinovn@gmail.com, компания naeel_test, ClientID WZ03709):
{
"impersonation": {
"is_impersonated": false
},
"isPortal": false,
"needChangePassword": false,
"owner": false,
"permissions": {
"can_write": false,
"has_write_permissions": false,
"is_impersonating": false,
"read_only_mode_enabled": false,
"reason": "insufficient_permissions"
},
"privileges": null,
"profiles": [
{
"id": 2833,
"client_id": "WZ03709",
"company_id": "019cc24a-727e-740f-b407-bec79dab4162",
"company_name": "naeel_test",
"company_numeric_id": 2645,
"is_active_profile": true
}
],
"roles": [
{
"created_at": "0001-01-01T00:00:00Z",
"role_id": 1,
"role_name": "Пользователь",
"user_uuid": "019cc268-6c6a-781e-8613-4bed4ec7cd20"
}
],
"sessionId": "",
"userId": "019cc268-6c6a-781e-8613-4bed4ec7cd20",
"userInfo": {
"accounts": [],
"avatar": [""],
"clientID": "WZ03709",
"company": "naeel_test",
"companyId": "019cc24a-727e-740f-b407-bec79dab4162",
"companyManager": "",
"companyNumericId": 2645,
"contactId": "019cc268-6c6a-781e-8613-4bed4ec7cd20",
"currentSupportLevel": "",
"elmaUserId": 17193,
"email": "tazetdinovn@gmail.com",
"externalUser": true,
"fio": {
"fullName": "Тазетдинов Наиль Фаритович",
"name": "Наиль",
"secondName": "Фаритович",
"surname": "Тазетдинов"
},
"groupIds": null,
"integration": { "serviceId": "" },
"isAdmin": false,
"login": "",
"mobilePhone": [],
"position": "",
"userId": "019cc268-6c6a-781e-8613-4bed4ec7cd20"
}
}
Ключевые поля для интеграции:
| Поле | Тип | Описание |
|---|---|---|
profiles[] |
array | Все доступные пользователю профили (компании) |
profiles[].id |
integer | ID профиля для POST /switch-profile |
profiles[].client_id |
string | WZ-код компании |
profiles[].company_id |
string | UUID компании |
profiles[].company_name |
string | Название компании |
profiles[].company_numeric_id |
integer | Числовой ID компании |
profiles[].is_active_profile |
boolean | Какой профиль активен в данный момент |
userInfo.clientID |
string | WZ-код активной компании |
userInfo.company |
string | Название активной компании |
userInfo.companyId |
string | UUID активной компании |
userInfo.companyNumericId |
integer | Числовой ID активной компании |
userInfo.email |
string | Email пользователя |
userInfo.isAdmin |
boolean | Флаг администратора |
userInfo.fio |
object | ФИО пользователя |
userInfo.contactId |
string | UUID контакта в Elma |
userId |
string | UUID пользователя |
Пример для пользователя с несколькими компаниями:
"profiles": [
{
"id": 2833,
"client_id": "WZ03709",
"company_name": "naeel_test",
"is_active_profile": true // ← активная
},
{
"id": 3120,
"client_id": "WZ12345",
"company_name": "Другая Компания",
"is_active_profile": false // ← неактивная
}
]
3.2. POST /api/v1/user/switch-profile — переключение компании
Назначение: переключить активный профиль пользователя на другую компанию.
Тело запроса:
{
"profile_id": 3120
}
или
{
"company_id": "uuid-компании"
}
Ответ:
{
"success": true,
"active_profile": {
"id": 3120,
"company_id": "uuid",
"company_name": "Другая Компания",
"email": "user@example.com",
"full_name": "ФИО",
"contact_id": "uuid"
}
}
4. Текущее состояние ipwhitelist-app
4.1. Как работает сейчас
Приложение НИ РАЗУ не вызывает IAM API. Вся информация о пользователе и компаниях извлекается из JWT-токена в src/auth.js:
// src/auth.js — userFromPayload()
const rawClientId = payload.ClientID || payload.client_id || '';
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
// + поиск WZ* в claims массиве
return {
clientId: activeClientId, // первый из allClientIds
allClientIds, // comma-separated из ClientID
activeClientId, // то же что и clientId
companyId: payload.company_id,
companyName: payload.company_name,
isAdmin: activeClientId === 'WZ01112',
};
4.2. Проблемы
-
allClientIdsиз токена — ненадёжно. JWT содержит толькоClientIDактивной компании (одно значение). Comma-separated значения — хак/предположение, не гарантированное IAM. -
Нет запроса к IAM API. Приложение должно делать
GET /api/v1/auth/userдля получения актуального списка профилей. -
is_active_profileне используется. Приложение понятия не имеет, какой профиль активен с точки зрения IAM. Активная компания определяется как «первая в списке». -
Переключение компаний — только локальное. В
ui/routes/entries.jsпараметр?switchTo=меняетactiveClientIdв сессии, но не вызываетPOST /switch-profileна IAM. Если пользователь переключит компанию через ЛК, приложение об этом не узнает. -
Проверка
isAdmin— хардкод.activeClientId === 'WZ01112'вместо использованияuserInfo.isAdminиз IAM. -
Избыточный парсинг JWT. Поля
company_id,company_name,ClientIDпарсятся из токена, хотя правильный источник — ответ IAM API.
4.3. Что должно быть
| Данные | Сейчас (источник) | Должно быть (источник) |
|---|---|---|
| Список компаний | ClientID из JWT |
profiles[].client_id из IAM |
| Активная компания | Первая из списка | profiles[].is_active_profile === true |
| WZ-код | payload.ClientID |
userInfo.clientID |
| Название компании | payload.company_name |
userInfo.company или profiles |
| UUID компании | payload.company_id |
userInfo.companyId |
| isAdmin | clientId === 'WZ01112' |
userInfo.isAdmin |
| Переключение | Сессия (req.session.user.activeClientId) |
POST /switch-profile + сессия |
5. План доработок
5.1. src/config.js — добавить URL IAM API
const IAM_API_URL = process.env.IAM_API_URL || 'https://auth-api-test.ngcloud.ru';
5.2. src/auth.js — добавить fetchIamUser(token)
Новая функция, которая делает HTTP-запрос к GET {IAM_API_URL}/api/v1/auth/user и возвращает объект пользователя на основе ответа IAM:
async function fetchIamUser(token) {
// GET {IAM_API_URL}/api/v1/auth/user
// Authorization: Bearer <token>
// Возвращает:
// {
// email: userInfo.email,
// clientId: userInfo.clientID, // активная компания
// allClientIds: profiles.map(p => p.client_id),
// activeProfileId: profiles.find(p => p.is_active_profile)?.id,
// companyId: userInfo.companyId,
// companyName: userInfo.company,
// isAdmin: userInfo.isAdmin,
// userId: userId,
// contactId: userInfo.contactId,
// fio: userInfo.fio,
// profiles: profiles, // полный список для UI
// }
}
5.3. src/auth.js — изменить userFromPayload()
После получения токена (в OIDC-режиме) вызывать fetchIamUser() и использовать его результат вместо парсинга JWT.
В mock-режиме — оставить текущую логику (IAM API недоступен локально).
5.4. src/auth.js — добавить switchProfile(token, profileId)
async function switchProfile(token, profileId) {
// POST {IAM_API_URL}/api/v1/user/switch-profile
// Body: { profile_id: profileId }
// Возвращает новый active_profile
}
5.5. ui/routes/entries.js — изменить логику ?switchTo=
При переключении компании:
- Вызвать
auth.switchProfile(token, profileId)на IAM - Обновить
req.session.userс новымactiveClientId - Сохранить актуальный список
profiles
5.6. ui/routes/entries.js — передавать profiles в шаблон
Для отображения списка доступных компаний в UI (выпадающий список с отметкой активной).
6. Сетевая доступность — ddos-guard и TLS fingerprint
6.1. Проблема
Все стенды *.ngcloud.ru защищены ddos-guard. При попытке доступа через curl без флага --http2 с сервера (IP 81.200.23.210) — 403 Forbidden на любых эндпоинтах.
При этом браузер с того же самого IP проходит нормально. То есть ddos-guard блокирует не по IP, а по сочетанию протокола (HTTP/1.1 vs HTTP/2) и TLS-отпечатка клиента.
6.2. Матрица доступности (проверено 2026-06-04)
| Клиент | auth-api | deck-api-test |
|---|---|---|
curl (HTTP/1.1, по умолчанию) |
❌ 403 | ❌ 403 |
curl --http2 |
✅ 200 | ❌ 403 |
curl --http2 + браузерные заголовки |
✅ 200 | ❌ 403 |
Node.js https |
✅ 200 | ✅ 200 |
Python urllib |
✅ 200 | Не проверялся |
| Браузер (Chrome/Firefox) | ✅ 200 | ✅ 200 |
Выводы:
auth-api: проблема curl решается флагом--http2deck-api-test: ddos-guard проверяет TLS fingerprint, curl не проходит даже с HTTP/2. Node.js — проходит- Приложение на Node.js работает везде
6.3. Как использовать curl (правильно!)
# ❌ НЕ РАБОТАЕТ — HTTP/1.1 режется ddos-guard
curl -H "Authorization: Bearer $TOKEN" https://auth-api.ngcloud.ru/api/v1/auth/user
# ✅ РАБОТАЕТ — HTTP/2 + браузерные заголовки
curl --http2 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-H "Accept-Language: ru-RU,ru;q=0.9" \
"https://auth-api.ngcloud.ru/api/v1/auth/user"
# ✅ РАБОТАЕТ — Node.js (всегда)
node -e "
const https = require('https');
https.get({
hostname: 'auth-api.ngcloud.ru',
path: '/api/v1/auth/user',
headers: { Authorization: 'Bearer $TOKEN', Accept: 'application/json' }
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); });
"
# ✅ РАБОТАЕТ — Python
python3 -c "
import urllib.request
req = urllib.request.Request('https://auth-api.ngcloud.ru/api/v1/auth/user',
headers={'Authorization': 'Bearer $TOKEN', 'Accept': 'application/json'})
print(urllib.request.urlopen(req, timeout=10).read().decode())
"
6.4. Значение для приложения
Приложение ipwhitelist-app написано на Node.js — оно может ходить в IAM API с этого сервера без проблем. curl без --http2 врал про отсутствие доступа.
7. Проверка токенов (подтверждено)
7.1. Типы токенов
| Тип | token_type |
TTL | Где взять |
|---|---|---|---|
| access | access |
12 часов | Ответ IAM после SSO через Keycloak (POST /token) |
| refresh | refresh |
7 дней | Там же, для обновления access |
| tech | tech |
6 месяцев | ЛК → вкладка «Токены» → «Выпустить тех-токен» |
7.2. Все токены подходят для GET /api/v1/auth/user
Не важно, access или tech — любой валидный IAM-токен (iss: "auth-api") принимается. Источник токена (ЛК, SSO через Keycloak, другое приложение) значения не имеет.
7.3. Проверка access_token из ЛК (2026-06-04)
# Токен, полученный через F12 → Network → /token response → access_token
node -e "const https=require('https');
https.get({hostname:'auth-api.ngcloud.ru', path:'/api/v1/auth/user',
headers:{Authorization:'Bearer $ACCESS_TOKEN', Accept:'application/json'}},
res=>{let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d))});"
# → HTTP 200 + JSON с profiles[] и userInfo
7.4. Тестовый пользователь
| Поле | Test | Prod |
|---|---|---|
email |
tazetdinovn@gmail.com |
tazetdinovn@gmail.com |
clientID |
WZ03709 |
WZ03709 |
company_name |
naeel_test |
naeel_test |
company_numeric_id |
2645 | 2645 |
profile.id |
2833 | 4357 |
elmaUserId |
17193 | 39715 |
isAdmin |
false | false |
is_active_profile |
true | true |
| Количество профилей | 1 | 1 |
Внимание: у этого пользователя один профиль. Реальная картина с несколькими компаниями будет содержать несколько объектов в profiles[].
7.5. Поток аутентификации (подтверждён)
Пользователь → Keycloak (SSO) → IAM (обмен code) → access_token
│
┌───────────────────────────────┘
▼
GET /api/v1/auth/user (Bearer access_token)
│
▼
{ profiles[], userInfo }
│
▼
Строим session.user
Никакого дополнительного обмена токенами не требуется. access_token от IAM сразу годится для вызова GET /api/v1/auth/user.
6.3. Токен
Использованный для тестирования токен:
- Тип: tech-токен (долгоживущий, 6 месяцев)
- Issuer:
auth-api - Пользователь:
tazetdinovn@gmail.com - ClientID:
WZ03709 - Компания:
naeel_test(numeric_id: 2645) - Выпущен: 31 мая 2026
- Истекает: 30 ноября 2026
- Роль: Пользователь (не админ)
- Стенд: test (
auth-api-test.ngcloud.ru)
7. Файлы, которые нужно изменить
| Файл | Что изменить |
|---|---|
.env.example |
Добавить IAM_API_URL |
src/config.js |
Добавить константу IAM_API_URL из env |
src/auth.js |
Добавить fetchIamUser(), switchProfile(), изменить userFromPayload() |
ui/routes/auth.js |
После логина вызывать fetchIamUser для обогащения user |
src/routes/oidc.js |
После callback вызывать fetchIamUser |
ui/routes/entries.js |
?switchTo= → вызывать IAM switchProfile, передавать profiles в шаблон |
views/index.ejs |
Отображать список компаний с активной |
src/api/routes/entries.js |
resolveCompany() — использовать profiles вместо allClientIds из токена |
8. Диаграмма целевого потока
sequenceDiagram
participant B as Браузер
participant A as ipwhitelist-app
participant KC as Keycloak
participant IAM as IAM API
B->>A: Заход на /
A->>KC: Редирект на /auth
B->>KC: Вход (SSO если есть сессия)
KC->>B: Редирект с code
B->>A: /callback?code=xxx
A->>KC: POST /token (обмен code)
KC-->>A: access_token
A->>IAM: GET /auth/user (Bearer access_token)
IAM-->>A: { profiles[], userInfo }
A->>A: Строим user из IAM ответа
A->>B: Сессия создана, добро пожаловать
Note over B,A: Пользователь переключает компанию
B->>A: /?switchTo=WZ12345
A->>IAM: POST /user/switch-profile { profile_id: 3120 }
IAM-->>A: { success: true, active_profile }
A->>A: Обновляем сессию
A->>B: Страница с новой активной компанией