Files
kita-planer/src/lib/password-management.ts
T

261 lines
8.1 KiB
TypeScript

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) } },
],
},
});
});
}