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 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue