diff --git a/apps/docs-new/content/docs/schools/set-up-payments.mdx b/apps/docs-new/content/docs/schools/set-up-payments.mdx index 8a46232d6..112756aab 100644 --- a/apps/docs-new/content/docs/schools/set-up-payments.mdx +++ b/apps/docs-new/content/docs/schools/set-up-payments.mdx @@ -34,7 +34,8 @@ CourseLit offers integrations with the following payment platforms: - In the destination type, select `Webhook endpoint`. - In the destination, enter your CourseLit school's webhook endpoint (listed in the same payment screen in your school). ![Stripe webhook destination](/assets/schools/stripe-courselit-webhook-entry.png) -9. That's it! Your Stripe configuration is complete, and you are ready to receive payments. +9. Copy the webhook signing secret from Stripe and paste it into `Stripe Webhook Secret` in your CourseLit payment settings. +10. That's it! Your Stripe configuration is complete, and you are ready to receive payments. ## Razorpay setup diff --git a/apps/docs/src/pages/en/schools/set-up-payments.md b/apps/docs/src/pages/en/schools/set-up-payments.md index 1766a92ff..1119d0c3e 100644 --- a/apps/docs/src/pages/en/schools/set-up-payments.md +++ b/apps/docs/src/pages/en/schools/set-up-payments.md @@ -35,7 +35,8 @@ CourseLit offers integrations with the following payment platforms: - In the destination type, select `Webhook endpoint`. - In the destination, enter your CourseLit school's webhook endpoint (listed in the same payment screen in your school). ![Stripe webhook destination](/assets/schools/stripe-courselit-webhook-entry.png) -9. That's it! Your Stripe configuration is complete, and you are ready to receive payments. +9. Copy the webhook signing secret from Stripe and paste it into `Stripe Webhook Secret` in your CourseLit payment settings. +10. That's it! Your Stripe configuration is complete, and you are ready to receive payments. ## Razorpay setup diff --git a/apps/web/app/api/payment/__tests__/stripe-payment.test.ts b/apps/web/app/api/payment/__tests__/stripe-payment.test.ts new file mode 100644 index 000000000..ee0e1df7f --- /dev/null +++ b/apps/web/app/api/payment/__tests__/stripe-payment.test.ts @@ -0,0 +1,86 @@ +/** + * @jest-environment node + */ + +import StripePayment from "../../../../payments-new/stripe-payment"; + +const siteInfo = { + currencyISOCode: "usd", + stripeKey: "pk_test_123", + stripeSecret: "sk_test_123", + stripeWebhookSecret: "whsec_test_123", +}; + +const checkoutSessionCompleted = { + id: "evt_test", + object: "event", + type: "checkout.session.completed", + data: { + object: { + id: "cs_test_123", + payment_status: "paid", + metadata: { + membershipId: "membership_123", + invoiceId: "invoice_123", + }, + }, + }, +}; + +describe("StripePayment webhook verification", () => { + it("accepts Stripe events with a valid signature over the raw body", async () => { + const payment = (await new StripePayment(siteInfo).setup()) as any; + const rawBody = JSON.stringify(checkoutSessionCompleted); + const signature = payment.stripe.webhooks.generateTestHeaderString({ + payload: rawBody, + secret: siteInfo.stripeWebhookSecret, + }); + + await expect( + payment.verify(checkoutSessionCompleted, { + rawBody, + signature, + }), + ).resolves.toBe(true); + }); + + it("rejects events when the Stripe signature is missing", async () => { + const payment = await new StripePayment(siteInfo).setup(); + const rawBody = JSON.stringify(checkoutSessionCompleted); + + await expect( + payment.verify(checkoutSessionCompleted, { + rawBody, + signature: null, + }), + ).resolves.toBe(false); + }); + + it("rejects events when the raw body has been changed", async () => { + const payment = (await new StripePayment(siteInfo).setup()) as any; + const rawBody = JSON.stringify(checkoutSessionCompleted); + const signature = payment.stripe.webhooks.generateTestHeaderString({ + payload: rawBody, + secret: siteInfo.stripeWebhookSecret, + }); + + await expect( + payment.verify(checkoutSessionCompleted, { + rawBody: JSON.stringify({ + ...checkoutSessionCompleted, + id: "evt_tampered", + }), + signature, + }), + ).resolves.toBe(false); + }); + + it("requires a Stripe webhook secret during setup", async () => { + await expect( + new StripePayment({ + ...siteInfo, + stripeWebhookSecret: undefined, + }).setup(), + ).rejects.toThrow("stripe"); + }); +}); diff --git a/apps/web/app/api/payment/webhook/route.ts b/apps/web/app/api/payment/webhook/route.ts index 4d1285fc9..724b5596e 100644 --- a/apps/web/app/api/payment/webhook/route.ts +++ b/apps/web/app/api/payment/webhook/route.ts @@ -17,7 +17,8 @@ import { activateMembership } from "../helpers"; export async function POST(req: NextRequest) { try { - const body = await req.json(); + const rawBody = await req.text(); + const body = JSON.parse(rawBody); const domainName = req.headers.get("domain"); const domain = await getDomain(domainName); @@ -30,11 +31,22 @@ export async function POST(req: NextRequest) { const paymentMethod = await getPaymentMethod(domain._id.toString()); if (!paymentMethod) { - return Response.json({ message: "Payment method not found" }); + return Response.json( + { message: "Payment method not found" }, + { status: 404 }, + ); } - if (!(await paymentMethod.verify(body))) { - return Response.json({ message: "Payment not verified" }); + if ( + !(await paymentMethod.verify(body, { + rawBody, + signature: req.headers.get("stripe-signature"), + })) + ) { + return Response.json( + { message: "Payment not verified" }, + { status: 400 }, + ); } const metadata = paymentMethod.getMetadata(body); @@ -42,7 +54,10 @@ export async function POST(req: NextRequest) { const membership = await getMembership(domain._id, membershipId); if (!membership) { - return Response.json({ message: "Membership not found" }); + return Response.json( + { message: "Membership not found" }, + { status: 404 }, + ); } const paymentPlan = await getPaymentPlan( diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index c502a8559..246c6cacc 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -8,6 +8,7 @@ import { SITE_SETTINGS_PAGE_HEADING, SITE_SETTINGS_CURRENCY, SITE_ADMIN_SETTINGS_STRIPE_SECRET, + SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET, SITE_ADMIN_SETTINGS_RAZORPAY_SECRET, SITE_ADMIN_SETTINGS_PAYPAL_SECRET, SITE_ADMIN_SETTINGS_PAYTM_SECRET, @@ -528,6 +529,7 @@ const Settings = (props: SettingsProps) => { $paymentMethod: String, $stripeKey: String, $stripeSecret: String, + $stripeWebhookSecret: String, $razorpayKey: String, $razorpaySecret: String, $razorpayWebhookSecret: String, @@ -543,6 +545,7 @@ const Settings = (props: SettingsProps) => { paymentMethod: $paymentMethod, stripeKey: $stripeKey, stripeSecret: $stripeSecret, + stripeWebhookSecret: $stripeWebhookSecret, razorpayKey: $razorpayKey, razorpaySecret: $razorpaySecret, razorpayWebhookSecret: $razorpayWebhookSecret, @@ -592,6 +595,7 @@ const Settings = (props: SettingsProps) => { paymentMethod: newSettings.paymentMethod, stripeKey: newSettings.stripeKey, stripeSecret: newSettings.stripeSecret, + stripeWebhookSecret: newSettings.stripeWebhookSecret, razorpayKey: newSettings.razorpayKey, razorpaySecret: newSettings.razorpaySecret, razorpayWebhookSecret: @@ -694,6 +698,9 @@ const Settings = (props: SettingsProps) => { stripeSecret: getNewSettings ? newSettings.stripeSecret : settings.stripeSecret, + stripeWebhookSecret: getNewSettings + ? newSettings.stripeWebhookSecret + : settings.stripeWebhookSecret, paypalSecret: getNewSettings ? newSettings.paypalSecret : settings.paypalSecret, @@ -1014,6 +1021,19 @@ const Settings = (props: SettingsProps) => { sx={{ mb: 2 }} autoComplete="off" /> + )} {newSettings.paymentMethod === diff --git a/apps/web/graphql/settings/helpers.ts b/apps/web/graphql/settings/helpers.ts index 8f23c6a8f..a32754e12 100644 --- a/apps/web/graphql/settings/helpers.ts +++ b/apps/web/graphql/settings/helpers.ts @@ -74,7 +74,11 @@ export const checkForInvalidPaymentMethodSettings = ( if ( siteInfo.paymentMethod === UIConstants.PAYMENT_METHOD_STRIPE && - !(siteInfo.stripeSecret && siteInfo.stripeKey) + !( + siteInfo.stripeSecret && + siteInfo.stripeKey && + siteInfo.stripeWebhookSecret + ) ) { failedPaymentMethod = UIConstants.PAYMENT_METHOD_STRIPE; } diff --git a/apps/web/graphql/settings/logic.ts b/apps/web/graphql/settings/logic.ts index aa39d58e0..6293cef9f 100644 --- a/apps/web/graphql/settings/logic.ts +++ b/apps/web/graphql/settings/logic.ts @@ -119,6 +119,7 @@ export const getSiteInfo = async (ctx: GQLContext) => { deleted: 0, customDomain: 0, "settings.stripeSecret": 0, + "settings.stripeWebhookSecret": 0, "settings.paytmSecret": 0, "settings.paypalSecret": 0, "settings.razorpaySecret": 0, diff --git a/apps/web/payments-new/payment.ts b/apps/web/payments-new/payment.ts index ba9d60e3d..ab5d8d43c 100644 --- a/apps/web/payments-new/payment.ts +++ b/apps/web/payments-new/payment.ts @@ -19,7 +19,10 @@ interface Metadata { export default interface Payment { setup: () => void; initiate: (obj: InitiateProps) => void; - verify: (event: any) => Promise; + verify: ( + event: any, + context?: { rawBody?: string; signature?: string | null }, + ) => Promise; getPaymentIdentifier: (event: any) => unknown; getMetadata: (event: any) => Record; getName: () => string; diff --git a/apps/web/payments-new/stripe-payment.ts b/apps/web/payments-new/stripe-payment.ts index 9f8aa3112..0c1fd41ce 100644 --- a/apps/web/payments-new/stripe-payment.ts +++ b/apps/web/payments-new/stripe-payment.ts @@ -33,6 +33,10 @@ export default class StripePayment implements Payment { throw new Error(`${this.name} ${paymentInvalidSettings}`); } + if (!this.siteinfo.stripeWebhookSecret) { + throw new Error(`${this.name} ${paymentInvalidSettings}`); + } + this.stripe = new Stripe(this.siteinfo.stripeSecret, { typescript: true, }); @@ -77,7 +81,28 @@ export default class StripePayment implements Payment { return this.siteinfo.currencyISOCode!; } - async verify(event: Stripe.Event) { + async verify( + event: Stripe.Event, + context?: { rawBody?: string; signature?: string | null }, + ) { + if ( + !context?.rawBody || + !context.signature || + !this.siteinfo.stripeWebhookSecret + ) { + return false; + } + + try { + event = this.stripe.webhooks.constructEvent( + context.rawBody, + context.signature, + this.siteinfo.stripeWebhookSecret, + ); + } catch { + return false; + } + if (!event) { return false; } diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index cf88ffb6e..c4c7c6f01 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -100,6 +100,8 @@ export const HEADER_COURSELIT = "About CourseLit"; export const MEDIA_SELECTOR_UPLOAD_BTN_CAPTION = "Upload a picture"; export const MEDIA_SELECTOR_REMOVE_BTN_CAPTION = "Remove picture"; export const SITE_ADMIN_SETTINGS_STRIPE_SECRET = "Stripe Secret Key"; +export const SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET = + "Stripe Webhook Secret"; export const SITE_ADMIN_SETTINGS_RAZORPAY_SECRET = "Razorpay Secret Key"; export const SITE_ADMIN_SETTINGS_RAZORPAY_WEBHOOK_SECRET = "Razorpay Webhook Secret"; diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts index 3430532a8..d88a40dd2 100644 --- a/packages/orm-models/src/models/site-info.ts +++ b/packages/orm-models/src/models/site-info.ts @@ -12,6 +12,7 @@ export const SettingsSchema = new mongoose.Schema({ codeInjectionHead: { type: String }, codeInjectionBody: { type: String }, stripeSecret: { type: String }, + stripeWebhookSecret: { type: String }, paytmSecret: { type: String }, paypalSecret: { type: String }, mailingAddress: { type: String },