feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+7 -4
View File
@@ -8,9 +8,6 @@ export interface FakeUser {
isOidc: boolean;
oidcIdentifier?: string | null;
ssoProviderId?: number | null;
totpEnabled?: boolean;
totpSecret?: string | null;
totpBackupCodes?: string | null;
}
/** In-memory stand-ins for the repositories the login pipeline touches. */
@@ -99,6 +96,12 @@ export function fakeFactory(state: AuthState) {
},
listSecondFactors: async (userId: string) =>
state.factors.filter((row) => row.userId === userId),
hasSecondFactor: async (userId: string) =>
state.factors.some((row) => row.userId === userId),
listUserIdsWithSecondFactors: async () =>
new Set(state.factors.map((row) => row.userId)),
countUsersWithSecondFactors: async () =>
new Set(state.factors.map((row) => row.userId)).size,
recordSecondFactor: async (
userId: string,
pluginId: string,
@@ -199,7 +202,7 @@ export function fakeAuthManager(state: AuthState) {
}),
authenticateUser: vi.fn(async () => true),
authenticateOIDCUser: vi.fn(async () => true),
authenticateWebAuthnUser: vi.fn(async () => true),
unlockWithSystemKey: vi.fn(async () => true),
registerOIDCUser: vi.fn(async () => {}),
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
state.trusted.has(`${userId}:${fingerprint}`),
+28 -10
View File
@@ -358,7 +358,7 @@ describe("LDAP through the pipeline", () => {
).rejects.toMatchObject({ status: 401 });
});
it("skips TOTP for an LDAP user who enrolled when the external-login setting is off", async () => {
it("skips second factors for an LDAP user who enrolled when the external-login setting is off", async () => {
h.providerConfig = providerConfig;
h.state.users.set("u-bob", {
id: "u-bob",
@@ -367,8 +367,11 @@ describe("LDAP through the pipeline", () => {
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
totpEnabled: true,
totpSecret: "JBSWY3DPEHPK3PXP",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
@@ -381,7 +384,7 @@ describe("LDAP through the pipeline", () => {
expect(result.kind).toBe("session");
});
it("runs TOTP for an LDAP user who enrolled when the external-login setting is on", async () => {
it("runs second factors for an LDAP user who enrolled when the external-login setting is on", async () => {
h.providerConfig = providerConfig;
h.state.settings.set("second_factor_after_external_login", "true");
h.state.users.set("u-bob", {
@@ -391,17 +394,32 @@ describe("LDAP through the pipeline", () => {
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
totpEnabled: true,
totpSecret: "JBSWY3DPEHPK3PXP",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
const { registerSecondFactor } = await import("../../auth/registry.js");
const dispose = registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
expect(result.kind).toBe("second-factor");
try {
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
});
+45 -24
View File
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import {
createAuthState,
fakeAuthManager,
@@ -98,7 +97,19 @@ const { registerLoginMethod, registerSecondFactor } =
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const PASSWORD = "correct-horse";
const totpSecret = speakeasy.generateSecret({ name: "test" }).base32;
// Stands in for the totp plugin's factor: core only sees the registration.
const TOTP_CODE = "123456";
let disposeTotp: (() => void) | null = null;
function registerTotpFixture() {
disposeTotp = registerSecondFactor({
id: "totp",
pluginId: "totp",
labelKey: "totp:factor",
isEnrolled: async () => false,
verify: async (_userId, body) => body.totp_code === TOTP_CODE,
reset: async () => {},
});
}
function addUser(
overrides: Partial<import("./auth-test-helpers.js").FakeUser> = {},
@@ -109,7 +120,6 @@ function addUser(
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
totpEnabled: false,
...overrides,
};
h.state.users.set(user.id, user);
@@ -124,6 +134,8 @@ beforeEach(() => {
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
loginRateLimiter.resetTOTPAttempts("u1");
delete process.env.ALLOW_PASSWORD_LOGIN;
disposeTotp?.();
registerTotpFixture();
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
@@ -241,12 +253,8 @@ describe("password login through the pipeline", () => {
describe("second factors", () => {
function enrolTotp() {
addUser({
totpEnabled: true,
totpSecret,
totpBackupCodes: JSON.stringify(["BACKUP01"]),
});
h.state.factors.push({ userId: "u1", pluginId: "core", factorId: "totp" });
addUser();
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
it("stops for TOTP with the pending token the old route returned", async () => {
@@ -259,7 +267,7 @@ describe("second factors", () => {
temp_token: "pending-1",
rememberMe: false,
second_factors: [
expect.objectContaining({ id: "totp", pluginId: "core" }),
expect.objectContaining({ id: "totp", pluginId: "totp" }),
],
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith("u1", {
@@ -280,7 +288,7 @@ describe("second factors", () => {
const req = fakeRequest({
body: {
temp_token: tempToken,
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
totp_code: TOTP_CODE,
rememberMe: true,
},
});
@@ -298,24 +306,40 @@ describe("second factors", () => {
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
});
it("accepts a backup code once, stored encrypted", async () => {
it("answers the 2.8 route with the user's first factor when none is named", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const tempToken = (first.body as { temp_token: string }).temp_token;
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: { temp_token: tempToken, totp_code: "BACKUP01" },
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
// Re-encrypted after use, and the code is gone.
expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01");
});
it("fails closed for a 2.8 TOTP user while the totp plugin is disabled", async () => {
enrolTotp();
disposeTotp?.();
disposeTotp = null;
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" });
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
});
it("still stops password login for TOTP when the external-login setting is off", async () => {
@@ -562,9 +586,8 @@ describe("external identities", () => {
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
h.state.identities.push({
id: 1,
userId: "u9",
@@ -586,9 +609,8 @@ describe("external identities", () => {
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
h.state.identities.push({
id: 1,
userId: "u9",
@@ -658,9 +680,8 @@ describe("redirect logins", () => {
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
h.state.identities.push({
id: 1,
userId: "u9",
@@ -686,7 +707,7 @@ describe("redirect logins", () => {
fakeRequest({
cookies: { termix_pending_login: res.cookies[0].value },
body: {
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
totp_code: TOTP_CODE,
},
}) as never,
verify as never,
@@ -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();
});
});
@@ -63,6 +63,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
isSystemEncrypted: (value: string) => value.startsWith("sysenc:"),
}));
vi.mock("../../utils/audit-logger.js", () => ({
@@ -156,4 +157,20 @@ describe("ctx.secrets store", () => {
});
expect(state.rows.size).toBe(0);
});
it("seals without an acting user and refuses without secrets:own", async () => {
const denied = contextFor([]);
await expect(denied.secrets.seal("v")).rejects.toThrow(/secrets:own/);
await expect(denied.secrets.unseal("sysenc:v")).rejects.toThrow(
/secrets:own/,
);
const ctx = contextFor(["secrets:own"]);
const sealed = await ctx.secrets.seal("totp-secret");
expect(sealed).toBe("sysenc:totp-secret");
expect(await ctx.secrets.unseal(sealed)).toBe("totp-secret");
// A plain string was never sealed and must not read back as one.
expect(await ctx.secrets.unseal("totp-secret")).toBeNull();
expect(JSON.stringify(auditEntries)).not.toContain("totp-secret");
});
});
+61 -1
View File
@@ -9,6 +9,10 @@ const h = vi.hoisted(() => ({
cleared: [] as string[],
clearedAll: 0,
pooled: [] as string[],
sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[],
policyError: null as Error | null,
}));
vi.mock("../../utils/logger.js", () => {
@@ -29,7 +33,15 @@ vi.mock("../../plugins/permissions.js", async () => {
},
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../plugins/actor.js", () => ({
getActor: () => h.actor,
getActorSessionId: () => h.sessionId,
}));
vi.mock("../../auth/core-auth.js", () => ({
assertSecondFactorEnrollmentAllowed: () => {
if (h.policyError) throw h.policyError;
},
}));
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
key: string,
@@ -74,6 +86,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
},
removeSecondFactor: async () => true,
}),
createCurrentSessionRepository: () => ({
revokeAllForUser: async (userId: string, except?: string) => {
h.revoked.push({ userId, except });
return 0;
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
h.trustedCleared.push(userId);
},
}),
}));
const { createPluginAuth, createPluginSsh } =
@@ -359,4 +382,41 @@ describe("ctx.auth", () => {
expect(getLoginMethod("corp-sso")).toBeUndefined();
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
});
it("revokes other sessions and trusted devices on enrolment, keeping the caller's session", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
h.revoked = [];
h.trustedCleared = [];
h.policyError = null;
h.actor = "user-1";
h.sessionId = "session-a";
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]);
h.actor = undefined;
h.sessionId = undefined;
});
it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
const { LoginMethodError } = await import("@termix/plugin-sdk/backend");
h.policyError = new LoginMethodError("password login is off", 409);
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await expect(auth.recordEnrollment("user-1", "pin")).rejects.toMatchObject({
status: 409,
});
expect(h.factors).toEqual([]);
h.policyError = null;
});
});
@@ -74,24 +74,19 @@ describe("parseLegacyIdentifier", () => {
});
describe("runExternalIdentityMigration", () => {
it("moves every identifier and TOTP enrolment without touching the old data", async () => {
it("moves every identifier without touching the old data", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
user("oidc-user", {
oidcIdentifier: "sub-123",
ssoProviderId: 3,
totpEnabled: true,
});
user("env-user", { oidcIdentifier: "someone@example.com" });
user("local", {
isOidc: false,
passwordHash: "hash",
totpEnabled: true,
});
user("local", { isOidc: false, passwordHash: "hash" });
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 4, factors: 2, skipped: 0 });
expect(result).toEqual({ identities: 4, skipped: 0 });
expect(
h.state.identities.map(({ userId, providerId, subject }) => ({
userId,
@@ -108,21 +103,15 @@ describe("runExternalIdentityMigration", () => {
subject: "someone@example.com",
},
]);
expect(h.state.factors).toEqual([
{ userId: "oidc-user", pluginId: "core", factorId: "totp" },
{ userId: "local", pluginId: "core", factorId: "totp" },
]);
// Lossless: the old column and flag stay.
// Lossless: the old column stays.
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
expect(h.state.users.get("local")!.totpEnabled).toBe(true);
});
it("is idempotent", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", totpEnabled: true });
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
await runExternalIdentityMigration();
const second = await runExternalIdentityMigration();
expect(second).toEqual({ identities: 0, factors: 0, skipped: 2 });
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
expect(h.state.factors).toHaveLength(1);
});
});
@@ -0,0 +1,196 @@
/**
* The 2.8 to 2.9 TOTP move. A user who had TOTP on must still be asked for it
* after upgrading: their enrolment row points at the totp plugin, and their
* secret and backup codes reach the plugin's table readable. Running it twice
* changes nothing, and without the plugin's table the enrolment still moves
* so the login fails closed.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTotpMigration } =
await import("../../utils/crypto-migration/totp-migration.js");
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, plugin_id, factor_id)
);
`);
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_totp_enrollments (
user_id TEXT PRIMARY KEY NOT NULL,
secret TEXT,
pending_secret TEXT,
backup_codes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
h.db = drizzle(sqlite);
}
function addTotpUser(id: string, secret: string, codes: string[]) {
const dek = crypto.randomBytes(32);
h.deks.set(id, dek);
sqlite
.prepare(
"INSERT INTO users (id, username, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, 1, ?)",
)
.run(
id,
id,
FieldCrypto.encryptField(secret, dek, id, "totpSecret"),
FieldCrypto.encryptField(
JSON.stringify(codes),
dek,
id,
"totpBackupCodes",
),
);
}
const factors = () =>
sqlite
.prepare(
"SELECT user_id, plugin_id, factor_id FROM user_second_factors ORDER BY user_id",
)
.all();
beforeEach(() => {
h.deks.clear();
});
describe("runTotpMigration", () => {
it("keeps a 2.8 TOTP user behind their factor, with a readable secret", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111", "BBBB2222"]);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
sqlite.exec(`INSERT INTO users (id, username) VALUES ('carol', 'carol')`);
// A8 recorded alice under core; bob only has the flag.
sqlite.exec(
`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('alice', 'core', 'totp')`,
);
expect(await runTotpMigration()).toEqual({ factors: 2, secrets: 2 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
{ user_id: "bob", plugin_id: "totp", factor_id: "totp" },
]);
const alice = sqlite
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'alice'")
.get() as Record<string, string>;
expect(alice.secret).toBe("sysenc:JBSWY3DPEHPK3PXP");
expect(alice.backup_codes).toBe(
`sysenc:${JSON.stringify(["AAAA1111", "BBBB2222"])}`,
);
expect(alice.pending_secret).toBeNull();
expect(
sqlite
.prepare(
"SELECT totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = 'alice'",
)
.get(),
).toEqual({ totp_enabled: 0, totp_secret: null, totp_backup_codes: null });
// A second boot changes nothing.
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
expect(factors()).toHaveLength(2);
expect(
sqlite.prepare("SELECT COUNT(*) AS n FROM p_totp_enrollments").get(),
).toEqual({ n: 2 });
});
it("moves the enrolment but keeps the columns while the plugin table is missing", async () => {
setup({ pluginTable: false });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111"]);
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
]);
const row = sqlite
.prepare("SELECT totp_enabled, totp_secret FROM users WHERE id = 'alice'")
.get() as { totp_enabled: number; totp_secret: string };
expect(row.totp_enabled).toBe(1);
expect(row.totp_secret).toBeTruthy();
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
it("skips a user whose data key is unavailable and retries later", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
const dek = h.deks.get("alice")!;
h.deks.delete("alice");
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
h.deks.set("alice", dek);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
});
it("drops a duplicate core row when the plugin row already exists", async () => {
setup({ pluginTable: true });
sqlite.exec(`
INSERT INTO users (id, username) VALUES ('dave', 'dave');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'core', 'totp');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'totp', 'totp');
`);
await runTotpMigration();
expect(factors()).toEqual([
{ user_id: "dave", plugin_id: "totp", factor_id: "totp" },
]);
});
it("does nothing on a fresh install without the legacy columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL, factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
h.db = drizzle(sqlite);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
});
@@ -12,12 +12,11 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
}
it("owns legacy user encryption migration reads", () => {
const { db, all, get } = createDb();
const { db, all } = createDb();
const store = new RawSqliteUserEncryptionMigrationStore(db);
expect(store.listHostRecords("user-1")).toEqual([{ id: 1 }]);
expect(store.listCredentialRecords("user-1")).toEqual([{ id: 1 }]);
expect(store.getUserRecord("user-1")).toEqual({ id: "user-1" });
expect(db.prepare).toHaveBeenCalledWith(
"SELECT * FROM ssh_data WHERE user_id = ?",
@@ -25,9 +24,7 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
expect(db.prepare).toHaveBeenCalledWith(
"SELECT * FROM ssh_credentials WHERE user_id = ?",
);
expect(db.prepare).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?");
expect(all).toHaveBeenCalledWith("user-1");
expect(get).toHaveBeenCalledWith("user-1");
});
it("owns legacy sensitive field update statements", () => {
@@ -52,12 +49,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
public_key: "pub",
key_type: "rsa",
});
store.updateUserSensitiveFields("user-1", {
totp_secret: "totp",
totp_backup_codes: "codes",
client_secret: "client",
oidc_identifier: "oidc",
});
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_data"),
@@ -65,9 +56,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_credentials"),
);
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE users"),
);
expect(run).toHaveBeenCalledWith(
"p",
"k",
@@ -80,13 +68,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
12,
);
expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13);
expect(run).toHaveBeenCalledWith(
"totp",
"codes",
"client",
"oidc",
"user-1",
);
});
it("owns password-reset dynamic field updates", () => {