summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-03-30 14:40:49 +0200
committerJustZvan <justzvan@justzvan.xyz>2026-03-30 14:40:49 +0200
commitde366e1607ae66fa5227ed8a5d7b0d2a2996e002 (patch)
treecb40d1a0c7f85c28415f5a44fc1eca25d986a336
parent3a4611cf51a1f5a5e6f3dd8b12f0fb31eda1a950 (diff)
feat: add reset password on backend
-rw-r--r--src/routes/signin.ts147
-rw-r--r--test/signin-routes.test.ts132
2 files changed, 279 insertions, 0 deletions
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<string> {
+ 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<number> {
+ 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<typeof SigninBodySchema> = 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,
+ );
+ });
+});