feat: add password recovery and everyday workflow improvements

This commit is contained in:
t.indorf
2026-10-12 17:51:50 +02:00
parent 061024d901
commit 661776e609
19 changed files with 1721 additions and 141 deletions
@@ -0,0 +1,584 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Test the actual services/actions with request and transport boundaries replaced. */
const { test, before, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const ts = require("typescript");
const { randomUUID } = require("node:crypto");
const { PrismaClient } = require("@prisma/client");
const { hash, compare } = require("bcryptjs");
const { render } = require("@react-email/render");
let prisma, current, f, service, actions, tokenHash, initialHash, jwt;
const tenants = [],
emails = [],
background = [];
let transportFailure = false;
const oldPassword = "Test-password-old-2026";
const newPassword = "Test-password-new-2026";
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} };
cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
},
fileName: file,
}).outputText;
const req = (spec) => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === "server-only") return {};
if (spec === "@/lib/prisma") return { prisma };
if (spec === "next/headers")
return { headers: async () => new Headers() };
if (spec === "next/cache") return { revalidatePath() {} };
if (spec === "next/navigation")
return {
redirect(url) {
throw new Error(`REDIRECT:${url}`);
},
notFound() {
throw new Error("NOT_FOUND");
},
};
if (spec.startsWith("@/") || spec.startsWith(".")) {
const target = spec.startsWith("@/")
? path.resolve("src", spec.slice(2))
: path.resolve(path.dirname(file), spec);
for (const suffix of [".ts", ".tsx", "/index.ts"])
if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
filename: file,
})(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const form = (data) => {
const f = new FormData();
for (const [key, value] of Object.entries(data)) f.set(key, value);
return f;
};
const token = () =>
new URL(emails.at(-1).react.props.resetLink).pathname.split("/").pop();
const resetInput = (link = token(), password = newPassword) => ({
token: link,
password,
confirmation: password,
});
const drain = async () => {
while (background.length) await background.shift()();
};
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
assert.ok(
raw,
"TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_account_test zeigen.",
);
const url = new URL(raw);
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
assert.equal(url.pathname, "/kita_account_test");
prisma = new PrismaClient({ datasourceUrl: raw });
initialHash = await hash(oldPassword, 12);
process.env.AUTH_URL = "http://127.0.0.1:3014";
const load = loader({
"@/lib/auth-utils": { requireSession: async () => ({ user: current }) },
"@/lib/mail": {
sendAppEmail: async (input) => {
emails.push(input);
return transportFailure
? { success: false, error: "test-only" }
: { success: true };
},
},
"next/server": { after: (fn) => background.push(fn) },
"next-auth": (config) => {
jwt = config.callbacks.jwt;
return {};
},
"next-auth/providers/credentials": (options) => options,
});
service = load("src/lib/password-management.ts");
actions = load("src/app/passwort-vergessen/actions.ts");
tokenHash = load("src/lib/tokens.ts").hashToken;
load("src/auth.ts");
});
beforeEach(async () => {
const kita = await prisma.kita.create({
data: { name: "Kontotest", slug: "account-" + randomUUID() },
});
tenants.push(kita.id);
const user = await prisma.user.create({
data: {
kitaId: kita.id,
email: randomUUID() + "@example.invalid",
firstName: "Test",
lastName: "Konto",
passwordHash: initialHash,
emailVerifiedAt: new Date(),
privacyPolicyAcceptedAt: new Date(),
},
});
f = { kita, user };
current = user;
emails.length = 0;
background.length = 0;
transportFailure = false;
});
after(async () => {
if (!prisma) return;
const users = await prisma.user.findMany({
where: { kitaId: { in: tenants } },
select: { id: true },
});
for (const u of users)
await prisma.verificationToken.deleteMany({
where: {
OR: [
{ identifier: u.id },
{
identifier: {
startsWith:
"password-reset:" +
Buffer.from(u.id).toString("base64url") +
":",
},
},
],
},
});
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
await prisma.$disconnect();
});
test("Reset speichert nur Hash, gültige App-Adresse und 30-Minuten-Frist; GET verbraucht den Link nicht", async () => {
const before = Date.now();
await service.requestPasswordReset(f.user.email);
const link = token(),
rows = await prisma.verificationToken.findMany({
where: { token: tokenHash(link) },
});
assert.equal(rows.length, 1);
assert.notEqual(rows[0].token, link);
assert.ok(rows[0].identifier.startsWith("password-reset:"));
assert.ok(rows[0].expires.getTime() >= before + 29 * 60_000);
assert.ok(rows[0].expires.getTime() <= Date.now() + 30 * 60_000);
assert.equal(await service.isPasswordResetValid(link), true);
assert.equal(await service.isPasswordResetValid(link), true);
assert.match(
emails[0].react.props.resetLink,
/^http:\/\/127\.0\.0\.1:3014\/neues-passwort\//,
);
const html = await render(emails[0].react);
assert.match(html, /30 Minuten/);
assert.match(html, /ignoriere|ignorieren/);
});
test("Anfrage antwortet gleich für bekannte, unbekannte und deaktivierte Adresse; Verarbeitung folgt nach der Antwort", async () => {
const known = await actions.requestResetAction(
{},
form({ email: f.user.email }),
);
assert.equal(emails.length, 0);
assert.equal(background.length, 1);
await drain();
assert.equal(emails.length, 1);
const missing = await actions.requestResetAction(
{},
form({ email: randomUUID() + "@example.invalid" }),
);
await drain();
await prisma.user.update({
where: { id: f.user.id },
data: { disabledAt: new Date() },
});
const disabled = await actions.requestResetAction(
{},
form({ email: f.user.email }),
);
await drain();
assert.deepEqual(known, missing);
assert.deepEqual(known, disabled);
assert.equal(emails.length, 1);
});
test("Anfrage normalisiert E-Mail und lässt offene Einladungen unverändert", async () => {
await service.requestPasswordReset(" " + f.user.email.toUpperCase() + " ");
assert.equal(emails.length, 1);
await prisma.user.update({
where: { id: f.user.id },
data: { emailVerifiedAt: null, passwordHash: "" },
});
emails.length = 0;
await prisma.verificationToken.create({
data: {
identifier: f.user.id,
token: "invitation-" + randomUUID(),
expires: new Date(Date.now() + 60_000),
},
});
await service.requestPasswordReset(f.user.email);
assert.equal(emails.length, 0);
assert.equal(
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
1,
);
});
test("Neue Anfrage ersetzt nur frühere Reset-Links und nicht Aktivierungstokens", async () => {
await prisma.verificationToken.create({
data: {
identifier: f.user.id,
token: "invite-" + randomUUID(),
expires: new Date(Date.now() + 60_000),
},
});
await service.requestPasswordReset(f.user.email);
const first = token();
await service.requestPasswordReset(f.user.email);
assert.equal(await service.isPasswordResetValid(first), false);
assert.equal(await service.isPasswordResetValid(token()), true);
assert.equal(
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
1,
);
});
test("Passwort wird gehasht, Lockout aufgehoben und alle alten Anmeldungen werden ungültig", async () => {
await service.requestPasswordReset(f.user.email);
await prisma.user.update({
where: { id: f.user.id },
data: {
lockedUntil: new Date(Date.now() + 60_000),
failedLoginAttempts: 10,
},
});
await service.resetPassword(resetInput());
const fresh = await prisma.user.findUniqueOrThrow({
where: { id: f.user.id },
});
assert.equal(await compare(newPassword, fresh.passwordHash), true);
assert.equal(await compare(oldPassword, fresh.passwordHash), false);
assert.equal(fresh.sessionVersion, 1);
assert.equal(fresh.failedLoginAttempts, 0);
assert.equal(fresh.lockedUntil, null);
assert.equal(
await jwt({
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
}),
null,
);
});
test("Reset-Link ist nur einmal nutzbar", async () => {
await service.requestPasswordReset(f.user.email);
const payload = resetInput();
await service.resetPassword(payload);
assert.equal(await service.isPasswordResetValid(payload.token), false);
await assert.rejects(service.resetPassword(payload), /Link ist ungültig/);
});
test("Parallele Verwendung desselben Links ändert das Passwort genau einmal", async () => {
await service.requestPasswordReset(f.user.email);
const payload = resetInput();
const results = await Promise.allSettled([
service.resetPassword(payload),
service.resetPassword({
...payload,
password: "Another-test-password",
confirmation: "Another-test-password",
}),
]);
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
.sessionVersion,
1,
);
});
test("Abgelaufene, erfundene und zweckfremde Aktivierungslinks funktionieren nicht", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await prisma.verificationToken.update({
where: { token: tokenHash(link) },
data: { expires: new Date(0) },
});
await assert.rejects(
service.resetPassword(resetInput(link)),
/Link ist ungültig/,
);
const fake = "a".repeat(64);
await assert.rejects(
service.resetPassword(resetInput(fake)),
/Link ist ungültig/,
);
await prisma.verificationToken.create({
data: {
identifier: f.user.id,
token: tokenHash(fake),
expires: new Date(Date.now() + 60_000),
},
});
await assert.rejects(
service.resetPassword(resetInput(fake)),
/Link ist ungültig/,
);
});
test("Deaktivierung und Kontolöschung verhindern die Verwendung eines vorhandenen Links", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await prisma.user.update({
where: { id: f.user.id },
data: { disabledAt: new Date(), sessionVersion: { increment: 1 } },
});
await assert.rejects(
service.resetPassword(resetInput(link)),
/Link ist ungültig/,
);
await prisma.user.delete({ where: { id: f.user.id } });
assert.equal(await service.isPasswordResetValid(link), false);
});
test("Ein Reset-Link für die alte E-Mail-Adresse bleibt auch nach neuer Aktivierung ungültig", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await prisma.user.update({
where: { id: f.user.id },
data: {
email: randomUUID() + "@example.invalid",
sessionVersion: { increment: 1 },
emailVerifiedAt: null,
passwordHash: "",
},
});
await prisma.user.update({
where: { id: f.user.id },
data: { emailVerifiedAt: new Date(), passwordHash: initialHash },
});
assert.equal(await service.isPasswordResetValid(link), false);
await assert.rejects(
service.resetPassword(resetInput(link)),
/Link ist ungültig/,
);
});
test("Mailfehler hinterlässt keinen nutzbaren Link und verrät keine Kontodaten in der öffentlichen Antwort", async () => {
transportFailure = true;
const response = await actions.requestResetAction(
{},
form({ email: f.user.email }),
);
await drain();
assert.equal(response.message, service.passwordResetMessage);
assert.equal(await service.isPasswordResetValid(token()), false);
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
.passwordHash,
initialHash,
);
});
test("Anfragelimit greift ohne abweichende öffentliche Antwort", async () => {
const responses = [];
for (let i = 0; i < 4; i++) {
responses.push(
await actions.requestResetAction({}, form({ email: f.user.email })),
);
await drain();
}
assert.equal(emails.length, 3);
for (const response of responses) assert.deepEqual(response, responses[0]);
});
test("Passwortbestätigung und UTF-8-Grenze werden geprüft, bevor ein gültiger Link verbraucht wird", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await assert.rejects(
service.resetPassword({ ...resetInput(link), confirmation: "different" }),
/stimmen nicht überein/,
);
await assert.rejects(
service.resetPassword(resetInput(link, "😀".repeat(19))),
/72 UTF-8/,
);
assert.equal(await service.isPasswordResetValid(link), true);
});
test("Eigene Passwortänderung verlangt aktuelles Passwort und kann kein fremdes Konto verändern", async () => {
const other = await prisma.user.create({
data: {
kitaId: f.kita.id,
email: randomUUID() + "@example.invalid",
firstName: "Andere",
lastName: "Person",
passwordHash: initialHash,
emailVerifiedAt: new Date(),
},
});
await assert.rejects(
service.changeOwnPassword(current, {
currentPassword: "wrong",
password: newPassword,
confirmation: newPassword,
}),
/aktuelle Passwort/,
);
assert.equal(
(await prisma.user.findUnique({ where: { id: f.user.id } })).sessionVersion,
0,
);
await service.changeOwnPassword(current, {
userId: other.id,
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
});
assert.equal(
(await prisma.user.findUnique({ where: { id: other.id } })).passwordHash,
initialHash,
);
assert.equal(
await compare(
newPassword,
(await prisma.user.findUnique({ where: { id: f.user.id } })).passwordHash,
),
true,
);
});
test("Aktuelles Passwort wiederverwenden und veraltete Sitzung werden zurückgewiesen", async () => {
await assert.rejects(
service.changeOwnPassword(current, {
currentPassword: oldPassword,
password: oldPassword,
confirmation: oldPassword,
}),
/anderes neues Passwort/,
);
await assert.rejects(
service.changeOwnPassword(
{ ...current, sessionVersion: 4 },
{
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
},
),
/aktuelle Passwort/,
);
});
test("Profil-Aktion ändert Passwort und widerruft ausstehende Reset-Links", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
const result = await actions.changePasswordAction(
{},
form({
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
}),
);
assert.equal(result.success, true);
assert.equal(await service.isPasswordResetValid(link), false);
assert.equal(
await jwt({
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
}),
null,
);
});
test("Anonyme Reset-Aktion benötigt keinen Login, validiert das Token und gibt keine internen Daten zurück", async () => {
await service.requestPasswordReset(f.user.email);
assert.equal(
(await actions.finishResetAction({}, form(resetInput()))).success,
true,
);
assert.deepEqual(
Object.keys(
await actions.finishResetAction(
{},
form({ ...resetInput(), token: "invalid" }),
),
),
["error"],
);
});
test("Ein fehlgeschlagener älterer Versand entfernt keinen inzwischen neu angeforderten Link", async () => {
let oldToken;
await service.requestPasswordReset(f.user.email, async (input) => {
oldToken = new URL(input.react.props.resetLink).pathname.split("/").pop();
await service.requestPasswordReset(f.user.email);
return { success: false, error: "test-only" };
});
assert.equal(await service.isPasswordResetValid(oldToken), false);
assert.equal(await service.isPasswordResetValid(token()), true);
});
test("Wiederholte falsche aktuelle Passwörter werden im Profil begrenzt", async () => {
for (let i = 0; i < 5; i++) {
assert.match(
(
await actions.changePasswordAction(
{},
form({
currentPassword: "wrong",
password: newPassword,
confirmation: newPassword,
}),
)
).error,
/aktuelle Passwort/,
);
}
assert.match(
(
await actions.changePasswordAction(
{},
form({
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
}),
)
).error,
/Zu viele Versuche/,
);
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
.sessionVersion,
0,
);
});
test("Fehlende oder unzulässige App-Adresse verschickt keinen nutzbaren Reset-Link", async () => {
const keys = ["AUTH_URL", "NEXTAUTH_URL", "NEXT_PUBLIC_SITE_URL", "NODE_ENV"];
const old = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
try {
process.env.NODE_ENV = "production";
delete process.env.AUTH_URL;
delete process.env.NEXTAUTH_URL;
delete process.env.NEXT_PUBLIC_SITE_URL;
await service.requestPasswordReset(f.user.email);
process.env.NEXT_PUBLIC_SITE_URL = "javascript:invalid";
await service.requestPasswordReset(f.user.email);
assert.equal(emails.length, 0);
assert.equal(
await prisma.verificationToken.count({
where: {
identifier: {
startsWith:
"password-reset:" +
Buffer.from(f.user.id).toString("base64url") +
":",
},
},
}),
0,
);
} finally {
for (const key of keys) {
if (old[key] === undefined) delete process.env[key];
else process.env[key] = old[key];
}
}
});