Files
ipwhitelist-app/src/auth.js
T
naeel a5170a062c security: fix 7 vulnerabilities from Opus code review
- [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)
2026-05-30 15:25:57 +03:00

310 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };