Files
ExcaliDash/backend/src/middleware/auth.ts
T
2026-02-07 17:47:41 -08:00

277 lines
7.0 KiB
TypeScript

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config";
import { PrismaClient } from "../generated/client";
import { prisma as defaultPrisma } from "../db/prisma";
import { createAuthModeService, type AuthModeService } from "../auth/authMode";
// Extend Express Request type to include user
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username?: string | null;
email: string;
name: string;
role: string;
mustResetPassword?: boolean;
impersonatorId?: string;
};
}
}
}
interface JwtPayload {
userId: string;
email: string;
type: "access" | "refresh";
impersonatorId?: string;
}
const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
if (typeof decoded !== "object" || decoded === null) {
return false;
}
const payload = decoded as Record<string, unknown>;
const impersonatorOk =
typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string";
return (
typeof payload.userId === "string" &&
typeof payload.email === "string" &&
(payload.type === "access" || payload.type === "refresh") &&
impersonatorOk
);
};
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];
};
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;
}
};
const normalizeRequestPath = (req: Request): string => {
const raw = (req.originalUrl || req.url || "").split("?")[0] || "";
// In some deployments the backend may see a /api prefix.
return raw.replace(/^\/api(?=\/)/, "");
};
const isAllowedWhileMustResetPassword = (req: Request): boolean => {
const path = normalizeRequestPath(req);
// Permit fetching current user and changing password.
if (req.method === "GET" && path === "/auth/me") return true;
if (req.method === "POST" && path === "/auth/change-password") return true;
if (req.method === "POST" && path === "/auth/must-reset-password") return true;
return false;
};
export type AuthMiddlewareDeps = {
prisma: PrismaClient;
authModeService: AuthModeService;
};
export const createAuthMiddleware = ({
prisma,
authModeService,
}: AuthMiddlewareDeps) => {
const requireAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
// Single-user mode: authentication disabled -> treat all requests as the bootstrap user.
try {
const authEnabled = await authModeService.getAuthEnabled();
if (!authEnabled) {
const user = await authModeService.getBootstrapActingUser();
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
};
return next();
}
} catch (error) {
console.error("Error reading auth mode:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to read authentication mode",
});
return;
}
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,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (!user || !user.isActive) {
res.status(401).json({
error: "Unauthorized",
message: "User account not found or inactive",
});
return;
}
if (user.mustResetPassword && !isAllowedWhileMustResetPassword(req)) {
res.status(403).json({
error: "Forbidden",
code: "MUST_RESET_PASSWORD",
message: "You must reset your password before using the app",
});
return;
}
// Attach user to request
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
};
next();
} catch (error) {
console.error("Error verifying user:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to verify user",
});
}
};
const optionalAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authEnabled = await authModeService.getAuthEnabled();
if (!authEnabled) {
return next();
}
} catch (error) {
console.error("Error reading auth mode:", error);
return next();
}
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,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (user && user.isActive) {
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
};
}
} catch (error) {
// Silently fail for optional auth
console.error("Error in optional auth:", error);
}
next();
};
return {
requireAuth,
optionalAuth,
};
};
const defaultAuthModeService = createAuthModeService(defaultPrisma);
const defaultAuthMiddleware = createAuthMiddleware({
prisma: defaultPrisma,
authModeService: defaultAuthModeService,
});
export const authModeService = defaultAuthModeService;
export const requireAuth = defaultAuthMiddleware.requireAuth;
export const optionalAuth = defaultAuthMiddleware.optionalAuth;