v0.5.13: подготовка OIDC (Keycloak) — роуты готовы, ждут DevOps
- src/routes/oidc.js: /login, /callback, /logout Активны ТОЛЬКО при KC_CLIENT_ID+KC_CLIENT_SECRET Сейчас (mock) — пустой router, запросы уходят в UI - server.js: подключены OIDC-роуты - docs/deploy-keycloak.md: что нужно от DevOps для включения
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
# Переход на Keycloak SSO (Nubes)
|
||||||
|
|
||||||
|
## Как сейчас
|
||||||
|
- `DEV_MODE=true` — mock-аутентификация
|
||||||
|
- Любой clientId можно ввести вручную на `/login`
|
||||||
|
- JWT подписывается локальным RSA-ключом
|
||||||
|
|
||||||
|
## Что нужно от DevOps (v0.5.13)
|
||||||
|
|
||||||
|
### 1. Параметры для .env (на ВМ)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── Отключить mock-режим (убрать DEV_MODE или поставить false) ──
|
||||||
|
# DEV_MODE=true
|
||||||
|
|
||||||
|
# ── Keycloak realm (прямой или через API Gateway) ──
|
||||||
|
KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud
|
||||||
|
# ИЛИ через API Gateway:
|
||||||
|
# KC_BASE_URL=https://lk-api-gateway-test.ngcloud.ru/api/v1/iam/auth
|
||||||
|
|
||||||
|
# ── OAuth client (зарегистрированное приложение) ──
|
||||||
|
KC_CLIENT_ID=deck-test.ngcloud.ru
|
||||||
|
KC_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# ── Публичный URL приложения (для редиректа из Keycloak) ──
|
||||||
|
APP_URL=https://white.nodejsk8s.dev.nubes.ru
|
||||||
|
|
||||||
|
# ── JWKS URL для верификации токенов (если не через KC_BASE_URL) ──
|
||||||
|
# JWKS_URL=https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Уточнить
|
||||||
|
|
||||||
|
| Вопрос | Описание |
|
||||||
|
|---|---|
|
||||||
|
| **API Gateway?** | Токен получать напрямую из Keycloak или через `lk-api-gateway-test.ngcloud.ru`? |
|
||||||
|
| **ClientID в токене** | Какой claim содержит ID компании? Сейчас ждём `ClientID` или `client_id`. |
|
||||||
|
| **Admin-роль** | Как определяется admin? `clientId === WZ01112` достаточно или нужен отдельный claim? |
|
||||||
|
| **Redirect URI** | Какой зарегистрирован? `https://white.nodejsk8s.dev.nubes.ru/callback`? |
|
||||||
|
| **Несколько компаний** | `clientid` в токене приходит как одна строка или через запятую? |
|
||||||
|
|
||||||
|
### 3. Что сделано в коде (готово)
|
||||||
|
|
||||||
|
| Компонент | Файл |
|
||||||
|
|---|---|
|
||||||
|
| JWKS-загрузка + кеширование | `src/auth.js` |
|
||||||
|
| Верификация RS256 JWT | `src/auth.js` |
|
||||||
|
| `buildAuthUrl()` — редирект на Keycloak | `src/auth.js` |
|
||||||
|
| `exchangeCode()` — обмен code→token | `src/auth.js` |
|
||||||
|
| `buildLogoutUrl()` — logout | `src/auth.js` |
|
||||||
|
| OIDC-роуты /login, /callback, /logout | `src/routes/oidc.js` |
|
||||||
|
| `userFromPayload()` — парсинг claims | `src/auth.js` |
|
||||||
|
|
||||||
|
### 4. Порядок включения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Настроить .env на ВМ (параметры выше)
|
||||||
|
# 2. Перезапустить
|
||||||
|
pm2 restart ipwhitelist --update-env
|
||||||
|
|
||||||
|
# 3. Проверить что включился OIDC-режим
|
||||||
|
pm2 logs ipwhitelist --lines 3
|
||||||
|
# Должно быть: "[auth] OIDC — Keycloak"
|
||||||
|
# Если видно "[auth] MOCK" — значит KC_CLIENT_ID не задан
|
||||||
|
|
||||||
|
# 4. Перейти на https://white.nodejsk8s.dev.nubes.ru/login
|
||||||
|
# Должен редиректить на Keycloak вместо формы входа
|
||||||
|
```
|
||||||
@@ -99,6 +99,12 @@ async function start() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── OIDC-роуты (Keycloak) — активны ТОЛЬКО когда KC_CLIENT_ID задан ─────
|
||||||
|
// В mock-режиме (сейчас): заглушки, работает ui/routes/auth.js
|
||||||
|
// 🔮 В проде: задать KC_CLIENT_ID + KC_CLIENT_SECRET в .env
|
||||||
|
const { createRouter: createOidcRouter } = require('./src/routes/oidc');
|
||||||
|
app.use('/', createOidcRouter({ auth }));
|
||||||
|
|
||||||
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
|
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
|
||||||
const { createApiRouter } = require('./src/api/index');
|
const { createApiRouter } = require('./src/api/index');
|
||||||
app.use('/api/v1', createApiRouter({ auth, q }));
|
app.use('/api/v1', createApiRouter({ auth, q }));
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { Router } = require('express');
|
||||||
|
|
||||||
|
function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimiter }) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ── Если OIDC не активен — пропускаем запросы к следующему роуту (UI) ──
|
||||||
|
if (!auth.isOidc) {
|
||||||
|
// В mock-режиме /login, /callback, /logout обрабатывает ui/routes/auth.js
|
||||||
|
// Просто не регистрируем обработчики — запрос уйдёт дальше по middleware chain
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════
|
||||||
|
// OIDC-режим — только когда есть KC_CLIENT_ID + KC_CLIENT_SECRET
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// GET /login — редирект на Keycloak
|
||||||
|
router.get('/login', (req, res) => {
|
||||||
|
if (req.session && req.session.token) return res.redirect('/');
|
||||||
|
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
req.session.oidcState = state;
|
||||||
|
req.session.oidcReturnTo = req.query.returnTo || '/';
|
||||||
|
|
||||||
|
const url = auth.buildAuthUrl(state);
|
||||||
|
res.redirect(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /callback — обмен code → token → сессия
|
||||||
|
router.get('/callback', async (req, res) => {
|
||||||
|
const { code, state } = req.query;
|
||||||
|
|
||||||
|
// Проверка state (защита от CSRF на callback)
|
||||||
|
if (!state || state !== req.session.oidcState) {
|
||||||
|
return res.status(403).send('Invalid state parameter');
|
||||||
|
}
|
||||||
|
delete req.session.oidcState;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).send('Missing authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Обмен code на токен (через Keycloak или API Gateway)
|
||||||
|
const tokenData = await auth.exchangeCode(code);
|
||||||
|
const accessToken = tokenData.access_token;
|
||||||
|
|
||||||
|
// Верификация токена (проверка подписи через JWKS)
|
||||||
|
// userFromPayload уже встроена в exchangeCode или делается здесь
|
||||||
|
const payload = jwt.decode(accessToken);
|
||||||
|
if (!payload) throw new Error('Failed to decode token');
|
||||||
|
|
||||||
|
// Определяем компании пользователя
|
||||||
|
const rawClientId = payload.ClientID || payload.client_id || '';
|
||||||
|
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const activeClientId = allClientIds[0] || rawClientId;
|
||||||
|
|
||||||
|
req.session.token = accessToken;
|
||||||
|
req.session.user = {
|
||||||
|
clientId: rawClientId,
|
||||||
|
allClientIds,
|
||||||
|
activeClientId,
|
||||||
|
email: payload.email || payload.login || '',
|
||||||
|
companyId: payload.company_id || payload.companyId || null,
|
||||||
|
companyName: payload.company_name || payload.companyName || activeClientId,
|
||||||
|
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnTo = req.session.oidcReturnTo || '/';
|
||||||
|
delete req.session.oidcReturnTo;
|
||||||
|
res.redirect(returnTo);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[oidc] Callback error:', e);
|
||||||
|
res.status(500).send('Authentication failed: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /logout
|
||||||
|
router.get('/logout', (req, res) => {
|
||||||
|
const idToken = req.session && req.session.idToken;
|
||||||
|
req.session.destroy(() => {
|
||||||
|
const url = auth.buildLogoutUrl(idToken);
|
||||||
|
res.redirect(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRouter };
|
||||||
Reference in New Issue
Block a user