reauthentication on secrets export
This commit is contained in:
parent
8e7e1694d5
commit
d72975e9e2
5 changed files with 192 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue