From b175706da1efd4a51f4385c2810f86df20aacf08 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 24 Jan 2026 17:11:52 +0100 Subject: [PATCH] feat(auth): add authentication middleware and utilities - Add requireAuth middleware for protecting routes - Add errorHandler and asyncHandler middleware - Add user isolation helpers for database queries --- backend/src/middleware/auth.ts | 179 +++++++++++++++++++ backend/src/middleware/errorHandler.ts | 86 +++++++++ backend/src/scripts/migrate-existing-data.ts | 92 ++++++++++ 3 files changed, 357 insertions(+) create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/errorHandler.ts create mode 100644 backend/src/scripts/migrate-existing-data.ts diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..b7dbe35 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,179 @@ +/** + * Authentication middleware for protecting routes + */ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { config } from "../config"; +import { PrismaClient } from "../generated/client"; + +const prisma = new PrismaClient(); + +// Extend Express Request type to include user +declare global { + namespace Express { + interface Request { + user?: { + id: string; + email: string; + name: string; + }; + } + } +} + +interface JwtPayload { + userId: string; + email: string; + type: "access" | "refresh"; +} + +/** + * Type guard to check if decoded JWT is our expected payload structure + */ +const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { + if (typeof decoded !== "object" || decoded === null) { + return false; + } + const payload = decoded as Record; + return ( + typeof payload.userId === "string" && + typeof payload.email === "string" && + (payload.type === "access" || payload.type === "refresh") + ); +}; + +/** + * Extract JWT token from Authorization header + */ +const extractToken = (req: Request): string | null => { + const authHeader = req.headers.authorization; + if (!authHeader || typeof authHeader !== "string") return null; + + const parts = authHeader.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") { + return null; + } + + return parts[1]; +}; + +/** + * Verify and decode JWT token + */ +const verifyToken = (token: string): JwtPayload | null => { + try { + const decoded = jwt.verify(token, config.jwtSecret); + if (!isJwtPayload(decoded)) { + return null; + } + if (decoded.type !== "access") { + return null; // Only accept access tokens in middleware + } + return decoded; + } catch { + return null; + } +}; + +/** + * Require authentication middleware + * Protects routes that require a valid JWT token + */ +export const requireAuth = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const token = extractToken(req); + + if (!token) { + res.status(401).json({ + error: "Unauthorized", + message: "Authentication token required", + }); + return; + } + + const payload = verifyToken(token); + + if (!payload) { + res.status(401).json({ + error: "Unauthorized", + message: "Invalid or expired token", + }); + return; + } + + // Verify user still exists and is active + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { id: true, email: true, name: true, isActive: true }, + }); + + if (!user || !user.isActive) { + res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + return; + } + + // Attach user to request + req.user = { + id: user.id, + email: user.email, + name: user.name, + }; + + next(); + } catch (error) { + console.error("Error verifying user:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to verify user", + }); + } +}; + +/** + * Optional authentication middleware + * Attaches user to request if token is present, but doesn't require it + */ +export const optionalAuth = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const token = extractToken(req); + + if (!token) { + return next(); + } + + const payload = verifyToken(token); + + if (!payload) { + return next(); + } + + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { id: true, email: true, name: true, isActive: true }, + }); + + if (user && user.isActive) { + req.user = { + id: user.id, + email: user.email, + name: user.name, + }; + } + } catch (error) { + // Silently fail for optional auth + console.error("Error in optional auth:", error); + } + + next(); +}; \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..c6edf17 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,86 @@ +/** + * Error handling middleware + * Sanitizes error messages in production to prevent information leakage + */ +import { Request, Response, NextFunction } from "express"; +import { config } from "../config"; + +export interface AppError extends Error { + statusCode?: number; + isOperational?: boolean; +} + +/** + * Error handler middleware + * Should be added last in the middleware chain + */ +export const errorHandler = ( + err: AppError, + req: Request, + res: Response, + next: NextFunction +): void => { + const statusCode = err.statusCode || 500; + const isDevelopment = config.nodeEnv === "development"; + + // Log full error details server-side + console.error("Error:", { + message: err.message, + stack: err.stack, + statusCode, + path: req.path, + method: req.method, + timestamp: new Date().toISOString(), + }); + + // In production, don't expose internal error details + if (!isDevelopment) { + // Generic error messages for clients + if (statusCode >= 500) { + res.status(statusCode).json({ + error: "Internal server error", + message: "An error occurred while processing your request", + }); + return; + } + + // For client errors (4xx), provide generic message + res.status(statusCode).json({ + error: "Request error", + message: err.isOperational ? err.message : "Invalid request", + }); + return; + } + + // In development, show full error details + res.status(statusCode).json({ + error: err.message, + stack: err.stack, + statusCode, + }); +}; + +/** + * Async error wrapper + * Wraps async route handlers to catch errors + */ +export const asyncHandler = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) => { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +/** + * Create an operational error (known error that can be safely shown to client) + */ +export const createError = ( + message: string, + statusCode: number = 400 +): AppError => { + const error: AppError = new Error(message); + error.statusCode = statusCode; + error.isOperational = true; + return error; +}; \ No newline at end of file diff --git a/backend/src/scripts/migrate-existing-data.ts b/backend/src/scripts/migrate-existing-data.ts new file mode 100644 index 0000000..ca89c97 --- /dev/null +++ b/backend/src/scripts/migrate-existing-data.ts @@ -0,0 +1,92 @@ +/** + * Data migration script for existing drawings and collections + * This script assigns existing data to a default user + * Run this if you have existing data before the auth migration + */ +import { PrismaClient } from '../generated/client'; +import bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function migrateExistingData() { + try { + console.log('Starting data migration...'); + + // Check if there are any drawings or collections without userId + // Note: After migration, userId is required, so this query is for pre-migration data + // We use a raw query or check for missing userId field + const allDrawings = await prisma.drawing.findMany({ + select: { id: true, userId: true }, + }); + const drawingsWithoutUser = allDrawings.filter((d) => !d.userId); + + const allCollections = await prisma.collection.findMany({ + select: { id: true, userId: true }, + }); + const collectionsWithoutUser = allCollections.filter((c) => !c.userId); + + if (drawingsWithoutUser.length === 0 && collectionsWithoutUser.length === 0) { + console.log('No data to migrate. All records already have userId.'); + return; + } + + console.log(`Found ${drawingsWithoutUser.length} drawings and ${collectionsWithoutUser.length} collections without userId`); + + // Create a default migration user + const defaultEmail = 'migration@excalidash.local'; + const defaultPassword = await bcrypt.hash('migration-temp-password-change-me', 10); + + let migrationUser = await prisma.user.findUnique({ + where: { email: defaultEmail }, + }); + + if (!migrationUser) { + migrationUser = await prisma.user.create({ + data: { + email: defaultEmail, + passwordHash: defaultPassword, + name: 'Migration User', + }, + }); + console.log('Created migration user:', migrationUser.id); + } + + // Update collections + if (collectionsWithoutUser.length > 0) { + const collectionIds = collectionsWithoutUser.map((c) => c.id); + await prisma.collection.updateMany({ + where: { + id: { in: collectionIds }, + }, + data: { + userId: migrationUser.id, + }, + }); + console.log(`Assigned ${collectionsWithoutUser.length} collections to migration user`); + } + + // Update drawings + if (drawingsWithoutUser.length > 0) { + const drawingIds = drawingsWithoutUser.map((d) => d.id); + await prisma.drawing.updateMany({ + where: { + id: { in: drawingIds }, + }, + data: { + userId: migrationUser.id, + }, + }); + console.log(`Assigned ${drawingsWithoutUser.length} drawings to migration user`); + } + + console.log('Migration completed successfully!'); + console.log(`⚠️ IMPORTANT: Change the password for user ${defaultEmail} or delete this user after assigning data to real users.`); + } catch (error) { + console.error('Migration failed:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +migrateExistingData(); \ No newline at end of file