Files
craftvia/src/server/auth-token.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
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>
2026-09-14 11:05:39 +02:00

158 lines
5.6 KiB
TypeScript
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.
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;
}