v2: Session-Login & Rollen, Premium-Admin, Visual-Block-Builder, KI-/MCP-API
- Auth-Umbau: Session-Login (signiertes HMAC-Cookie, scrypt-Hashing) statt Basic-Auth; users-/audit-Tabellen, Initial-Owner aus ENV, Rate-Limit, konfigurierbarer ADMIN_PATH (Middleware-Rewrite), Rollen-Gate (owner/redaktion/versand), Nutzerverwaltung, Audit-Log, Login/Logout/Konto-Seiten. - Premium-Pass: Command-Palette (Cmd-K), Toasts, Account-Menue, aufgewertetes Dashboard (KPI-Trend+Sparkline, Aktivitaets-Feed, Schnellaktionen), schoene Empty-States. - Block-Builder: pages.blocks, Vollbild-Editor (Liste/Live-Vorschau/Settings, Desktop/Mobil), 10 Block-Typen, Storefront-BlockRenderer auf /seite/[slug], Save-Endpoint. - KI-Editierbarkeit: token-gesicherte /api/admin/* (CRUD), Manifest /api/admin + /ai-admin.txt, MCP-Server unter mcp/ (14 Tools). - Docs: README + .env.example + mcp/README aktualisiert.
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
// hd-commerce — Session-Auth (stateless signiertes Cookie), Rollen-Gate, Rate-Limit.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { getUserById } from './store.js';
|
||||
|
||||
const SECRET = process.env.SESSION_SECRET || 'hd-commerce-dev-secret-change-me';
|
||||
export const COOKIE_NAME = 'hdc_session';
|
||||
|
||||
// --- konfigurierbarer Admin-Pfad ---
|
||||
function rawAdminPath() {
|
||||
let p = (process.env.ADMIN_PATH || 'admin').trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!p) p = 'admin';
|
||||
return p;
|
||||
}
|
||||
export const adminBase = () => '/' + rawAdminPath(); // z.B. "/login" oder "/admin"
|
||||
export const adminPathSegment = () => rawAdminPath(); // "login"
|
||||
export const isCustomAdminPath = () => rawAdminPath() !== 'admin';
|
||||
// Hilfsfunktion für Links in Astro-Seiten:
|
||||
export const ab = (suffix = '') => adminBase() + (suffix || '');
|
||||
|
||||
// --- Cookie-Signatur ---
|
||||
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
||||
function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
|
||||
|
||||
export function signSession(uid, maxAgeSeconds) {
|
||||
const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 12);
|
||||
const payload = b64url(JSON.stringify({ uid: Number(uid), exp }));
|
||||
const sig = b64url(createHmac('sha256', SECRET).update(payload).digest());
|
||||
return payload + '.' + sig;
|
||||
}
|
||||
|
||||
export function verifySession(token) {
|
||||
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
|
||||
const [payload, sig] = token.split('.');
|
||||
if (!payload || !sig) return null;
|
||||
const expected = b64url(createHmac('sha256', SECRET).update(payload).digest());
|
||||
try {
|
||||
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
} catch { return null; }
|
||||
let data;
|
||||
try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; }
|
||||
if (!data || !data.uid || !data.exp) return null;
|
||||
if (data.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function buildCookie(token, remember) {
|
||||
const parts = [`${COOKIE_NAME}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
||||
if (remember) parts.push('Max-Age=' + (60 * 60 * 24 * 30));
|
||||
return parts.join('; ');
|
||||
}
|
||||
export function clearCookie() {
|
||||
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
export function parseCookies(request) {
|
||||
const h = request.headers.get('cookie') || '';
|
||||
const out = {};
|
||||
h.split(';').forEach(p => {
|
||||
const i = p.indexOf('=');
|
||||
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function currentUser(request) {
|
||||
const token = parseCookies(request)[COOKIE_NAME];
|
||||
const sess = verifySession(token);
|
||||
if (!sess) return null;
|
||||
const u = getUserById(sess.uid);
|
||||
if (!u || !u.active) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
// --- Rollen-Gate ---
|
||||
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
|
||||
const ROLE_SECTIONS = {
|
||||
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'inhalte', 'einstellungen', 'nutzer', 'audit'],
|
||||
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'analytics'],
|
||||
versand: ['bestellungen'],
|
||||
};
|
||||
export function canAccess(role, section) {
|
||||
const allowed = ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
|
||||
return allowed.includes(section);
|
||||
}
|
||||
export function allowedSections(role) {
|
||||
return ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
|
||||
}
|
||||
export function landingFor(role) {
|
||||
if (role === 'versand') return adminBase() + '/bestellungen';
|
||||
return adminBase();
|
||||
}
|
||||
|
||||
// --- Login-Rate-Limit (In-Memory) ---
|
||||
const attempts = new Map(); // ip -> { count, until }
|
||||
export function rateLimited(ip) {
|
||||
const r = attempts.get(ip);
|
||||
if (r && r.until && Date.now() < r.until) return true;
|
||||
return false;
|
||||
}
|
||||
export function registerFail(ip) {
|
||||
const r = attempts.get(ip) || { count: 0, until: 0 };
|
||||
r.count += 1;
|
||||
if (r.count >= 5) { r.until = Date.now() + 60 * 1000; r.count = 0; }
|
||||
attempts.set(ip, r);
|
||||
}
|
||||
export function clearFails(ip) { attempts.delete(ip); }
|
||||
|
||||
export function clientIp(request) {
|
||||
return (request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'local').split(',')[0].trim();
|
||||
}
|
||||
Reference in New Issue
Block a user