Files
ipwhitelist-app/src/auth.js
T
naeel 86ca944c52 feat: Authorization Code Flow (KK) + session middleware + dev-login
- src/auth.js: OIDC mode (KC_CLIENT_ID/KC_CLIENT_SECRET), session-based
  middleware, legacy jwt-cookie migration, httpGet/httpPost без внешних зав.
- src/routes/auth.js: GET /callback (OIDC), GET+POST /dev-login (backdoor),
  session-based login/logout для обоих режимов
- server.js: express-session (8h, httpOnly, sameSite lax), trust proxy в prod
- views/dev-login.ejs: форма с пресетами MOCK_USERS + произвольные поля
- views/login.ejs: ссылка на /dev-login если devLoginEnabled
- tests: +5 тестов (session middleware), 50 PASS / 0 FAIL

Env-переменные:
  KC_CLIENT_ID, KC_CLIENT_SECRET, KC_BASE_URL, APP_URL  — OIDC режим
  SESSION_SECRET                                         — секрет сессии
  DEV_MODE=true                                          — /dev-login без пароля
  DEV_SECRET=xyz                                         — /dev-login с ?key=xyz
2026-05-30 14:37:29 +03:00

275 lines
12 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 ────────────────────────────────────────────────────────────────
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 };