- 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
481 lines
20 KiB
Markdown
481 lines
20 KiB
Markdown
# 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: Страница с новой активной компанией
|
||
```
|