summaryrefslogtreecommitdiff
path: root/src/routes/signin.ts
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 /src/routes/signin.ts
parent3a4611cf51a1f5a5e6f3dd8b12f0fb31eda1a950 (diff)
feat: add reset password on backend
Diffstat (limited to 'src/routes/signin.ts')
-rw-r--r--src/routes/signin.ts147
1 files changed, 147 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");