261 lines
8.1 KiB
TypeScript
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) } },
|
|
],
|
|
},
|
|
});
|
|
});
|
|
}
|