'use strict'; /** * src/auth.js — аутентификация и авторизация. * * Два режима работы (определяются по наличию KC_CLIENT_ID + KC_CLIENT_SECRET): * * OIDC-режим — Authorization Code Flow через Keycloak. * Токен обменивается на /callback, пользователь сохраняется в сессии. * * Mock-режим — локальная RSA-пара, JWT выдаётся при логине. * Пользователь выбирается на /login из MOCK_USERS и тоже хранится в сессии. * * Dev-backdoor (DEV_MODE=true или DEV_SECRET задан): * /dev-login — форма выбора произвольного пользователя, в обход OIDC/mock. * DEV_MODE=true → доступен без пароля (локальная разработка). * DEV_SECRET=xyz → доступен с ?key=xyz (staging/prod тестирование). * * Экспортируемые функции: * initAuth() → объект auth с middleware, OIDC-хелперами и флагами * requireAdmin() → middleware для admin-only маршрутов */ const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); const { IAM_API_URL } = require('./config'); // ── Конфигурация из окружения ──────────────────────────────────────────────── const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api'; const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'; const KC_BASE_URL = (process.env.KC_BASE_URL || 'https://keycloak.nubes.ru/realms/cloud').replace(/\/$/, ''); const KC_CLIENT_ID = process.env.KC_CLIENT_ID || ''; const KC_CLIENT_SECRET = process.env.KC_CLIENT_SECRET || ''; const APP_URL = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''); // DEV_MODE и DEV_SECRET читаются внутри initAuth() — иначе при тестах // значение фиксируется при первом require() и последующее присвоение process.env // не влияет на уже загруженный модуль (кеш Node.js). // OIDC endpoints (Keycloak realm cloud) const OIDC = { auth: KC_BASE_URL + '/protocol/openid-connect/auth', token: KC_BASE_URL + '/protocol/openid-connect/token', certs: KC_BASE_URL + '/protocol/openid-connect/certs', logout: KC_BASE_URL + '/protocol/openid-connect/logout', }; // ── Кеши (инициализируются в initAuth) ───────────────────────────────────── let mockKeyPair = null; let mockJwks = null; let cachedJwks = null; // OIDC public keys // ── initAuth ──────────────────────────────────────────────────────────────── async function initAuth() { // Читаем здесь — не на уровне модуля, чтобы тесты могли менять process.env // до вызова initAuth() и видеть актуальные значения. const devMode = process.env.DEV_MODE === 'true'; const devSecret = process.env.DEV_SECRET || ''; // DEV_MODE=true недопустим в production — жёсткая защита от случайного деплоя. if (process.env.NODE_ENV === 'production' && devMode) { throw new Error('DEV_MODE=true запрещён в production (NODE_ENV=production)'); } const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET); // Всегда предзагружаем JWKS (даже в mock-режиме), чтобы можно было // проверить реальный Bearer токен от Keycloak без KC_CLIENT_ID/SECRET. try { cachedJwks = JSON.parse(await httpGet(OIDC.certs)); console.log('[auth] JWKS loaded:', cachedJwks.keys.length, 'ключей'); } catch (e) { console.warn('[auth] JWKS preload failed:', e.message, '— только mock-режим'); } if (isOidc) { console.log('[auth] OIDC — Keycloak', KC_BASE_URL); } else { console.log('[auth] MOCK — local RS256 key'); mockKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); const jwkPub = crypto.createPublicKey(mockKeyPair.publicKey).export({ format: 'jwk' }); Object.assign(jwkPub, { alg: 'RS256', use: 'sig', kid: 'mock-1' }); mockJwks = { keys: [jwkPub] }; } if (devMode) console.log('[auth] DEV_MODE — /dev-login доступен без пароля'); if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом'); return { isOidc, middleware: createMiddleware(isOidc, devMode), bearerMiddleware: createBearerMiddleware(isOidc, devMode), // OIDC-хелперы (используются в src/routes/auth.js) buildAuthUrl, exchangeCode, buildLogoutUrl, // Mock JWT (для API-клиентов если нужен bearer-токен) issueMockToken, // Флаги для routes/auth.js devLoginEnabled: devMode || !!devSecret, DEV_SECRET: devSecret, // JWKS endpoint (только в mock-режиме) jwksHandler: !isOidc ? (req, res) => res.json(mockJwks) : null, // IAM API хелперы fetchIamUser, switchProfile, }; } // ── Middleware — проверяет req.session.user или Bearer-токен ───────────────── function createMiddleware(isOidc, devMode) { return function authMiddleware(req, res, next) { // 1. Сессия — основной источник пользователя (OIDC и mock) if (req.session && req.session.user) { req.user = req.session.user; return next(); } // 2. Bearer-токен из заголовка (для API-клиентов без браузерной сессии) const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); if (bearer) { try { const payload = verifyAnyToken(bearer, devMode); req.user = userFromPayload(payload); return next(); } catch (e) { return res.status(401).json({ error: 'Invalid token: ' + e.message }); } } // 3. Устаревший jwt-cookie — плавная миграция: переносим в сессию if (!isOidc && req.cookies && req.cookies.jwt) { try { const payload = verifyMockToken(req.cookies.jwt); req.user = userFromPayload(payload); if (req.session) { req.session.user = req.user; res.clearCookie('jwt'); } return next(); } catch (_) { res.clearCookie('jwt'); } } // 4. Не аутентифицирован → редирект (GET) или 401 (остальное) if (req.method === 'GET') { return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl)); } return res.status(401).send('Unauthorized'); }; } // ── OIDC — redirect URL для Keycloak ──────────────────────────────────────── function buildAuthUrl(state) { const params = new URLSearchParams({ response_type: 'code', client_id: KC_CLIENT_ID, redirect_uri: APP_URL + '/callback', scope: 'openid profile email', state, }); return OIDC.auth + '?' + params; } // ── OIDC — обмен authorization code → токены, возвращает { user, idToken, refreshToken } async function exchangeCode(code) { const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: KC_CLIENT_ID, client_secret: KC_CLIENT_SECRET, redirect_uri: APP_URL + '/callback', code, }).toString(); const raw = await httpPost(OIDC.token, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); const data = JSON.parse(raw); if (!data.access_token) throw new Error('No access_token: ' + raw.slice(0, 200)); // cachedJwks загружается в initAuth(); при провале preload — подгружаем здесь. if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs)); const payload = verifyOidcToken(data.access_token); return { user: userFromPayload(payload), accessToken: data.access_token, idToken: data.id_token || null, refreshToken: data.refresh_token || null, }; } // ── OIDC — URL для logout на Keycloak ─────────────────────────────────────── function buildLogoutUrl(idToken) { const params = new URLSearchParams({ post_logout_redirect_uri: APP_URL + '/login' }); if (idToken) params.set('id_token_hint', idToken); return OIDC.logout + '?' + params; } // ── Mock — выпустить JWT (для API-клиентов) ────────────────────────────────── function issueMockToken(claims) { if (!mockKeyPair) throw new Error('Mock keypair not initialized'); return jwt.sign( { ClientID: claims.clientId, company_id: claims.companyId, company_name: claims.companyName, email: claims.email, login: claims.email }, mockKeyPair.privateKey, { algorithm: 'RS256', issuer: ISSUER, subject: claims.companyId || 'mock', expiresIn: '24h', keyid: 'mock-1' } ); } // ── Внутренние утилиты ─────────────────────────────────────────────────────── function verifyMockToken(token) { if (!mockKeyPair) throw new Error('Mock keypair not initialized'); return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER }); } function verifyOidcToken(token) { if (!cachedJwks) throw new Error('JWKS not loaded yet'); // Декодируем заголовок токена для выбора ключа по kid. // base64url безопаснее base64: обрабатывает '-' и '_' без паддинга. const headerRaw = token.split('.')[0]; let header; try { header = JSON.parse(Buffer.from(headerRaw, 'base64url').toString()); } catch (e) { throw new Error('Malformed JWT header'); } const kid = header.kid; // Ищем ключ по kid (точный match) или первый RSA-sig ключ (если kid не указан). const key = kid ? cachedJwks.keys.find(k => k.kid === kid && k.kty === 'RSA' && k.use === 'sig') : cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig'); if (!key) throw new Error('No matching RSA sig key in JWKS' + (kid ? ` (kid=${kid})` : '')); const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' }); // Проверяем issuer — защита от токенов других realm. // audience не проверяем — не все KC-клиенты корректно выставляют aud claim. const opts = { algorithms: ['RS256'], issuer: KC_BASE_URL }; return jwt.verify(token, pem, opts); } /** * Универсальная верификация: пробует mock, затем OIDC (JWKS). * Позволяет принимать и mock-токены (тесты, dev), и реальные токены от Keycloak. */ function verifyAnyToken(token, devMode) { let lastErr = null; // 1. Mock (быстрый, локальный) if (mockKeyPair) { try { return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER }); } catch (e) { lastErr = e; } } // 2. OIDC (JWKS от Keycloak) if (cachedJwks) { try { return verifyOidcToken(token); } catch (e) { lastErr = e; } } // 3. DEV_MODE — просто декодировать без проверки подписи if (devMode) { console.log('[auth] DEV_MODE — декодирую токен без проверки подписи:', lastErr ? lastErr.message : 'no verifier'); const decoded = jwt.decode(token); if (!decoded) throw new Error('Malformed JWT'); return decoded; } throw lastErr || new Error('Token verification failed: no verifier available'); } // ── IAM API — получение профилей пользователя ───────────────────────────────── /** * Выполняет HTTP-запрос к IAM API и возвращает обогащённые данные пользователя. * @param {string} token — access_token от IAM * @returns {object} { email, clientId, allClientIds, activeProfileId, companyId, companyName, isAdmin, fio, profiles, raw } */ async function fetchIamUser(token) { const raw = await new Promise((resolve, reject) => { const url = new URL(IAM_API_URL + '/api/v1/auth/user'); const lib = url.protocol === 'https:' ? https : http; const req = lib.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'GET', headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, }, (res) => { let data = ''; res.on('data', c => { data += c; if (data.length > 100_000) { req.destroy(); reject(new Error('Response too large')); } }); res.on('end', () => { if (res.statusCode !== 200) { return reject(new Error(`IAM API returned ${res.statusCode}: ${data.slice(0, 200)}`)); } try { resolve(JSON.parse(data)); } catch (e) { reject(e); } }); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('IAM API timeout')); }); req.end(); }); const profiles = raw.profiles || []; const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {}; const ui = raw.userInfo || {}; const imp = raw.impersonation || {}; return { email: ui.email || '', clientId: ui.clientID || activeProfile.client_id || '', allClientIds: profiles.map(p => p.client_id).filter(Boolean), activeProfileId: activeProfile.id || null, companyId: ui.companyId || '', companyName: ui.company || activeProfile.company_name || '', isAdmin: !!ui.isAdmin, isImpersonated: !!(imp.is_impersonated), impersonationType: imp.type || null, originalUserEmail: imp.originalUserEmail || '', originalUserFullName: imp.originalUserFullName || '', originalUserCompany: imp.originalUserCompany || '', impersonatedCompanyId: imp.impersonatedCompanyId || '', fio: ui.fio || null, profiles, // полный массив для UI raw, // сырой ответ (для отладки) }; } /** * Переключает активный профиль пользователя через IAM API. * @param {string} token — access_token от IAM * @param {number} profileId — profiles[].id нового активного профиля * @returns {object} { success, active_profile } */ async function switchProfile(token, profileId) { const body = JSON.stringify({ profile_id: profileId }); const raw = await new Promise((resolve, reject) => { const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile'); const lib = url.protocol === 'https:' ? https : http; const req = lib.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', 'Accept': 'application/json', }, }, (res) => { let data = ''; res.on('data', c => { data += c; if (data.length > 100_000) { req.destroy(); reject(new Error('Response too large')); } }); res.on('end', () => { if (res.statusCode !== 200) { return reject(new Error(`IAM switch-profile failed: ${res.statusCode} ${data.slice(0, 200)}`)); } try { resolve(JSON.parse(data)); } catch (e) { reject(e); } }); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('IAM switch-profile timeout')); }); req.write(body); req.end(); }); return raw; } function userFromPayload(payload, iamData) { // ── Если есть данные из IAM API — используем их как основной источник ──── if (iamData) { return { email: iamData.email, clientId: iamData.clientId, allClientIds: iamData.allClientIds, activeClientId: iamData.clientId, // активная по IAM activeProfileId: iamData.activeProfileId, companyId: iamData.companyId, companyName: iamData.companyName, isAdmin: iamData.isAdmin, fio: iamData.fio, profiles: iamData.profiles, }; } // ── Без IAM — fallback на JWT (mock-режим) ────────────────────────────── let allClientIds = []; // 1. auth-api: ClientID = "WZ03709" или "WZ03709, WZ54321" const rawClientId = payload.ClientID || payload.client_id || ''; if (rawClientId) { allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean); } // 2. Keycloak: claims = ["wz03709-shturval-...", "wz03709-...-test-3"] if (!allClientIds.length && Array.isArray(payload.claims)) { const wzRe = /wz(\d+)/i; const seen = new Set(); for (const c of payload.claims) { const m = String(c).match(wzRe); if (m) { const wz = 'WZ' + m[1]; if (!seen.has(wz)) { seen.add(wz); allClientIds.push(wz); } } } } const activeClientId = allClientIds[0] || 'UNKNOWN'; // ── Email ───────────────────────────────────────────────────────────── const isEmail = s => typeof s === 'string' && s.includes('@'); const email = isEmail(payload.email) ? payload.email : isEmail(payload.login) ? payload.login : isEmail(payload.sub) ? payload.sub : isEmail(payload.preferred_username) ? payload.preferred_username : payload.email || payload.login || payload.sub || 'unknown'; return { email, clientId: activeClientId, allClientIds, activeClientId, companyId: payload.company_id || null, companyName: payload.company_name || activeClientId, isAdmin: activeClientId === ADMIN_CLIENT_ID, }; } // ── Bearer-only middleware (для /api/v1) ──────────────────────────────────── // Не смотрит на сессию — всегда проверяет Bearer JWT явно. // Используется в src/api/index.js вместо auth.middleware. function createBearerMiddleware(isOidc, devMode) { return function bearerAuthMiddleware(req, res, next) { const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); if (!bearer) { return res.status(401).json({ error: 'Bearer token required' }); } try { const payload = verifyAnyToken(bearer, devMode); req.user = userFromPayload(payload); return next(); } catch (e) { return res.status(401).json({ error: 'Invalid token: ' + e.message }); } }; } // ── requireAdmin middleware ────────────────────────────────────────────────── /** * Middleware-защита для admin-only маршрутов. * Использовать после app.use(auth.middleware): * router.get('/admin', requireAdmin, handler) */ function requireAdmin(req, res, next) { if (!req.user || !req.user.isAdmin) { return res.status(403).send('403 Forbidden: требуется роль администратора'); } next(); } // ── HTTP-утилиты (без внешних зависимостей) ───────────────────────────────── function httpGet(url) { return new Promise((resolve, reject) => { const lib = url.startsWith('https') ? https : http; const req = lib.get(url, { timeout: 5000 }, (res) => { let data = ''; res.on('data', c => { data += c; }); res.on('end', () => res.statusCode === 200 ? resolve(data) : reject(new Error('HTTP ' + res.statusCode))); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); }); } function httpPost(url, body, headers) { 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: 'POST', headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, timeout: 5000, }; const req = lib.request(opts, (res) => { let data = ''; res.on('data', c => { data += c; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) resolve(data); else reject(new Error('HTTP ' + res.statusCode + ': ' + data.slice(0, 200))); }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); req.write(body); req.end(); }); } /** * Валидирует redirect-цель: разрешает только локальные пути. * Защита от open redirect: //evil.com и https://evil.com → '/'. */ function safeReturn(target) { if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) { return target; } return '/'; } module.exports = { initAuth, requireAdmin, safeReturn, fetchIamUser, switchProfile };