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:
Generated
+50
@@ -14,6 +14,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1"
|
||||||
@@ -360,6 +361,28 @@
|
|||||||
"express": ">= 4.11"
|
"express": ">= 4.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cookie-signature": "~1.0.7",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "~5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||||
@@ -743,6 +766,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -903,6 +934,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -1111,6 +1150,17 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const session = require('express-session');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
@@ -41,6 +42,9 @@ app.set('views', path.join(__dirname, 'views'));
|
|||||||
// Referrer-Policy и пр. CSP отключён явно — EJS использует inline-стили/скрипты.
|
// Referrer-Policy и пр. CSP отключён явно — EJS использует inline-стили/скрипты.
|
||||||
app.use(helmet({ contentSecurityPolicy: false }));
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
|
||||||
|
// За reverse-proxy (nginx/k8s ingress) — доверяем первому хопу для secure cookies.
|
||||||
|
if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
|
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
@@ -48,6 +52,21 @@ app.use(cookieParser());
|
|||||||
async function start() {
|
async function start() {
|
||||||
const auth = await initAuth();
|
const auth = await initAuth();
|
||||||
|
|
||||||
|
// ── Сессии ────────────────────────────────────────────────────────────────
|
||||||
|
// SESSION_SECRET из .env (обязателен в production).
|
||||||
|
// MemoryStore — только для одного инстанса; для multi-pod нужен Redis.
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.SESSION_SECRET || 'dev-session-secret-change-me',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production', // только HTTPS в проде
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 8 * 60 * 60 * 1000, // 8 часов
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// ── CSRF защита ────────────────────────────────────────────────────────────
|
// ── CSRF защита ────────────────────────────────────────────────────────────
|
||||||
// initCsrf() читает CSRF_SECRET и NODE_ENV из process.env.
|
// initCsrf() читает CSRF_SECRET и NODE_ENV из process.env.
|
||||||
// Возвращает: doubleCsrfProtection (middleware на POST) и
|
// Возвращает: doubleCsrfProtection (middleware на POST) и
|
||||||
|
|||||||
+232
-94
@@ -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 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 платформы, которому выдаётся роль администратора.
|
const KC_BASE_URL = (process.env.KC_BASE_URL || 'https://keycloak.nubes.ru/realms/cloud').replace(/\/$/, '');
|
||||||
// Задаётся через .env, по умолчанию WZ01112 (Nubes) согласно ТЗ.
|
const KC_CLIENT_ID = process.env.KC_CLIENT_ID || '';
|
||||||
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
|
const KC_CLIENT_SECRET = process.env.KC_CLIENT_SECRET || '';
|
||||||
|
const APP_URL = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||||
|
|
||||||
let keyPair = null;
|
// DEV_MODE и DEV_SECRET читаются внутри initAuth() — иначе при тестах
|
||||||
let jwks = null;
|
// значение фиксируется при первом 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() {
|
function initAuth() {
|
||||||
if (JWKS_URL) {
|
// Читаем здесь — не на уровне модуля, чтобы тесты могли менять process.env
|
||||||
console.log('Auth: external JWKS ' + JWKS_URL);
|
// до вызова initAuth() и видеть актуальные значения.
|
||||||
return { verify: verifyJWT, middleware: createMiddleware(), jwksHandler: null, issue: null };
|
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');
|
if (devMode) console.log('[auth] DEV_MODE — /dev-login доступен без пароля');
|
||||||
keyPair = crypto.generateKeyPairSync('rsa', {
|
if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом');
|
||||||
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] };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
verify: verifyJWT,
|
isOidc,
|
||||||
issue: issueJWT,
|
middleware: createMiddleware(isOidc),
|
||||||
middleware: createMiddleware(),
|
// OIDC-хелперы (используются в src/routes/auth.js)
|
||||||
jwksHandler: function(req, res) { res.json(jwks); },
|
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) {
|
return function authMiddleware(req, res, next) {
|
||||||
// DEV_MODE: обход авторизации для локальной разработки.
|
// 1. Сессия — основной источник пользователя (OIDC и mock)
|
||||||
// Активен только если NODE_ENV != 'production' — в prod всегда требует токен.
|
if (req.session && req.session.user) {
|
||||||
// DEV_ADMIN=true позволяет тестировать admin-функционал в dev-режиме.
|
req.user = req.session.user;
|
||||||
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',
|
|
||||||
};
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Токен извлекаем из httpOnly-cookie (браузер) или Authorization header (API)
|
// 2. Bearer-токен из заголовка (для API-клиентов без браузерной сессии)
|
||||||
var token = (req.cookies && req.cookies.jwt) ||
|
const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
||||||
(req.headers.authorization || '').replace('Bearer ', '');
|
if (bearer) {
|
||||||
|
try {
|
||||||
if (!token) {
|
const payload = isOidc ? verifyOidcToken(bearer) : verifyMockToken(bearer);
|
||||||
if (req.method === 'GET' && req.path.indexOf('/.well-known') !== 0) {
|
req.user = userFromPayload(payload);
|
||||||
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
|
return next();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token: ' + e.message });
|
||||||
}
|
}
|
||||||
return res.status(401).send('Unauthorized');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 3. Устаревший jwt-cookie — плавная миграция: переносим в сессию
|
||||||
var payload = verifyJWT(token);
|
if (!isOidc && req.cookies && req.cookies.jwt) {
|
||||||
req.user = {
|
try {
|
||||||
email: payload.email || 'unknown',
|
const payload = verifyMockToken(req.cookies.jwt);
|
||||||
clientId: payload.ClientID,
|
req.user = userFromPayload(payload);
|
||||||
companyId: payload.company_id,
|
if (req.session) {
|
||||||
companyName: payload.company_name || payload.ClientID,
|
req.session.user = req.user;
|
||||||
// Роль admin определяется по clientId платформы согласно ТЗ.
|
res.clearCookie('jwt');
|
||||||
// В проде: можно расширить проверкой отдельного claim из Keycloak.
|
}
|
||||||
isAdmin: payload.ClientID === ADMIN_CLIENT_ID,
|
return next();
|
||||||
};
|
} catch (_) {
|
||||||
next();
|
res.clearCookie('jwt');
|
||||||
} catch (e) {
|
|
||||||
res.clearCookie('jwt');
|
|
||||||
if (req.method === 'GET' && req.path.indexOf('/.well-known') !== 0) {
|
|
||||||
return res.redirect('/login');
|
|
||||||
}
|
}
|
||||||
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 маршрутов.
|
* Middleware-защита для admin-only маршрутов.
|
||||||
* Использовать после app.use(auth.middleware):
|
* Использовать после app.use(auth.middleware):
|
||||||
* app.get('/admin', requireAdmin, handler)
|
* router.get('/admin', requireAdmin, handler)
|
||||||
*/
|
*/
|
||||||
function requireAdmin(req, res, next) {
|
function requireAdmin(req, res, next) {
|
||||||
if (!req.user || !req.user.isAdmin) {
|
if (!req.user || !req.user.isAdmin) {
|
||||||
@@ -100,37 +230,45 @@ function requireAdmin(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyJWT(token) {
|
// ── HTTP-утилиты (без внешних зависимостей) ─────────────────────────────────
|
||||||
if (JWKS_URL) {
|
function httpGet(url) {
|
||||||
// TODO: реализовать верификацию через внешний JWKS-endpoint.
|
return new Promise((resolve, reject) => {
|
||||||
// Нужен URL от команды Keycloak: JWKS_URL=https://auth-api.../jwks
|
const lib = url.startsWith('https') ? https : http;
|
||||||
throw new Error('JWKS_URL verification not implemented yet');
|
const req = lib.get(url, { timeout: 5000 }, (res) => {
|
||||||
}
|
let data = '';
|
||||||
return jwt.verify(token, keyPair.publicKey, {
|
res.on('data', c => { data += c; });
|
||||||
algorithms: ['RS256'],
|
res.on('end', () => res.statusCode === 200 ? resolve(data) : reject(new Error('HTTP ' + res.statusCode)));
|
||||||
issuer: ISSUER,
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueJWT(claims) {
|
function httpPost(url, body, headers) {
|
||||||
if (!keyPair) throw new Error('Cannot issue tokens with external JWKS');
|
return new Promise((resolve, reject) => {
|
||||||
return jwt.sign(
|
const u = new URL(url);
|
||||||
{
|
const lib = u.protocol === 'https:' ? https : http;
|
||||||
ClientID: claims.clientId,
|
const opts = {
|
||||||
company_id: claims.companyId,
|
hostname: u.hostname,
|
||||||
company_name: claims.companyName,
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
||||||
email: claims.email,
|
path: u.pathname + u.search,
|
||||||
login: claims.email,
|
method: 'POST',
|
||||||
},
|
headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
|
||||||
keyPair.privateKey,
|
timeout: 5000,
|
||||||
{
|
};
|
||||||
algorithm: 'RS256',
|
const req = lib.request(opts, (res) => {
|
||||||
issuer: ISSUER,
|
let data = '';
|
||||||
subject: claims.companyId,
|
res.on('data', c => { data += c; });
|
||||||
expiresIn: '24h',
|
res.on('end', () => {
|
||||||
keyid: 'mock-1',
|
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 };
|
module.exports = { initAuth, requireAdmin };
|
||||||
|
|||||||
+148
-56
@@ -1,19 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* src/routes/auth.js — маршруты аутентификации (mock-режим).
|
* src/routes/auth.js — маршруты аутентификации.
|
||||||
*
|
*
|
||||||
* Маршруты:
|
* OIDC-режим (KC_CLIENT_ID + KC_CLIENT_SECRET заданы в окружении):
|
||||||
* GET /login — страница выбора пользователя (мок-форма)
|
* GET /login → генерирует state, сохраняет в сессии, редиректит на Keycloak
|
||||||
* POST /login — обработка выбора: выпускает JWT, кладёт в cookie
|
* GET /callback → проверяет state, обменивает code→токен, кладёт user в сессию
|
||||||
* GET /logout — очищает cookie, возвращает на /login
|
* GET /logout → уничтожает сессию + редирект на KK logout
|
||||||
*
|
*
|
||||||
* Зависимости (приходят через deps):
|
* Mock-режим (нет OIDC credentials):
|
||||||
* auth — объект из initAuth(): { issue, middleware, jwksHandler }
|
* GET /login → рендерит views/login.ejs — список MOCK_USERS
|
||||||
* doubleCsrfProtection — middleware проверки CSRF-токена
|
* POST /login → CSRF → сохраняет выбранного user в сессию → /
|
||||||
* generateCsrfToken — функция генерации токена для передачи в шаблон
|
* GET /logout → уничтожает сессию → /login
|
||||||
* MOCK_USERS — список мок-пользователей (из src/config.js)
|
|
||||||
*
|
*
|
||||||
* В продакшене этот файл заменяется на редирект к Keycloak (OIDC Authorization Code Flow).
|
* Dev-backdoor (DEV_MODE=true или DEV_SECRET задан):
|
||||||
* Маршруты регистрируются ДО auth.middleware — т.е. доступны без авторизации.
|
* GET /dev-login → рендерит views/dev-login.ejs (требует ?key если DEV_SECRET задан)
|
||||||
|
* POST /dev-login → CSRF → кладёт произвольного user в сессию → /
|
||||||
|
* Работает в ЛЮБОМ режиме (OIDC или mock) — обходит нормальный поток.
|
||||||
*
|
*
|
||||||
* @param {{ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }} deps
|
* @param {{ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }} deps
|
||||||
* @returns {import('express').Router}
|
* @returns {import('express').Router}
|
||||||
@@ -21,70 +22,161 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const { Router } = require('express');
|
const { Router } = require('express');
|
||||||
|
|
||||||
function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }) {
|
function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// GET /login — отдаём форму с выпадающим списком мок-пользователей.
|
// ── GET /login ─────────────────────────────────────────────────────────────
|
||||||
// Генерируем CSRF-токен здесь, чтобы он попал в скрытое поле формы.
|
|
||||||
// Без этого POST /login будет отклонён doubleCsrfProtection.
|
|
||||||
router.get('/login', (req, res) => {
|
router.get('/login', (req, res) => {
|
||||||
// generateCsrfToken одновременно: устанавливает CSRF cookie и возвращает токен-строку
|
if (auth.isOidc) {
|
||||||
|
// OIDC: генерируем state (защита от CSRF на callback), редиректим на KK
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
req.session.oidcState = state;
|
||||||
|
req.session.returnTo = req.query.returnTo || '/';
|
||||||
|
return res.redirect(auth.buildAuthUrl(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock-режим: форма выбора пользователя
|
||||||
const csrfToken = generateCsrfToken(req, res);
|
const csrfToken = generateCsrfToken(req, res);
|
||||||
res.render('login', {
|
res.render('login', {
|
||||||
users: MOCK_USERS,
|
users: MOCK_USERS,
|
||||||
error: req.query.error || null, // ошибка от предыдущего неудачного логина
|
error: req.query.error || null,
|
||||||
|
csrfToken,
|
||||||
|
devLoginUrl: auth.devLoginEnabled ? '/dev-login' : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /login (только mock-режим) ────────────────────────────────────────
|
||||||
|
router.post('/login', doubleCsrfProtection, (req, res) => {
|
||||||
|
if (auth.isOidc) return res.redirect('/login');
|
||||||
|
|
||||||
|
const user = MOCK_USERS.find(u => u.id === req.body.user);
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
email: user.email,
|
||||||
|
clientId: user.clientId,
|
||||||
|
companyId: user.companyId,
|
||||||
|
companyName: user.companyName,
|
||||||
|
isAdmin: user.role === 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.redirect(req.query.returnTo || '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /callback (только OIDC-режим) ──────────────────────────────────────
|
||||||
|
router.get('/callback', async (req, res) => {
|
||||||
|
if (!auth.isOidc) return res.redirect('/login');
|
||||||
|
|
||||||
|
const { code, state, error } = req.query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[auth] Keycloak error:', error, req.query.error_description || '');
|
||||||
|
return res.redirect('/login?error=' + encodeURIComponent('Keycloak: ' + error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем state (защита от CSRF)
|
||||||
|
if (!state || state !== req.session.oidcState) {
|
||||||
|
return res.redirect('/login?error=' + encodeURIComponent('Недействительный state'));
|
||||||
|
}
|
||||||
|
delete req.session.oidcState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, idToken, refreshToken } = await auth.exchangeCode(code);
|
||||||
|
req.session.user = user;
|
||||||
|
req.session.idToken = idToken;
|
||||||
|
req.session.refreshToken = refreshToken;
|
||||||
|
const returnTo = req.session.returnTo || '/';
|
||||||
|
delete req.session.returnTo;
|
||||||
|
res.redirect(returnTo);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[auth] exchangeCode error:', e.message);
|
||||||
|
res.redirect('/login?error=' + encodeURIComponent('Ошибка авторизации: ' + e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /dev-login ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Тестовый вход в обход OIDC/mock. Доступен если:
|
||||||
|
// • DEV_MODE=true → без пароля
|
||||||
|
// • DEV_SECRET=xyz → с ?key=xyz или session.devKey
|
||||||
|
router.get('/dev-login', (req, res) => {
|
||||||
|
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
|
||||||
|
|
||||||
|
if (auth.DEV_SECRET) {
|
||||||
|
const keyOk = req.query.key === auth.DEV_SECRET || req.session.devKey === auth.DEV_SECRET;
|
||||||
|
if (!keyOk) return res.status(403).send('403 Forbidden: неверный dev-ключ');
|
||||||
|
req.session.devKey = auth.DEV_SECRET; // кешируем в сессии: ?key= нужен только раз
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfToken = generateCsrfToken(req, res);
|
||||||
|
res.render('dev-login', {
|
||||||
|
users: MOCK_USERS,
|
||||||
|
error: req.query.error || null,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /login — обработка формы входа.
|
// ── POST /dev-login ────────────────────────────────────────────────────────
|
||||||
// doubleCsrfProtection идёт первым: если CSRF-токен невалиден — отдаёт 403
|
router.post('/dev-login', doubleCsrfProtection, (req, res) => {
|
||||||
// ещё до того как мы начнём работать с req.body.
|
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
|
||||||
router.post('/login', doubleCsrfProtection, async (req, res) => {
|
|
||||||
// Ищем выбранного пользователя по id ('admin', 'test', 'client2')
|
|
||||||
const user = MOCK_USERS.find(u => u.id === req.body.user);
|
|
||||||
|
|
||||||
if (!user) {
|
if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) {
|
||||||
// Неизвестный id — скорее всего попытка подделки запроса
|
return res.status(403).send('403 Forbidden');
|
||||||
return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let user;
|
||||||
// Выпускаем JWT с clientId, companyId, companyName, email.
|
|
||||||
// В продакшене этот токен приходит от Keycloak — здесь мы его имитируем.
|
|
||||||
const token = await auth.issue({
|
|
||||||
clientId: user.clientId,
|
|
||||||
companyId: user.companyId,
|
|
||||||
companyName: user.companyName,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Кладём токен в httpOnly cookie — JS на странице его не видит (защита от XSS).
|
if (req.body.preset) {
|
||||||
// sameSite: lax — cookie отправляется при навигации, но не при кросс-сайтовых запросах.
|
// Пресет из MOCK_USERS
|
||||||
res.cookie('jwt', token, {
|
const mockUser = MOCK_USERS.find(u => u.id === req.body.preset);
|
||||||
httpOnly: true,
|
if (!mockUser) {
|
||||||
secure: process.env.NODE_ENV === 'production', // только HTTPS в проде
|
return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
|
||||||
sameSite: 'lax',
|
}
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 часа в миллисекундах
|
user = {
|
||||||
});
|
email: mockUser.email,
|
||||||
|
clientId: mockUser.clientId,
|
||||||
// returnTo — параметр от auth.middleware: куда перенаправить после логина.
|
companyId: mockUser.companyId,
|
||||||
// Если не задан — на главную.
|
companyName: mockUser.companyName,
|
||||||
res.redirect(req.query.returnTo || '/');
|
isAdmin: mockUser.role === 'admin',
|
||||||
} catch (e) {
|
};
|
||||||
console.error('Login error:', e);
|
} else {
|
||||||
res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена'));
|
// Произвольные поля
|
||||||
|
const clientId = (req.body.clientId || '').trim();
|
||||||
|
if (!clientId) {
|
||||||
|
return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
|
||||||
|
}
|
||||||
|
user = {
|
||||||
|
email: (req.body.email || '').trim() || clientId + '@dev.local',
|
||||||
|
clientId,
|
||||||
|
companyId: (req.body.companyId || '').trim() || null,
|
||||||
|
companyName: (req.body.companyName || '').trim() || clientId,
|
||||||
|
isAdmin: req.body.isAdmin === 'on',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.session.user = user;
|
||||||
|
res.redirect('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /logout — удаляем cookie и отправляем на /login.
|
// ── GET /logout ────────────────────────────────────────────────────────────
|
||||||
// Простой GET (а не POST) допустим для мок-режима.
|
|
||||||
// В продакшене логаут должен быть POST с CSRF-токеном + редирект к Keycloak.
|
|
||||||
router.get('/logout', (req, res) => {
|
router.get('/logout', (req, res) => {
|
||||||
res.clearCookie('jwt');
|
const idToken = req.session.idToken || null;
|
||||||
res.redirect('/login');
|
const isOidc = auth.isOidc;
|
||||||
|
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
res.clearCookie('jwt'); // удаляем устаревший cookie если остался от старой версии
|
||||||
|
|
||||||
|
if (isOidc && idToken) {
|
||||||
|
return res.redirect(auth.buildLogoutUrl(idToken));
|
||||||
|
}
|
||||||
|
res.redirect('/login');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
+31
-18
@@ -127,40 +127,53 @@ test('overlap exact match', overlaps('192.168.0.0/24', '192.168.0.0/24'), true);
|
|||||||
test('no overlap public', overlaps('8.8.8.0/24', '9.9.9.0/24'), false);
|
test('no overlap public', overlaps('8.8.8.0/24', '9.9.9.0/24'), false);
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// 4. src/auth.js — DEV_MODE (без JWT, без БД)
|
// 4. src/auth.js — session middleware (без JWT, без БД)
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
section('auth.js (DEV_MODE)');
|
section('auth.js (session middleware)');
|
||||||
|
|
||||||
process.env.DEV_MODE = 'true';
|
process.env.DEV_MODE = 'true';
|
||||||
process.env.DEV_ADMIN = 'true';
|
|
||||||
process.env.ADMIN_CLIENT_ID = 'WZ01112';
|
process.env.ADMIN_CLIENT_ID = 'WZ01112';
|
||||||
|
|
||||||
const { initAuth, requireAdmin } = require('../src/auth');
|
const { initAuth, requireAdmin } = require('../src/auth');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = initAuth();
|
const auth = initAuth();
|
||||||
test('middleware is function', typeof auth.middleware, 'function');
|
test('middleware is function', typeof auth.middleware, 'function');
|
||||||
test('jwksHandler present in DEV_MODE', typeof auth.jwksHandler, 'function');
|
test('jwksHandler present (mock mode)', typeof auth.jwksHandler, 'function');
|
||||||
|
test('devLoginEnabled when DEV_MODE', auth.devLoginEnabled, true);
|
||||||
|
test('isOidc false (no KC creds)', auth.isOidc, false);
|
||||||
|
|
||||||
// DEV_MODE: middleware выставляет req.user без JWT
|
// ── Пользователь из session ──
|
||||||
const req = { headers: {}, cookies: {} };
|
const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', companyName: 'Test', email: 'test@test.local', isAdmin: false };
|
||||||
const res = { redirect: () => {} };
|
const reqSess = { headers: {}, cookies: {}, session: { user: mockUser } };
|
||||||
let nextCalled = false;
|
let nextCalled = false;
|
||||||
auth.middleware(req, res, () => { nextCalled = true; });
|
auth.middleware(reqSess, {}, () => { nextCalled = true; });
|
||||||
test('DEV_MODE: next() called', nextCalled, true);
|
test('session user: next() called', nextCalled, true);
|
||||||
test('DEV_MODE: req.user set', !!req.user, true);
|
test('session user: req.user set', !!reqSess.user, true);
|
||||||
test('DEV_MODE: req.user.isAdmin = true (DEV_ADMIN=true)', req.user.isAdmin, true);
|
test('session user: clientId match', reqSess.user.clientId, 'WZ01325');
|
||||||
|
|
||||||
// requireAdmin: пропускает admin
|
// ── Admin user из session ──
|
||||||
const reqAdmin = { user: { isAdmin: true } };
|
const adminUser = { clientId: 'WZ01112', isAdmin: true };
|
||||||
|
const reqAdmin = { headers: {}, cookies: {}, session: { user: adminUser } };
|
||||||
|
let adminNext = false;
|
||||||
|
auth.middleware(reqAdmin, {}, () => { adminNext = true; });
|
||||||
|
test('admin session: next() called', adminNext, true);
|
||||||
|
test('admin session: isAdmin=true', reqAdmin.user.isAdmin, true);
|
||||||
|
|
||||||
|
// ── Нет аутентификации → редирект ──
|
||||||
|
let redirectUrl = null;
|
||||||
|
const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' };
|
||||||
|
auth.middleware(reqNone, { redirect: (url) => { redirectUrl = url; } }, () => {});
|
||||||
|
test('no auth: redirected to /login', redirectUrl !== null && redirectUrl.startsWith('/login'), true);
|
||||||
|
|
||||||
|
// ── requireAdmin: admin проходит ──
|
||||||
let adminPassed = false;
|
let adminPassed = false;
|
||||||
requireAdmin(reqAdmin, {}, () => { adminPassed = true; });
|
requireAdmin({ user: { isAdmin: true } }, {}, () => { adminPassed = true; });
|
||||||
test('requireAdmin: admin passes', adminPassed, true);
|
test('requireAdmin: admin passes', adminPassed, true);
|
||||||
|
|
||||||
// requireAdmin: блокирует не-admin
|
// ── requireAdmin: не-admin блокируется ──
|
||||||
const reqUser = { user: { isAdmin: false } };
|
|
||||||
let blockedStatus = null;
|
let blockedStatus = null;
|
||||||
requireAdmin(reqUser, {
|
requireAdmin({ user: { isAdmin: false } }, {
|
||||||
status: (s) => { blockedStatus = s; return { send: () => {} }; },
|
status: (s) => { blockedStatus = s; return { send: () => {} }; },
|
||||||
}, () => {});
|
}, () => {});
|
||||||
test('requireAdmin: non-admin blocked (403)', blockedStatus, 403);
|
test('requireAdmin: non-admin blocked (403)', blockedStatus, 403);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dev-вход — IP WhiteList</title>
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||||
|
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||||
|
--amber: #d97706; --amber-bg: #fef3c7; --amber-border: #fde68a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||||
|
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.06); width: 440px; padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.2rem; margin-bottom: .25rem; }
|
||||||
|
.sub { color: var(--muted); font-size: .85rem; margin-bottom: 1.25rem; }
|
||||||
|
.warn {
|
||||||
|
padding: .75rem 1rem; background: var(--amber-bg); border: 1px solid var(--amber-border);
|
||||||
|
border-radius: 8px; font-size: .82rem; color: var(--amber); margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.warn strong { display: block; margin-bottom: .25rem; font-size: .9rem; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
|
||||||
|
.section-title { font-size: .75rem; font-weight: 600; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; margin-bottom: .75rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .85rem; }
|
||||||
|
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.field select, .field input[type=text], .field input[type=email] {
|
||||||
|
padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
font-size: .9rem; outline: none; background: #fff; width: 100%;
|
||||||
|
}
|
||||||
|
.field select:focus, .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||||
|
.check-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .85rem; }
|
||||||
|
.check-row input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; }
|
||||||
|
.check-row label { font-size: .88rem; cursor: pointer; }
|
||||||
|
.btn {
|
||||||
|
width: 100%; padding: .6rem; border: none; border-radius: 6px; font-size: .9rem;
|
||||||
|
font-weight: 500; cursor: pointer; background: var(--blue); color: #fff;
|
||||||
|
transition: background .15s; margin-top: .25rem;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--blue-h); }
|
||||||
|
.error { color: #dc2626; font-size: .85rem; margin-bottom: 1rem; }
|
||||||
|
.hint { font-size: .78rem; color: var(--muted); margin-top: .25rem; }
|
||||||
|
.or-divider { text-align: center; font-size: .8rem; color: var(--muted); margin: 1.25rem 0 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>Тестовый вход</h1>
|
||||||
|
<p class="sub">Dev-backdoor — в обход Keycloak</p>
|
||||||
|
|
||||||
|
<div class="warn">
|
||||||
|
<strong>⚠ ТЕСТОВЫЙ РЕЖИМ</strong>
|
||||||
|
Этот вход создаёт произвольного пользователя без проверки пароля.
|
||||||
|
Используйте только для тестирования.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (error) { %><div class="error"><%= error %></div><% } %>
|
||||||
|
|
||||||
|
<form method="POST" action="/dev-login">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
|
||||||
|
<%# ── Пресеты ── %>
|
||||||
|
<p class="section-title">Быстрый выбор</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>Пресет</label>
|
||||||
|
<select name="preset" id="preset-select">
|
||||||
|
<option value="">— вручную —</option>
|
||||||
|
<% users.forEach(u => { %>
|
||||||
|
<option value="<%= u.id %>"><%= u.label %> (<%= u.role === 'admin' ? 'admin' : 'user' %>)</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="or-divider">— или задайте вручную —</div>
|
||||||
|
|
||||||
|
<%# ── Кастомные поля ── %>
|
||||||
|
<p class="section-title">Произвольный пользователь</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>ClientID <span style="color:#dc2626">*</span></label>
|
||||||
|
<input type="text" name="clientId" placeholder="WZ01999" autocomplete="off">
|
||||||
|
<span class="hint">WZ-номер компании в Nubes (обязателен если пресет не выбран)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Company ID</label>
|
||||||
|
<input type="text" name="companyId" placeholder="uuid или пусто" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Company Name</label>
|
||||||
|
<input type="text" name="companyName" placeholder="Название компании" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" placeholder="test@example.com" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-row">
|
||||||
|
<input type="checkbox" name="isAdmin" id="isAdmin">
|
||||||
|
<label for="isAdmin">Администратор (isAdmin = true)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" type="submit">Войти (dev)</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p style="font-size:.78rem;color:var(--muted);text-align:center">
|
||||||
|
<a href="/login" style="color:inherit">← Обычный вход</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Если выбран пресет — очищаем кастомные поля (чтобы не путаться).
|
||||||
|
// Если пресет = "", кастомные поля активны.
|
||||||
|
document.getElementById('preset-select').addEventListener('change', function() {
|
||||||
|
const custom = ['clientId','companyId','companyName','email'];
|
||||||
|
const clear = !!this.value;
|
||||||
|
custom.forEach(id => {
|
||||||
|
const el = document.querySelector('[name=' + id + ']');
|
||||||
|
if (el) { el.disabled = clear; if (clear) el.value = ''; }
|
||||||
|
});
|
||||||
|
const chk = document.getElementById('isAdmin');
|
||||||
|
if (chk) { chk.disabled = clear; if (clear) chk.checked = false; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+4
-1
@@ -73,7 +73,10 @@
|
|||||||
|
|
||||||
<div class="note">
|
<div class="note">
|
||||||
В реальном продакшене здесь будет редирект на Keycloak.<br>
|
В реальном продакшене здесь будет редирект на Keycloak.<br>
|
||||||
После входа JWT сохраняется в httpOnly cookie — middleware проверяет подпись RS256.
|
После входа пользователь сохраняется в session middleware.
|
||||||
|
<% if (devLoginUrl) { %>
|
||||||
|
<br><a href="<%= devLoginUrl %>" style="color:inherit">Тестовый вход (dev-backdoor) →</a>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user