reauthentication on secrets export

This commit is contained in:
Jakub Trávník 2025-12-01 15:41:33 +01:00
parent 8e7e1694d5
commit d72975e9e2
5 changed files with 192 additions and 20 deletions

View file

@ -215,6 +215,8 @@ To export, click the "Export" button on any list page or detail page. A dialog w
- **Include recovery key** (full export only) - Include the master encryption key for all repositories
- **Include password hash** (full export only) - Include the hashed admin password for seamless migration
When exporting sensitive data (recovery key or decrypted secrets), you'll be prompted to re-enter your password as an additional security confirmation. This is a UI-level safeguard to prevent accidental exports of sensitive information.
Exports are downloaded as JSON files that can be used for reference or future import functionality.
## Propagating mounts to host

View file

@ -12,6 +12,7 @@ import {
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import {
Select,
@ -21,6 +22,15 @@ import {
SelectValue,
} from "~/client/components/ui/select";
async function verifyPassword(password: string): Promise<boolean> {
const response = await fetch("/api/v1/auth/verify-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
return response.ok;
}
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
export type ExportOptions = {
@ -158,6 +168,9 @@ export function ExportDialog({
const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [isExporting, setIsExporting] = useState(false);
const [showPasswordStep, setShowPasswordStep] = useState(false);
const [password, setPassword] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const config = exportConfigs[entityType];
const isSingleItem = !!(name || id);
@ -165,8 +178,9 @@ export function ExportDialog({
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
const hasSecrets = entityType !== "backups" && entityType !== "volumes";
const entityLabel = isSingleItem ? config.label : config.labelPlural;
const requiresPassword = includeRecoveryKey || secretsMode === "cleartext";
const handleExport = async () => {
const performExport = async () => {
setIsExporting(true);
try {
await exportConfig(entityType, {
@ -181,6 +195,8 @@ export function ExportDialog({
});
toast.success(`${entityLabel} exported successfully`);
setOpen(false);
setShowPasswordStep(false);
setPassword("");
} catch (err) {
toast.error("Export failed", {
description: err instanceof Error ? err.message : String(err),
@ -190,6 +206,45 @@ export function ExportDialog({
}
};
const handleExport = () => {
if (requiresPassword) {
setShowPasswordStep(true);
} else {
performExport();
}
};
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
setIsVerifying(true);
try {
const isValid = await verifyPassword(password);
if (!isValid) {
toast.error("Incorrect password");
return;
}
// Password verified, proceed with export
await performExport();
} catch {
toast.error("Incorrect password");
} finally {
setIsVerifying(false);
}
};
const handleDialogChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setShowPasswordStep(false);
setPassword("");
}
};
const defaultTrigger =
variant === "card" ? (
<div className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full">
@ -206,19 +261,65 @@ export function ExportDialog({
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogDescription>
{isSingleItem
? `Export the configuration for this ${config.label.toLowerCase()}.`
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
</DialogDescription>
</DialogHeader>
{showPasswordStep ? (
<form onSubmit={handlePasswordSubmit}>
<DialogHeader>
<DialogTitle>Confirm Export</DialogTitle>
<DialogDescription>
For security reasons, please enter your password to export
{includeRecoveryKey && secretsMode === "cleartext"
? " the recovery key and decrypted secrets."
: includeRecoveryKey
? " the recovery key."
: " decrypted secrets."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="export-password">Your Password</Label>
<Input
id="export-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setShowPasswordStep(false);
setPassword("");
}}
>
Back
</Button>
<Button type="submit" loading={isVerifying || isExporting}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</form>
) : (
<>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogDescription>
{isSingleItem
? `Export the configuration for this ${config.label.toLowerCase()}.`
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeIds"
@ -321,15 +422,17 @@ export function ExportDialog({
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleExport} loading={isExporting}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleExport} loading={isExporting}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);

View file

@ -12,12 +12,15 @@ import {
logoutDto,
registerBodySchema,
registerDto,
verifyPasswordBodySchema,
verifyPasswordDto,
type ChangePasswordDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
type VerifyPasswordDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
@ -138,4 +141,27 @@ export const authController = new Hono()
} catch (error) {
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
}
})
.post("/verify-password", verifyPasswordDto, validator("json", verifyPasswordBodySchema), async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json<VerifyPasswordDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json<VerifyPasswordDto>({ success: false, message: "Not authenticated" }, 401);
}
const body = c.req.valid("json");
const isValid = await authService.verifyPassword(session.user.id, body.password);
if (!isValid) {
return c.json<VerifyPasswordDto>({ success: false, message: "Incorrect password" }, 401);
}
return c.json<VerifyPasswordDto>({ success: true, message: "Password verified" });
});

View file

@ -148,6 +148,34 @@ export const changePasswordDto = describeRoute({
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
export const verifyPasswordBodySchema = type({
password: "string>0",
});
const verifyPasswordResponseSchema = type({
success: "boolean",
message: "string",
});
export const verifyPasswordDto = describeRoute({
description: "Verify current user password for re-authentication",
operationId: "verifyPassword",
tags: ["Auth"],
responses: {
200: {
description: "Password verification result",
content: {
"application/json": {
schema: resolver(verifyPasswordResponseSchema),
},
},
},
},
});
export type VerifyPasswordDto = typeof verifyPasswordResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
export type VerifyPasswordBody = typeof verifyPasswordBodySchema.infer;

View file

@ -174,6 +174,19 @@ export class AuthService {
logger.info(`Password changed for user: ${user.username}`);
}
/**
* Verify password for a user (for re-authentication purposes)
*/
async verifyPassword(userId: number, password: string): Promise<boolean> {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
if (!user) {
return false;
}
return Bun.password.verify(password, user.passwordHash);
}
}
export const authService = new AuthService();