# 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 ` **Реальный ответ** (тестовый пользователь `tazetdinovn@gmail.com`, компания `naeel_test`, ClientID `WZ03709`): ```json { "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 пользователя | #### Пример для пользователя с несколькими компаниями: ```json "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` — переключение компании **Назначение:** переключить активный профиль пользователя на другую компанию. **Тело запроса:** ```json { "profile_id": 3120 } ``` или ```json { "company_id": "uuid-компании" } ``` **Ответ:** ```json { "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`: ```javascript // 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. Проблемы 1. **`allClientIds` из токена — ненадёжно.** JWT содержит только `ClientID` активной компании (одно значение). Comma-separated значения — хак/предположение, не гарантированное IAM. 2. **Нет запроса к IAM API.** Приложение должно делать `GET /api/v1/auth/user` для получения актуального списка профилей. 3. **`is_active_profile` не используется.** Приложение понятия не имеет, какой профиль активен с точки зрения IAM. Активная компания определяется как «первая в списке». 4. **Переключение компаний — только локальное.** В `ui/routes/entries.js` параметр `?switchTo=` меняет `activeClientId` в сессии, но не вызывает `POST /switch-profile` на IAM. Если пользователь переключит компанию через ЛК, приложение об этом не узнает. 5. **Проверка `isAdmin` — хардкод.** `activeClientId === 'WZ01112'` вместо использования `userInfo.isAdmin` из IAM. 6. **Избыточный парсинг 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 ```javascript 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: ```javascript async function fetchIamUser(token) { // GET {IAM_API_URL}/api/v1/auth/user // Authorization: Bearer // Возвращает: // { // 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)` ```javascript 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=` При переключении компании: 1. Вызвать `auth.switchProfile(token, profileId)` на IAM 2. Обновить `req.session.user` с новым `activeClientId` 3. Сохранить актуальный список `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 решается флагом `--http2` - `deck-api-test`: ddos-guard проверяет TLS fingerprint, curl не проходит даже с HTTP/2. Node.js — проходит - **Приложение на Node.js работает везде** ### 6.3. Как использовать curl (правильно!) ```bash # ❌ НЕ РАБОТАЕТ — 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) ```bash # Токен, полученный через 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. Диаграмма целевого потока ```mermaid 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: Страница с новой активной компанией ```