177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import request from "supertest";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import argon2 from "argon2";
|
|
import { createHash } from "node:crypto";
|
|
import { buildApp } from "../src/server";
|
|
|
|
const prisma = new PrismaClient();
|
|
let app: FastifyInstance;
|
|
|
|
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp({
|
|
AUTH_DISABLED: false,
|
|
SEED_DEFAULT_BUDGET: false,
|
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: 20,
|
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: 20,
|
|
});
|
|
await app.ready();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (app) await app.close();
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("Forgot password security", () => {
|
|
it("returns generic success and only issues reset token for verified users", async () => {
|
|
const verifiedEmail = `fp-verified-${Date.now()}@test.dev`;
|
|
const unverifiedEmail = `fp-unverified-${Date.now()}@test.dev`;
|
|
const passwordHash = await argon2.hash("SupersAFE123!");
|
|
|
|
const [verifiedUser, unverifiedUser] = await Promise.all([
|
|
prisma.user.create({
|
|
data: { email: verifiedEmail, passwordHash, emailVerified: true },
|
|
}),
|
|
prisma.user.create({
|
|
data: { email: unverifiedEmail, passwordHash, emailVerified: false },
|
|
}),
|
|
]);
|
|
|
|
const unknownRes = await request(app.server)
|
|
.post("/auth/forgot-password/request")
|
|
.send({ email: `missing-${Date.now()}@test.dev` });
|
|
const verifiedRes = await request(app.server)
|
|
.post("/auth/forgot-password/request")
|
|
.send({ email: verifiedEmail });
|
|
const unverifiedRes = await request(app.server)
|
|
.post("/auth/forgot-password/request")
|
|
.send({ email: unverifiedEmail });
|
|
|
|
expect(unknownRes.status).toBe(200);
|
|
expect(verifiedRes.status).toBe(200);
|
|
expect(unverifiedRes.status).toBe(200);
|
|
|
|
expect(unknownRes.body).toEqual({
|
|
ok: true,
|
|
message: "If an account exists, reset instructions were sent.",
|
|
});
|
|
expect(verifiedRes.body).toEqual(unknownRes.body);
|
|
expect(unverifiedRes.body).toEqual(unknownRes.body);
|
|
|
|
const [verifiedTokens, unverifiedTokens] = await Promise.all([
|
|
prisma.emailToken.count({
|
|
where: { userId: verifiedUser.id, type: "password_reset", usedAt: null },
|
|
}),
|
|
prisma.emailToken.count({
|
|
where: { userId: unverifiedUser.id, type: "password_reset", usedAt: null },
|
|
}),
|
|
]);
|
|
expect(verifiedTokens).toBe(1);
|
|
expect(unverifiedTokens).toBe(0);
|
|
|
|
await prisma.user.deleteMany({
|
|
where: { email: { in: [verifiedEmail, unverifiedEmail] } },
|
|
});
|
|
});
|
|
|
|
it("consumes reset token once, updates password, and invalidates prior session", async () => {
|
|
const email = `fp-confirm-${Date.now()}@test.dev`;
|
|
const oldPassword = "SupersAFE123!";
|
|
const newPassword = "EvenStrong3rPass!";
|
|
const rawToken = "reset-token-value-0123456789abcdef";
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
emailVerified: true,
|
|
passwordHash: await argon2.hash(oldPassword),
|
|
},
|
|
});
|
|
|
|
const login = await request(app.server).post("/auth/login").send({ email, password: oldPassword });
|
|
expect(login.status).toBe(200);
|
|
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c: string) => c.startsWith("session="));
|
|
expect(sessionCookie).toBeTruthy();
|
|
|
|
await prisma.emailToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "password_reset",
|
|
tokenHash: hashToken(rawToken),
|
|
expiresAt: new Date(Date.now() + 30 * 60_000),
|
|
},
|
|
});
|
|
|
|
const confirm = await request(app.server)
|
|
.post("/auth/forgot-password/confirm")
|
|
.send({ uid: user.id, token: rawToken, newPassword });
|
|
expect(confirm.status).toBe(200);
|
|
expect(confirm.body.ok).toBe(true);
|
|
|
|
const tokenRecord = await prisma.emailToken.findFirst({
|
|
where: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken) },
|
|
});
|
|
expect(tokenRecord?.usedAt).toBeTruthy();
|
|
|
|
const secondUse = await request(app.server)
|
|
.post("/auth/forgot-password/confirm")
|
|
.send({ uid: user.id, token: rawToken, newPassword: "AnotherStrongPass4!" });
|
|
expect(secondUse.status).toBe(400);
|
|
expect(secondUse.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
|
|
|
|
const oldSessionAccess = await request(app.server)
|
|
.get("/dashboard")
|
|
.set("Cookie", [sessionCookie as string]);
|
|
expect(oldSessionAccess.status).toBe(401);
|
|
|
|
const oldPasswordLogin = await request(app.server)
|
|
.post("/auth/login")
|
|
.send({ email, password: oldPassword });
|
|
expect(oldPasswordLogin.status).toBe(401);
|
|
|
|
const newPasswordLogin = await request(app.server)
|
|
.post("/auth/login")
|
|
.send({ email, password: newPassword });
|
|
expect(newPasswordLogin.status).toBe(200);
|
|
|
|
await prisma.user.deleteMany({ where: { id: user.id } });
|
|
});
|
|
|
|
it("rejects uid/token mismatches with generic error", async () => {
|
|
const emailA = `fp-a-${Date.now()}@test.dev`;
|
|
const emailB = `fp-b-${Date.now()}@test.dev`;
|
|
const rawToken = "mismatch-token-value-abcdef0123456789";
|
|
|
|
const [userA, userB] = await Promise.all([
|
|
prisma.user.create({
|
|
data: { email: emailA, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
|
|
}),
|
|
prisma.user.create({
|
|
data: { email: emailB, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
|
|
}),
|
|
]);
|
|
|
|
await prisma.emailToken.create({
|
|
data: {
|
|
userId: userA.id,
|
|
type: "password_reset",
|
|
tokenHash: hashToken(rawToken),
|
|
expiresAt: new Date(Date.now() + 30 * 60_000),
|
|
},
|
|
});
|
|
|
|
const mismatch = await request(app.server)
|
|
.post("/auth/forgot-password/confirm")
|
|
.send({ uid: userB.id, token: rawToken, newPassword: "MismatchPass9!" });
|
|
|
|
expect(mismatch.status).toBe(400);
|
|
expect(mismatch.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
|
|
|
|
await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } });
|
|
});
|
|
});
|