From de366e1607ae66fa5227ed8a5d7b0d2a2996e002 Mon Sep 17 00:00:00 2001 From: JustZvan Date: Mon, 30 Mar 2026 14:40:49 +0200 Subject: feat: add reset password on backend --- src/routes/signin.ts | 147 +++++++++++++++++++++++++++++++++++++++++++++ test/signin-routes.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 test/signin-routes.test.ts diff --git a/src/routes/signin.ts b/src/routes/signin.ts index 7c17432..2060596 100644 --- a/src/routes/signin.ts +++ b/src/routes/signin.ts @@ -6,6 +6,8 @@ import { Infer, z } from "zod"; import { signJwt } from "../account/jwt"; import { eq } from "drizzle-orm"; import { logger } from "../lib/pino"; +import * as jose from "jose"; +import { getTransporter } from "../email/email"; /** Validates signin request body with email and password */ const SigninBodySchema = z.object({ @@ -20,8 +22,153 @@ const SigninBodySchema = z.object({ password: z.string(), }); +const ResetPasswordRequestSchema = z.object({ + email: z + .string() + .transform((val) => val.trim()) + .pipe( + z + .email({ error: "Invalid email" }) + .nonempty({ error: "Email can't be empty" }), + ), + link: z.url({ error: "Invalid link" }), +}); + +const ResetPasswordConfirmSchema = z.object({ + token: z.string().min(1, { error: "Token can't be empty" }), + password: z.string().min(1, { error: "Password can't be empty" }), +}); + const router: express.Router = express.Router(); +function getResetPasswordSecret(): Uint8Array { + const secret = process.env.RESET_PASSWORD_JWT_SECRET; + + if (!secret) { + logger.error("RESET_PASSWORD_JWT_SECRET environment variable not set"); + throw new Error("RESET_PASSWORD_JWT_SECRET not configured"); + } + + return new TextEncoder().encode(secret); +} + +async function signResetPasswordToken(userId: number): Promise { + return new jose.SignJWT({ id: userId, type: "password_reset" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer("urn:lajosh:buddy") + .setAudience("urn:buddy:password-reset") + .setExpirationTime("1h") + .sign(getResetPasswordSecret()); +} + +async function verifyResetPasswordToken(token: string): Promise { + const { payload } = await jose.jwtVerify(token, getResetPasswordSecret(), { + issuer: "urn:lajosh:buddy", + audience: "urn:buddy:password-reset", + }); + + if (payload.type !== "password_reset" || typeof payload.id !== "number") { + throw new Error("Invalid reset token payload"); + } + + return payload.id; +} + +router.post("/resetpassword", async (req, res) => { + const requestResetParsed = ResetPasswordRequestSchema.safeParse(req.body); + + if (requestResetParsed.success) { + const { email, link } = requestResetParsed.data; + logger.info({ email }, "Password reset requested"); + + const existingUser = ( + await db.select().from(users).where(eq(users.email, email)).limit(1) + )[0]; + + if (!existingUser) { + logger.info({ email }, "Password reset requested for unknown email"); + return res.send({ + success: true, + reason: "", + }); + } + + try { + const token = await signResetPasswordToken(existingUser.id); + const separator = link.includes("?") ? "&" : "?"; + const resetLink = `${link}${separator}token=${encodeURIComponent(token)}`; + + await getTransporter().sendMail({ + from: `"Buddy 🐶" <${process.env.SMTP_EMAIL}>`, + to: email, + subject: "Buddy password reset", + text: `Your Buddy password reset link is ${resetLink}`, + }); + + logger.info( + { email, userId: existingUser.id }, + "Password reset email sent", + ); + + return res.send({ + success: true, + reason: "", + }); + } catch (error) { + logger.error({ error, email }, "Failed to send password reset email"); + return res.status(500).send({ + success: false, + reason: "Failed to send password reset email", + }); + } + } + + const confirmResetParsed = ResetPasswordConfirmSchema.safeParse(req.body); + + if (!confirmResetParsed.success) { + logger.warn( + { error: confirmResetParsed.error }, + "Reset password validation failed", + ); + return res.status(400).send({ + success: false, + reason: confirmResetParsed.error, + }); + } + + try { + const userId = await verifyResetPasswordToken(confirmResetParsed.data.token); + const hashedPassword = await argon2.hash(confirmResetParsed.data.password); + + const updatedUsers = await db + .update(users) + .set({ password: hashedPassword }) + .where(eq(users.id, userId)) + .returning({ id: users.id }); + + if (updatedUsers.length === 0) { + logger.warn({ userId }, "Password reset failed: user not found"); + return res.status(400).send({ + success: false, + reason: "Invalid or expired reset token", + }); + } + + logger.info({ userId }, "Password reset completed successfully"); + return res.send({ + success: true, + reason: "", + }); + } catch (error) { + logger.warn({ error }, "Password reset failed: invalid token"); + return res.status(400).send({ + success: false, + reason: "Invalid or expired reset token", + }); + } +}); + router.post("/signin", async (req, res) => { const body: Infer = req.body; logger.info({ email: body.email }, "Signin attempt initiated"); diff --git a/test/signin-routes.test.ts b/test/signin-routes.test.ts new file mode 100644 index 0000000..98d1153 --- /dev/null +++ b/test/signin-routes.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import argon2 from "argon2"; +import * as jose from "jose"; +import { db } from "../src/db/db"; +import { users } from "../src/db/schema"; +import { eq } from "drizzle-orm"; +import signinRouter from "../src/routes/signin"; +import type { Request, Response } from "express"; + +const sendMail = vi.fn(); + +vi.mock("../src/email/email", () => ({ + getTransporter: () => ({ + sendMail, + }), +})); + +describe("Signin Routes", () => { + beforeEach(() => { + process.env.RESET_PASSWORD_JWT_SECRET = "test-reset-secret"; + process.env.SMTP_EMAIL = "buddy@example.com"; + sendMail.mockReset(); + }); + + function getRouteHandler(path: string) { + const layer = ( + signinRouter as unknown as { + stack: Array<{ + route?: { + path: string; + stack: Array<{ handle: (req: Request, res: Response) => unknown }>; + }; + }>; + } + ).stack.find((candidate) => candidate.route?.path === path); + + if (!layer?.route?.stack[0]) { + throw new Error(`Route handler for ${path} not found`); + } + + return layer.route.stack[0].handle; + } + + async function invokeRoute(body: unknown) { + const handler = getRouteHandler("/resetpassword"); + const req = { body } as Request; + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + send(payload: unknown) { + this.body = payload; + return this; + }, + } as Response & { statusCode: number; body: unknown }; + + await handler(req, res); + + return res; + } + + test("should send a password reset email with a signed reset link", async () => { + const response = await invokeRoute({ + email: "test@example.com", + link: "https://buddy.example/reset-password", + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: true, + reason: "", + }); + expect(sendMail).toHaveBeenCalledTimes(1); + + const mail = sendMail.mock.calls[0]?.[0]; + expect(mail.subject).toBe("Buddy password reset"); + expect(mail.text).toContain("Your Buddy password reset link is "); + + const sentLink = mail.text.replace( + "Your Buddy password reset link is ", + "", + ); + const token = new URL(sentLink).searchParams.get("token"); + + expect(token).toBeTruthy(); + + const { payload } = await jose.jwtVerify( + token!, + new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET), + { + issuer: "urn:lajosh:buddy", + audience: "urn:buddy:password-reset", + }, + ); + + expect(payload.id).toBe(1); + expect(payload.type).toBe("password_reset"); + }); + + test("should update the user password when given a valid reset token", async () => { + const token = await new jose.SignJWT({ id: 1, type: "password_reset" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer("urn:lajosh:buddy") + .setAudience("urn:buddy:password-reset") + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET!)); + + const response = await invokeRoute({ + token, + password: "new-password", + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: true, + reason: "", + }); + + const updatedUser = ( + await db.select().from(users).where(eq(users.id, 1)).limit(1) + )[0]; + + expect(updatedUser).toBeTruthy(); + expect(await argon2.verify(updatedUser!.password, "new-password")).toBe( + true, + ); + }); +}); -- cgit v1.2.3