import { createElement } from "react"; import { compare, hash } from "bcryptjs"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { serializableTransaction } from "@/lib/transaction"; import { hashToken, newToken } from "@/lib/tokens"; import { passwordSchema } from "@/lib/password"; import { sendAppEmail } from "@/lib/mail"; import { PasswordResetEmail } from "@/emails/PasswordResetEmail"; import { UserFacingError } from "@/lib/action-error"; const RESET_PREFIX = "password-reset:"; const resetScope = (userId: string) => `${RESET_PREFIX}${Buffer.from(userId).toString("base64url")}:`; function resetIdentity(identifier: string) { const match = /^password-reset:([a-zA-Z0-9_-]+):(\d+)$/.exec(identifier); if (!match) return null; const sessionVersion = Number(match[2]); if (!Number.isSafeInteger(sessionVersion)) return null; return { id: Buffer.from(match[1], "base64url").toString("utf8"), sessionVersion, }; } const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/i); export const passwordResetMessage = "Wenn für diese E-Mail-Adresse ein aktiviertes Konto besteht, erhältst du einen Link für ein neues Passwort. Prüfe auch deinen Spamordner."; const newPasswordSchema = z .object({ password: passwordSchema, confirmation: z.string() }) .refine((input) => input.password === input.confirmation, { message: "Die neuen Passwörter stimmen nicht überein.", path: ["confirmation"], }); const resetSchema = newPasswordSchema.and(z.object({ token: tokenSchema })); const invalidLink = () => new UserFacingError( "Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte fordere einen neuen Link an.", ); function siteOrigin() { const base = process.env.NEXT_PUBLIC_SITE_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL; if (!base && process.env.NODE_ENV !== "development") throw new Error("App-Adresse fehlt."); const url = new URL(base || "http://localhost:3000"); if ( !["http:", "https:"].includes(url.protocol) || url.username || url.password ) throw new Error("Ungültige App-Adresse."); return url.origin; } export async function requestPasswordReset(email: string, send = sendAppEmail) { const parsed = z .string() .trim() .max(254) .email() .toLowerCase() .safeParse(email); if (!parsed.success) return; const resetToken = newToken(); const tokenHash = hashToken(resetToken); const job = await serializableTransaction(async (tx) => { const user = await tx.user.findUnique({ where: { email: parsed.data } }); if (!user || user.disabledAt || !user.emailVerifiedAt || !user.passwordHash) return null; await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`; const fresh = await tx.user.findUniqueOrThrow({ where: { id: user.id } }); if ( fresh.email !== parsed.data || fresh.disabledAt || !fresh.emailVerifiedAt || !fresh.passwordHash ) return null; const identifier = resetScope(user.id) + fresh.sessionVersion; await tx.verificationToken.deleteMany({ where: { identifier: { startsWith: resetScope(user.id) } }, }); await tx.verificationToken.create({ data: { identifier, token: tokenHash, expires: new Date(Date.now() + 30 * 60_000), }, }); return { id: user.id, email: fresh.email, name: fresh.firstName, identifier, }; }); if (!job) return; try { const result = await send({ to: job.email, subject: "Kita-Planer: Neues Passwort festlegen", react: createElement(PasswordResetEmail, { name: job.name, resetLink: `${siteOrigin()}/neues-passwort/${resetToken}`, }), idempotencyKey: `password-reset/${tokenHash}`, }); if (result.success) return; } catch { /* Return the same public response, including transport failure. */ } await prisma.verificationToken.deleteMany({ where: { identifier: job.identifier, token: tokenHash }, }); } export async function isPasswordResetValid(token: string) { if (!tokenSchema.safeParse(token).success) return false; const entry = await prisma.verificationToken.findUnique({ where: { token: hashToken(token) }, }); if (!entry || !resetIdentity(entry.identifier) || entry.expires <= new Date()) return false; return !!(await prisma.user.findFirst({ where: { ...resetIdentity(entry.identifier)!, disabledAt: null, emailVerifiedAt: { not: null }, passwordHash: { not: "" }, }, select: { id: true }, })); } export async function resetPassword(raw: unknown) { const input = resetSchema.safeParse(raw); if (!input.success) throw new UserFacingError( input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.", ); if (!(await isPasswordResetValid(input.data.token))) throw invalidLink(); const passwordHash = await hash(input.data.password, 12); await serializableTransaction(async (tx) => { const tokenHash = hashToken(input.data.token); const entry = await tx.verificationToken.findUnique({ where: { token: tokenHash }, }); if ( !entry || !resetIdentity(entry.identifier) || entry.expires <= new Date() ) throw invalidLink(); const identity = resetIdentity(entry.identifier)!; const userId = identity.id; await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`; const user = await tx.user.findFirst({ where: { ...identity, disabledAt: null, emailVerifiedAt: { not: null }, passwordHash: { not: "" }, }, }); if (!user) throw invalidLink(); const consumed = await tx.verificationToken.deleteMany({ where: { identifier: entry.identifier, token: tokenHash, expires: { gt: new Date() }, }, }); if (consumed.count !== 1) throw invalidLink(); await tx.user.update({ where: { id: userId }, data: { passwordHash, sessionVersion: { increment: 1 }, failedLoginAttempts: 0, lockedUntil: null, }, }); await tx.verificationToken.deleteMany({ where: { OR: [ { identifier: userId }, { identifier: { startsWith: resetScope(userId) } }, ], }, }); }); } export async function changeOwnPassword( actor: { id: string; sessionVersion: number }, raw: unknown, ) { const schema = newPasswordSchema.and( z.object({ currentPassword: z .string() .min(1) .refine( (value) => Buffer.byteLength(value, "utf8") <= 72, "Aktuelles Passwort ist ungültig.", ), }), ); const input = schema.safeParse(raw); if (!input.success) throw new UserFacingError( input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.", ); const user = await prisma.user.findFirst({ where: { id: actor.id, sessionVersion: actor.sessionVersion, disabledAt: null, emailVerifiedAt: { not: null }, }, }); if (!user || !(await compare(input.data.currentPassword, user.passwordHash))) throw new UserFacingError( "Das aktuelle Passwort ist nicht korrekt. Bitte melde dich bei Bedarf erneut an.", ); if (await compare(input.data.password, user.passwordHash)) throw new UserFacingError("Bitte wähle ein anderes neues Passwort."); const passwordHash = await hash(input.data.password, 12); await serializableTransaction(async (tx) => { const changed = await tx.user.updateMany({ where: { id: user.id, passwordHash: user.passwordHash, sessionVersion: actor.sessionVersion, disabledAt: null, emailVerifiedAt: { not: null }, }, data: { passwordHash, sessionVersion: { increment: 1 }, failedLoginAttempts: 0, lockedUntil: null, }, }); if (changed.count !== 1) throw new UserFacingError( "Das Konto wurde inzwischen geändert. Bitte melde dich erneut an.", ); await tx.verificationToken.deleteMany({ where: { OR: [ { identifier: user.id }, { identifier: { startsWith: resetScope(user.id) } }, ], }, }); }); }