v0.5.17: owner_email в companies + универсальный парсер токенов + jwt.decode fallback

This commit is contained in:
2026-06-03 15:17:56 +03:00
parent 0b3f1ab445
commit 878e46287b
6 changed files with 58 additions and 22 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ipwhitelist", "name": "ipwhitelist",
"version": "0.5.14", "version": "0.5.17",
"description": "IP WhiteList microservice for cloud provider", "description": "IP WhiteList microservice for cloud provider",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+1
View File
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS companies (
client_id VARCHAR(64) UNIQUE NOT NULL, client_id VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(255), name VARCHAR(255),
custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0), custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0),
owner_email TEXT DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
+1 -1
View File
@@ -58,7 +58,7 @@ function createEntriesRouter({ q }) {
const effectiveName = effectiveClientId === req.user.clientId const effectiveName = effectiveClientId === req.user.clientId
? req.user.companyName ? req.user.companyName
: effectiveClientId; : effectiveClientId;
return q.getOrCreateCompany(effectiveClientId, effectiveName); return q.getOrCreateCompany(effectiveClientId, effectiveName, req.user.email);
} }
// GET /api/v1/entries — список записей + лимит // GET /api/v1/entries — список записей + лимит
+49 -15
View File
@@ -94,8 +94,8 @@ async function initAuth() {
return { return {
isOidc, isOidc,
middleware: createMiddleware(isOidc), middleware: createMiddleware(isOidc, devMode),
bearerMiddleware: createBearerMiddleware(isOidc), bearerMiddleware: createBearerMiddleware(isOidc, devMode),
// OIDC-хелперы (используются в src/routes/auth.js) // OIDC-хелперы (используются в src/routes/auth.js)
buildAuthUrl, buildAuthUrl,
exchangeCode, exchangeCode,
@@ -111,7 +111,7 @@ async function initAuth() {
} }
// ── Middleware — проверяет req.session.user или Bearer-токен ───────────────── // ── Middleware — проверяет req.session.user или Bearer-токен ─────────────────
function createMiddleware(isOidc) { function createMiddleware(isOidc, devMode) {
return function authMiddleware(req, res, next) { return function authMiddleware(req, res, next) {
// 1. Сессия — основной источник пользователя (OIDC и mock) // 1. Сессия — основной источник пользователя (OIDC и mock)
if (req.session && req.session.user) { if (req.session && req.session.user) {
@@ -123,7 +123,7 @@ function createMiddleware(isOidc) {
const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
if (bearer) { if (bearer) {
try { try {
const payload = verifyAnyToken(bearer); const payload = verifyAnyToken(bearer, devMode);
req.user = userFromPayload(payload); req.user = userFromPayload(payload);
return next(); return next();
} catch (e) { } catch (e) {
@@ -246,7 +246,7 @@ function verifyOidcToken(token) {
* Универсальная верификация: пробует mock, затем OIDC (JWKS). * Универсальная верификация: пробует mock, затем OIDC (JWKS).
* Позволяет принимать и mock-токены (тесты, dev), и реальные токены от Keycloak. * Позволяет принимать и mock-токены (тесты, dev), и реальные токены от Keycloak.
*/ */
function verifyAnyToken(token) { function verifyAnyToken(token, devMode) {
let lastErr = null; let lastErr = null;
// 1. Mock (быстрый, локальный) // 1. Mock (быстрый, локальный)
if (mockKeyPair) { if (mockKeyPair) {
@@ -264,21 +264,55 @@ function verifyAnyToken(token) {
lastErr = 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'); throw lastErr || new Error('Token verification failed: no verifier available');
} }
function userFromPayload(payload) { function userFromPayload(payload) {
// clientid может быть "WZ01325" или "WZ01325, WZ54321" (ТЗ-плюс §6.1) // ── Компании ──────────────────────────────────────────────────────────
const rawClientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN'; let allClientIds = [];
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
// 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'; 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 { return {
email: payload.email || payload.login || 'unknown', email,
clientId: activeClientId, // активная компания (первая в списке) clientId: activeClientId,
allClientIds, // все компании пользователя (массив) allClientIds,
activeClientId, // дубль для ясности activeClientId,
companyId: payload.company_id || payload.sub || null, companyId: payload.company_id || null,
companyName: payload.company_name || activeClientId, companyName: payload.company_name || activeClientId,
isAdmin: activeClientId === ADMIN_CLIENT_ID, isAdmin: activeClientId === ADMIN_CLIENT_ID,
}; };
@@ -287,14 +321,14 @@ function userFromPayload(payload) {
// ── Bearer-only middleware (для /api/v1) ──────────────────────────────────── // ── Bearer-only middleware (для /api/v1) ────────────────────────────────────
// Не смотрит на сессию — всегда проверяет Bearer JWT явно. // Не смотрит на сессию — всегда проверяет Bearer JWT явно.
// Используется в src/api/index.js вместо auth.middleware. // Используется в src/api/index.js вместо auth.middleware.
function createBearerMiddleware(isOidc) { function createBearerMiddleware(isOidc, devMode) {
return function bearerAuthMiddleware(req, res, next) { return function bearerAuthMiddleware(req, res, next) {
const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
if (!bearer) { if (!bearer) {
return res.status(401).json({ error: 'Bearer token required' }); return res.status(401).json({ error: 'Bearer token required' });
} }
try { try {
const payload = verifyAnyToken(bearer); const payload = verifyAnyToken(bearer, devMode);
req.user = userFromPayload(payload); req.user = userFromPayload(payload);
return next(); return next();
} catch (e) { } catch (e) {
+5 -4
View File
@@ -3,13 +3,14 @@ const { validate, overlaps } = require('./validators');
// ── Companies ── // ── Companies ──
async function getOrCreateCompany(clientId, companyName) { async function getOrCreateCompany(clientId, companyName, ownerEmail) {
// Атомарный upsert — исключает дубли компаний при параллельных запросах // Атомарный upsert — исключает дубли компаний при параллельных запросах.
// ownerEmail сохраняется только при INSERT (не перезаписывается при UPDATE).
const res = await pool.query( const res = await pool.query(
`INSERT INTO companies (client_id, name) VALUES ($1, $2) `INSERT INTO companies (client_id, name, owner_email) VALUES ($1, $2, $3)
ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name) ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name)
RETURNING *`, RETURNING *`,
[clientId, companyName || clientId] [clientId, companyName || clientId, ownerEmail || null]
); );
return res.rows[0]; return res.rows[0];
} }
+1 -1
View File
@@ -247,7 +247,7 @@
<div class="lbl">компания</div> <div class="lbl">компания</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="num"><%= user.email %></div> <div class="num"><%= isAdmin && selectedCompany ? (selectedCompany.owner_email || user.email) : user.email %></div>
<div class="lbl">пользователь</div> <div class="lbl">пользователь</div>
</div> </div>
</div> </div>