'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 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 ──────────────────────────────────────────────────────────────── function initAuth() { // Читаем здесь — не на уровне модуля, чтобы тесты могли менять process.env // до вызова initAuth() и видеть актуальные значения. const devMode = process.env.DEV_MODE === 'true'; const devSecret = process.env.DEV_SECRET || ''; const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET); 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), // 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, }; } // ── Middleware — проверяет req.session.user или Bearer-токен ───────────────── function createMiddleware(isOidc) { 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 = isOidc ? verifyOidcToken(bearer) : verifyMockToken(bearer); 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)); if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs)); const payload = verifyOidcToken(data.access_token); return { user: userFromPayload(payload), 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'); const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig'); if (!key) throw new Error('No RSA sig key in JWKS'); const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' }); return jwt.verify(token, pem, { algorithms: ['RS256'] }); } function userFromPayload(payload) { const clientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN'; return { email: payload.email || payload.login || 'unknown', clientId, companyId: payload.company_id || payload.sub || null, companyName: payload.company_name || clientId, isAdmin: clientId === ADMIN_CLIENT_ID, }; } // ── 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(); }); } module.exports = { initAuth, requireAdmin };