feat: API HTTP authentifiée (chat + review) (v0.20.0)
registerApi : login rate-limité → JWT, puis Bearer obligatoire sur /api/chat, /api/review (+approve/refuse), /api/state. CORS restreint + rate-limit. Réutilise ChatService + ReviewService. API activée seulement si auth configurée (apiAuth). 65 tests (5 via inject), 0 vuln. Palier de risque : privilégié (surface exposée) — montée seulement si auth présente ; non exposée tant que .env non renseigné. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+70
-2
@@ -8,10 +8,12 @@
|
||||
"name": "chlova-orchestrator",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"fastify": "5.8.5",
|
||||
"jose": "^6.2.3",
|
||||
"otplib": "^13.4.1",
|
||||
"jose": "6.2.3",
|
||||
"otplib": "13.4.1",
|
||||
"pino": "10.3.1",
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
@@ -522,6 +524,26 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -612,6 +634,27 @@
|
||||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz",
|
||||
"integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
@@ -631,6 +674,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -1867,6 +1919,22 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/rate-limit": "11.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"fastify": "5.8.5",
|
||||
"jose": "6.2.3",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import type { ChatService } from "../agent/chat-service.js";
|
||||
import type { ReviewService } from "../gatekeeper/review.js";
|
||||
import { login, verifyJwt, type AuthConfig } from "./auth.js";
|
||||
|
||||
/**
|
||||
* API HTTP de la surface exposée (Phase 4). JWT obligatoire sauf /api/auth/login.
|
||||
* Réutilise ChatService (même comportement que Telegram) et ReviewService.
|
||||
*
|
||||
* Sécurité : login rate-limité (anti brute-force), JWT Bearer sur tout le reste,
|
||||
* CORS restreint à l'origine du SPA (dev) — same-origin en prod.
|
||||
*/
|
||||
export interface ApiDeps {
|
||||
auth: AuthConfig;
|
||||
chat: ChatService;
|
||||
review: ReviewService | null;
|
||||
state: () => Record<string, unknown>;
|
||||
webOrigin?: string | undefined;
|
||||
}
|
||||
|
||||
export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<void> {
|
||||
await app.register(cors, {
|
||||
origin: deps.webOrigin ?? false,
|
||||
methods: ["GET", "POST"],
|
||||
allowedHeaders: ["content-type", "authorization"],
|
||||
});
|
||||
await app.register(rateLimit, { max: 120, timeWindow: "1 minute" });
|
||||
|
||||
// Garde JWT : 401 si Bearer absent/invalide.
|
||||
const requireAuth = async (req: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
const header = req.headers.authorization ?? "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
const sub = token ? await verifyJwt(token, deps.auth.jwtSecret) : null;
|
||||
if (!sub) {
|
||||
await reply.code(401).send({ error: "non autorisé" });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Login (rate-limit serré, anti brute-force) ──────────────────────────
|
||||
app.post(
|
||||
"/api/auth/login",
|
||||
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const user = String(body.user ?? "");
|
||||
const password = String(body.password ?? "");
|
||||
const totp = String(body.totp ?? "");
|
||||
const token = await login(deps.auth, { user, password, totp });
|
||||
if (!token) return reply.code(401).send({ error: "identifiants invalides" });
|
||||
return { token };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Chat ────────────────────────────────────────────────────────────────
|
||||
app.post("/api/chat", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const message = String(body.message ?? "").trim();
|
||||
if (!message) return reply.code(400).send({ error: "message vide" });
|
||||
const replyText = await deps.chat.handle("api:owner", message);
|
||||
return { reply: replyText };
|
||||
});
|
||||
|
||||
// ── Review ────────────────────────────────────────────────────────────────
|
||||
app.get("/api/review", { preHandler: requireAuth }, async () => {
|
||||
return { assets: deps.review ? deps.review.listPending() : [] };
|
||||
});
|
||||
|
||||
const decide = (action: "approve" | "refuse") =>
|
||||
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!deps.review) return reply.code(409).send({ error: "review indisponible (Phase 1)" });
|
||||
const id = (req.params as { id: string }).id;
|
||||
try {
|
||||
const asset = action === "approve" ? deps.review.approve(id) : deps.review.refuse(id);
|
||||
return { asset };
|
||||
} catch {
|
||||
return reply.code(404).send({ error: "asset inconnu" });
|
||||
}
|
||||
};
|
||||
app.post("/api/review/:id/approve", { preHandler: requireAuth }, decide("approve"));
|
||||
app.post("/api/review/:id/refuse", { preHandler: requireAuth }, decide("refuse"));
|
||||
|
||||
// ── État ────────────────────────────────────────────────────────────────
|
||||
app.get("/api/state", { preHandler: requireAuth }, async () => deps.state());
|
||||
}
|
||||
@@ -67,6 +67,17 @@ const schema = z.object({
|
||||
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
||||
z.string().url().optional(),
|
||||
),
|
||||
|
||||
// API/UI (Phase 4) — surface exposée, login fort. L'API n'est activée que si
|
||||
// ces 4 valeurs sont présentes (apiAuth()). Tout optionnel sinon.
|
||||
adminUser: z.string().optional(),
|
||||
adminPasswordHash: z.string().optional(), // SECRET — hash scrypt (cf. scripts/hash-password)
|
||||
totpSecret: z.string().optional(), // SECRET
|
||||
jwtSecret: z.string().optional(), // SECRET
|
||||
webOrigin: z.preprocess(
|
||||
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
||||
z.string().url().optional(),
|
||||
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof schema>;
|
||||
@@ -78,6 +89,9 @@ const SECRET_KEYS = new Set<keyof Config>([
|
||||
"portainerMcpAuthToken",
|
||||
"telegramBotToken",
|
||||
"alertWebhookUrl",
|
||||
"adminPasswordHash",
|
||||
"totpSecret",
|
||||
"jwtSecret",
|
||||
]);
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
@@ -97,6 +111,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
|
||||
dbPath: env.CHLOVA_DB_PATH,
|
||||
alertWebhookUrl: env.ALERT_WEBHOOK_URL,
|
||||
adminUser: env.CHLOVA_ADMIN_USER,
|
||||
adminPasswordHash: env.CHLOVA_ADMIN_PASSWORD_HASH,
|
||||
totpSecret: env.CHLOVA_TOTP_SECRET,
|
||||
jwtSecret: env.CHLOVA_JWT_SECRET,
|
||||
webOrigin: env.CHLOVA_WEB_ORIGIN,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -130,6 +149,22 @@ export function assertReadOnlyPhase(cfg: Config): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null
|
||||
* (API/UI désactivée — surface non exposée).
|
||||
*/
|
||||
export function apiAuth(cfg: Config): import("./api/auth.js").AuthConfig | null {
|
||||
if (cfg.adminUser && cfg.adminPasswordHash && cfg.totpSecret && cfg.jwtSecret) {
|
||||
return {
|
||||
adminUser: cfg.adminUser,
|
||||
adminPasswordHash: cfg.adminPasswordHash,
|
||||
totpSecret: cfg.totpSecret,
|
||||
jwtSecret: cfg.jwtSecret,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Vue de la config sûre pour les logs : secrets masqués. */
|
||||
export function redactedConfig(cfg: Config): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Fastify from "fastify";
|
||||
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "./config.js";
|
||||
import Fastify, { type FastifyBaseLogger } from "fastify";
|
||||
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
|
||||
import { registerApi } from "./api/routes.js";
|
||||
import { createLogger } from "./audit/log.js";
|
||||
import { OllamaClient } from "./llm/ollama.js";
|
||||
import { McpRegistry } from "./mcp/registry.js";
|
||||
@@ -103,12 +104,30 @@ async function main(): Promise<void> {
|
||||
);
|
||||
|
||||
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
|
||||
const app = Fastify({ loggerInstance: logger });
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le
|
||||
// type d'instance avec registerApi (comportement runtime identique).
|
||||
const app = Fastify({ loggerInstance: logger as unknown as FastifyBaseLogger });
|
||||
const stateOf = () => ({
|
||||
phase: cfg.phase === 2 ? "2-write-review" : "1-readonly",
|
||||
tools: tools.length,
|
||||
}));
|
||||
});
|
||||
app.get("/health", async () => ({ status: "ok", ...stateOf() }));
|
||||
|
||||
// API/UI : activée seulement si l'auth est configurée (surface exposée).
|
||||
const auth = apiAuth(cfg);
|
||||
if (auth) {
|
||||
await registerApi(app, {
|
||||
auth,
|
||||
chat,
|
||||
review,
|
||||
state: stateOf,
|
||||
webOrigin: cfg.webOrigin,
|
||||
});
|
||||
logger.info("API/UI activée (auth configurée)");
|
||||
} else {
|
||||
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule");
|
||||
}
|
||||
|
||||
await app.listen({ host: "0.0.0.0", port: 8080 });
|
||||
logger.info({ port: 8080 }, "healthcheck interne prêt");
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { generateSync, generateSecret } from "otplib";
|
||||
import { registerApi } from "../src/api/routes.js";
|
||||
import { hashPassword, type AuthConfig } from "../src/api/auth.js";
|
||||
import { ChatService } from "../src/agent/chat-service.js";
|
||||
import { ReviewService } from "../src/gatekeeper/review.js";
|
||||
import { AssetRepository } from "../src/gatekeeper/repository.js";
|
||||
import { createAsset } from "../src/gatekeeper/assets.js";
|
||||
import { createLogger } from "../src/audit/log.js";
|
||||
|
||||
const log = createLogger("silent");
|
||||
const auth: AuthConfig = {
|
||||
adminUser: "kantin",
|
||||
adminPasswordHash: hashPassword("pw"),
|
||||
totpSecret: generateSecret(),
|
||||
jwtSecret: "jwt-secret-suffisamment-long-pour-hs256",
|
||||
};
|
||||
|
||||
// ChatService stub : pas de vrai LLM.
|
||||
const chatStub = {
|
||||
handle: async (_actor: string, text: string) => `echo:${text}`,
|
||||
} as unknown as ChatService;
|
||||
|
||||
let app: FastifyInstance;
|
||||
let repo: AssetRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repo = new AssetRepository(":memory:");
|
||||
const review = new ReviewService(repo, log);
|
||||
app = Fastify();
|
||||
await registerApi(app, { auth, chat: chatStub, review, state: () => ({ phase: "2-write-review", tools: 3 }) });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
repo.close();
|
||||
});
|
||||
|
||||
async function tokenOf(): Promise<string> {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) },
|
||||
});
|
||||
return res.json().token as string;
|
||||
}
|
||||
|
||||
describe("API auth", () => {
|
||||
it("login renvoie un token avec bons facteurs", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().token).toBeTruthy();
|
||||
});
|
||||
|
||||
it("login 401 avec mauvais mot de passe", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { user: "kantin", password: "bad", totp: generateSync({ secret: auth.totpSecret }) },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("/api/chat refuse sans token (401)", async () => {
|
||||
const res = await app.inject({ method: "POST", url: "/api/chat", payload: { message: "salut" } });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("/api/chat répond avec token", async () => {
|
||||
const token = await tokenOf();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/chat",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { message: "salut" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().reply).toBe("echo:salut");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API review", () => {
|
||||
it("liste, approuve et refuse", async () => {
|
||||
repo.create(createAsset({ id: "tool:x", type: "tool", version: "1.0.0", riskTier: "privileged" }));
|
||||
const token = await tokenOf();
|
||||
const auth_h = { authorization: `Bearer ${token}` };
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/api/review", headers: auth_h });
|
||||
expect(list.json().assets).toHaveLength(1);
|
||||
|
||||
const ok = await app.inject({ method: "POST", url: "/api/review/tool:x/approve", headers: auth_h });
|
||||
expect(ok.json().asset.status).toBe("approuvé");
|
||||
|
||||
const missing = await app.inject({ method: "POST", url: "/api/review/nope/refuse", headers: auth_h });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user