feat(ssh): support JumpServer keyboard-interactive authentication

This commit is contained in:
Rson9 authored and GitHub committed 2026-08-04 14:23:44 +08:00
1 parent 8228c2af17
commit 947cc16d92
28 files changed
+873 -72

No files matched your search

@@ -17,6 +17,7 @@ interface SshPromptRequest {
key_type?: string | null;
fingerprint?: string | null;
prompt?: string | null;
echo?: boolean;
}
interface SshHostKeyNotice {
@@ -35,6 +36,7 @@ const { toast } = useToast();
// Using a queue keyed by request id means every prompt is shown and answered.
const queue = ref<SshPromptRequest[]>([]);
const current = computed<SshPromptRequest | null>(() => queue.value[0] ?? null);
const isSecretPrompt = computed(() => current.value?.kind === "SecretInput");
const visible = ref(false);
const remember = ref(true);
@@ -235,17 +237,19 @@ function submitSecret() {
</script>
<template>
<Dialog v-model:open="visible" :dismissible="false">
<DialogContent class="sm:max-w-[460px]">
<Dialog v-model:open="visible">
<DialogContent class="sm:max-w-[460px]" :show-close-button="false" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ t("connection.sshHostKeyVerifyTitle") }}</DialogTitle>
<DialogTitle>
{{ t(isSecretPrompt ? "connection.sshInteractiveTitle" : "connection.sshHostKeyVerifyTitle") }}
</DialogTitle>
<DialogDescription class="text-muted-foreground">
{{ t("connection.sshHostKeyVerifyMessage", { host: current?.host ?? "", port: current?.port ?? "" }) }}
{{ t(isSecretPrompt ? "connection.sshInteractiveMessage" : "connection.sshHostKeyVerifyMessage", { host: current?.host ?? "", port: current?.port ?? "" }) }}
</DialogDescription>
</DialogHeader>
<div v-if="current" class="space-y-3 py-1">
<div class="rounded-md border border-border bg-muted/40 p-3 text-sm">
<div v-if="current.kind === 'HostKeyVerify'" class="rounded-md border border-border bg-muted/40 p-3 text-sm">
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground">{{ t("connection.sshHostKeyVerifyKeyType") }}</span>
<span class="font-medium">{{ current.key_type || "—" }}</span>
@@ -262,18 +266,29 @@ function submitSecret() {
</label>
<div v-else-if="current.kind === 'SecretInput'" class="space-y-2">
<p class="text-sm text-muted-foreground">{{ current.prompt || "Enter verification code" }}</p>
<input v-model="secretCode" type="text" autocomplete="off" class="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-ring" :placeholder="'••••••'" />
<p class="whitespace-pre-wrap text-sm text-muted-foreground">
{{ current.prompt || t("connection.sshInteractiveDefaultPrompt") }}
</p>
<input
v-model="secretCode"
:type="current.echo ? 'text' : 'password'"
autocomplete="one-time-code"
class="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-ring"
:placeholder="t('connection.sshInteractivePlaceholder')"
@keydown.enter="submitSecret"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="resolving" @click="reject">{{ t("connection.sshHostKeyVerifyReject") }}</Button>
<Button variant="outline" :disabled="resolving" @click="reject">
{{ t(isSecretPrompt ? "connection.sshInteractiveCancel" : "connection.sshHostKeyVerifyReject") }}
</Button>
<Button v-if="current?.kind !== 'SecretInput'" :disabled="resolving" @click="accept">
{{ t("connection.sshHostKeyVerifyAccept") }}
</Button>
<Button v-else :disabled="resolving || !secretCode" @click="submitSecret">
{{ t("connection.sshHostKeyVerifyAccept") }}
{{ t("connection.sshInteractiveSubmit") }}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
@@ -49,6 +51,8 @@ vi.mock("@/components/ui/button", async () => {
import SshHostKeyPromptDialog from "@/components/ssh/SshHostKeyPromptDialog.vue";
const dialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue"), "utf8");
class MockEventSource {
static instances: MockEventSource[] = [];
@@ -113,6 +117,13 @@ async function mountDialog() {
}
describe("SshHostKeyPromptDialog web bridge", () => {
it("requires an explicit answer for blocking SSH prompts", () => {
expect(dialogSource).toContain(':show-close-button="false"');
expect(dialogSource).toContain("@interact-outside.prevent");
expect(dialogSource).toContain("@escape-key-down.prevent");
expect(dialogSource).not.toContain(':dismissible="false"');
});
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
await mountDialog();
@@ -217,4 +228,44 @@ describe("SshHostKeyPromptDialog web bridge", () => {
eventSource?.error();
expect(setSpy).toHaveBeenCalledTimes(2);
});
it("submits a keyboard-interactive TOTP challenge as a secret response", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "totp-1",
kind: "SecretInput",
host: "jump.example.test",
port: 2222,
prompt: "JumpServer\n\nOTP Code:",
echo: false,
},
});
await nextTick();
expect(document.body.textContent).toContain("SSH Verification Required");
expect(document.body.textContent).toContain("OTP Code:");
const input = document.body.querySelector<HTMLInputElement>("input");
expect(input?.type).toBe("password");
if (!input) throw new Error("TOTP input was not rendered");
input.value = "123456";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
buttons.item(buttons.length - 1).click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "totp-1",
action: "secret",
remember: undefined,
secret: "123456",
});
});
});
});