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

20 KiB
Raw Blame History

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. Проблемы

  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

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=

При переключении компании:

  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 (правильно!)

# ❌ НЕ РАБОТАЕТ — 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: Страница с новой активной компанией