feat(auth): consolidate multi-user auth and admin controls

This commit is contained in:
Zimeng Xiong
2026-02-06 00:25:13 -08:00
parent 700e153740
commit 75a1f11a96
13 changed files with 612 additions and 97 deletions
+57 -6
View File
@@ -2,11 +2,53 @@
* Test utilities for backend integration tests
*/
import { PrismaClient } from "../generated/client";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
// Use a separate test database
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
// Use a unique test database per test-file import to avoid cross-file contention
// when Vitest runs test files in parallel.
const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`;
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME);
const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock");
const sleepSync = (ms: number) => {
const shared = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(shared, 0, 0, ms);
};
const withDbPushLock = (fn: () => void) => {
const start = Date.now();
let fd: number | null = null;
while (fd === null) {
try {
fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx");
fs.writeFileSync(fd, String(process.pid));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "EEXIST") throw error;
if (Date.now() - start > 30_000) {
throw new Error("Timed out waiting for Prisma db push lock");
}
sleepSync(50);
}
}
try {
fn();
} finally {
try {
fs.closeSync(fd);
} catch {
// ignore
}
try {
fs.unlinkSync(DB_PUSH_LOCK_PATH);
} catch {
// ignore
}
}
};
/**
* Get a test Prisma client pointing to the test database
@@ -32,10 +74,19 @@ export const setupTestDb = () => {
// Run Prisma migrations to create the test database
try {
execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl },
stdio: "pipe",
withDbPushLock(() => {
execSync("npx prisma db push --skip-generate --force-reset", {
cwd: path.resolve(__dirname, "../../"),
env: {
...process.env,
DATABASE_URL: databaseUrl,
// Work around Prisma schema engine failures on this repo's schema
// (seen as a blank "Schema engine error:" from `prisma db push`).
// `RUST_LOG=info` reliably avoids the failure mode.
RUST_LOG: "info",
},
stdio: "pipe",
});
});
} catch (error) {
console.error("Failed to setup test database:", error);
+314 -14
View File
@@ -8,7 +8,7 @@ import type { StringValue } from "ms";
import { z } from "zod";
import { PrismaClient } from "./generated/client";
import { config } from "./config";
import { requireAuth } from "./middleware/auth";
import { requireAuth, optionalAuth } from "./middleware/auth";
import { sanitizeText } from "./security";
import rateLimit from "express-rate-limit";
import { logAuditEvent } from "./utils/audit";
@@ -38,6 +38,17 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
const router = express.Router();
const prisma = new PrismaClient();
const BOOTSTRAP_USER_ID = "bootstrap-admin";
const DEFAULT_SYSTEM_CONFIG_ID = "default";
const ensureSystemConfig = async () => {
return prisma.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {},
create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: false },
});
};
// Rate limiting for auth endpoints (stricter than general rate limiting)
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
@@ -52,16 +63,50 @@ const authRateLimiter = rateLimit({
// Validation schemas
const registerSchema = z.object({
username: z.string().trim().min(3).max(50).optional(),
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(100),
name: z.string().trim().min(1).max(100),
});
const loginSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z.string(),
const loginSchema = z
.object({
identifier: z.string().trim().min(1).max(255).optional(),
email: z.string().email().toLowerCase().trim().optional(),
username: z.string().trim().min(1).max(255).optional(),
password: z.string(),
})
.refine((data) => Boolean(data.identifier || data.email || data.username), {
message: "identifier/email/username is required",
});
const registrationToggleSchema = z.object({
enabled: z.boolean(),
});
const adminRoleUpdateSchema = z.object({
identifier: z.string().trim().min(1).max(255),
role: z.enum(["ADMIN", "USER"]),
});
const findUserByIdentifier = async (identifier: string) => {
const trimmed = identifier.trim();
if (trimmed.length === 0) return null;
const looksLikeEmail = trimmed.includes("@");
if (looksLikeEmail) {
return prisma.user.findUnique({
where: { email: trimmed.toLowerCase() },
});
}
return prisma.user.findFirst({
where: {
OR: [{ username: trimmed }, { email: trimmed.toLowerCase() }],
},
});
};
/**
* Generate JWT tokens (access and refresh)
* Note: expiresIn accepts string (like "15m", "7d") or number (seconds)
@@ -105,7 +150,102 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
});
}
const { email, password, name } = parsed.data;
const { email, password, name, username } = parsed.data;
const systemConfig = await ensureSystemConfig();
const activeUsers = await prisma.user.count({ where: { isActive: true } });
const bootstrapUser = await prisma.user.findUnique({
where: { id: BOOTSTRAP_USER_ID },
select: { id: true, isActive: true },
});
const isBootstrapFlow =
Boolean(bootstrapUser) &&
bootstrapUser?.isActive === false &&
activeUsers === 0 &&
bootstrapUser.id === BOOTSTRAP_USER_ID;
// Bootstrap flow: first registration activates the bootstrap admin user
// created during migration and retains ownership of migrated data.
if (isBootstrapFlow) {
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const sanitizedName = sanitizeText(name, 100);
const user = await prisma.user.update({
where: { id: BOOTSTRAP_USER_ID },
data: {
email,
username: username ?? null,
passwordHash,
name: sanitizedName,
role: "ADMIN",
mustResetPassword: false,
isActive: true,
},
select: {
id: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
},
});
// Create trash collection if it doesn't exist (shared across all users)
const existingTrash = await prisma.collection.findUnique({
where: { id: "trash" },
});
if (!existingTrash) {
await prisma.collection.create({
data: {
id: "trash",
name: "Trash",
userId: user.id, // Shared, but pick a stable owner
},
});
}
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
if (config.enableRefreshTokenRotation) {
const expiresAt = new Date();
expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
await prisma.refreshToken.create({
data: { userId: user.id, token: refreshToken, expiresAt },
});
}
if (config.enableAuditLogging) {
await logAuditEvent({
userId: user.id,
action: "bootstrap_admin",
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
});
}
return res.status(201).json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
},
accessToken,
refreshToken,
registrationEnabled: systemConfig.registrationEnabled,
bootstrapped: true,
});
}
if (!systemConfig.registrationEnabled) {
return res.status(403).json({
error: "Forbidden",
message: "User registration is disabled.",
});
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
@@ -119,6 +259,19 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
});
}
if (username) {
const existingUsername = await prisma.user.findFirst({
where: { username },
select: { id: true },
});
if (existingUsername) {
return res.status(409).json({
error: "Conflict",
message: "User with this username already exists",
});
}
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
@@ -132,11 +285,14 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
email,
passwordHash,
name: sanitizedName,
username: username ?? null,
},
select: {
id: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
createdAt: true,
},
});
@@ -195,9 +351,12 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
},
accessToken,
refreshToken,
registrationEnabled: systemConfig.registrationEnabled,
});
} catch (error) {
console.error("Registration error:", error);
@@ -223,12 +382,29 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
});
}
const { email, password } = parsed.data;
const identifier =
parsed.data.email ||
parsed.data.username ||
parsed.data.identifier ||
"";
const { password } = parsed.data;
// Find user
const user = await prisma.user.findUnique({
where: { email },
// Block login until bootstrap is completed (so migrated data remains reachable)
const bootstrapUser = await prisma.user.findUnique({
where: { id: BOOTSTRAP_USER_ID },
select: { id: true, isActive: true },
});
if (bootstrapUser && bootstrapUser.isActive === false) {
const activeUsers = await prisma.user.count({ where: { isActive: true } });
if (activeUsers === 0) {
return res.status(409).json({
error: "Bootstrap required",
message: "Initial admin account has not been configured yet. Register to bootstrap.",
});
}
}
const user = await findUserByIdentifier(identifier);
if (!user) {
// Don't reveal if user exists (prevent user enumeration)
@@ -255,7 +431,7 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
action: "login_failed",
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { email },
details: { identifier },
});
}
@@ -304,6 +480,8 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
},
accessToken,
refreshToken,
@@ -467,8 +645,11 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => {
where: { id: req.user.id },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
createdAt: true,
updatedAt: true,
},
@@ -491,6 +672,124 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => {
}
});
/**
* GET /auth/status
* Lightweight auth + registration status (supports bootstrap UX)
*/
router.get("/status", optionalAuth, async (req: Request, res: Response) => {
try {
const systemConfig = await ensureSystemConfig();
const bootstrapUser = await prisma.user.findUnique({
where: { id: BOOTSTRAP_USER_ID },
select: { id: true, isActive: true },
});
res.json({
enabled: true,
authenticated: Boolean(req.user),
registrationEnabled: systemConfig.registrationEnabled,
bootstrapRequired: Boolean(bootstrapUser && bootstrapUser.isActive === false),
user: req.user
? {
id: req.user.id,
username: req.user.username ?? null,
email: req.user.email,
name: req.user.name,
role: req.user.role,
mustResetPassword: req.user.mustResetPassword ?? false,
}
: null,
});
} catch (error) {
console.error("Auth status error:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to fetch auth status",
});
}
});
/**
* POST /auth/registration/toggle
* Enable/disable registration (admin-only)
*/
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
if (req.user.role !== "ADMIN") {
return res.status(403).json({ error: "Forbidden", message: "Admin access required" });
}
const parsed = registrationToggleSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "Bad request", message: "Invalid toggle payload" });
}
const updated = await prisma.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: { registrationEnabled: parsed.data.enabled },
create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: parsed.data.enabled },
});
res.json({ registrationEnabled: updated.registrationEnabled });
} catch (error) {
console.error("Registration toggle error:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to update registration setting",
});
}
});
/**
* POST /auth/admins
* Promote/demote a user (admin-only)
*/
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
if (req.user.role !== "ADMIN") {
return res.status(403).json({ error: "Forbidden", message: "Admin access required" });
}
const parsed = adminRoleUpdateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "Bad request", message: "Invalid admin update payload" });
}
const target = await findUserByIdentifier(parsed.data.identifier);
if (!target) {
return res.status(404).json({ error: "Not found", message: "User not found" });
}
const updated = await prisma.user.update({
where: { id: target.id },
data: { role: parsed.data.role },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
res.json({ user: updated });
} catch (error) {
console.error("Admin role update error:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to update user role",
});
}
});
/**
* POST /auth/password-reset-request
* Request a password reset (sends reset token via email)
@@ -562,7 +861,8 @@ router.post("/password-reset-request", authRateLimiter, async (req: Request, res
// For now, we'll return the token in development (remove in production!)
if (config.nodeEnv === "development") {
console.log(`[DEV] Password reset token for ${email}: ${resetToken}`);
console.log(`[DEV] Reset URL: ${config.frontendUrl}/reset-password?token=${resetToken}`);
const baseUrl = config.frontendUrl || "http://localhost:6767";
console.log(`[DEV] Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
}
}
@@ -643,7 +943,7 @@ router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res
// Update user password
await prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash },
data: { passwordHash, mustResetPassword: false },
});
// Mark reset token as used
@@ -811,7 +1111,7 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques
// Update password
await prisma.user.update({
where: { id: user.id },
data: { passwordHash },
data: { passwordHash, mustResetPassword: false },
});
// Revoke all refresh tokens for this user (force re-login) - if rotation enabled
@@ -852,4 +1152,4 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques
}
});
export default router;
export default router;
+67 -13
View File
@@ -2,14 +2,16 @@
* Configuration validation and environment variable management
*/
import dotenv from "dotenv";
import crypto from "crypto";
import path from "path";
dotenv.config();
interface Config {
port: number;
nodeEnv: string;
databaseUrl: string;
frontendUrl: string;
databaseUrl?: string;
frontendUrl?: string;
jwtSecret: string;
jwtAccessExpiresIn: string;
jwtRefreshExpiresIn: string;
@@ -34,6 +36,65 @@ const getOptionalEnv = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue;
};
const resolveJwtSecret = (nodeEnv: string): string => {
const provided = process.env.JWT_SECRET;
if (provided && provided.trim().length > 0) {
return provided;
}
if (nodeEnv === "production") {
throw new Error("Missing required environment variable: JWT_SECRET");
}
const generated = crypto.randomBytes(32).toString("hex");
console.warn(
"[security] JWT_SECRET is not set (non-production). Using an ephemeral secret; tokens will be invalidated on restart."
);
return generated;
};
const parseFrontendUrl = (raw: string | undefined): string | undefined => {
if (!raw || raw.trim().length === 0) return undefined;
const first = raw.split(",")[0]?.trim();
if (!first) return undefined;
try {
// Validate basic format
new URL(/^https?:\/\//i.test(first) ? first : `http://${first}`);
} catch {
// Don't hard-fail; FRONTEND_URL supports multiple origins in other parts of the app.
return first;
}
return first;
};
const resolveDatabaseUrl = (rawUrl?: string) => {
const backendRoot = path.resolve(__dirname, "../");
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
if (!rawUrl || rawUrl.trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!rawUrl.startsWith("file:")) {
return rawUrl;
}
const filePath = rawUrl.replace(/^file:/, "");
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
// Ensure DATABASE_URL is resolved before any PrismaClient is created.
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
const getOptionalBoolean = (key: string, defaultValue: boolean): boolean => {
const value = process.env[key];
if (!value) return defaultValue;
@@ -53,9 +114,9 @@ const getRequiredEnvNumber = (key: string, defaultValue: number): number => {
export const config: Config = {
port: getRequiredEnvNumber("PORT", 8000),
nodeEnv: getOptionalEnv("NODE_ENV", "development"),
databaseUrl: getRequiredEnv("DATABASE_URL"),
frontendUrl: getOptionalEnv("FRONTEND_URL", "http://localhost:6767"),
jwtSecret: getRequiredEnv("JWT_SECRET"),
databaseUrl: process.env.DATABASE_URL,
frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL),
jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")),
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000),
@@ -77,11 +138,4 @@ if (config.nodeEnv === "production") {
}
}
// Validate frontend URL format
try {
new URL(config.frontendUrl);
} catch {
throw new Error(`Invalid FRONTEND_URL format: ${config.frontendUrl}`);
}
console.log("Configuration validated successfully");
console.log("Configuration validated successfully");
-31
View File
@@ -33,37 +33,6 @@ import { logAuditEvent } from "./utils/audit";
const backendRoot = path.resolve(__dirname, "../");
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
const resolveDatabaseUrl = (rawUrl?: string) => {
if (!rawUrl || rawUrl.trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!rawUrl.startsWith("file:")) {
return rawUrl;
}
const filePath = rawUrl.replace(/^file:/, "");
// Prisma treats relative SQLite paths as relative to the schema directory
// (i.e. `backend/prisma/schema.prisma`). Historically this project used
// `file:./prisma/dev.db`, which Prisma interprets as `prisma/prisma/dev.db`.
// To keep runtime and migrations aligned:
// - Prefer resolving relative paths against `backend/prisma`
// - But if the path already includes a leading `prisma/`, resolve from repo root
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL);
// Helper to get the resolved database file path
+28 -3
View File
@@ -14,8 +14,11 @@ declare global {
interface Request {
user?: {
id: string;
username?: string | null;
email: string;
name: string;
role: string;
mustResetPassword?: boolean;
};
}
}
@@ -108,7 +111,15 @@ export const requireAuth = async (
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true, isActive: true },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (!user || !user.isActive) {
@@ -122,8 +133,11 @@ export const requireAuth = async (
// Attach user to request
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
};
next();
@@ -160,14 +174,25 @@ export const optionalAuth = async (
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true, isActive: true },
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,
};
}
} catch (error) {
@@ -176,4 +201,4 @@ export const optionalAuth = async (
}
next();
};
};
+8 -3
View File
@@ -3,7 +3,12 @@
*/
import { PrismaClient } from "../generated/client";
const prisma = new PrismaClient();
let prisma: PrismaClient | null = null;
const getPrisma = () => {
if (prisma) return prisma;
prisma = new PrismaClient();
return prisma;
};
export interface AuditLogData {
userId?: string;
@@ -27,7 +32,7 @@ export const logAuditEvent = async (data: AuditLogData): Promise<void> => {
return; // Feature disabled, silently skip
}
await prisma.auditLog.create({
await getPrisma().auditLog.create({
data: {
userId: data.userId || null,
action: data.action,
@@ -62,7 +67,7 @@ export const getAuditLogs = async (
return []; // Feature disabled, return empty array
}
const logs = await prisma.auditLog.findMany({
const logs = await getPrisma().auditLog.findMany({
where: userId ? { userId } : undefined,
orderBy: { createdAt: "desc" },
take: limit,