mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: {} })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
}));
|
||||
|
||||
const browserMock = vi.hoisted(() => ({
|
||||
startAuthentication: vi.fn(async () => ({ id: "cred-1" })),
|
||||
startRegistration: vi.fn(async () => ({ id: "cred-1" })),
|
||||
browserSupportsWebAuthn: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
|
||||
vi.mock("@simplewebauthn/browser", () => browserMock);
|
||||
|
||||
import { isPasskeySupported, loginWithPasskey } from "../../api/webauthn-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
authApiMock.delete.mockClear();
|
||||
browserMock.startAuthentication.mockClear();
|
||||
browserMock.browserSupportsWebAuthn.mockClear();
|
||||
});
|
||||
|
||||
describe("isPasskeySupported", () => {
|
||||
it("reports what the browser helper returns", () => {
|
||||
browserMock.browserSupportsWebAuthn.mockReturnValueOnce(false);
|
||||
expect(isPasskeySupported()).toBe(false);
|
||||
browserMock.browserSupportsWebAuthn.mockReturnValueOnce(true);
|
||||
expect(isPasskeySupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginWithPasskey", () => {
|
||||
it("passes the challenge from options through to verify", async () => {
|
||||
authApiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-1" },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true, username: "luke" } });
|
||||
|
||||
const result = await loginWithPasskey("luke", true);
|
||||
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/users/webauthn/authenticate/options",
|
||||
{ username: "luke" },
|
||||
);
|
||||
expect(browserMock.startAuthentication).toHaveBeenCalledWith({
|
||||
optionsJSON: { challenge: "abc" },
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/users/webauthn/authenticate/verify",
|
||||
{
|
||||
challengeId: "chal-1",
|
||||
response: { id: "cred-1" },
|
||||
rememberMe: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ success: true, username: "luke" });
|
||||
});
|
||||
|
||||
it("omits the username so discoverable passkeys work", async () => {
|
||||
authApiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-2" },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
await loginWithPasskey();
|
||||
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/users/webauthn/authenticate/options",
|
||||
{},
|
||||
);
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/users/webauthn/authenticate/verify",
|
||||
expect.objectContaining({ rememberMe: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the totp challenge instead of a session", async () => {
|
||||
authApiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-3" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { success: true, requires_totp: true, temp_token: "tmp" },
|
||||
});
|
||||
|
||||
const result = await loginWithPasskey("luke");
|
||||
|
||||
expect(result.requires_totp).toBe(true);
|
||||
expect(result.temp_token).toBe("tmp");
|
||||
});
|
||||
|
||||
it("surfaces a cancelled prompt to the caller", async () => {
|
||||
authApiMock.post.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-4" },
|
||||
});
|
||||
const cancelled = Object.assign(new Error("cancelled"), {
|
||||
name: "NotAllowedError",
|
||||
});
|
||||
browserMock.startAuthentication.mockRejectedValueOnce(cancelled);
|
||||
|
||||
await expect(loginWithPasskey("luke")).rejects.toMatchObject({
|
||||
name: "NotAllowedError",
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ const mainAxios = vi.hoisted(() => ({
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
getOIDCAuthorizeUrl: vi.fn(),
|
||||
verifyTOTPLogin: vi.fn(),
|
||||
isElectron: vi.fn(),
|
||||
getCurrentToken: vi.fn(),
|
||||
getOidcSilentLoginDefault: vi.fn(),
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock("@/main-axios", () => ({
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
getOIDCAuthorizeUrl: vi.fn(),
|
||||
verifyTOTPLogin: vi.fn(),
|
||||
getCurrentToken: vi.fn().mockReturnValue(null),
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
@@ -42,11 +41,6 @@ vi.mock("@/api/sso-provider-api", () => ({
|
||||
ldapLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/webauthn-api", () => ({
|
||||
isPasskeySupported: () => false,
|
||||
loginWithPasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { Auth } from "@/auth/Auth";
|
||||
|
||||
function setEmbeddedServerStatus(failure: unknown) {
|
||||
|
||||
@@ -246,23 +246,69 @@ describe("login methods on the login screen", () => {
|
||||
});
|
||||
|
||||
it("picks up the second factor step after a redirect login", async () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/?second_factor=1&second_factors=totp",
|
||||
);
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
const input = await screen.findByPlaceholderText("000000");
|
||||
// Backup codes are letters and digits, and are accepted now.
|
||||
fireEvent.change(input, { target: { value: "abcd-2345" } });
|
||||
expect((input as HTMLInputElement).value).toBe("ABCD2345");
|
||||
const dispose = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: ({ verify }: SecondFactorUIProps) => (
|
||||
<button onClick={() => void verify({ pin: "1234" })}>send pin</button>
|
||||
),
|
||||
});
|
||||
window.history.replaceState({}, "", "/?second_factor=1&second_factors=pin");
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({ success: true });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("totp", {
|
||||
totp_code: "ABCD2345",
|
||||
rememberMe: false,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByText("send pin"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("pin", {
|
||||
pin: "1234",
|
||||
rememberMe: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers every registered factor when the server lists none", async () => {
|
||||
const dispose = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: () => <span>pin challenge</span>,
|
||||
});
|
||||
window.history.replaceState({}, "", "/?second_factor=1");
|
||||
try {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
expect(await screen.findByText("pin challenge")).toBeTruthy();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("draws an inline method under the password form, not in the external list", async () => {
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "quick-key",
|
||||
pluginId: "keys",
|
||||
kind: "form",
|
||||
labelKey: "keys.title",
|
||||
instances: [],
|
||||
},
|
||||
]);
|
||||
const dispose = registerLoginMethod({
|
||||
id: "quick-key",
|
||||
pluginId: "keys",
|
||||
titleKey: "keys.title",
|
||||
placement: "inline",
|
||||
component: () => <button>quick key</button>,
|
||||
});
|
||||
try {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
expect(await screen.findByText("quick key")).toBeTruthy();
|
||||
expect(screen.queryByText("auth.external")).toBeNull();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,7 @@ describe("ElectronLoginForm", () => {
|
||||
|
||||
it.each([
|
||||
"auth_component",
|
||||
"totp_auth_component",
|
||||
"passkey_auth_component",
|
||||
"second_factor_auth_component",
|
||||
"method_auth_component",
|
||||
])("accepts a login hand-off from %s", async (source) => {
|
||||
const onAuthSuccess = vi.fn();
|
||||
|
||||
@@ -22,9 +22,9 @@ describe("AdminSecondFactorsSection", () => {
|
||||
it("shows a factor whose plugin is off and resets after confirming", async () => {
|
||||
api.getUserSecondFactors.mockResolvedValue([
|
||||
{
|
||||
pluginId: "core",
|
||||
pluginId: "totp",
|
||||
factorId: "totp",
|
||||
labelKey: "auth.totpFactor",
|
||||
labelKey: "factor",
|
||||
available: true,
|
||||
},
|
||||
{ pluginId: "yubi", factorId: "otp", labelKey: null, available: false },
|
||||
@@ -46,6 +46,8 @@ describe("AdminSecondFactorsSection", () => {
|
||||
expect(
|
||||
await screen.findByText("admin.secondFactorUnavailable:yubi"),
|
||||
).toBeTruthy();
|
||||
// A plugin's label resolves in its own namespace.
|
||||
expect(screen.getByText("totp:factor")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("admin.resetSecondFactors"));
|
||||
expect(requestConfirm).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -15,7 +15,6 @@ const api = vi.hoisted(() => ({
|
||||
adminUpdateUserSnippet: vi.fn(async () => ({})),
|
||||
adminDeleteUserSnippet: vi.fn(async () => ({})),
|
||||
adminResetUserPassword: vi.fn(async () => ({ dataWiped: false })),
|
||||
adminDisableUserTotp: vi.fn(async () => ({})),
|
||||
adminExportUserData: vi.fn(async () => ({})),
|
||||
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
|
||||
revokeSession: vi.fn(async () => ({})),
|
||||
@@ -56,7 +55,6 @@ function makeUser(overrides: Partial<AdminUser> = {}): AdminUser {
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
dataUnlocked: true,
|
||||
totpEnabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -72,7 +70,7 @@ function renderPanel(
|
||||
onBack={callbacks.onBack ?? vi.fn()}
|
||||
onOpenHostTab={callbacks.onOpenHostTab as never}
|
||||
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
|
||||
onTotpDisabled={callbacks.onTotpDisabled ?? vi.fn()}
|
||||
onSecondFactorsReset={callbacks.onSecondFactorsReset ?? vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -144,21 +142,6 @@ describe("AdminUserManagePanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables TOTP through the confirm dialog", async () => {
|
||||
const onTotpDisabled = vi.fn();
|
||||
renderPanel(makeUser({ totpEnabled: true }), { onTotpDisabled });
|
||||
|
||||
expect(screen.getByText("admin.totpStatusEnabled")).toBeTruthy();
|
||||
await userEvent.click(screen.getByText("admin.disableTotp"));
|
||||
await userEvent.click(screen.getByText("common.confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.adminDisableUserTotp).toHaveBeenCalledWith("u2");
|
||||
expect(onTotpDisabled).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("admin.totpStatusDisabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a snippet for the target user", async () => {
|
||||
renderPanel(makeUser());
|
||||
|
||||
|
||||
Reference in new issue
Block a user