Files
ipwhitelist-app/docs/iam-integration.md
T
naeel a8a07fe019 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
2026-06-04 13:53:44 +03:00

481 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```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 <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)`
```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: Страница с новой активной компанией
```