Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user