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
This commit is contained in:
2026-05-30 14:37:29 +03:00
parent 876e03b78b
commit 86ca944c52
8 changed files with 624 additions and 169 deletions
+232 -94
View File
@@ -1,97 +1,227 @@
const jwt = require('jsonwebtoken');
'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 JWKS_URL = process.env.JWKS_URL || '';
// ── Конфигурация из окружения ────────────────────────────────────────────────
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
// clientId платформы, которому выдаётся роль администратора.
// Задаётся через .env, по умолчанию WZ01112 (Nubes) согласно ТЗ.
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(/\/$/, '');
let keyPair = null;
let jwks = null;
// 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() {
if (JWKS_URL) {
console.log('Auth: external JWKS ' + JWKS_URL);
return { verify: verifyJWT, middleware: createMiddleware(), jwksHandler: null, issue: null };
// Читаем здесь — не на уровне модуля, чтобы тесты могли менять 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] };
}
console.log('Auth: MOCK - local RS256 key');
keyPair = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const pubKeyObj = crypto.createPublicKey(keyPair.publicKey).export({ format: 'jwk' });
pubKeyObj.alg = 'RS256';
pubKeyObj.use = 'sig';
pubKeyObj.kid = 'mock-1';
jwks = { keys: [pubKeyObj] };
if (devMode) console.log('[auth] DEV_MODE — /dev-login доступен без пароля');
if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом');
return {
verify: verifyJWT,
issue: issueJWT,
middleware: createMiddleware(),
jwksHandler: function(req, res) { res.json(jwks); },
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,
};
}
function createMiddleware() {
// ── Middleware — проверяет req.session.user или Bearer-токен ─────────────────
function createMiddleware(isOidc) {
return function authMiddleware(req, res, next) {
// DEV_MODE: обход авторизации для локальной разработки.
// Активен только если NODE_ENV != 'production' — в prod всегда требует токен.
// DEV_ADMIN=true позволяет тестировать admin-функционал в dev-режиме.
if (process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production') {
req.user = {
email: 'dev@test.local',
clientId: 'WZ01325',
companyId: '00000000-0000-0000-0000-000000000000',
companyName: 'DEV',
// DEV_ADMIN=true → имитировать admin-права в dev-окружении
isAdmin: process.env.DEV_ADMIN === 'true',
};
// 1. Сессия — основной источник пользователя (OIDC и mock)
if (req.session && req.session.user) {
req.user = req.session.user;
return next();
}
// Токен извлекаем из httpOnly-cookie (браузер) или Authorization header (API)
var token = (req.cookies && req.cookies.jwt) ||
(req.headers.authorization || '').replace('Bearer ', '');
if (!token) {
if (req.method === 'GET' && req.path.indexOf('/.well-known') !== 0) {
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
// 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 });
}
return res.status(401).send('Unauthorized');
}
try {
var payload = verifyJWT(token);
req.user = {
email: payload.email || 'unknown',
clientId: payload.ClientID,
companyId: payload.company_id,
companyName: payload.company_name || payload.ClientID,
// Роль admin определяется по clientId платформы согласно ТЗ.
// В проде: можно расширить проверкой отдельного claim из Keycloak.
isAdmin: payload.ClientID === ADMIN_CLIENT_ID,
};
next();
} catch (e) {
res.clearCookie('jwt');
if (req.method === 'GET' && req.path.indexOf('/.well-known') !== 0) {
return res.redirect('/login');
// 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');
}
return res.status(401).send('Unauthorized');
}
// 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):
* app.get('/admin', requireAdmin, handler)
* router.get('/admin', requireAdmin, handler)
*/
function requireAdmin(req, res, next) {
if (!req.user || !req.user.isAdmin) {
@@ -100,37 +230,45 @@ function requireAdmin(req, res, next) {
next();
}
function verifyJWT(token) {
if (JWKS_URL) {
// TODO: реализовать верификацию через внешний JWKS-endpoint.
// Нужен URL от команды Keycloak: JWKS_URL=https://auth-api.../jwks
throw new Error('JWKS_URL verification not implemented yet');
}
return jwt.verify(token, keyPair.publicKey, {
algorithms: ['RS256'],
issuer: ISSUER,
// ── 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 issueJWT(claims) {
if (!keyPair) throw new Error('Cannot issue tokens with external JWKS');
return jwt.sign(
{
ClientID: claims.clientId,
company_id: claims.companyId,
company_name: claims.companyName,
email: claims.email,
login: claims.email,
},
keyPair.privateKey,
{
algorithm: 'RS256',
issuer: ISSUER,
subject: claims.companyId,
expiresIn: '24h',
keyid: 'mock-1',
}
);
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 };