Security: force first-login password change, gate admin tools, validate restore

Fixes from the code review:
- #1 Wire mustChangePassword: dashboard layout redirects to a new /change-password
  page + /api/account/change-password (verifies current pw, re-hashes, clears the
  flag, refreshes the session). Provisioned admins can no longer keep the default.
- #2 Assert NEXTAUTH_SECRET in production (not during build); set it on authOptions.
- #3 Backup page now server-gated to admins (split into server page + client panel),
  matching the Updates page.
- #4 Restore validates input: strict zod schema for the users table (known fields,
  role enum) + array checks for the rest; returns generic errors instead of String(err).

No version bump/changelog — left for you to fold into the next release (you're
editing those live). Not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:59:39 -05:00
parent c5bf108cb9
commit 0904790dca
8 changed files with 413 additions and 148 deletions

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { compare, hash } from "bcryptjs";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const schema = z.object({
currentPassword: z.string().min(1, "Enter your current password."),
newPassword: z.string().min(8, "New password must be at least 8 characters."),
});
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input." },
{ status: 400 }
);
}
const { currentPassword, newPassword } = parsed.data;
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!(await compare(currentPassword, user.passwordHash))) {
return NextResponse.json({ error: "Current password is incorrect." }, { status: 400 });
}
if (await compare(newPassword, user.passwordHash)) {
return NextResponse.json(
{ error: "New password must be different from your current one." },
{ status: 400 }
);
}
await db.user.update({
where: { id: session.user.id },
data: { passwordHash: await hash(newPassword, 10), mustChangePassword: false },
});
await db.auditEvent.create({
data: { action: "user.change_password", actorId: session.user.id, entityId: session.user.id },
});
return NextResponse.json({ ok: true });
}

View File

@@ -1,43 +1,86 @@
import { NextResponse } from "next/server";
import { unzipSync, strFromU8 } from "fflate";
import { z } from "zod";
import { UserRole } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
export async function POST(req: Request) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
export const dynamic = "force-dynamic";
// A malformed/crafted backup must not be able to inject arbitrary auth rows.
// users is the security boundary, so it's validated strictly (known fields only,
// role constrained to the enum). Other tables are app-owned data — we only check
// they're arrays; Prisma rejects any unknown columns on insert.
class BadBackup extends Error {}
const userSchema = z
.object({
id: z.string(),
email: z.string(),
username: z.string().nullable().optional(),
name: z.string(),
passwordHash: z.string(),
role: z.nativeEnum(UserRole).optional(),
active: z.boolean().optional(),
mustChangePassword: z.boolean().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
})
.strict();
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
const buf = await file.arrayBuffer();
const files = unzipSync(new Uint8Array(buf));
let files: Record<string, Uint8Array>;
try {
files = unzipSync(new Uint8Array(await file.arrayBuffer()));
} catch {
throw new BadBackup("not a valid zip");
}
const parse = (name: string) => {
if (!files[name]) throw new Error(`Missing ${name} in zip`);
return JSON.parse(strFromU8(files[name]));
const parse = (name: string): unknown => {
if (!files[name]) throw new BadBackup(`missing ${name}`);
try {
return JSON.parse(strFromU8(files[name]));
} catch {
throw new BadBackup(`invalid JSON in ${name}`);
}
};
const asArray = (name: string): unknown[] => {
const v = parse(name);
if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`);
return v;
};
const manifest = parse("manifest.json");
if (manifest.version !== 1) {
const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
if (manifest?.version !== 1) {
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
}
// Older backups won't have newer tables — tolerate their absence.
const locations = files["locations.json"] ? parse("locations.json") : [];
const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : [];
const users = parse("users.json");
const apiTokens = files["apiTokens.json"] ? parse("apiTokens.json") : [];
const plantGroups = parse("plantGroups.json");
const plants = parse("plants.json");
const plantLogs = parse("plantLogs.json");
const tasks = parse("tasks.json");
const taskCompletions = parse("taskCompletions.json");
const auditEvents = parse("auditEvents.json");
const locations = files["locations.json"] ? asArray("locations.json") : [];
const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
const users = z.array(userSchema).parse(asArray("users.json"));
const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
const plantGroups = asArray("plantGroups.json");
const plants = asArray("plants.json");
const plantLogs = asArray("plantLogs.json");
const tasks = asArray("tasks.json");
const taskCompletions = asArray("taskCompletions.json");
const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order.
await db.$transaction(async (tx) => {
@@ -53,17 +96,17 @@ export async function POST(req: Request) {
await tx.user.deleteMany();
if (users.length) await tx.user.createMany({ data: users });
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens });
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never });
// Location self-references parentId; a single createMany is one statement,
// so the FK is validated only after every row exists — order-independent.
if (locations.length) await tx.location.createMany({ data: locations });
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes });
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
if (plants.length) await tx.plant.createMany({ data: plants });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
if (tasks.length) await tx.task.createMany({ data: tasks });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents });
if (locations.length) await tx.location.createMany({ data: locations as never });
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
if (plants.length) await tx.plant.createMany({ data: plants as never });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
if (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
});
await db.auditEvent.create({
@@ -76,10 +119,13 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (err instanceof BadBackup || err instanceof z.ZodError) {
return NextResponse.json(
{ error: "The backup file is invalid or corrupted." },
{ status: 400 }
);
}
console.error(err);
return NextResponse.json({ error: String(err) }, { status: 500 });
console.error("restore failed:", err);
return NextResponse.json({ error: "Restore failed." }, { status: 500 });
}
}