fix(system): block recovery key download without credential password
This commit is contained in:
parent
4b66ad73a7
commit
4ddbc155a5
7 changed files with 166 additions and 32 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type User = {
|
|||
dateFormat: string;
|
||||
timeFormat: string;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
hasCredentialPassword?: boolean;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue