mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -31,6 +31,8 @@ const state = vi.hoisted(() => ({
|
||||
}[],
|
||||
// Which users have admin.plugins.manage, keyed by userId.
|
||||
managers: new Set<string>(["admin-1"]),
|
||||
// Plugins the loader reports as running.
|
||||
active: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
@@ -93,7 +95,18 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: { list: () => [], get: () => undefined },
|
||||
loader: {
|
||||
list: () => [],
|
||||
get: (id: string) =>
|
||||
state.active.has(id)
|
||||
? {
|
||||
id,
|
||||
state: "active",
|
||||
dir: "/nonexistent/plugin",
|
||||
manifest: { id, locales: "locales" },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
activatePlugin: vi.fn(),
|
||||
deactivatePlugin: vi.fn(),
|
||||
@@ -166,6 +179,7 @@ describe("plugins route", () => {
|
||||
state.plugins = new Map();
|
||||
state.grants = [];
|
||||
state.managers = new Set(["admin-1"]);
|
||||
state.active = new Set();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -226,6 +240,94 @@ describe("plugins route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /plugins/public-manifest", () => {
|
||||
it("lists only running plugins with login or second-factor UI, and nothing sensitive", async () => {
|
||||
state.plugins.set(
|
||||
"login-plugin",
|
||||
makePlugin({
|
||||
id: "login-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["auth:provide", "credentials:read"],
|
||||
contributes: {
|
||||
auth: { loginMethods: ["corp-sso"], sshAuthTypes: ["corp"] },
|
||||
settings: { admin: [{ key: "clientSecret", type: "secret" }] },
|
||||
permissions: [{ name: "manage" }],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"factor-plugin",
|
||||
makePlugin({
|
||||
id: "factor-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
contributes: { auth: { secondFactors: ["pin"] } },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"stopped-login",
|
||||
makePlugin({
|
||||
id: "stopped-login",
|
||||
manifestJson: JSON.stringify({
|
||||
contributes: { auth: { loginMethods: ["other"] } },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"disabled-login",
|
||||
makePlugin({
|
||||
id: "disabled-login",
|
||||
state: "disabled",
|
||||
manifestJson: JSON.stringify({
|
||||
contributes: { auth: { loginMethods: ["x"] } },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.active = new Set([
|
||||
"login-plugin",
|
||||
"factor-plugin",
|
||||
"disabled-login",
|
||||
"sample-plugin",
|
||||
]);
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins/public-manifest`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"login-plugin",
|
||||
"factor-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({
|
||||
auth: { loginMethods: ["corp-sso"], secondFactors: [] },
|
||||
});
|
||||
for (const plugin of body) {
|
||||
expect(Object.keys(plugin).sort()).toEqual(
|
||||
[
|
||||
"assetVersion",
|
||||
"contributes",
|
||||
"css",
|
||||
"dependencies",
|
||||
"enabled",
|
||||
"frontend",
|
||||
"id",
|
||||
"locales",
|
||||
"name",
|
||||
"optionalDependencies",
|
||||
"state",
|
||||
"version",
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain("clientSecret");
|
||||
expect(raw).not.toContain("credentials:read");
|
||||
expect(raw).not.toContain("sshAuthTypes");
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a plugin's declared and granted capabilities", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
|
||||
@@ -9,6 +9,10 @@ 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", () => ({
|
||||
|
||||
@@ -17,6 +17,8 @@ const state = vi.hoisted(() => ({
|
||||
unlockedUsers: new Set<string>(),
|
||||
updates: [] as { id: string; changes: Record<string, unknown> }[],
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
secondFactors: [] as { userId: string; pluginId: string; factorId: string }[],
|
||||
trustedDevicesCleared: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
@@ -89,6 +91,38 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
switchUserRoleName: async () => {},
|
||||
assignRoleNameToUser: async () => {},
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.secondFactors.filter((row) => row.userId === userId),
|
||||
removeSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
state.secondFactors = state.secondFactors.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.userId === userId &&
|
||||
row.pluginId === pluginId &&
|
||||
row.factorId === factorId
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
clearSecondFactors: async (userId: string) => {
|
||||
const before = state.secondFactors.length;
|
||||
state.secondFactors = state.secondFactors.filter(
|
||||
(row) => row.userId !== userId,
|
||||
);
|
||||
return before - state.secondFactors.length;
|
||||
},
|
||||
}),
|
||||
createCurrentTrustedDeviceRepository: () => ({
|
||||
deleteByUserId: async (userId: string) => {
|
||||
state.trustedDevicesCleared.push(userId);
|
||||
},
|
||||
}),
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
|
||||
const { registerUserAdminRoutes } =
|
||||
@@ -180,6 +214,8 @@ async function invoke(
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "admin1";
|
||||
state.secondFactors = [];
|
||||
state.trustedDevicesCleared = [];
|
||||
state.users = new Map([
|
||||
[
|
||||
"admin1",
|
||||
@@ -356,3 +392,64 @@ describe("GET /admin/export/:userId", () => {
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /admin/:userId/second-factors", () => {
|
||||
it("clears every factor, including one whose plugin is gone, and audits", async () => {
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
];
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "target1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.secondFactors).toEqual([
|
||||
{ userId: "locked1", pluginId: "core", 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 });
|
||||
const audit = state.auditCalls.find(
|
||||
(c) => c.action === "admin_reset_second_factors",
|
||||
);
|
||||
expect(audit).toMatchObject({ resourceId: "target1", success: true });
|
||||
expect(JSON.parse(audit!.details as string).removed).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists factors with their availability", async () => {
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
];
|
||||
const res = await invoke("get", "/admin/:userId/second-factors", {
|
||||
params: { userId: "target1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.jsonBody as { factors: unknown[] }).factors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ factorId: "yubikey", available: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("403s when the caller is not an admin", async () => {
|
||||
state.currentUserId = "target1";
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "locked1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("404s for an unknown target", async () => {
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "ghost" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,24 @@ 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/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateAuthenticationOptions: vi
|
||||
@@ -17,6 +35,10 @@ vi.mock("@simplewebauthn/server", () => ({
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById: mocks.user }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async () => [],
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentWebauthnCredentialRepository: () => ({
|
||||
findByCredentialId: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
@@ -30,7 +52,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: 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",
|
||||
@@ -40,12 +71,7 @@ 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 = {
|
||||
authenticateWebAuthnUser: vi.fn().mockResolvedValue(true),
|
||||
isTrustedDevice: vi.fn().mockResolvedValue(false),
|
||||
generateJWTToken: vi.fn().mockResolvedValue("token"),
|
||||
getSecureCookieOptions: vi.fn().mockReturnValue({ httpOnly: true }),
|
||||
};
|
||||
const manager = mocks.manager;
|
||||
registerUserWebAuthnRoutes(
|
||||
{
|
||||
post: (path: string, ...callbacks: Handler[]) =>
|
||||
@@ -62,6 +88,7 @@ registerUserWebAuthnRoutes(
|
||||
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) {
|
||||
@@ -78,13 +105,21 @@ async function login(userVerification: string) {
|
||||
await handlers.get("/webauthn/authenticate/verify")!(
|
||||
{
|
||||
body: { challengeId, response: { id: "key" }, rememberMe: true },
|
||||
} as Request,
|
||||
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",
|
||||
@@ -134,7 +169,11 @@ describe("WebAuthn second factor policy", () => {
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
it("allows users without TOTP to log in with a possession-only key", async () => {
|
||||
mocks.user.mockResolvedValue({ id: "user", totpEnabled: false });
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
totpEnabled: false,
|
||||
});
|
||||
expect((await login("discouraged")).cookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user