Import aus Monorepo (frischer Start)
This commit is contained in:
276
extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
276
extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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 =
|
||||
`<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>`;
|
||||
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 };
|
||||
Reference in New Issue
Block a user