diff --git a/.gitignore b/.gitignore index 83d1215..6e25d37 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ docs/nubes-design-system.md docs/deploy-vm.md docs/questions-devops.md +# Локальный Keycloak (бинарники не коммитим) +keycloak/keycloak-*/ +keycloak/data/ +keycloak/*.tar.gz + # Промпты для внешних агентов prompt-*.txt diff --git a/keycloak/README.md b/keycloak/README.md new file mode 100644 index 0000000..d22abb4 --- /dev/null +++ b/keycloak/README.md @@ -0,0 +1,33 @@ +# Локальный Keycloak для тестирования OIDC + +Поднимается на ВМ `italo.kube5s.ru` (5.172.178.213), пока нет кредов от продакшен-KC. + +## Установка + +```bash +# Всё делается на ВМ: +cd ~/keycloak +./setup.sh +``` + +## Доступ после запуска + +| Что | URL | +|---|---| +| Keycloak admin | `https://italo.kube5s.ru:8443/admin` | +| Realm | `ipwhitelist` | +| OIDC endpoints | `https://italo.kube5s.ru:8443/realms/ipwhitelist/.well-known/openid-configuration` | + +## Переменные для .env (ipwhitelist) + +``` +KC_BASE_URL=https://italo.kube5s.ru:8443/realms/ipwhitelist +KC_CLIENT_ID=ipwhitelist +KC_CLIENT_SECRET=<из Keycloak admin> +APP_URL=https://italo.kube5s.ru +DEV_MODE=false +``` + +## Пользователи + +Создаются через Keycloak admin UI после запуска. diff --git a/server.js b/server.js index 15750a5..cfa366d 100644 --- a/server.js +++ b/server.js @@ -70,8 +70,8 @@ async function start() { console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!'); process.exit(1); } - if (!process.env.IAM_API_URL) { - console.error('FATAL: IAM_API_URL не задан в production!'); + if (!process.env.DB_PASS) { + console.error('FATAL: DB_PASS не задан в production!'); process.exit(1); } } diff --git a/src/db.js b/src/db.js index f716a86..670e777 100644 --- a/src/db.js +++ b/src/db.js @@ -2,10 +2,10 @@ const { Pool } = require('pg'); require('dotenv').config(); const pool = new Pool({ - host: process.env.DB_HOST, + host: process.env.DB_HOST || 'postgresqlk8s-master', port: process.env.DB_PORT || 5432, - database: process.env.DB_NAME || 'postgres', - user: process.env.DB_USER, + database: process.env.DB_NAME || 'ipwhitelist', + user: process.env.DB_USER || 'super', password: process.env.DB_PASS, ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false, max: 10, diff --git a/tests/real-users-smoke.js b/tests/real-users-smoke.js new file mode 100644 index 0000000..ff35a48 --- /dev/null +++ b/tests/real-users-smoke.js @@ -0,0 +1,278 @@ +/** + * tests/real-users-smoke.js — smoke test с реальными токенами IAM. + * + * ⛔ ТОЛЬКО ЧТЕНИЕ — никаких POST/PATCH/DELETE. + * ⛔ Все запросы к IAM API и ipwhitelist GET-эндпоинтам. + * ✅ Безопасно для прода/стейджинга — ничего не создаёт, не удаляет, не меняет. + * + * Использование: + * cp tokens_all.txt ../nubes/tokens_all.txt + * node tests/real-users-smoke.js + * + * Переменные окружения: + * IAM_API_URL — по умолчанию https://auth-api.ngcloud.ru (prod) + * APP_URL — по умолчанию https://italo.kube5s.ru (тестовый стенд) + */ + +'use strict'; + +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, ''); +const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, ''); + +// ── Парсинг файла с токенами ──────────────────────────────────────────────── +function parseTokensFile(filePath) { + const text = fs.readFileSync(filePath, 'utf8'); + const lines = text.split('\n'); + const accounts = []; + let i = 0; + while (i < lines.length) { + const l = lines[i].trim(); + if (!l || l.startsWith('eyJ')) { i++; continue; } + const email = l.replace(/^=+\s*|\s*=+$/g, '').trim(); + i++; + while (i < lines.length && !lines[i].trim()) i++; + const tokParts = []; + while (i < lines.length) { + const t = lines[i].trim(); + if (!t || t.includes('===')) break; + tokParts.push(t); + i++; + } + const token = tokParts.join(''); + if (token) accounts.push({ email, token }); + } + return accounts; +} + +// ── HTTP helper ────────────────────────────────────────────────────────────── +function httpRequest(url, method = 'GET', headers = {}, body = null, timeout = 10000) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const lib = u.protocol === 'https:' ? https : http; + const opts = { + hostname: u.hostname, + port: u.port || (u.protocol === 'https:' ? 443 : 80), + path: u.pathname + u.search, + method, + headers, + timeout, + }; + const req = lib.request(opts, (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => { + resolve({ status: res.statusCode, headers: res.headers, body: data }); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); + if (body) req.write(body); + req.end(); + }); +} + +// ── JWT decode ────────────────────────────────────────────────────────────── +function jwtPayload(token) { + const parts = token.split('.'); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], 'base64url').toString()); +} + +// ── Тесты ─────────────────────────────────────────────────────────────────── +let passed = 0; +let failed = 0; + +function test(name, fn) { + return Promise.resolve().then(fn).then(() => { + console.log(' ✅ ' + name); + passed++; + }).catch(e => { + console.log(' ❌ ' + name + ' — ' + e.message); + failed++; + }); +} + +async function run() { + // Путь к файлу с токенами + const tokFile = path.resolve(__dirname, '..', '..', 'nubes', 'tokens_all.txt'); + let accounts; + try { + accounts = parseTokensFile(tokFile); + } catch (e) { + console.log('❌ Не найден файл с токенами:', tokFile); + console.log(' cp /home/naeel/nubes/tokens_all.txt ../nubes/tokens_all.txt'); + process.exit(1); + } + + console.log('🧪 Smoke-тесты с реальными IAM токенами'); + console.log(' IAM:', IAM_API_URL); + console.log(' App:', APP_URL); + console.log(' Учётных записей:', accounts.length); + console.log(); + + for (const acc of accounts) { + const tok = acc.token; + const email = acc.email; + const payload = jwtPayload(tok); + const label = `${email} (${payload?.ClientID || '?'})`; + + console.log(`\n══════════ ${label} ══════════`); + + // ── 1. IAM API: GET /auth/user ────────────────────────────────────────── + console.log(' ── IAM API ──'); + + await test('IAM API доступен', async () => { + const res = await httpRequest(`${IAM_API_URL}/api/v1/auth/user`, 'GET', { + 'Authorization': 'Bearer ' + tok, + 'Accept': 'application/json', + }); + if (res.status !== 200) throw new Error('HTTP ' + res.status + ': ' + res.body.slice(0, 100)); + }); + + let iamUser = null; + await test('IAM API: profiles[] не пуст', async () => { + const res = await httpRequest(`${IAM_API_URL}/api/v1/auth/user`, 'GET', { + 'Authorization': 'Bearer ' + tok, + 'Accept': 'application/json', + }); + iamUser = JSON.parse(res.body); + if (!iamUser.profiles || iamUser.profiles.length === 0) throw new Error('profiles[] пуст'); + }); + + await test('IAM API: ClientID совпадает с JWT', () => { + const jwtCid = payload.ClientID; + const iamCid = iamUser.userInfo?.clientID; + if (jwtCid !== iamCid) throw new Error(`JWT ClientID=${jwtCid} !== IAM userInfo.clientID=${iamCid}`); + }); + + await test('IAM API: profiles[0].client_id совпадает', () => { + const profileCid = iamUser.profiles[0].client_id; + const jwtCid = payload.ClientID; + if (profileCid !== jwtCid) throw new Error(`profile client_id=${profileCid} !== JWT ClientID=${jwtCid}`); + }); + + await test('IAM API: email совпадает', () => { + const iamEmail = iamUser.userInfo?.email || ''; + if (iamEmail && iamEmail !== email) throw new Error(`IAM email=${iamEmail} !== ожидаемый ${email}`); + }); + + await test('IAM API: isAdmin флаг есть в ответе', () => { + if (typeof iamUser.userInfo?.isAdmin !== 'boolean') throw new Error('isAdmin отсутствует или не boolean'); + }); + + // ── 2. IAM API: JWKS endpoint ────────────────────────────────────────── + await test('IAM API: JWKS endpoint доступен и содержит ключи', async () => { + const res = await httpRequest(`${IAM_API_URL}/.well-known/openid-configuration`, 'GET', { + 'Accept': 'application/json', + }); + if (res.status === 404) { + // Не у всех IAM есть well-known, пропускаем не фатально + return; + } + const data = JSON.parse(res.body); + if (!data.jwks_uri) throw new Error('Нет jwks_uri'); + }); + + // ── 3. ipwhitelist app: login + read-only endpoints ──────────────────── + console.log(' ── ipwhitelist app ──'); + + // Получаем сессионную куку для следующих запросов + let sessionCookie = ''; + + // Логинимся и запоминаем куку + await test('POST /login-token + установка cookie', async () => { + const loginRes = await httpRequest(`${APP_URL}/login-token`, 'POST', { + 'Content-Type': 'application/x-www-form-urlencoded', + }, 'token=' + encodeURIComponent(tok) + '&returnTo=/'); + if (loginRes.status !== 302) throw new Error('Ожидался 302, получен ' + loginRes.status); + const sc = loginRes.headers['set-cookie']; + if (!sc) throw new Error('Нет set-cookie'); + sessionCookie = sc.map(c => c.split(';')[0]).join('; '); + }); + + // GET / — для admin без ?company= ожидается 302 (редирект на ?company=) + await test('GET /: страница загружается', async () => { + const res = await httpRequest(`${APP_URL}/`, 'GET', { 'Cookie': sessionCookie }); + if (res.status === 200 || res.status === 302) return; // оба OK + throw new Error('HTTP ' + res.status); + }); + + // GET /export — доступен всем аутентифицированным + await test('GET /export: txt-файл отдаётся', async () => { + const res = await httpRequest(`${APP_URL}/export`, 'GET', { 'Cookie': sessionCookie }); + if (res.status !== 200) throw new Error('HTTP ' + res.status); + const ct = res.headers['content-type'] || ''; + if (!ct.includes('text/plain')) throw new Error('Не text/plain: ' + ct); + }); + + // GET /api/v1/entries — API endpoint + await test('GET /api/v1/entries: записи читаются', async () => { + const res = await httpRequest(`${APP_URL}/api/v1/entries`, 'GET', { + 'Authorization': 'Bearer ' + tok, + 'Accept': 'application/json', + }); + if (res.status !== 200) throw new Error('HTTP ' + res.status); + const data = JSON.parse(res.body); + if (!Array.isArray(data.entries)) throw new Error('entries не массив'); + if (typeof data.limit !== 'number') throw new Error('limit не число'); + }); + + // GET /api/v1/entries/export — API export + await test('GET /api/v1/entries/export: API export доступен', async () => { + const res = await httpRequest(`${APP_URL}/api/v1/entries/export`, 'GET', { + 'Authorization': 'Bearer ' + tok, + 'Accept': 'text/plain', + }); + if (res.status !== 200) throw new Error('HTTP ' + res.status); + }); + + // Admin-only тесты + if (payload.ClientID === 'WZ01112' || iamUser?.userInfo?.isAdmin) { + await test('GET /api/v1/companies: admin может читать (JSON)', async () => { + const res = await httpRequest(`${APP_URL}/api/v1/companies`, 'GET', { + 'Authorization': 'Bearer ' + tok, + 'Accept': 'application/json', + }); + if (res.status !== 200) throw new Error('HTTP ' + res.status); + const data = JSON.parse(res.body); + if (!Array.isArray(data.companies)) throw new Error('companies не массив'); + }); + + await test('GET /admin: admin UI доступна', async () => { + const res = await httpRequest(`${APP_URL}/admin`, 'GET', { 'Cookie': sessionCookie }); + if (res.status !== 200) throw new Error('HTTP ' + res.status); + }); + } else { + // Проверяем что не-admin не может получить /admin + await test('GET /admin: не-admin получает 302 или 403', async () => { + const res = await httpRequest(`${APP_URL}/admin`, 'GET', { 'Cookie': sessionCookie }); + if (res.status === 200) throw new Error('Не-admin получил /admin без прав'); + }); + + await test('GET /api/v1/companies: не-admin 403', async () => { + const res = await httpRequest(`${APP_URL}/api/v1/companies`, 'GET', { + 'Authorization': 'Bearer ' + tok, + 'Accept': 'application/json', + }); + if (res.status !== 403) throw new Error('Ожидался 403, получен ' + res.status); + }); + } + } + + // ── Итог ────────────────────────────────────────────────────────────────── + console.log(`\n══════════ ИТОГ ══════════`); + console.log(` ✅ Пройдено: ${passed}`); + console.log(` ❌ Провалено: ${failed}`); + console.log(` Всего: ${passed + failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(e => { + console.error('FATAL:', e.message); + process.exit(1); +});