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:
57
src/app/api/account/change-password/route.ts
Normal file
57
src/app/api/account/change-password/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user