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:
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user