diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/routes/signin.ts | 147 |
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"); |