import { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; import { generateRandomPassword, hashPassword } from "../../utils/password"; import { sendMail } from "../../utils/mailer"; import { authUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema"; import { tenants } from "../../../db/schema"; import { and, eq } from "drizzle-orm"; import { createAccessToken, issueRefreshToken, revokeRefreshToken, rotateRefreshToken, } from "../../utils/authTokens"; export default async function authRoutes(server: FastifyInstance) { // ----------------------------------------------------- // REGISTER // ----------------------------------------------------- server.post("/auth/register", { schema: { tags: ["Auth"], summary: "Register User", body: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email" }, password: { type: "string" }, }, }, }, }, async (req, reply) => { const body = req.body as { email: string; password: string }; const passwordHash = await bcrypt.hash(body.password, 10); const [user] = await server.db .insert(authUsers) .values({ email: body.email.toLowerCase(), passwordHash, }) .returning({ id: authUsers.id, email: authUsers.email, }); return { user }; }); // ----------------------------------------------------- // LOGIN // ----------------------------------------------------- server.post("/auth/login", { schema: { tags: ["Auth"], summary: "Login User", body: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email" }, password: { type: "string" }, rememberMe: { type: "boolean" }, }, }, }, }, async (req, reply) => { const body = req.body as { email: string; password: string; rememberMe?: boolean }; let user: any = null; // ------------------------------- // SINGLE TENANT MODE // ------------------------------- /* if (req.tenant) { const tenantId = req.tenant.id; const result = await server.db .select({ user: authUsers, }) .from(authUsers) .innerJoin( authTenantUsers, eq(authTenantUsers.userId, authUsers.id) ) .innerJoin( tenants, eq(authTenantUsers.tenantId, tenants.id) ) .where(and( eq(authUsers.email, body.email.toLowerCase()), eq(authTenantUsers.tenantId, tenantId) )); if (result.length === 0) { return reply.code(401).send({ error: "Invalid credentials" }); } user = result[0].user; // ------------------------------- // MULTI TENANT MODE // ------------------------------- } else {*/ const [found] = await server.db .select() .from(authUsers) .where(eq(authUsers.email, body.email.toLowerCase())) .limit(1); if (!found) { return reply.code(401).send({ error: "Invalid credentials" }); } user = found; /*}*/ // Passwort prüfen const valid = await bcrypt.compare(body.password, user.passwordHash); if (!valid) { return reply.code(401).send({ error: "Invalid credentials" }); } const tenantId = req.tenant?.id ? Number(req.tenant.id) : null; const token = createAccessToken(user, tenantId); const refreshToken = await issueRefreshToken(server, user.id, tenantId); reply.setCookie("token", token, { path: "/", httpOnly: true, sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", secure: process.env.NODE_ENV === "production", ...(body.rememberMe ? { maxAge: 60 * 60 * 6 } : {}), }); return { token, refreshToken }; }); server.post("/auth/refresh", { schema: { tags: ["Auth"], summary: "Refresh a persistent session", body: { type: "object", required: ["refreshToken"], properties: { refreshToken: { type: "string" }, }, }, }, }, async (req, reply) => { const { refreshToken } = req.body as { refreshToken: string }; const refreshed = await rotateRefreshToken(server, refreshToken); if (!refreshed) { return reply.code(401).send({ error: "Invalid or expired refresh token" }); } return refreshed; }); // ----------------------------------------------------- // LOGOUT // ----------------------------------------------------- server.post("/auth/logout", { schema: { tags: ["Auth"], summary: "Logout User" } }, async (req, reply) => { const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken; if (refreshToken) { await revokeRefreshToken(server, refreshToken); } reply.clearCookie("token", { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", }); return { success: true }; }); // ----------------------------------------------------- // PASSWORD RESET // ----------------------------------------------------- server.post("/auth/password/reset", { schema: { tags: ["Auth"], summary: "Reset Password", body: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } }, async (req, reply) => { const { email } = req.body as { email: string }; const [user] = await server.db .select({ id: authUsers.id, email: authUsers.email, passwordHash: authUsers.passwordHash, mustChangePassword: authUsers.must_change_password, }) .from(authUsers) .where(eq(authUsers.email, email.toLowerCase())) .limit(1); if (!user) { return reply.code(404).send({ error: "User not found" }); } const plainPassword = generateRandomPassword(); const passwordHash = await hashPassword(plainPassword); await server.db .update(authUsers) .set({ passwordHash, must_change_password: true, }) .where(eq(authUsers.id, user.id)); const mailResult = await sendMail( user.email, "FEDEO | Dein neues Passwort", `

Hallo,

Dein Passwort wurde zurückgesetzt.

Neues Passwort: ${plainPassword}

Bitte ändere es nach dem Login umgehend.

` ); if (!mailResult.success) { // Ein fehlgeschlagener Mailversand darf das bisherige Passwort nicht // unbrauchbar machen. Die Hash-Bedingung schützt parallele Resets. await server.db .update(authUsers) .set({ passwordHash: user.passwordHash, must_change_password: user.mustChangePassword, }) .where(and( eq(authUsers.id, user.id), eq(authUsers.passwordHash, passwordHash), )); return reply.code(502).send({ error: "Password reset email could not be sent" }); } return { success: true }; }); }