feat: мок JWT-аутентификация (RS256, JWKS, cookie)
- src/auth.js: генерация ключей RS256, JWKS endpoint, verify/issue/middleware - views/login.ejs: мок-страница входа с выбором пользователя - server.js: cookie-parser, /login, /logout, auth.middleware - При переключении на прод: JWKS_URL в env → всё остальное без изменений
This commit is contained in:
Generated
+1004
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,11 @@
|
|||||||
"dev": "node --watch server.js"
|
"dev": "node --watch server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,92 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const { checkConnection } = require('./src/db');
|
const { checkConnection } = require('./src/db');
|
||||||
|
const { initAuth } = require('./src/auth');
|
||||||
const q = require('./src/queries');
|
const q = require('./src/queries');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const DEV = process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production';
|
|
||||||
|
|
||||||
if (DEV) console.warn('⚠ DEV_MODE активен — аутентификация отключена');
|
|
||||||
|
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
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());
|
||||||
|
|
||||||
// ── Health (выше auth — k8s probe не должна требовать токен) ──
|
const MOCK_USERS = [
|
||||||
app.get('/healthz', (req, res) => res.send('OK'));
|
{ id: 'admin', label: 'Администратор (WZ01112)', role: 'admin', clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', companyName: 'Nubes Admin', email: 'admin@nubes.ru' },
|
||||||
|
{ id: 'test', label: 'Тест (WZ01325)', role: 'user', clientId: 'WZ01325', companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5', companyName: 'Тест', email: 'tazet@narod.ru' },
|
||||||
|
{ id: 'client2', label: 'Компания 2 (WZ02001)', role: 'user', clientId: 'WZ02001', companyId: '11111111-2222-3333-4444-555555555555', companyName: 'Вторая Компания', email: 'user2@example.com' },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Auth middleware ──
|
async function start() {
|
||||||
app.use((req, res, next) => {
|
const auth = await initAuth();
|
||||||
if (DEV) {
|
|
||||||
req.user = { email: 'dev@test.local', clientId: 'WZ01325', companyId: null, companyName: 'DEV' };
|
app.get('/healthz', (req, res) => res.send('OK'));
|
||||||
return next();
|
|
||||||
}
|
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
|
||||||
const auth = req.headers.authorization || '';
|
|
||||||
|
app.get('/login', (req, res) => res.render('login', { users: MOCK_USERS, error: req.query.error || null }));
|
||||||
|
|
||||||
|
app.post('/login', async (req, res) => {
|
||||||
|
const user = MOCK_USERS.find(u => u.id === req.body.user);
|
||||||
|
if (!user) return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(Buffer.from(auth.replace('Bearer ', '').split('.')[1], 'base64').toString());
|
const token = await auth.issue({ clientId: user.clientId, companyId: user.companyId, companyName: user.companyName, email: user.email });
|
||||||
req.user = {
|
res.cookie('jwt', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 });
|
||||||
email: payload.email || 'unknown',
|
res.redirect(req.query.returnTo || '/');
|
||||||
clientId: payload.ClientID,
|
} catch (e) { res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена')); }
|
||||||
companyId: payload.company_id,
|
});
|
||||||
companyName: payload.company_name || payload.ClientID,
|
|
||||||
};
|
|
||||||
} catch { req.user = {}; }
|
|
||||||
// Без clientId — 401, анонимы не должны делить NULL-компанию
|
|
||||||
if (!req.user.clientId) return res.status(401).send('Unauthorized');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Главная ──
|
app.get('/logout', (req, res) => { res.clearCookie('jwt'); res.redirect('/login'); });
|
||||||
app.get('/', async (req, res) => {
|
|
||||||
|
app.use(auth.middleware);
|
||||||
|
|
||||||
|
app.get('/', async (req, res) => {
|
||||||
const { clientId, companyName } = req.user;
|
const { clientId, companyName } = req.user;
|
||||||
try {
|
try {
|
||||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||||
const limit = await q.getLimit(company);
|
const limit = await q.getLimit(company);
|
||||||
const entries = await q.listEntries(company.id);
|
const entries = await q.listEntries(company.id);
|
||||||
res.render('index', {
|
res.render('index', { entries, limit, used: entries.length, user: req.user, error: req.query.error || null, message: req.query.message || null, wasNormalized: req.query.wasNormalized === '1' });
|
||||||
entries, limit, used: entries.length, user: req.user,
|
} catch (e) { res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: 'Ошибка загрузки', message: null, wasNormalized: false }); }
|
||||||
error: req.query.error || null,
|
|
||||||
message: req.query.message || null,
|
|
||||||
wasNormalized: req.query.wasNormalized === '1',
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: 'Ошибка загрузки', message: null, wasNormalized: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Создать ──
|
app.post('/add', async (req, res) => {
|
||||||
app.post('/add', async (req, res) => {
|
|
||||||
const { value, comment } = req.body;
|
const { value, comment } = req.body;
|
||||||
const { clientId, companyName, email } = req.user;
|
const { clientId, companyName, email } = req.user;
|
||||||
try {
|
try {
|
||||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||||
const result = await q.createEntry(company.id, value, comment, email);
|
const result = await q.createEntry(company.id, value, comment, email);
|
||||||
const message = result.wasNormalized
|
const message = result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено';
|
||||||
? `Адрес нормализован в ${result.entry.value_cidr}`
|
|
||||||
: 'Добавлено';
|
|
||||||
const wasNormalized = result.wasNormalized ? '1' : '0';
|
const wasNormalized = result.wasNormalized ? '1' : '0';
|
||||||
res.redirect(`/?message=${encodeURIComponent(message)}&wasNormalized=${wasNormalized}`);
|
res.redirect('/?message=' + encodeURIComponent(message) + '&wasNormalized=' + wasNormalized);
|
||||||
} catch (e) {
|
} catch (e) { res.redirect('/?error=' + encodeURIComponent(e.message)); }
|
||||||
res.redirect('/?error=' + encodeURIComponent(e.message));
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Удалить (soft) ──
|
app.post('/delete/:id', async (req, res) => {
|
||||||
app.post('/delete/:id', async (req, res) => {
|
|
||||||
const { clientId, companyName, email } = req.user;
|
const { clientId, companyName, email } = req.user;
|
||||||
try {
|
try {
|
||||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||||
await q.deleteEntry(req.params.id, company.id, email);
|
await q.deleteEntry(req.params.id, company.id, email);
|
||||||
res.redirect('/');
|
res.redirect('/');
|
||||||
} catch (e) {
|
} catch (e) { res.redirect('/?error=' + encodeURIComponent(e.message)); }
|
||||||
res.redirect('/?error=' + encodeURIComponent(e.message));
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Экспорт (только для своей компании) ──
|
app.get('/export', async (req, res) => {
|
||||||
app.get('/export', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { clientId, companyName } = req.user;
|
const { clientId, companyName } = req.user;
|
||||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||||
const cidrs = await q.getExportCIDRs(company.id);
|
const cidrs = await q.getExportCIDRs(company.id);
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
res.send(cidrs.join('\n') + '\n');
|
res.send(cidrs.join('\n') + '\n');
|
||||||
} catch (e) {
|
} catch (e) { res.status(500).send('Export error'); }
|
||||||
res.status(500).send('Export error');
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Старт ──
|
checkConnection().then(() => console.log('DB connected')).catch(e => console.error('DB not ready:', e.message));
|
||||||
checkConnection()
|
app.listen(PORT, () => console.log('Server on port ' + PORT));
|
||||||
.then(() => console.log('DB connected'))
|
}
|
||||||
.catch(e => console.error('DB not ready:', e.message));
|
|
||||||
|
|
||||||
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
|
start().catch(e => { console.error('Startup error:', e); process.exit(1); });
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
const jose = require('jose');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ── Конфигурация ──
|
||||||
|
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
||||||
|
const JWKS_URL = process.env.JWKS_URL || ''; // пусто → локальные ключи
|
||||||
|
|
||||||
|
let keyPair = null; // { publicKey, privateKey } — CryptoKeyPair
|
||||||
|
let jwks = null; // предсобранный JWKS
|
||||||
|
|
||||||
|
// ── Инициализация ──
|
||||||
|
|
||||||
|
async function initAuth() {
|
||||||
|
// Если задан внешний JWKS — используем его (продакшен)
|
||||||
|
if (JWKS_URL) {
|
||||||
|
console.log(`🔐 Auth: внешний JWKS ${JWKS_URL}`);
|
||||||
|
return { verify: verifyJWT, middleware: createMiddleware(), jwksHandler: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock: генерируем локальную ключевую пару
|
||||||
|
console.log('🔐 Auth: MOCK — локальный ключ RS256');
|
||||||
|
keyPair = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSASSA-PKCS1-v1_5',
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['sign', 'verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||||
|
jwks = { keys: [{ ...publicJwk, alg: 'RS256', use: 'sig', kid: 'mock-1' }] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
verify: verifyJWT,
|
||||||
|
issue: issueJWT,
|
||||||
|
middleware: createMiddleware(),
|
||||||
|
jwksHandler: (req, res) => res.json(jwks),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware ──
|
||||||
|
|
||||||
|
function createMiddleware() {
|
||||||
|
return async function authMiddleware(req, res, next) {
|
||||||
|
// DEV_MODE — полный обход (разработка)
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем JWT из cookie или заголовка
|
||||||
|
const token =
|
||||||
|
req.cookies?.jwt ||
|
||||||
|
(req.headers.authorization || '').replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Если нет токена — редиректим на логин (только GET, не API)
|
||||||
|
if (req.method === 'GET' && !req.path.startsWith('/.well-known')) {
|
||||||
|
return res.redirect(`/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
|
||||||
|
}
|
||||||
|
return res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await verifyJWT(token);
|
||||||
|
req.user = {
|
||||||
|
email: payload.email || 'unknown',
|
||||||
|
clientId: payload.ClientID,
|
||||||
|
companyId: payload.company_id,
|
||||||
|
companyName: payload.company_name || payload.ClientID,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
// Токен невалиден → чистим cookie и на логин
|
||||||
|
res.clearCookie('jwt');
|
||||||
|
if (req.method === 'GET' && !req.path.startsWith('/.well-known')) {
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
return res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JWT-верификация ──
|
||||||
|
|
||||||
|
async function verifyJWT(token) {
|
||||||
|
if (JWKS_URL) {
|
||||||
|
// Продакшен: JWKS от auth-api
|
||||||
|
const { payload } = await jose.jwtVerify(token, jose.createRemoteJWKSet(new URL(JWKS_URL)), {
|
||||||
|
issuer: ISSUER,
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock: локальный публичный ключ
|
||||||
|
const { payload } = await jose.jwtVerify(token, keyPair.publicKey, {
|
||||||
|
issuer: ISSUER,
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JWT-выпуск (только mock) ──
|
||||||
|
|
||||||
|
async function issueJWT(claims) {
|
||||||
|
if (!keyPair) throw new Error('JWKS_URL задан — выпуск токенов невозможен');
|
||||||
|
|
||||||
|
const privateKey = keyPair.privateKey;
|
||||||
|
// Для jose.SignJWT нужен ключ в формате, который он понимает
|
||||||
|
// Экспортируем как JWK и импортируем обратно как KeyLike
|
||||||
|
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||||
|
|
||||||
|
return new jose.SignJWT({
|
||||||
|
ClientID: claims.clientId,
|
||||||
|
company_id: claims.companyId,
|
||||||
|
company_name: claims.companyName,
|
||||||
|
email: claims.email,
|
||||||
|
login: claims.email,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: 'mock-1' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setIssuer(ISSUER)
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.setSubject(claims.companyId)
|
||||||
|
.sign(await jose.importJWK(jwk, 'RS256'));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initAuth };
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Вход — 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;
|
||||||
|
}
|
||||||
|
* { 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: 380px; padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.2rem; margin-bottom: .25rem; }
|
||||||
|
.sub { color: var(--muted); font-size: .85rem; margin-bottom: 1.5rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: 1rem; }
|
||||||
|
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.field select, .field input {
|
||||||
|
padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
font-size: .9rem; outline: none; background: #fff;
|
||||||
|
}
|
||||||
|
.field select:focus, .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--blue-h); }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: .15rem .5rem; border-radius: 4px;
|
||||||
|
font-size: .7rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-admin { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-user { background: #dcfce7; color: #166534; }
|
||||||
|
.note {
|
||||||
|
margin-top: 1.5rem; padding: .75rem; background: #fef3c7; border-radius: 8px;
|
||||||
|
font-size: .8rem; color: #92400e;
|
||||||
|
}
|
||||||
|
.error { color: #dc2626; font-size: .85rem; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>Вход в IP WhiteList</h1>
|
||||||
|
<p class="sub">Мок-аутентификация — имитация Keycloak</p>
|
||||||
|
|
||||||
|
<% if (error) { %><div class="error"><%= error %></div><% } %>
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="field">
|
||||||
|
<label>Пользователь</label>
|
||||||
|
<select name="user">
|
||||||
|
<% users.forEach(u => { %>
|
||||||
|
<option value="<%= u.id %>">
|
||||||
|
<%= u.label %> <%= u.role === 'admin' ? '(admin)' : '' %>
|
||||||
|
</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
В реальном продакшене здесь будет редирект на Keycloak.<br>
|
||||||
|
После входа JWT сохраняется в httpOnly cookie — middleware проверяет подпись RS256.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user