Directus als Daten-/Auth-Schicht + Backend-Anbindung
Directus (Postgres, hinter NPM) wird die zentrale Datenquelle für Kunden, Aufträge, Produkte, Preise und Gutscheine; das skrift-backend bleibt reine Generierungs-Engine. - Docker/directus: docker-compose (Directus 11 + Postgres), Bootstrap-Skript legt Collections, Rollen/Policies/Rechte und Seed idempotent an. - Extension skrift-auth: passwortloser Login per E-Mail-Code (Codes nur als Hash, Ablauf, Versuchslimit) sowie Kontoanlage aus dem Checkout. - Extension skrift-orders: kontrollierter Bestell-Endpunkt für Gäste und eingeloggte Kunden; Preis wird immer serverseitig berechnet (editierbare Formel via sicherem Interpreter, Werte aus price_items). - skrift-backend: neuer Endpunkt /api/order/from-directus erzeugt SVGs aus einem Directus-Auftrag und schreibt Status + artifact_path zurück. Dafür wurde die Kernlogik aus generateOrder als runGeneration herausgelöst (Verhalten unverändert); generateOrder ist jetzt ein HTTP-Wrapper. - compose: NPM-Netz ergänzt, API_TOKEN/DIRECTUS_* werden durchgereicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
230
Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
230
Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Skrift – Passwortloser Login per E-Mail-Code
|
||||
* ---------------------------------------------------------------------------
|
||||
* Directus kennt ab Werk nur Passwort/SSO. Diese Endpoint-Extension ergänzt den
|
||||
* Kunden-Login per Einmalcode:
|
||||
*
|
||||
* POST /skrift-auth/request-code { email } → Code per Mail
|
||||
* POST /skrift-auth/verify-code { email, code } → Tokens
|
||||
* POST /skrift-auth/register { email, ... } → Konto anlegen + Code
|
||||
*
|
||||
* Sicherheitsprinzipien:
|
||||
* - `request-code` antwortet IMMER generisch (verrät nicht, ob es das Konto gibt).
|
||||
* - Codes werden nur als Hash gespeichert (SHA-256 + SECRET als Pepper).
|
||||
* - Ablauf nach 10 Minuten, max. 5 Fehlversuche, 60 s Wiederhol-Sperre.
|
||||
*
|
||||
* Token-Ausstellung: Der Code-Login setzt für den Nutzer ein frisches Zufalls-
|
||||
* passwort und meldet ihn damit über Directus' offiziellen AuthenticationService
|
||||
* an. So sind Session/Refresh-Handling garantiert korrekt (statt selbstgebauter
|
||||
* JWTs) – das Passwort kennt niemand und rotiert bei jedem Login.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const CODE_TTL_MIN = 10;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RESEND_COOLDOWN_SEC = 60;
|
||||
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
|
||||
|
||||
const handler = (router, { services, getSchema, env, logger }) => {
|
||||
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
|
||||
|
||||
const norm = (e) => String(e || '').trim().toLowerCase();
|
||||
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
const hashCode = (code, email) =>
|
||||
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
|
||||
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||
|
||||
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
||||
|
||||
async function findUserByEmail(email, schema) {
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
const found = await users.readByQuery({
|
||||
filter: { email: { _eq: email } },
|
||||
fields: ['id', 'email', 'status'],
|
||||
limit: 1,
|
||||
});
|
||||
return found?.[0] || null;
|
||||
}
|
||||
|
||||
async function sendCodeMail(email, code) {
|
||||
const mail = new MailService({ schema: await getSchema(), accountability: null });
|
||||
await mail.send({
|
||||
to: email,
|
||||
subject: `Ihr Anmeldecode: ${code}`,
|
||||
html: `
|
||||
<p>Guten Tag,</p>
|
||||
<p>Ihr Anmeldecode für Skrift lautet:</p>
|
||||
<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>
|
||||
<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung
|
||||
angefordert haben, können Sie diese E-Mail ignorieren.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Code anfordern ────────────────────────────────────────────────────────
|
||||
router.post('/request-code', async (req, res) => {
|
||||
const email = norm(req.body?.email);
|
||||
// Antwort ist bewusst immer gleich – keine Auskunft über Kontoexistenz.
|
||||
const generic = () => res.json({ ok: true });
|
||||
|
||||
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
|
||||
|
||||
try {
|
||||
const { schema } = await ctx();
|
||||
const codes = new ItemsService('login_codes', { schema, accountability: null });
|
||||
|
||||
// Wiederhol-Sperre
|
||||
const recent = await codes.readByQuery({
|
||||
filter: { email: { _eq: email } },
|
||||
sort: ['-date_created'], limit: 1, fields: ['date_created'],
|
||||
});
|
||||
if (recent?.[0]?.date_created) {
|
||||
const age = (Date.now() - new Date(recent[0].date_created).getTime()) / 1000;
|
||||
if (age < RESEND_COOLDOWN_SEC) return generic();
|
||||
}
|
||||
|
||||
const user = await findUserByEmail(email, schema);
|
||||
if (!user || user.status !== 'active') return generic();
|
||||
|
||||
const code = genCode();
|
||||
await codes.createOne({
|
||||
email,
|
||||
code_hash: hashCode(code, email),
|
||||
expires_at: new Date(Date.now() + CODE_TTL_MIN * 60_000).toISOString(),
|
||||
attempts: 0,
|
||||
used: false,
|
||||
});
|
||||
await sendCodeMail(email, code);
|
||||
return generic();
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-auth] request-code: ${err.message}`);
|
||||
return generic();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Code prüfen + anmelden ────────────────────────────────────────────────
|
||||
router.post('/verify-code', async (req, res) => {
|
||||
const email = norm(req.body?.email);
|
||||
const code = String(req.body?.code || '').trim();
|
||||
if (!isEmail(email) || !/^\d{6}$/.test(code))
|
||||
return res.status(400).json({ error: 'E-Mail oder Code ungültig.' });
|
||||
|
||||
try {
|
||||
const { schema } = await ctx();
|
||||
const codes = new ItemsService('login_codes', { schema, accountability: null });
|
||||
|
||||
const rows = await codes.readByQuery({
|
||||
filter: { email: { _eq: email }, used: { _eq: false } },
|
||||
sort: ['-date_created'], limit: 1,
|
||||
fields: ['id', 'code_hash', 'expires_at', 'attempts'],
|
||||
});
|
||||
const row = rows?.[0];
|
||||
if (!row) return res.status(401).json({ error: 'Code ungültig oder abgelaufen.' });
|
||||
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||
await codes.updateOne(row.id, { used: true });
|
||||
return res.status(401).json({ error: 'Code abgelaufen. Bitte neu anfordern.' });
|
||||
}
|
||||
if ((row.attempts ?? 0) >= MAX_ATTEMPTS) {
|
||||
await codes.updateOne(row.id, { used: true });
|
||||
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte neuen Code anfordern.' });
|
||||
}
|
||||
|
||||
const expected = Buffer.from(row.code_hash, 'utf8');
|
||||
const actual = Buffer.from(hashCode(code, email), 'utf8');
|
||||
const ok = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
if (!ok) {
|
||||
await codes.updateOne(row.id, { attempts: (row.attempts ?? 0) + 1 });
|
||||
return res.status(401).json({ error: 'Code ungültig.' });
|
||||
}
|
||||
await codes.updateOne(row.id, { used: true });
|
||||
|
||||
const user = await findUserByEmail(email, schema);
|
||||
if (!user || user.status !== 'active')
|
||||
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
|
||||
|
||||
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
const tempPassword = crypto.randomBytes(32).toString('base64url');
|
||||
await users.updateOne(user.id, { password: tempPassword });
|
||||
|
||||
const auth = new AuthenticationService({ schema, accountability: null });
|
||||
const result = await auth.login('default', { email, password: tempPassword });
|
||||
|
||||
// Kundendatensatz mit dem Login-Konto verknüpfen, falls noch offen.
|
||||
const customers = new ItemsService('customers', { schema, accountability: null });
|
||||
const cust = await customers.readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'linked_user'],
|
||||
});
|
||||
if (cust?.[0] && !cust[0].linked_user) {
|
||||
await customers.updateOne(cust[0].id, { linked_user: user.id });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
access_token: result.accessToken ?? result.access_token,
|
||||
refresh_token: result.refreshToken ?? result.refresh_token,
|
||||
expires: result.expires,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-auth] verify-code: ${err.message}`);
|
||||
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Konto anlegen (aus dem Checkout) ──────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
const email = norm(req.body?.email);
|
||||
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
|
||||
|
||||
try {
|
||||
const { schema } = await ctx();
|
||||
|
||||
// Bereits vorhanden? Dann einfach Login-Code schicken (kein Fehler).
|
||||
const existing = await findUserByEmail(email, schema);
|
||||
if (existing) return res.json({ ok: true, existed: true });
|
||||
|
||||
const roles = new RolesService({ schema, accountability: null });
|
||||
const role = (await roles.readByQuery({
|
||||
filter: { name: { _eq: CUSTOMER_ROLE_NAME } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
if (!role) {
|
||||
logger.error(`[skrift-auth] Rolle "${CUSTOMER_ROLE_NAME}" nicht gefunden.`);
|
||||
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||
}
|
||||
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
const userId = await users.createOne({
|
||||
email,
|
||||
password: crypto.randomBytes(32).toString('base64url'),
|
||||
role: role.id,
|
||||
status: 'active',
|
||||
first_name: req.body?.first_name ?? null,
|
||||
last_name: req.body?.last_name ?? null,
|
||||
});
|
||||
|
||||
// Kundendatensatz anlegen oder verknüpfen (Zuordnung immer per E-Mail).
|
||||
const customers = new ItemsService('customers', { schema, accountability: null });
|
||||
const cust = (await customers.readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
if (cust) await customers.updateOne(cust.id, { linked_user: userId });
|
||||
else await customers.createOne({
|
||||
email, linked_user: userId,
|
||||
first_name: req.body?.first_name ?? null,
|
||||
last_name: req.body?.last_name ?? null,
|
||||
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||
company: req.body?.company ?? null,
|
||||
});
|
||||
|
||||
return res.json({ ok: true, existed: false });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-auth] register: ${err.message}`);
|
||||
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Explizite ID → Routen liegen garantiert unter /skrift-auth/*,
|
||||
// unabhängig von Ordner- oder Paketnamen.
|
||||
export default { id: 'skrift-auth', handler };
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-auth",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Passwortloser Login per E-Mail-Code für Skrift-Kunden.",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user