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
@@ -54,7 +54,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
});
|
||||
|
||||
expect(await repo.users.countAdmins()).toBe(1);
|
||||
expect(await repo.users.countTotpEnabled()).toBe(0);
|
||||
expect((await repo.users.listAll()).map((user) => user.id)).toEqual([
|
||||
"user-1",
|
||||
]);
|
||||
@@ -66,10 +65,8 @@ describe("UserRepository and SessionRepository", () => {
|
||||
|
||||
const updated = await repo.users.update("user-1", {
|
||||
oidcIdentifier: "oidc:admin",
|
||||
totpEnabled: true,
|
||||
});
|
||||
expect(updated?.oidcIdentifier).toBe("oidc:admin");
|
||||
expect(await repo.users.countTotpEnabled()).toBe(1);
|
||||
expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ function makeUser(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
username: "local",
|
||||
passwordHash: "",
|
||||
isOidc: false,
|
||||
totpEnabled: false,
|
||||
isAdmin: false,
|
||||
registeredAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
@@ -146,11 +145,6 @@ describe("resolveDesktopAutoSessionUser", () => {
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the sole local user even when TOTP-enabled", () => {
|
||||
const user = makeUser({ totpEnabled: true });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the auto-provisioned passwordless placeholder", () => {
|
||||
const user = makeUser({ passwordHash: "" });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
|
||||
const userUpdate = vi.fn().mockResolvedValue(null);
|
||||
const findById = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById, update: userUpdate }),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
createCurrentUserSessionRepository: () => ({}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
removeSecondFactor: vi.fn().mockResolvedValue(true),
|
||||
recordSecondFactor: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
const { registerUserTotpRoutes } =
|
||||
await import("../../../database/routes/user-totp-routes.js");
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
const PASSWORD = "correct-horse";
|
||||
|
||||
/**
|
||||
* The disable dialog has one field, labelled "Enter TOTP code or password",
|
||||
* and its caller passes that single value as `disableTOTP(input)` — which
|
||||
* lands in the `password` argument, leaving `totp_code` undefined.
|
||||
*
|
||||
* 2.5.1 changed the route to require both, so from then on the first check
|
||||
* rejected every attempt regardless of what was typed. Nobody could turn 2FA
|
||||
* off, and the client reported it as the generic "Failed to disable 2FA".
|
||||
*/
|
||||
describe("POST /totp/disable", () => {
|
||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
||||
let setupHandler: (req: unknown, res: unknown) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const routes = new Map<string, (req: unknown, res: unknown) => unknown>();
|
||||
const router = {
|
||||
post: (path: string, ...rest: unknown[]) => {
|
||||
routes.set(path, rest[rest.length - 1] as never);
|
||||
},
|
||||
get: () => {},
|
||||
put: () => {},
|
||||
delete: () => {},
|
||||
};
|
||||
|
||||
registerUserTotpRoutes(
|
||||
router as never,
|
||||
{
|
||||
authenticateJWT: (() => {}) as never,
|
||||
authManager: { getUserDataKey: () => null } as never,
|
||||
isNativeAppRequest: () => false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
handler = routes.get("/totp/disable") as never;
|
||||
setupHandler = routes.get("/totp/setup") as never;
|
||||
findById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "test-user",
|
||||
isOidc: false,
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01"]),
|
||||
totpEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
function call(body: Record<string, unknown>) {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return handler({ userId: "user-1", body }, res).then(() => res);
|
||||
}
|
||||
|
||||
function callSetup(body: Record<string, unknown>) {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return setupHandler({ userId: "user-1", body }, res).then(() => res);
|
||||
}
|
||||
|
||||
it("reveals the existing enrollment only after re-authentication", async () => {
|
||||
const denied = await callSetup({ credential: "wrong" });
|
||||
expect(denied.statusCode).toBe(401);
|
||||
|
||||
const allowed = await callSetup({ credential: PASSWORD });
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
expect(allowed.body).toEqual(
|
||||
expect.objectContaining({ secret, additional: true }),
|
||||
);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a credential before adding another authenticator", async () => {
|
||||
const res = await callSetup({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the TOTP code on its own", async () => {
|
||||
// What the dialog sends: one value, in whichever field the client used.
|
||||
const res = await call({
|
||||
totp_code: speakeasy.totp({ secret, encoding: "base32" }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(userUpdate).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ totpEnabled: false, totpSecret: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the account password on its own", async () => {
|
||||
// The single field is labelled "TOTP code or password", and the client
|
||||
// passes it as the password argument — this is the exact failing call.
|
||||
const res = await call({ password: PASSWORD });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(userUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a backup code", async () => {
|
||||
const res = await call({ totp_code: "BACKUP01" });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("still refuses a wrong value", async () => {
|
||||
const res = await call({ password: "not-my-password" });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an empty request rather than disabling anything", async () => {
|
||||
const res = await call({});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let an OIDC user disable it with a password", async () => {
|
||||
// No password to compare against; only a TOTP or backup code will do.
|
||||
findById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
isOidc: true,
|
||||
passwordHash: null,
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify([]),
|
||||
totpEnabled: true,
|
||||
});
|
||||
|
||||
const res = await call({ password: "anything" });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ const state = vi.hoisted(() => ({
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash: string | null;
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
>(),
|
||||
unlockedUsers: new Set<string>(),
|
||||
@@ -94,6 +93,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.secondFactors.filter((row) => row.userId === userId),
|
||||
listUserIdsWithSecondFactors: async () =>
|
||||
new Set(state.secondFactors.map((row) => row.userId)),
|
||||
removeSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
@@ -127,6 +128,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
|
||||
const { registerUserAdminRoutes } =
|
||||
await import("../../../database/routes/user-admin-routes.js");
|
||||
const { registerSecondFactor } = await import("../../../auth/registry.js");
|
||||
|
||||
// Capture the handlers registered on the router so we can invoke them directly
|
||||
// without spinning up an HTTP server.
|
||||
@@ -225,7 +227,6 @@ beforeEach(() => {
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -236,7 +237,6 @@ beforeEach(() => {
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -247,7 +247,6 @@ beforeEach(() => {
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -257,15 +256,19 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /list", () => {
|
||||
it("includes data_unlocked and totp_enabled for admin callers", async () => {
|
||||
it("includes data_unlocked and second_factor_enabled for admin callers", async () => {
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "totp", factorId: "totp" },
|
||||
];
|
||||
const res = await invoke("get", "/list");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
|
||||
const target = users.find((u) => u.userId === "target1")!;
|
||||
expect(target.data_unlocked).toBe(true);
|
||||
expect(target.totp_enabled).toBe(true);
|
||||
expect(target.second_factor_enabled).toBe(true);
|
||||
const locked = users.find((u) => u.userId === "locked1")!;
|
||||
expect(locked.data_unlocked).toBe(false);
|
||||
expect(locked.second_factor_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("omits management fields for non-admin callers", async () => {
|
||||
@@ -273,7 +276,7 @@ describe("GET /list", () => {
|
||||
const res = await invoke("get", "/list");
|
||||
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
|
||||
expect(users[0].data_unlocked).toBeUndefined();
|
||||
expect(users[0].totp_enabled).toBeUndefined();
|
||||
expect(users[0].second_factor_enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns every user when no limit is given", async () => {
|
||||
@@ -335,46 +338,6 @@ describe("GET /list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /admin/totp/disable", () => {
|
||||
it("clears TOTP fields for the target and audits", async () => {
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "target1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const update = state.updates.find((u) => u.id === "target1");
|
||||
expect(update?.changes).toMatchObject({
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
expect(
|
||||
state.auditCalls.some((c) => c.action === "admin_disable_totp"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("403s when the caller is not an admin", async () => {
|
||||
state.currentUserId = "target1";
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "locked1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("400s when TOTP is not enabled for the target", async () => {
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "locked1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("404s for an unknown target", async () => {
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "ghost" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /admin/export/:userId", () => {
|
||||
it("423s when the target's data is locked", async () => {
|
||||
const res = await invoke("get", "/admin/export/:userId", {
|
||||
@@ -395,23 +358,31 @@ describe("GET /admin/export/:userId", () => {
|
||||
|
||||
describe("DELETE /admin/:userId/second-factors", () => {
|
||||
it("clears every factor, including one whose plugin is gone, and audits", async () => {
|
||||
const reset = vi.fn(async () => {});
|
||||
const dispose = registerSecondFactor({
|
||||
id: "totp",
|
||||
pluginId: "totp",
|
||||
labelKey: "factor",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => false,
|
||||
reset,
|
||||
});
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "target1", pluginId: "totp", factorId: "totp" },
|
||||
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "locked1", pluginId: "totp", factorId: "totp" },
|
||||
];
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "target1" },
|
||||
});
|
||||
dispose();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.secondFactors).toEqual([
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "locked1", pluginId: "totp", factorId: "totp" },
|
||||
]);
|
||||
expect(state.trustedDevicesCleared).toContain("target1");
|
||||
// TOTP's own reset cleared its secret.
|
||||
expect(
|
||||
state.updates.find((u) => u.id === "target1")?.changes,
|
||||
).toMatchObject({ totpEnabled: false, totpSecret: null });
|
||||
// The running plugin's own reset dropped its secret.
|
||||
expect(reset).toHaveBeenCalledWith("target1");
|
||||
const audit = state.auditCalls.find(
|
||||
(c) => c.action === "admin_reset_second_factors",
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ const calls = vi.hoisted(() => ({
|
||||
rotatedFor: [] as string[],
|
||||
legacyWrapsDeletedFor: [] as string[],
|
||||
dataWipedEvents: [] as Array<{ userId: string }>,
|
||||
secondFactorsReset: [] as string[],
|
||||
}));
|
||||
|
||||
function deletingRepo(label: string) {
|
||||
@@ -59,6 +60,13 @@ vi.mock("../../../utils/crypto-migration/dek-migration.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../auth/second-factor-admin.js", () => ({
|
||||
resetUserSecondFactors: async (userId: string) => {
|
||||
calls.secondFactorsReset.push(userId);
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
|
||||
import { resetUserPassword } from "../../../database/routes/user-password-reset-routes.js";
|
||||
|
||||
function fakeAuthManager(unlocked: boolean): AuthManager {
|
||||
@@ -70,6 +78,7 @@ function fakeAuthManager(unlocked: boolean): AuthManager {
|
||||
|
||||
beforeEach(() => {
|
||||
calls.userUpdates = [];
|
||||
calls.secondFactorsReset = [];
|
||||
calls.deletedFor = [];
|
||||
calls.rotatedFor = [];
|
||||
calls.legacyWrapsDeletedFor = [];
|
||||
@@ -124,8 +133,6 @@ describe("resetUserPassword", () => {
|
||||
expect(calls.dataWipedEvents).toEqual([{ userId: "user-1" }]);
|
||||
expect(calls.rotatedFor).toEqual(["user-1"]);
|
||||
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
|
||||
expect(
|
||||
calls.userUpdates.some(([, update]) => update.totpEnabled === false),
|
||||
).toBe(true);
|
||||
expect(calls.secondFactorsReset).toEqual(["user-1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
|
||||
// The route module imports repository factories and the logger; stub both so
|
||||
// importing stays inert.
|
||||
const userRepositoryUpdate = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
update: userRepositoryUpdate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { verifyTotpReauth } =
|
||||
await import("../../../database/routes/user-totp-routes.js");
|
||||
|
||||
type AnyUser = Parameters<typeof verifyTotpReauth>[0];
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
|
||||
function makeUser(overrides: Partial<AnyUser> = {}): AnyUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
isOidc: false,
|
||||
passwordHash: bcrypt.hashSync("correct-horse", 4),
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01", "BACKUP02"]),
|
||||
totpEnabled: true,
|
||||
...overrides,
|
||||
} as AnyUser;
|
||||
}
|
||||
|
||||
describe("verifyTotpReauth", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not accept the account password as a second factor", async () => {
|
||||
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a valid TOTP code without a password", async () => {
|
||||
const token = speakeasy.totp({ secret, encoding: "base32" });
|
||||
expect(await verifyTotpReauth(makeUser(), token)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a valid backup code and consumes it", async () => {
|
||||
const result = await verifyTotpReauth(makeUser(), "BACKUP01");
|
||||
expect(result).toBe(true);
|
||||
expect(userRepositoryUpdate).toHaveBeenCalledWith("user-1", {
|
||||
totpBackupCodes: JSON.stringify(["BACKUP02"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong password / invalid code", async () => {
|
||||
expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false);
|
||||
expect(userRepositoryUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores the password path for OIDC users but still accepts TOTP", async () => {
|
||||
const token = speakeasy.totp({ secret, encoding: "base32" });
|
||||
const oidcUser = makeUser({ isOidc: true, passwordHash: null });
|
||||
expect(await verifyTotpReauth(oidcUser, token)).toBe(true);
|
||||
expect(await verifyTotpReauth(oidcUser, "anything")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles malformed backup-code JSON without throwing", async () => {
|
||||
const user = makeUser({ totpBackupCodes: "not json" });
|
||||
expect(await verifyTotpReauth(user, "BACKUP01")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AuthManager } from "../../../utils/auth-manager.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verify: vi.fn(),
|
||||
user: vi.fn(),
|
||||
update: vi.fn(),
|
||||
manager: {
|
||||
authenticateWebAuthnUser: vi.fn(),
|
||||
authenticateOIDCUser: vi.fn(),
|
||||
isTrustedDevice: vi.fn(),
|
||||
generateJWTToken: vi.fn(),
|
||||
getSecureCookieOptions: vi.fn(),
|
||||
getClearCookieOptions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => mocks.manager },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateAuthenticationOptions: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ challenge: "challenge" }),
|
||||
verifyAuthenticationResponse: mocks.verify,
|
||||
generateRegistrationOptions: vi.fn(),
|
||||
verifyRegistrationResponse: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById: mocks.user }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async () => [],
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentWebauthnCredentialRepository: () => ({
|
||||
findByCredentialId: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
credentialId: "key",
|
||||
userId: "user",
|
||||
publicKey: "YQ",
|
||||
counter: 0,
|
||||
transports: "[]",
|
||||
}),
|
||||
updateAuthState: mocks.update,
|
||||
}),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return { authLogger: log, sshLogger: log, logger: log, databaseLogger: log };
|
||||
});
|
||||
vi.mock("../../../utils/user-agent-parser.js", () => ({
|
||||
parseUserAgent: () => ({ type: "browser", deviceInfo: "test" }),
|
||||
generateDeviceFingerprint: () => "device",
|
||||
getDeviceId: () => "id",
|
||||
}));
|
||||
const { registerUserWebAuthnRoutes } =
|
||||
await import("../../../database/routes/user-webauthn-routes.js");
|
||||
type Handler = (req: Request, res: Response) => Promise<unknown>;
|
||||
const handlers = new Map<string, Handler>();
|
||||
const manager = mocks.manager;
|
||||
registerUserWebAuthnRoutes(
|
||||
{
|
||||
post: (path: string, ...callbacks: Handler[]) =>
|
||||
handlers.set(path, callbacks.at(-1)!),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Router,
|
||||
{
|
||||
authenticateJWT: vi.fn(),
|
||||
authManager: manager as unknown as AuthManager,
|
||||
isNativeAppRequest: () => false,
|
||||
},
|
||||
);
|
||||
function response() {
|
||||
const res = { json: vi.fn(), status: vi.fn(), cookie: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
res.cookie.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
async function login(userVerification: string) {
|
||||
const options = response();
|
||||
await handlers.get("/webauthn/authenticate/options")!(
|
||||
{
|
||||
body: { userVerification },
|
||||
get: () => "https://termix.test",
|
||||
} as unknown as Request,
|
||||
options as unknown as Response,
|
||||
);
|
||||
const challengeId = options.json.mock.calls[0][0].challengeId;
|
||||
const res = response();
|
||||
await handlers.get("/webauthn/authenticate/verify")!(
|
||||
{
|
||||
body: { challengeId, response: { id: "key" }, rememberMe: true },
|
||||
get: () => undefined,
|
||||
headers: {},
|
||||
socket: {},
|
||||
} as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager.authenticateWebAuthnUser.mockResolvedValue(true);
|
||||
manager.authenticateOIDCUser.mockResolvedValue(true);
|
||||
manager.generateJWTToken.mockResolvedValue("token");
|
||||
manager.getSecureCookieOptions.mockReturnValue({ httpOnly: true });
|
||||
manager.getClearCookieOptions.mockReturnValue({});
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
totpEnabled: true,
|
||||
});
|
||||
manager.isTrustedDevice.mockResolvedValue(false);
|
||||
mocks.verify.mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { userVerified: false, newCounter: 1 },
|
||||
});
|
||||
});
|
||||
describe("WebAuthn second factor policy", () => {
|
||||
it.each(["discouraged", "preferred", "required"])(
|
||||
"does not let request preference %s bypass TOTP without verified UV",
|
||||
async (preference) => {
|
||||
const res = await login(preference);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requires_totp: true, temp_token: "token" }),
|
||||
);
|
||||
expect(manager.generateJWTToken).toHaveBeenCalledWith("user", {
|
||||
pendingTOTP: true,
|
||||
expiresIn: "10m",
|
||||
});
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("allows verified user verification to satisfy TOTP", async () => {
|
||||
mocks.verify.mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { userVerified: true, newCounter: 1 },
|
||||
});
|
||||
const res = await login("preferred");
|
||||
expect(res.cookie).toHaveBeenCalledWith("jwt", "token", { httpOnly: true });
|
||||
expect(manager.isTrustedDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves trusted-device exemption", async () => {
|
||||
manager.isTrustedDevice.mockResolvedValue(true);
|
||||
const res = await login("discouraged");
|
||||
expect(manager.isTrustedDevice).toHaveBeenCalledWith("user", "device");
|
||||
expect(res.cookie).toHaveBeenCalled();
|
||||
});
|
||||
it("never creates a session for an invalid assertion", async () => {
|
||||
mocks.verify.mockResolvedValue({ verified: false });
|
||||
const res = await login("preferred");
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(manager.generateJWTToken).not.toHaveBeenCalled();
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
it("allows users without TOTP to log in with a possession-only key", async () => {
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
totpEnabled: false,
|
||||
});
|
||||
expect((await login("discouraged")).cookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user