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 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 - **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. Exports are downloaded as JSON files that can be used for reference or future import functionality.
## Propagating mounts to host ## Propagating mounts to host

View file

@ -12,6 +12,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/client/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { import {
Select, Select,
@ -21,6 +22,15 @@ import {
SelectValue, SelectValue,
} from "~/client/components/ui/select"; } 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 SecretsMode = "exclude" | "encrypted" | "cleartext";
export type ExportOptions = { export type ExportOptions = {
@ -158,6 +168,9 @@ export function ExportDialog({
const [includePasswordHash, setIncludePasswordHash] = useState(false); const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude"); const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [showPasswordStep, setShowPasswordStep] = useState(false);
const [password, setPassword] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const config = exportConfigs[entityType]; const config = exportConfigs[entityType];
const isSingleItem = !!(name || id); 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 // TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
const hasSecrets = entityType !== "backups" && entityType !== "volumes"; const hasSecrets = entityType !== "backups" && entityType !== "volumes";
const entityLabel = isSingleItem ? config.label : config.labelPlural; const entityLabel = isSingleItem ? config.label : config.labelPlural;
const requiresPassword = includeRecoveryKey || secretsMode === "cleartext";
const handleExport = async () => { const performExport = async () => {
setIsExporting(true); setIsExporting(true);
try { try {
await exportConfig(entityType, { await exportConfig(entityType, {
@ -181,6 +195,8 @@ export function ExportDialog({
}); });
toast.success(`${entityLabel} exported successfully`); toast.success(`${entityLabel} exported successfully`);
setOpen(false); setOpen(false);
setShowPasswordStep(false);
setPassword("");
} catch (err) { } catch (err) {
toast.error("Export failed", { toast.error("Export failed", {
description: err instanceof Error ? err.message : String(err), 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 = const defaultTrigger =
variant === "card" ? ( variant === "card" ? (
<div className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full"> <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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger> <DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> {showPasswordStep ? (
<DialogTitle>Export {entityLabel}</DialogTitle> <form onSubmit={handlePasswordSubmit}>
<DialogDescription> <DialogHeader>
{isSingleItem <DialogTitle>Confirm Export</DialogTitle>
? `Export the configuration for this ${config.label.toLowerCase()}.` <DialogDescription>
: `Export all ${config.labelPlural.toLowerCase()} configurations.`} For security reasons, please enter your password to export
</DialogDescription> {includeRecoveryKey && secretsMode === "cleartext"
</DialogHeader> ? " 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"> <div className="flex items-center space-x-3">
<Checkbox <Checkbox
id="includeIds" id="includeIds"
@ -321,15 +422,17 @@ export function ExportDialog({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleExport} loading={isExporting}> <Button onClick={handleExport} loading={isExporting}>
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
Export Export
</Button> </Button>
</DialogFooter> </DialogFooter>
</>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -12,12 +12,15 @@ import {
logoutDto, logoutDto,
registerBodySchema, registerBodySchema,
registerDto, registerDto,
verifyPasswordBodySchema,
verifyPasswordDto,
type ChangePasswordDto, type ChangePasswordDto,
type GetMeDto, type GetMeDto,
type GetStatusDto, type GetStatusDto,
type LoginDto, type LoginDto,
type LogoutDto, type LogoutDto,
type RegisterDto, type RegisterDto,
type VerifyPasswordDto,
} from "./auth.dto"; } from "./auth.dto";
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
@ -138,4 +141,27 @@ export const authController = new Hono()
} catch (error) { } catch (error) {
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400); 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 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 LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer;
export type ChangePasswordBody = typeof changePasswordBodySchema.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}`); 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(); export const authService = new AuthService();