feat(auth): default to single-user mode with enable toggle
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- Add authEnabled flag to SystemConfig to support single-user mode by default.
|
||||
|
||||
-- SQLite supports simple ADD COLUMN for non-null with default.
|
||||
ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
@@ -32,6 +32,7 @@ model User {
|
||||
|
||||
model SystemConfig {
|
||||
id String @id @default("default")
|
||||
authEnabled Boolean @default(false)
|
||||
registrationEnabled Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
+131
-2
@@ -45,10 +45,26 @@ const ensureSystemConfig = async () => {
|
||||
return prisma.systemConfig.upsert({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
update: {},
|
||||
create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: false },
|
||||
create: {
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: false,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
|
||||
const systemConfig = await ensureSystemConfig();
|
||||
if (!systemConfig.authEnabled) {
|
||||
res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Rate limiting for auth endpoints (stricter than general rate limiting)
|
||||
const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
@@ -89,6 +105,10 @@ const adminRoleUpdateSchema = z.object({
|
||||
role: z.enum(["ADMIN", "USER"]),
|
||||
});
|
||||
|
||||
const authEnabledToggleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
const findUserByIdentifier = async (identifier: string) => {
|
||||
const trimmed = identifier.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
@@ -150,6 +170,7 @@ const getRefreshTokenExpiresAt = (): Date =>
|
||||
*/
|
||||
router.post("/register", authRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -380,6 +401,7 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
|
||||
*/
|
||||
router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -507,6 +529,7 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
|
||||
*/
|
||||
router.post("/refresh", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
const { refreshToken: oldRefreshToken } = req.body;
|
||||
|
||||
if (!oldRefreshToken || typeof oldRefreshToken !== "string") {
|
||||
@@ -639,6 +662,7 @@ router.post("/refresh", async (req: Request, res: Response) => {
|
||||
*/
|
||||
router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
@@ -684,16 +708,31 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
||||
router.get("/status", optionalAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const systemConfig = await ensureSystemConfig();
|
||||
if (!systemConfig.authEnabled) {
|
||||
return res.json({
|
||||
enabled: false,
|
||||
authenticated: false,
|
||||
authEnabled: false,
|
||||
registrationEnabled: false,
|
||||
bootstrapRequired: false,
|
||||
user: null,
|
||||
});
|
||||
}
|
||||
|
||||
const bootstrapUser = await prisma.user.findUnique({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
const activeUsers = await prisma.user.count({ where: { isActive: true } });
|
||||
const bootstrapRequired =
|
||||
Boolean(bootstrapUser && bootstrapUser.isActive === false) && activeUsers === 0;
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
authEnabled: true,
|
||||
authenticated: Boolean(req.user),
|
||||
registrationEnabled: systemConfig.registrationEnabled,
|
||||
bootstrapRequired: Boolean(bootstrapUser && bootstrapUser.isActive === false),
|
||||
bootstrapRequired,
|
||||
user: req.user
|
||||
? {
|
||||
id: req.user.id,
|
||||
@@ -714,12 +753,97 @@ router.get("/status", optionalAuth, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/auth-enabled
|
||||
* Enable/disable authentication mode.
|
||||
*
|
||||
* - Enabling auth is allowed without login (single-user mode).
|
||||
* - Disabling auth requires an authenticated ADMIN.
|
||||
*/
|
||||
router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = authEnabledToggleSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Bad request", message: "Invalid toggle payload" });
|
||||
}
|
||||
|
||||
const systemConfig = await ensureSystemConfig();
|
||||
const current = systemConfig.authEnabled;
|
||||
const next = parsed.data.enabled;
|
||||
|
||||
if (current && !next) {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the bootstrap user exists for the bootstrap registration flow.
|
||||
if (!current && next) {
|
||||
const bootstrap = await prisma.user.findUnique({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!bootstrap) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.systemConfig.upsert({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
update: { authEnabled: next },
|
||||
create: {
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: next,
|
||||
registrationEnabled: systemConfig.registrationEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
const bootstrapUser = await prisma.user.findUnique({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
const activeUsers = await prisma.user.count({ where: { isActive: true } });
|
||||
const bootstrapRequired =
|
||||
Boolean(updated.authEnabled && bootstrapUser && bootstrapUser.isActive === false) &&
|
||||
activeUsers === 0;
|
||||
|
||||
res.json({ authEnabled: updated.authEnabled, bootstrapRequired });
|
||||
} catch (error) {
|
||||
console.error("Auth enabled toggle error:", error);
|
||||
res.status(500).json({
|
||||
error: "Internal server error",
|
||||
message: "Failed to update authentication mode",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/registration/toggle
|
||||
* Enable/disable registration (admin-only)
|
||||
*/
|
||||
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||
}
|
||||
@@ -754,6 +878,7 @@ router.post("/registration/toggle", requireAuth, async (req: Request, res: Respo
|
||||
*/
|
||||
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||
}
|
||||
@@ -805,6 +930,7 @@ const passwordResetRequestSchema = z.object({
|
||||
});
|
||||
|
||||
router.post("/password-reset-request", authRateLimiter, async (req: Request, res: Response) => {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
// Check if password reset feature is enabled
|
||||
if (!config.enablePasswordReset) {
|
||||
return res.status(404).json({
|
||||
@@ -901,6 +1027,7 @@ const passwordResetConfirmSchema = z.object({
|
||||
});
|
||||
|
||||
router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res: Response) => {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
// Check if password reset feature is enabled
|
||||
if (!config.enablePasswordReset) {
|
||||
return res.status(404).json({
|
||||
@@ -1010,6 +1137,7 @@ const updateProfileSchema = z.object({
|
||||
|
||||
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
@@ -1074,6 +1202,7 @@ const changePasswordSchema = z.object({
|
||||
|
||||
router.post("/change-password", requireAuth, authRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
|
||||
@@ -7,6 +7,76 @@ import { config } from "../config";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
||||
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
||||
|
||||
type AuthEnabledCache = {
|
||||
value: boolean;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
let authEnabledCache: AuthEnabledCache | null = null;
|
||||
const AUTH_ENABLED_TTL_MS = 0;
|
||||
|
||||
const getAuthEnabled = async (): Promise<boolean> => {
|
||||
const now = Date.now();
|
||||
if (authEnabledCache && now - authEnabledCache.fetchedAt < AUTH_ENABLED_TTL_MS) {
|
||||
return authEnabledCache.value;
|
||||
}
|
||||
|
||||
const systemConfig = await prisma.systemConfig.upsert({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: false,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
select: { authEnabled: true },
|
||||
});
|
||||
|
||||
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
|
||||
return systemConfig.authEnabled;
|
||||
};
|
||||
|
||||
const getBootstrapActingUser = async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
mustResetPassword: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) return user;
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
mustResetPassword: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Extend Express Request type to include user
|
||||
declare global {
|
||||
@@ -87,6 +157,30 @@ export const requireAuth = async (
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
// Single-user mode: authentication disabled -> treat all requests as the bootstrap user.
|
||||
try {
|
||||
const authEnabled = await getAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
const user = await 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) {
|
||||
@@ -159,6 +253,16 @@ export const optionalAuth = async (
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const authEnabled = await getAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return next();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading auth mode:", error);
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = extractToken(req);
|
||||
|
||||
if (!token) {
|
||||
|
||||
Reference in New Issue
Block a user