From 7ffb92bf8c78fa69f7bb65df3874997f23560483 Mon Sep 17 00:00:00 2001 From: ZhuDejia <1140985124@qq.com> Date: Wed, 27 May 2026 23:27:23 +0800 Subject: [PATCH] Add authentication options for webhooks --- client/src/Hooks/useNotificationForm.ts | 4 + .../src/Pages/Notifications/create/index.tsx | 106 +++++++++++++++++- client/src/Types/Notification.ts | 6 + client/src/Validation/notifications.ts | 31 +++++ client/src/locales/en.json | 16 +++ client/src/locales/zh-CN.json | 16 +++ server/openapi/routes/notification.ts | 8 +- server/src/db/models/Notification.ts | 4 + .../MongoNotificationsRepository.ts | 4 + .../notificationProviders/webhook.ts | 25 ++++- server/src/types/notification.ts | 6 + .../src/validation/notificationValidation.ts | 31 +++++ server/test/helpers/notificationMessage.ts | 1 + .../notifications/webhookProvider.test.ts | 51 +++++++++ 14 files changed, 299 insertions(+), 10 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index d08f175481..4e3dcccdcb 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -44,6 +44,10 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "webhook", notificationName: data.notificationName || "", address: data.address || "", + authType: data.authType || "none", + authUsername: data.authUsername || "", + authPassword: data.authPassword || "", + authToken: data.authToken || "", }; } if (data?.type === "pager_duty") { diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 0f6d9b6525..b9f755e520 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -13,9 +13,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; import type { NotificationFormData } from "@/Validation/notifications"; -import type { Notification } from "@/Types/Notification"; +import { NotificationChannels, WebhookAuthTypes, type Notification } from "@/Types/Notification"; import { useTranslation } from "react-i18next"; -import { NotificationChannels } from "@/Types/Notification"; const NotificationsCreatePage = () => { const { t } = useTranslation(); @@ -46,10 +45,13 @@ const NotificationsCreatePage = () => { }, [defaults, reset]); const watchedType = watch("type"); + const watchedValues = watch(); + const watchedAuthType = + watchedType === "webhook" && "authType" in watchedValues ? watchedValues.authType : "none"; useEffect(() => { clearErrors(); - }, [watchedType, clearErrors]); + }, [watchedType, watchedAuthType, clearErrors]); const addressConfig = useMemo(() => { if (watchedType === "pager_duty") { @@ -174,6 +176,104 @@ const NotificationsCreatePage = () => { } /> )} + {watchedType === "webhook" && ( + + ( + + )} + /> + {watchedAuthType === "basic" && ( + <> + ( + + )} + /> + ( + + )} + /> + + )} + {watchedAuthType === "bearer" && ( + ( + + )} + /> + )} + + } + /> + )} {watchedType === "telegram" && ( { + if (data.authType === "basic") { + if (!data.authUsername?.trim()) { + ctx.addIssue({ + code: "custom", + path: ["authUsername"], + message: "Username is required", + }); + } + if (!data.authPassword?.trim()) { + ctx.addIssue({ + code: "custom", + path: ["authPassword"], + message: "Password is required", + }); + } + } + + if (data.authType === "bearer" && !data.authToken?.trim()) { + ctx.addIssue({ + code: "custom", + path: ["authToken"], + message: "Token is required", + }); + } }); const pagerDutySchema = baseSchema.extend({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 42b4bf34fa..a34739a5fd 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1119,6 +1119,22 @@ "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "title": "Integration key" }, + "webhookAuth": { + "title": "Webhook authentication", + "description": "Optionally attach an Authorization header to webhook requests.", + "optionAuthType": "Authentication type", + "optionUsername": "Username", + "optionPassword": "Password", + "optionToken": "Bearer token", + "placeholderUsername": "webhook-user", + "placeholderPassword": "Enter password", + "placeholderToken": "Enter bearer token", + "types": { + "none": "None", + "basic": "Basic Auth", + "bearer": "Bearer Token" + } + }, "roomId": { "optionRoomId": "Room ID", "placeholder": "!abcdefg:matrix.org" diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index a7166e6c25..dc8544b074 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -1059,6 +1059,22 @@ "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "title": "集成密钥" }, + "webhookAuth": { + "title": "Webhook 璁よ瘉", + "description": "鍙€夊湴涓?webhook 璇锋眰闄勫姞 Authorization 璇锋眰澶淬€?", + "optionAuthType": "璁よ瘉鏂瑰紡", + "optionUsername": "鐢ㄦ埛鍚?", + "optionPassword": "瀵嗙爜", + "optionToken": "Bearer Token", + "placeholderUsername": "webhook-user", + "placeholderPassword": "璇疯緭鍏ュ瘑鐮?", + "placeholderToken": "璇疯緭鍏?Bearer Token", + "types": { + "none": "鏃?", + "basic": "Basic Auth", + "bearer": "Bearer Token" + } + }, "roomId": { "optionRoomId": "房间 ID", "placeholder": "!abcdefg:matrix.org" diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index b34c542e71..595faac4de 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -21,7 +21,13 @@ const notificationVariantMeta: Record( required: true, }, address: { type: String }, + authType: { type: String, enum: ["none", "basic", "bearer"] }, + authUsername: { type: String }, + authPassword: { type: String }, + authToken: { type: String }, phone: { type: String }, homeserverUrl: { type: String }, roomId: { type: String }, diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 5303aa6ab2..68926acd31 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -28,6 +28,10 @@ class MongoNotificationsRepository implements INotificationsRepository { type: doc.type, notificationName: doc.notificationName, address: doc.address ?? undefined, + authType: doc.authType ?? undefined, + authUsername: doc.authUsername ?? undefined, + authPassword: doc.authPassword ?? undefined, + authToken: doc.authToken ?? undefined, phone: doc.phone ?? undefined, homeserverUrl: doc.homeserverUrl ?? undefined, roomId: doc.roomId ?? undefined, diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 292650f915..2052e8c23a 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -6,6 +6,23 @@ import { getTestMessage } from "@/service/infrastructure/notificationProviders/u import got from "got"; export class WebhookProvider extends NotificationProvider { + private buildHeaders(notification: Partial) { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (notification.authType === "basic" && notification.authUsername && notification.authPassword) { + const credentials = Buffer.from(`${notification.authUsername}:${notification.authPassword}`).toString("base64"); + headers.Authorization = `Basic ${credentials}`; + } + + if (notification.authType === "bearer" && notification.authToken) { + headers.Authorization = `Bearer ${notification.authToken}`; + } + + return headers; + } + sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { if (!notification.address) { return false; @@ -17,9 +34,7 @@ export class WebhookProvider extends NotificationProvider { try { await got.post(notification.address, { json: payload, - headers: { - "Content-Type": "application/json", - }, + headers: this.buildHeaders(notification), ...this.gotRequestOptions(), }); this.logger.info({ @@ -99,9 +114,7 @@ export class WebhookProvider extends NotificationProvider { try { await got.post(notification.address, { json: { text: getTestMessage() }, - headers: { - "Content-Type": "application/json", - }, + headers: this.buildHeaders(notification), ...this.gotRequestOptions(), }); return true; diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 9d5b914c02..c75bcaea0a 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -11,6 +11,8 @@ export const NotificationChannels = [ "twilio", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +export const WebhookAuthTypes = ["none", "basic", "bearer"] as const; +export type WebhookAuthType = (typeof WebhookAuthTypes)[number]; export interface Notification { id: string; @@ -19,6 +21,10 @@ export interface Notification { type: NotificationChannel; notificationName: string; address?: string; + authType?: WebhookAuthType; + authUsername?: string; + authPassword?: string; + authToken?: string; phone?: string; homeserverUrl?: string; roomId?: string; diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 523cb2a6e1..198c35e2d7 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +const webhookAuthTypeSchema = z.enum(["none", "basic", "bearer"]); + //**************************************** // Notification Validations //**************************************** @@ -19,9 +21,38 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("webhook"), address: z.url({ message: "Please enter a valid Webhook URL" }), + authType: webhookAuthTypeSchema, + authUsername: z.union([z.string(), z.literal("")]).optional(), + authPassword: z.union([z.string(), z.literal("")]).optional(), + authToken: z.union([z.string(), z.literal("")]).optional(), homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), + }).superRefine((data, ctx) => { + if (data.authType === "basic") { + if (!data.authUsername?.trim()) { + ctx.addIssue({ + code: "custom", + path: ["authUsername"], + message: "Username is required", + }); + } + if (!data.authPassword?.trim()) { + ctx.addIssue({ + code: "custom", + path: ["authPassword"], + message: "Password is required", + }); + } + } + + if (data.authType === "bearer" && !data.authToken?.trim()) { + ctx.addIssue({ + code: "custom", + path: ["authToken"], + message: "Token is required", + }); + } }), // Slack notification z.object({ diff --git a/server/test/helpers/notificationMessage.ts b/server/test/helpers/notificationMessage.ts index c25644c814..694796fb7e 100644 --- a/server/test/helpers/notificationMessage.ts +++ b/server/test/helpers/notificationMessage.ts @@ -4,6 +4,7 @@ import type { NotificationMessage } from "../../src/types/notificationMessage.ts export const makeNotification = (overrides?: Partial): Notification => ({ address: "https://hooks.example.com/webhook", + authType: "none", accessToken: "token-abc", homeserverUrl: "https://matrix.example.com", roomId: "!room:example.com", diff --git a/server/test/unit/providers/notifications/webhookProvider.test.ts b/server/test/unit/providers/notifications/webhookProvider.test.ts index caf17851b7..4e7160303d 100644 --- a/server/test/unit/providers/notifications/webhookProvider.test.ts +++ b/server/test/unit/providers/notifications/webhookProvider.test.ts @@ -29,6 +29,22 @@ describe("WebhookProvider", () => { expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); }); + it("uses webhook auth headers for test alerts", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert( + makeNotification({ + authType: "bearer", + authToken: "token-123", + }) + ); + + expect(mockGotPost.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Authorization: "Bearer token-123", + }) + ); + }); + it("returns false when address is missing", async () => { expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); }); @@ -57,6 +73,41 @@ describe("WebhookProvider", () => { expect(payload.monitor.id).toBe("mon-1"); }); + it("adds a Basic Authorization header when configured", async () => { + const { provider } = createProvider(); + await provider.sendMessage( + makeNotification({ + authType: "basic", + authUsername: "alice", + authPassword: "secret", + }) as any, + makeMessage() + ); + + expect(mockGotPost.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + ); + }); + + it("adds a Bearer Authorization header when configured", async () => { + const { provider } = createProvider(); + await provider.sendMessage( + makeNotification({ + authType: "bearer", + authToken: "token-123", + }) as any, + makeMessage() + ); + + expect(mockGotPost.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Authorization: "Bearer token-123", + }) + ); + }); + it("returns false when address is missing", async () => { expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); });