159 lines
4.2 KiB
TypeScript
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" })
|
|
})
|
|
} |