Files
skrift-programme/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
Lucas Orth 2dbbc4611a 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>
2026-07-20 19:13:35 +02:00

231 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };