mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Add API key host enrollment endpoint (#1029)
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
isUserDataUnlocked: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/simple-db-ops.js", () => ({
|
||||||
|
SimpleDBOps: {
|
||||||
|
isUserDataUnlocked: mocks.isUserDataUnlocked,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath } =
|
||||||
|
await import("./host-enrollment-auth.js");
|
||||||
|
|
||||||
|
function response() {
|
||||||
|
const json = vi.fn();
|
||||||
|
const status = vi.fn(() => ({ json }));
|
||||||
|
return { status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("requireHostEnrollmentAccessForPath", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.isUserDataUnlocked.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an API key scoped to an unlocked user", () => {
|
||||||
|
const res = response();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireHostEnrollmentAccessForPath(
|
||||||
|
{ path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never,
|
||||||
|
res as never,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.isUserDataUnlocked).toHaveBeenCalledWith("user-1");
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects regular JWT sessions", () => {
|
||||||
|
const res = response();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireHostEnrollmentAccessForPath(
|
||||||
|
{ path: "/enroll", userId: "user-1" } as never,
|
||||||
|
res as never,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: "Host enrollment requires an API key",
|
||||||
|
code: "API_KEY_REQUIRED",
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports locked encrypted data explicitly", () => {
|
||||||
|
mocks.isUserDataUnlocked.mockReturnValue(false);
|
||||||
|
const res = response();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireHostEnrollmentAccessForPath(
|
||||||
|
{ path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never,
|
||||||
|
res as never,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(423);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: "User data is locked. Sign in before enrolling hosts.",
|
||||||
|
code: "DATA_LOCKED",
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the existing host route unchanged", () => {
|
||||||
|
const res = response();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireHostEnrollmentAccessForPath(
|
||||||
|
{ path: "/db/host", userId: "user-1" } as never,
|
||||||
|
res as never,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.isUserDataUnlocked).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyHostEnrollmentDefaults", () => {
|
||||||
|
it("creates a usable SSH host from a minimal enrollment payload", () => {
|
||||||
|
expect(applyHostEnrollmentDefaults({ ip: "server.example" })).toEqual({
|
||||||
|
connectionType: "ssh",
|
||||||
|
ip: "server.example",
|
||||||
|
port: 22,
|
||||||
|
authType: "none",
|
||||||
|
enableTerminal: true,
|
||||||
|
enableSsh: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit enrollment settings", () => {
|
||||||
|
expect(
|
||||||
|
applyHostEnrollmentDefaults({
|
||||||
|
ip: "server.example",
|
||||||
|
port: 2222,
|
||||||
|
authType: "password",
|
||||||
|
enableTerminal: false,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
port: 2222,
|
||||||
|
authType: "password",
|
||||||
|
enableTerminal: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||||
|
|
||||||
|
export function applyHostEnrollmentDefaults(
|
||||||
|
hostData: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
connectionType: "ssh",
|
||||||
|
port: 22,
|
||||||
|
authType: "none",
|
||||||
|
enableTerminal: true,
|
||||||
|
enableSsh: true,
|
||||||
|
...hostData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireHostEnrollmentAccessForPath(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): void {
|
||||||
|
if (req.path !== "/enroll") {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
if (!authReq.apiKeyId) {
|
||||||
|
res.status(401).json({
|
||||||
|
error: "Host enrollment requires an API key",
|
||||||
|
code: "API_KEY_REQUIRED",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SimpleDBOps.isUserDataUnlocked(authReq.userId)) {
|
||||||
|
res.status(423).json({
|
||||||
|
error: "User data is locked. Sign in before enrolling hosts.",
|
||||||
|
code: "DATA_LOCKED",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
|
|||||||
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
||||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||||
|
import {
|
||||||
|
applyHostEnrollmentDefaults,
|
||||||
|
requireHostEnrollmentAccessForPath,
|
||||||
|
} from "./host-enrollment-auth.js";
|
||||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -164,9 +168,10 @@ registerHostInternalRoutes(router);
|
|||||||
* description: Failed to save SSH data.
|
* description: Failed to save SSH data.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/db/host",
|
["/db/host", "/enroll"],
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
|
requireHostEnrollmentAccessForPath,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -199,6 +204,10 @@ router.post(
|
|||||||
hostData = req.body;
|
hostData = req.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.path === "/enroll") {
|
||||||
|
hostData = applyHostEnrollmentDefaults(hostData);
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
connectionType,
|
connectionType,
|
||||||
name,
|
name,
|
||||||
@@ -542,6 +551,67 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/enroll:
|
||||||
|
* post:
|
||||||
|
* summary: Enroll a host with an API key
|
||||||
|
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
|
||||||
|
* tags:
|
||||||
|
* - Host Enrollment
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [ip]
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* ip:
|
||||||
|
* type: string
|
||||||
|
* port:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 65535
|
||||||
|
* default: 22
|
||||||
|
* username:
|
||||||
|
* type: string
|
||||||
|
* authType:
|
||||||
|
* type: string
|
||||||
|
* enum: [none, password, key, credential, agent]
|
||||||
|
* default: none
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* folder:
|
||||||
|
* type: string
|
||||||
|
* tags:
|
||||||
|
* oneOf:
|
||||||
|
* - type: string
|
||||||
|
* - type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* enableTerminal:
|
||||||
|
* type: boolean
|
||||||
|
* enableFileManager:
|
||||||
|
* type: boolean
|
||||||
|
* enableTunnel:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host enrolled successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid host data.
|
||||||
|
* 401:
|
||||||
|
* description: Missing or invalid API key.
|
||||||
|
* 423:
|
||||||
|
* description: The API key user's encrypted data is locked.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to enroll the host.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /host/quick-connect:
|
* /host/quick-connect:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface WrappedDataKey {
|
|||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
pendingTOTP?: boolean;
|
pendingTOTP?: boolean;
|
||||||
dataKey?: Buffer;
|
dataKey?: Buffer;
|
||||||
}
|
}
|
||||||
@@ -881,6 +882,7 @@ class AuthManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
req.userId = matchedKey.userId;
|
req.userId = matchedKey.userId;
|
||||||
|
req.apiKeyId = matchedKey.id;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("API key authentication failed", error, {
|
databaseLogger.error("API key authentication failed", error, {
|
||||||
|
|||||||
@@ -955,6 +955,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
|||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
userId: string;
|
userId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
user?: {
|
user?: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user