fix(download-password): revoke url after 60 secs
This commit is contained in:
parent
11dacd7c71
commit
419204d587
3 changed files with 91 additions and 22 deletions
|
|
@ -25,7 +25,7 @@ export function DownloadRecoveryKeyPage() {
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
||||||
|
|
||||||
toast.success("Recovery key downloaded successfully!");
|
toast.success("Recovery key downloaded successfully!");
|
||||||
void navigate({ to: "/volumes", replace: true });
|
void navigate({ to: "/volumes", replace: true });
|
||||||
|
|
@ -59,8 +59,9 @@ export function DownloadRecoveryKeyPage() {
|
||||||
<AlertTriangle className="size-5" />
|
<AlertTriangle className="size-5" />
|
||||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
Your Restic password is essential for recovering your backup data. If you lose access to this server
|
||||||
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
without this file, your backups will be unrecoverable. Store it in a password manager or encrypted
|
||||||
|
storage.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|
@ -76,7 +77,9 @@ export function DownloadRecoveryKeyPage() {
|
||||||
required
|
required
|
||||||
disabled={downloadResticPassword.isPending}
|
disabled={downloadResticPassword.isPending}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter your account password to download the recovery key
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
||||||
|
|
||||||
toast.success("Restic password file downloaded successfully");
|
toast.success("Restic password file downloaded successfully");
|
||||||
setDownloadDialogOpen(false);
|
setDownloadDialogOpen(false);
|
||||||
|
|
@ -213,11 +213,22 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={appContext.user?.username}
|
||||||
|
disabled
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={appContext.user?.email}
|
||||||
|
disabled
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
@ -237,7 +248,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Label htmlFor="date-format">Date format</Label>
|
<Label htmlFor="date-format">Date format</Label>
|
||||||
<Select
|
<Select
|
||||||
value={dateFormat}
|
value={dateFormat}
|
||||||
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
|
onValueChange={(value) =>
|
||||||
|
void handleDateFormatChange(value as DateFormatPreference)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="date-format">
|
<SelectTrigger id="date-format">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -255,7 +268,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Label htmlFor="time-format">Time format</Label>
|
<Label htmlFor="time-format">Time format</Label>
|
||||||
<Select
|
<Select
|
||||||
value={timeFormat}
|
value={timeFormat}
|
||||||
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
|
onValueChange={(value) =>
|
||||||
|
void handleTimeFormatChange(value as TimeFormatPreference)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="time-format">
|
<SelectTrigger id="time-format">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -270,7 +285,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Preview: {formatDateTime(new Date())}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
@ -279,7 +296,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<KeyRound className="size-5" />
|
<KeyRound className="size-5" />
|
||||||
Change Password
|
Change Password
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
<CardDescription className="mt-1.5">
|
||||||
|
Update your password to keep your account secure
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
|
@ -305,7 +324,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Must be at least 8 characters long
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||||
|
|
@ -331,13 +352,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Download className="size-5" />
|
<Download className="size-5" />
|
||||||
Backup Recovery Key
|
Backup Recovery Key
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
|
<CardDescription className="mt-1.5">
|
||||||
|
Download your recovery key for Restic backups
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||||
This file contains the encryption password used by Restic to secure your backups. Store it in a safe
|
This file contains the encryption password used by Restic to secure your backups.
|
||||||
place (like a password manager or encrypted storage). If you lose access to this server, you'll need
|
Store it in a safe place (like a password manager or encrypted storage). If you lose
|
||||||
this file to recover your backup data.
|
access to this server, you'll need this file to recover your backup data.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||||
|
|
@ -352,7 +375,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Download Recovery Key</DialogTitle>
|
<DialogTitle>Download Recovery Key</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
For security reasons, please enter your account password to download the recovery key file.
|
For security reasons, please enter your account password to download
|
||||||
|
the recovery key file.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
@ -402,7 +426,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Fingerprint className="size-5" />
|
<Fingerprint className="size-5" />
|
||||||
Organization Details
|
Organization Details
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">Reference details for the active organization</CardDescription>
|
<CardDescription className="mt-1.5">
|
||||||
|
Reference details for the active organization
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-2">
|
<CardContent className="p-6 space-y-2">
|
||||||
<Label htmlFor="organization-id">Organization ID</Label>
|
<Label htmlFor="organization-id">Organization ID</Label>
|
||||||
|
|
@ -421,7 +447,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Building2 className="size-5" />
|
<Building2 className="size-5" />
|
||||||
Members
|
Members
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription>
|
<CardDescription className="mt-1.5">
|
||||||
|
Manage organization members and roles
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<OrgMembersSection initialMembers={initialMembers} />
|
<OrgMembersSection initialMembers={initialMembers} />
|
||||||
|
|
@ -434,10 +462,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<SettingsIcon className="size-5" />
|
<SettingsIcon className="size-5" />
|
||||||
Single Sign-On
|
Single Sign-On
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
<CardDescription className="mt-1.5">
|
||||||
|
Configure OIDC provider settings
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
|
<SsoSettingsSection
|
||||||
|
initialSettings={initialSsoSettings}
|
||||||
|
initialOrigin={initialOrigin}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { createApp } from "~/server/app";
|
||||||
import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth";
|
import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth";
|
||||||
import { systemService } from "../system.service";
|
import { systemService } from "../system.service";
|
||||||
import * as authHelpers from "~/server/modules/auth/helpers";
|
import * as authHelpers from "~/server/modules/auth/helpers";
|
||||||
|
import { db } from "~/server/db/db";
|
||||||
|
import { organization } from "~/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { cryptoUtils } from "~/server/utils/crypto";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -123,6 +127,35 @@ describe("system security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
|
test("should return complete decrypted restic password content", async () => {
|
||||||
|
const { cryptoUtils: actualCryptoUtils } =
|
||||||
|
await vi.importActual<typeof import("~/server/utils/crypto")>("~/server/utils/crypto");
|
||||||
|
const resticPassword = "correct-restic-passwordb";
|
||||||
|
const encryptedResticPassword = await actualCryptoUtils.sealSecret(resticPassword);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(organization)
|
||||||
|
.set({ metadata: { resticPassword: encryptedResticPassword } })
|
||||||
|
.where(eq(organization.id, session.organizationId));
|
||||||
|
vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(true);
|
||||||
|
vi.spyOn(cryptoUtils, "resolveSecret").mockImplementationOnce(actualCryptoUtils.resolveSecret);
|
||||||
|
|
||||||
|
const res = await app.request("/api/v1/system/restic-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...session.headers,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: "password",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toContain("text/plain");
|
||||||
|
expect(await res.text()).toBe(resticPassword);
|
||||||
|
});
|
||||||
|
|
||||||
test("should return 400 for invalid payload on restic-password", async () => {
|
test("should return 400 for invalid payload on restic-password", async () => {
|
||||||
const res = await app.request("/api/v1/system/restic-password", {
|
const res = await app.request("/api/v1/system/restic-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -137,7 +170,7 @@ describe("system security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 for incorrect password on restic-password", async () => {
|
test("should return 401 for incorrect password on restic-password", async () => {
|
||||||
vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValue(false);
|
vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(false);
|
||||||
|
|
||||||
const res = await app.request("/api/v1/system/restic-password", {
|
const res = await app.request("/api/v1/system/restic-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue