- [A01] /export: mount after auth.middleware, filter by company for non-admin - [A02] OIDC: verify issuer, audience, select key by kid; preload JWKS at startup - [A07] session fixation: session.regenerate() in POST /login, GET /callback, POST /dev-login - [A10] open redirect: safeReturn() helper validates returnTo (blocks //evil.com, https:// etc) - [A05] CSRF: getSessionIdentifier uses req.sessionID instead of dead req.cookies.jwt - [A05] fail-fast: reject default SESSION_SECRET / CSRF_SECRET in NODE_ENV=production - [A05] DEV_MODE=true blocked in production Tests: 68 PASS, 0 FAIL (was 50, added 18 new security-focused tests)
310 lines
14 KiB
JavaScript
310 lines
14 KiB
JavaScript
'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 ────────────────────────────────────────────────────────────────
|
||
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);
|
||
|
||
if (isOidc) {
|
||
console.log('[auth] OIDC — Keycloak', KC_BASE_URL);
|
||
// Предзагрузка JWKS при старте — избегаем задержки на первом запросе.
|
||
// При ошибке логируем предупреждение: JWKS будет загружен в exchangeCode().
|
||
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, '— будет загружен при первом запросе');
|
||
}
|
||
} 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));
|
||
|
||
// cachedJwks загружается в initAuth(); при провале preload — подгружаем здесь.
|
||
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');
|
||
|
||
// Декодируем заголовок токена для выбора ключа по 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 и audience — защита от токенов других realm/клиентов (A02).
|
||
return jwt.verify(token, pem, {
|
||
algorithms: ['RS256'],
|
||
issuer: KC_BASE_URL,
|
||
audience: KC_CLIENT_ID,
|
||
});
|
||
}
|
||
|
||
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 };
|