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