import { FastifyInstance } from "fastify" import argon2 from "argon2" import { db } from "../db" import { users, passwordResetTokens } from "../db/schema" import { eq, and, gt } from "drizzle-orm" import { LoginRequestSchema } from "@emberclone/shared" import { z } from "zod" import { emailService } from "../services/email" import { randomBytes } from "crypto" import { rateLimiter } from "../services/rate-limit" const ForgotPasswordRequestSchema = z.object({ email: z.string().email() }) const ResetPasswordRequestSchema = z.object({ token: z.string().min(1), newPassword: z.string().min(8) }) export default async function authRoutes(fastify: FastifyInstance) { fastify.post("/login", { preHandler: async (request, reply) => { const allowed = await rateLimiter.check(request.ip, 10, 60_000) if (!allowed) { return reply.code(429).send({ message: "Too many requests" }) } } }, async (request, reply) => { const body = LoginRequestSchema.parse(request.body) const [user] = await db .select() .from(users) .where(eq(users.email, body.email)) .limit(1) if (!user || !(await argon2.verify(user.passwordHash, body.password))) { return reply.code(401).send({ message: "Invalid credentials" }) } const token = fastify.jwt.sign({ sub: user.id, role: user.role }) reply .setCookie("token", token, { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" }) .send({ user: { id: user.id, email: user.email, name: user.name, role: user.role } }) }) fastify.get("/me", async (request, reply) => { try { await request.jwtVerify() const { sub } = request.user as { sub: string } const [user] = await db .select() .from(users) .where(eq(users.id, sub)) .limit(1) if (!user) { return reply.code(404).send({ message: "User not found" }) } return { id: user.id, email: user.email, name: user.name, role: user.role } } catch (err) { return reply.code(401).send({ message: "Unauthorized" }) } }) fastify.post("/logout", async (request, reply) => { reply .clearCookie("token", { path: "/" }) .send({ message: "Logged out" }) }) fastify.post("/forgot-password", async (request, reply) => { const body = ForgotPasswordRequestSchema.parse(request.body) const [user] = await db .select() .from(users) .where(eq(users.email, body.email)) .limit(1) if (user) { const token = randomBytes(32).toString("hex") const tokenHash = await argon2.hash(token) const expiresAt = new Date(Date.now() + 60 * 60 * 1000) await db.insert(passwordResetTokens).values({ userId: user.id, tokenHash, expiresAt }) await emailService.sendPasswordReset(user.email, token) } return reply.code(200).send({ message: "If an account exists, a reset link has been sent." }) }) fastify.post("/reset-password", async (request, reply) => { const body = ResetPasswordRequestSchema.parse(request.body) const resets = await db .select() .from(passwordResetTokens) .where( and( gt(passwordResetTokens.expiresAt, new Date()), eq(passwordResetTokens.used, false) ) ) let matchedReset = null for (const r of resets) { if (await argon2.verify(r.tokenHash, body.token)) { matchedReset = r break } } if (!matchedReset) { return reply.code(400).send({ message: "Invalid or expired token" }) } const newPasswordHash = await argon2.hash(body.newPassword) await db.transaction(async (tx) => { await tx .update(users) .set({ passwordHash: newPasswordHash }) .where(eq(users.id, matchedReset.userId)) await tx .update(passwordResetTokens) .set({ used: true }) .where(eq(passwordResetTokens.id, matchedReset.id)) }) return reply.code(200).send({ message: "Password has been reset successfully" }) }) }