/** * 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'; 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'); // Temporäres Passwort, das jede übliche Passwort-Richtlinie erfüllt // (Groß-, Kleinbuchstabe, Ziffer, Sonderzeichen). Wird nie angezeigt und // rotiert bei jedem Login. const tempPasswort = () => `Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`; const ctx = async () => ({ schema: await getSchema(), accountability: null }); // Findet das Konto per primärer E-Mail ODER per zusätzlicher Login-Mail // (alt_emails). So melden sich Zweit-Mails im SELBEN Konto an. async function findUserByEmail(email, schema) { const users = new UsersService({ schema, accountability: null }); const felder = ['id', 'email', 'status', 'alt_emails']; const primaer = await users.readByQuery({ filter: { email: { _eq: email } }, fields: felder, limit: 1 }); if (primaer?.[0]) return primaer[0]; // Zusätzliche Login-Mails: JSON-Feld ist DB-abhängig schwer filterbar, daher // die (wenigen) Konten mit alt_emails laden und exakt in JS prüfen. try { const cand = await users.readByQuery({ filter: { alt_emails: { _nnull: true } }, fields: felder, limit: -1 }); const treffer = (cand || []).find((u) => { const liste = Array.isArray(u.alt_emails) ? u.alt_emails : (typeof u.alt_emails === 'string' ? (() => { try { return JSON.parse(u.alt_emails); } catch { return []; } })() : []); return Array.isArray(liste) && liste.map((x) => norm(x)).includes(email); }); if (treffer) return treffer; } catch { /* alt_emails evtl. noch nicht vorhanden → ignorieren */ } return null; } async function sendCodeMail(email, code) { const schema = await getSchema(); // Standardinhalt (Fallback, falls keine Vorlage hinterlegt ist). let subject = `Ihr Anmeldecode: ${code}`; let body = `
Guten Tag,
Ihr Anmeldecode für Skrift lautet:
` + `${code}
` + `Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung ` + `angefordert haben, können Sie diese E-Mail ignorieren.
`; let signature = ''; let absender = ''; // Editierbare Vorlage „login_code" + gemeinsame Signatur laden (falls vorhanden). try { const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({ filter: { key: { _eq: 'login_code' }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'], }); const tpl = rows?.[0]; const fuelle = (s) => String(s || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (k === 'code' ? code : '')); if (tpl) { if (tpl.subject) subject = fuelle(tpl.subject); if (tpl.body) body = fuelle(tpl.body); } const es = await new ItemsService('email_settings', { schema, accountability: null }) .readSingleton({ fields: ['signature', 'sender_name', 'sender_email'] }); if (es?.signature) signature = es.signature; if (es?.sender_email) { absender = es.sender_name ? `${es.sender_name} <${es.sender_email}>` : es.sender_email; } } catch { /* Vorlage optional – Fallback oben greift */ } const mail = new MailService({ schema, accountability: null }); const nachricht = { to: email, subject, html: body + signature }; if (absender) nachricht.from = absender; await mail.send(nachricht); } // ── 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.' }); // WICHTIG: Directus kennt nur die PRIMÄRE E-Mail als Login. Wurde der Code // an eine Zweit-Mail geschickt, melden wir dennoch mit der primären an – // dadurch landet die Zweit-Mail im selben Konto (voller Zugriff). const loginEmail = norm(user.email); // Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar). const users = new UsersService({ schema, accountability: null }); const tempPassword = tempPasswort(); await users.updateOne(user.id, { password: tempPassword }); // Die Signatur von AuthenticationService.login variiert zwischen // Directus-Versionen. Der Aufruf mit ('default', payload) hat bei dir per // curl funktioniert – daher zuerst diese Variante, dann Fallbacks. const auth = new AuthenticationService({ schema, accountability: null }); const versuche = [ () => auth.login('default', { email: loginEmail, password: tempPassword }), () => auth.login({ email: loginEmail, password: tempPassword }), () => auth.login({ email: loginEmail, password: tempPassword }, {}), ]; let result, letzterFehler; for (const versuch of versuche) { try { result = await versuch(); break; } catch (e) { letzterFehler = e; } } if (!result) { logger.error(`[skrift-auth] login fehlgeschlagen: ${letzterFehler?.message}`); const e = new Error(`Login-Service: ${letzterFehler?.message || 'unbekannt'}`); e.detail = letzterFehler?.message; throw e; } // Kunde = Login-Konto selbst – keine separate Verknüpfung mehr nötig. 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.stack || 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.' }); } // Kunde = Login-Konto: Profil hängt direkt am Nutzer. const users = new UsersService({ schema, accountability: null }); 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, company: req.body?.company ?? null, phone: req.body?.phone ?? null, person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat', }); 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 };