feat: add password recovery and everyday workflow improvements
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user