diff --git a/README.md b/README.md index 4d61740a..93f4c129 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/client/components/export-dialog.tsx b/app/client/components/export-dialog.tsx index f36314cc..db2e7c0f 100644 --- a/app/client/components/export-dialog.tsx +++ b/app/client/components/export-dialog.tsx @@ -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 { + 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("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" ? (
@@ -206,19 +261,65 @@ export function ExportDialog({ ); return ( - + {trigger ?? defaultTrigger} - - Export {entityLabel} - - {isSingleItem - ? `Export the configuration for this ${config.label.toLowerCase()}.` - : `Export all ${config.labelPlural.toLowerCase()} configurations.`} - - + {showPasswordStep ? ( +
+ + Confirm Export + + For security reasons, please enter your password to export + {includeRecoveryKey && secretsMode === "cleartext" + ? " the recovery key and decrypted secrets." + : includeRecoveryKey + ? " the recovery key." + : " decrypted secrets."} + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+
+ + + + +
+ ) : ( + <> + + Export {entityLabel} + + {isSingleItem + ? `Export the configuration for this ${config.label.toLowerCase()}.` + : `Export all ${config.labelPlural.toLowerCase()} configurations.`} + + -
+
- - - - + + + + + + )}
); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index c78d2818..adf9c966 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -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({ 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({ success: false, message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ 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({ success: false, message: "Incorrect password" }, 401); + } + + return c.json({ success: true, message: "Password verified" }); }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index f305f9c1..846c204b 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -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; diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 4a97c0df..47f91e3d 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -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 { + 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();