Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||
import { prisma } from "@/server/db";
|
||
|
||
/**
|
||
* SEC2 — Einmal-Tokens für Passwort-Reset und E-Mail-Änderung.
|
||
*
|
||
* Regeln (verbindlich, SEC2 §1/§6):
|
||
* - Rohtoken = 32 Byte CSPRNG, base64url. Es existiert **nur** im Link und in
|
||
* der Rückgabe von `issueToken()`; gespeichert wird ausschließlich der
|
||
* SHA-256-Hash. Nie loggen, nie ins MailLog.
|
||
* - Lookup über den Hash (unique). Der anschließende Vergleich läuft in
|
||
* konstanter Zeit — auch wenn der Index-Lookup bereits eindeutig ist, hält
|
||
* das die Prüfung frei von Timing-Unterschieden.
|
||
* - **Single-use**: beim Einlösen wird `usedAt` gesetzt, und zwar über ein
|
||
* bedingtes `updateMany` (nur wenn `usedAt` noch NULL ist). Zwei parallele
|
||
* Einlösungen desselben Tokens können so nicht beide gewinnen.
|
||
* - Beim Neuanfordern werden offene Tokens desselben Typs und Prinzipals
|
||
* entwertet.
|
||
*
|
||
* Alle Zugriffe laufen über den **rohen** `prisma`-Client: Einlösung passiert
|
||
* ohne Session (der Nutzer ist gerade nicht angemeldet) und damit ohne
|
||
* Mandantenkontext. Die RLS-Policy auf `auth_tokens` bleibt als zweite
|
||
* Verteidigungslinie bestehen.
|
||
*/
|
||
|
||
// Option C (WS4): der Mandanten-Prinzipal ist jetzt die GLOBALE Identity
|
||
// (Passwort/Reset gehören der Identity, nicht mehr der per-Mandant-Mitgliedschaft).
|
||
// "tenant_user" bleibt für Bestands-/Kompatibilität im Typ, wird aber nicht mehr
|
||
// ausgestellt (principalTypeOf(tenant) → "identity").
|
||
export type PrincipalType = "identity" | "tenant_user" | "platform_admin";
|
||
export type TokenType = "password_reset" | "email_change" | "invitation";
|
||
|
||
/** Gültigkeitsdauer je Typ (SEC2 §1: Reset 30–60 Min, Verify 60 Min; Einladung 7 Tage). */
|
||
const TTL_MINUTES: Record<TokenType, number> = {
|
||
password_reset: 60,
|
||
email_change: 60,
|
||
invitation: 7 * 24 * 60,
|
||
};
|
||
|
||
function hashToken(raw: string): string {
|
||
return createHash("sha256").update(raw).digest("hex");
|
||
}
|
||
|
||
/** Konstante-Zeit-Vergleich zweier Hex-Hashes gleicher Länge. */
|
||
function hashesEqual(a: string, b: string): boolean {
|
||
const bufA = Buffer.from(a, "hex");
|
||
const bufB = Buffer.from(b, "hex");
|
||
if (bufA.length !== bufB.length) return false;
|
||
return timingSafeEqual(bufA, bufB);
|
||
}
|
||
|
||
export type IssuedToken = { raw: string; expiresAt: Date };
|
||
|
||
/**
|
||
* Erzeugt ein neues Token und entwertet offene Tokens desselben Typs.
|
||
* Gibt das **Rohtoken** zurück — der einzige Moment, in dem es existiert.
|
||
*/
|
||
export async function issueToken(input: {
|
||
principalType: PrincipalType;
|
||
principalId: string;
|
||
tenantId?: string | null;
|
||
type: TokenType;
|
||
newEmail?: string;
|
||
requestIp?: string | null;
|
||
/** Überschreibt die Default-TTL des Typs (z. B. längerer Einladungslink). */
|
||
ttlMinutes?: number;
|
||
}): Promise<IssuedToken> {
|
||
// Offene Tokens desselben Typs entwerten: eine neue Anforderung macht die
|
||
// vorherige ungültig (verhindert mehrere gleichzeitig gültige Links).
|
||
await prisma.authToken.updateMany({
|
||
where: {
|
||
principalType: input.principalType,
|
||
principalId: input.principalId,
|
||
type: input.type,
|
||
usedAt: null,
|
||
},
|
||
data: { usedAt: new Date() },
|
||
});
|
||
|
||
const raw = randomBytes(32).toString("base64url");
|
||
const expiresAt = new Date(Date.now() + (input.ttlMinutes ?? TTL_MINUTES[input.type]) * 60_000);
|
||
|
||
await prisma.authToken.create({
|
||
data: {
|
||
principalType: input.principalType,
|
||
principalId: input.principalId,
|
||
tenantId: input.tenantId ?? null,
|
||
type: input.type,
|
||
tokenHash: hashToken(raw),
|
||
newEmail: input.newEmail,
|
||
expiresAt,
|
||
requestIp: input.requestIp ?? null,
|
||
},
|
||
});
|
||
|
||
return { raw, expiresAt };
|
||
}
|
||
|
||
export type ResolvedToken = {
|
||
id: string;
|
||
principalType: PrincipalType;
|
||
principalId: string;
|
||
tenantId: string | null;
|
||
type: TokenType;
|
||
newEmail: string | null;
|
||
};
|
||
|
||
/**
|
||
* Prüft ein Rohtoken, **ohne** es zu verbrauchen (für die Anzeige des
|
||
* Reset-Formulars). Liefert `null` bei jedem Fehlerfall — der Aufrufer gibt
|
||
* daraufhin eine generische Meldung aus, nie den konkreten Grund.
|
||
*/
|
||
export async function peekToken(raw: string, type: TokenType): Promise<ResolvedToken | null> {
|
||
if (!raw) return null;
|
||
const hash = hashToken(raw);
|
||
const row = await prisma.authToken.findUnique({ where: { tokenHash: hash } });
|
||
if (!row) return null;
|
||
if (!hashesEqual(row.tokenHash, hash)) return null;
|
||
if (row.type !== type) return null;
|
||
if (row.usedAt) return null;
|
||
if (row.expiresAt <= new Date()) return null;
|
||
return {
|
||
id: row.id,
|
||
principalType: row.principalType as PrincipalType,
|
||
principalId: row.principalId,
|
||
tenantId: row.tenantId,
|
||
type: row.type as TokenType,
|
||
newEmail: row.newEmail,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Löst ein Token ein und markiert es als verbraucht.
|
||
*
|
||
* Das `updateMany` mit `usedAt: null` in der Bedingung ist die eigentliche
|
||
* Sperre: gewinnt ein paralleler Aufruf, ändert dieser Aufruf 0 Zeilen und
|
||
* bekommt `null` — der Token wird garantiert nur einmal wirksam.
|
||
*/
|
||
export async function consumeToken(raw: string, type: TokenType): Promise<ResolvedToken | null> {
|
||
const token = await peekToken(raw, type);
|
||
if (!token) return null;
|
||
|
||
const result = await prisma.authToken.updateMany({
|
||
where: { id: token.id, usedAt: null },
|
||
data: { usedAt: new Date() },
|
||
});
|
||
if (result.count !== 1) return null;
|
||
return token;
|
||
}
|
||
|
||
/** Aufräumen abgelaufener/verbrauchter Tokens (für einen späteren Wartungsjob). */
|
||
export async function purgeExpiredTokens(before: Date = new Date()): Promise<number> {
|
||
const { count } = await prisma.authToken.deleteMany({
|
||
where: { OR: [{ expiresAt: { lt: before } }, { usedAt: { not: null } }] },
|
||
});
|
||
return count;
|
||
}
|