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
This commit is contained in:
Matteo
2026-01-24 17:11:52 +01:00
parent 381dd95543
commit b175706da1
3 changed files with 357 additions and 0 deletions
+179
View File
@@ -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<string, unknown>;
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<void> => {
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<void> => {
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();
};
+86
View File
@@ -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 = <T = void>(
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
) => {
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;
};
@@ -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();