fix(system): block recovery key download without credential password (#899)

This commit is contained in:
Nico 2026-05-19 20:28:09 +02:00 committed by GitHub
parent 4b66ad73a7
commit c071596151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 166 additions and 32 deletions

View file

@ -8,11 +8,20 @@ import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
export function DownloadRecoveryKeyPage() {
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = {
hasCredentialPassword: boolean;
};
export function DownloadRecoveryKeyPage({ hasCredentialPassword }: Props) {
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
@ -28,10 +37,13 @@ export function DownloadRecoveryKeyPage() {
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Recovery key downloaded successfully!");
setBlockedMessage(null);
void navigate({ to: "/volumes", replace: true });
},
onError: (error) => {
toast.error("Failed to download recovery key", { description: error.message });
const message = parseError(error)?.message;
setBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download recovery key", { description: message });
},
});
@ -43,6 +55,7 @@ export function DownloadRecoveryKeyPage() {
return;
}
setBlockedMessage(null);
downloadResticPassword.mutate({
body: {
password,
@ -66,27 +79,41 @@ export function DownloadRecoveryKeyPage() {
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={downloadResticPassword.isPending}
/>
<p className="text-xs text-muted-foreground">
Enter your account password to download the recovery key
</p>
</div>
{(!hasCredentialPassword || blockedMessage) && (
<Alert variant="warning">
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
)}
{hasCredentialPassword && (
<div className="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={downloadResticPassword.isPending}
/>
<p className="text-xs text-muted-foreground">
Enter your account password to download the recovery key
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
</Button>
{hasCredentialPassword && (
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
</Button>
)}
</div>
</form>
</AuthLayout>

View file

@ -1,11 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import { Download, Fingerprint, KeyRound, User, X, Settings as SettingsIcon, Building2 } from "lucide-react";
import {
AlertTriangle,
Download,
Fingerprint,
KeyRound,
User,
X,
Settings as SettingsIcon,
Building2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import {
Dialog,
DialogContent,
@ -29,12 +39,17 @@ import {
useTimeFormat,
} from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils";
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = {
appContext: AppContext;
@ -49,6 +64,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const [confirmPassword, setConfirmPassword] = useState("");
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { dateFormat, timeFormat } = useRootLoaderData();
@ -59,6 +75,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const { activeMember, activeOrganization } = useOrganizationContext();
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
const { formatDateTime } = useTimeFormat();
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
const handleLogout = async () => {
await authClient.signOut({
@ -90,10 +107,13 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
setDownloadBlockedMessage(null);
},
onError: (error) => {
const message = parseError(error)?.message;
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download Restic password", {
description: error.message,
description: message,
});
},
});
@ -145,6 +165,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
return;
}
setDownloadBlockedMessage(null);
downloadResticPassword.mutate({
body: {
password: downloadPassword,
@ -291,7 +312,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</div>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<div
className={cn("border-t border-border/50 bg-card-header p-6", {
hidden: !hasCredentialPassword,
})}
>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
Change Password
@ -300,7 +325,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
Update your password to keep your account secure
</CardDescription>
</div>
<CardContent className="p-6">
<CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
@ -375,12 +400,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription>
For security reasons, please enter your account password to download
the recovery key file.
{!hasCredentialPassword
? "A local credential password is required before this recovery key can be downloaded."
: "For security reasons, please enter your account password to download the recovery key file."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Alert
variant="warning"
className={cn({
hidden: hasCredentialPassword && !downloadBlockedMessage,
})}
>
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{downloadBlockedMessage ??
RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
<div className={cn("space-y-2", { hidden: !hasCredentialPassword })}>
<Label htmlFor="download-password">Your Password</Label>
<Input
id="download-password"
@ -404,7 +443,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" loading={downloadResticPassword.isPending}>
<Button
type="submit"
loading={downloadResticPassword.isPending}
className={cn({ hidden: !hasCredentialPassword })}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>

View file

@ -7,6 +7,7 @@ type User = {
dateFormat: string;
timeFormat: string;
twoFactorEnabled?: boolean | null;
hasCredentialPassword?: boolean;
role?: string | null | undefined;
};

View file

@ -1,9 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key";
import { auth } from "~/server/lib/auth";
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
const getRecoveryKeyUserState = createServerFn({ method: "GET" }).handler(async () => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
return {
hasCredentialPassword: session?.user ? await userHasCredentialPassword(session.user.id) : false,
};
});
export const Route = createFileRoute("/(auth)/download-recovery-key")({
component: DownloadRecoveryKeyPage,
component: RouteComponent,
errorComponent: () => <div>Failed to load recovery key</div>,
loader: async () => getRecoveryKeyUserState(),
head: () => ({
meta: [
{ title: "Zerobyte - Download Recovery Key" },
@ -14,3 +28,9 @@ export const Route = createFileRoute("/(auth)/download-recovery-key")({
],
}),
});
function RouteComponent() {
const { hasCredentialPassword } = Route.useLoaderData();
return <DownloadRecoveryKeyPage hasCredentialPassword={hasCredentialPassword} />;
}

View file

@ -7,17 +7,23 @@ import { authMiddleware } from "~/middleware/auth";
import { auth } from "~/server/lib/auth";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { getServerConstants } from "~/server/lib/functions/server-constants";
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
import { authService } from "~/server/modules/auth/auth.service";
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
const hasUsers = await authService.hasUsers();
const hasCredentialPassword = session?.user ? await userHasCredentialPassword(session.user.id) : false;
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
return { user: session?.user ?? null, hasUsers, sidebarOpen };
return {
user: session?.user ? { ...session.user, hasCredentialPassword } : null,
hasUsers,
sidebarOpen,
};
});
export const Route = createFileRoute("/(dashboard)")({

View file

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { db } from "~/server/db/db";
import { account, usersTable } from "~/server/db/schema";
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
import { verifyUserPassword } from "../helpers";
import { userHasCredentialPassword, verifyUserPassword } from "../helpers";
const { verifyPassword } = vi.hoisted(() => ({
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
@ -61,3 +61,31 @@ describe("verifyUserPassword", () => {
expect(verifyPassword).not.toHaveBeenCalled();
});
});
describe("userHasCredentialPassword", () => {
beforeEach(async () => {
await db.delete(account);
await db.delete(usersTable);
});
test("returns true when the user has a credential account with a password", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "credential", password: "credential-password-hash" });
await expect(userHasCredentialPassword(userId)).resolves.toBe(true);
});
test("returns false when the user only has SSO accounts", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "oidc-acme", password: null });
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
});
test("returns false when the credential account has no password", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "credential", password: null });
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
});
});

View file

@ -22,3 +22,12 @@ export const verifyUserPassword = async ({ password, userId }: PasswordVerificat
return true;
};
export const userHasCredentialPassword = async (userId: string) => {
const userAccount = await db.query.account.findFirst({
where: { AND: [{ userId }, { providerId: "credential" }] },
columns: { password: true },
});
return Boolean(userAccount?.password);
};