EmberClone/apps/api/src/routes/auth.ts

159 lines
4.2 KiB
TypeScript

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" })
})
}