Files
ipwhitelist-app/src/auth.js
T
naeel 9c51e449ab fix: remove canWrite from IAM — it is IAM RBAC, not our app logic
- IAM permissions.can_write defaults to false for all users
- Our app controls writes via adminMode + company membership
2026-06-11 16:01:55 +04:00

521 lines
22 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 { 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 };