From 363ca39db73d7dc7f765a06cfcef30d15fa4117a Mon Sep 17 00:00:00 2001 From: Oscar Date: Fri, 14 Feb 2025 07:11:19 +0100 Subject: [PATCH] feat: Captcha Plugin with multiple providers (#957) * feat: create Cloudflare Turnstile middleware plugin * fix: middleware returns new Response & working unit tests * chore: renamed plugin to cloudflare-turnstile * docs: adds Cloudflare Turnstile plugin documentation * docs: improves Cloudflare Turnstile's docs * feat: move to generic captcha plugin, supporting multiple providers --- docs/components/sidebar-content.tsx | 17 ++ docs/content/docs/plugins/captcha.mdx | 79 +++++++ .../src/plugins/captcha/captcha.test.ts | 212 ++++++++++++++++++ .../src/plugins/captcha/constants.ts | 15 ++ .../src/plugins/captcha/error-codes.ts | 6 + .../better-auth/src/plugins/captcha/index.ts | 67 ++++++ .../better-auth/src/plugins/captcha/types.ts | 31 +++ .../verify-handlers/cloudflare-turnstile.ts | 44 ++++ .../verify-handlers/google-recaptcha.ts | 44 ++++ .../plugins/captcha/verify-handlers/index.ts | 2 + .../src/utils/middleware-response.ts | 15 ++ 11 files changed, 532 insertions(+) create mode 100644 docs/content/docs/plugins/captcha.mdx create mode 100644 packages/better-auth/src/plugins/captcha/captcha.test.ts create mode 100644 packages/better-auth/src/plugins/captcha/constants.ts create mode 100644 packages/better-auth/src/plugins/captcha/error-codes.ts create mode 100644 packages/better-auth/src/plugins/captcha/index.ts create mode 100644 packages/better-auth/src/plugins/captcha/types.ts create mode 100644 packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts create mode 100644 packages/better-auth/src/plugins/captcha/verify-handlers/google-recaptcha.ts create mode 100644 packages/better-auth/src/plugins/captcha/verify-handlers/index.ts create mode 100644 packages/better-auth/src/utils/middleware-response.ts diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index 888a3988c8..7a6574c308 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -990,6 +990,23 @@ export const contents: Content[] = [ icon: () => , href: "/docs/plugins/bearer", }, + { + title: "Captcha", + href: "/docs/plugins/captcha", + icon: () => ( + + + + ), + }, { title: "Multi Session", icon: () => ( diff --git a/docs/content/docs/plugins/captcha.mdx b/docs/content/docs/plugins/captcha.mdx new file mode 100644 index 0000000000..fe6808b986 --- /dev/null +++ b/docs/content/docs/plugins/captcha.mdx @@ -0,0 +1,79 @@ +--- +title: Captcha +description: Captcha plugin +--- + +The **Captcha Plugin** integrates bot protection into your Better Auth system by adding captcha verification for key endpoints. This plugin ensures that only human users can perform actions like signing up, signing in, or resetting passwords. Two providers are currently supported: [Google reCAPTCHA](https://developers.google.com/recaptcha) and [Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/). + +## Installation + + + + ### Add the plugin to your **auth** config + + ```ts title="auth.ts" + import { betterAuth } from "better-auth"; + import { turnstile } from "better-auth/plugins"; + + export const auth = betterAuth({ + plugins: [ // [!code highlight] + turnstile({ // [!code highlight] + provider: "cloudflare-turnstile", // or "google-recaptcha" // [!code highlight] + secretKey: process.env.TURNSTILE_SECRET_KEY!, // [!code highlight] + }), // [!code highlight] + ], // [!code highlight] + }); + ``` + + + + ### Add the captcha token to your request headers + + Add the captcha token to your request headers for all protected endpoints. This example shows how to include it in a `signIn` request: + + ```ts + await authClient.signIn.email({ + email: "user@example.com", + password: "secure-password", + fetchOptions: { // [!code highlight] + headers: { // [!code highlight] + "x-captcha-response": turnstileToken, // [!code highlight] + }, // [!code highlight] + }, + }); + ``` + + - To implement Cloudflare Turnstile on the client side, see the official [Cloudflare Turnstile documentation](https://developers.cloudflare.com/turnstile/) or use a library like [react-turnstile](https://www.npmjs.com/package/@marsidev/react-turnstile). + - To implement Google reCAPTCHA on the client side, see the official [Google reCAPTCHA documentation](https://developers.google.com/recaptcha/intro) or use a library like [react-google-recaptcha](https://www.npmjs.com/package/react-google-recaptcha). + + + + +## How it works + + + + The plugin acts as a middleware: it intercepts all `POST` requests to configured endpoints (see `endpoints` + in the [Plugin Options](#plugin-options) section). + + + it validates the captcha token on the server, by calling the captcha provider's `/siteverify`. + + + - if the token is missing, gets rejected by the captcha provider, or if the `/siteverify` endpoint is + unavailable, the plugin returns an error and interrupts the request. + - if the token is accepted by the captcha provider, the middleware returns `undefined`, meaning the request is allowed to proceed. + + + + +## Plugin Options + +- **`provider` (required)** + Your captcha provider. Supported values are `cloudflare-turnstile` and `google-recaptcha`. +- **`secretKey` (required)** + Your captcha provider secret key used for the server-side validation of captcha tokens. +- **`endpoints` (optional)** + An array of paths where captcha validation is enforced. Defaults to: `["/sign-up", "/sign-in", "/forget-password"]`. +- **`siteVerifyURLOverride` (optional)** + Overrides the endpoint URL for the captcha verification request. diff --git a/packages/better-auth/src/plugins/captcha/captcha.test.ts b/packages/better-auth/src/plugins/captcha/captcha.test.ts new file mode 100644 index 0000000000..b743172db9 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/captcha.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, vi } from "vitest"; +import { getTestInstance } from "../../test-utils/test-instance"; +import { captcha } from "."; +import * as betterFetchModule from "@better-fetch/fetch"; + +vi.mock("@better-fetch/fetch", async (importOriginal) => { + const actual = (await importOriginal()) as typeof betterFetchModule; + return { + ...actual, + betterFetch: vi.fn(), + }; +}); + +describe("cloudflare-turnstile", async (it) => { + const mockBetterFetch = betterFetchModule.betterFetch as ReturnType< + typeof vi.fn + >; + + const { client } = await getTestInstance({ + plugins: [ + captcha({ provider: "cloudflare-turnstile", secretKey: "xx-secret-key" }), + ], + }); + const headers = new Headers(); + + it("Should successfuly sign users if they passed the CAPTCHA challenge", async () => { + mockBetterFetch.mockResolvedValue({ + data: { + success: true, + challenge_ts: "2022-02-28T15:14:30.096Z", + hostname: "example.com", + "error-codes": [], + action: "login", + cdata: "sessionid-123456789", + metadata: { + ephemeral_id: "x:9f78e0ed210960d7693b167e", + }, + }, + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.data?.user).toBeDefined(); + }); + + it("Should return 400 if no captcha token is found in the request headers", async () => { + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: {}, + }, + }); + expect(res.error?.status).toBe(400); + }); + + it("Should return 503 if the call to /siteverify fails", async () => { + mockBetterFetch.mockResolvedValue({ + error: "Failed to fetch", + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(503); + }); + + it("Should return 403 in case of a validation failure", async () => { + mockBetterFetch.mockResolvedValue({ + data: { + success: false, + "error-codes": ["invalid-input-response"], + }, + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(403); + }); + + it("Should return 500 if an unexpected error occurs", async () => { + mockBetterFetch.mockRejectedValue(new Error("Failed to fetch")); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(500); + }); +}); + +describe("google-recaptcha", async (it) => { + const mockBetterFetch = betterFetchModule.betterFetch as ReturnType< + typeof vi.fn + >; + + const { client } = await getTestInstance({ + plugins: [ + captcha({ provider: "google-recaptcha", secretKey: "xx-secret-key" }), + ], + }); + const headers = new Headers(); + + it("Should successfuly sign users if they passed the CAPTCHA challenge", async () => { + mockBetterFetch.mockResolvedValue({ + data: { + success: true, + challenge_ts: "2022-02-28T15:14:30.096Z", + hostname: "example.com", + }, + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.data?.user).toBeDefined(); + }); + + it("Should return 400 if no captcha token is found in the request headers", async () => { + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: {}, + }, + }); + expect(res.error?.status).toBe(400); + }); + + it("Should return 503 if the call to /siteverify fails", async () => { + mockBetterFetch.mockResolvedValue({ + error: "Failed to fetch", + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(503); + }); + + it("Should return 403 in case of a validation failure", async () => { + mockBetterFetch.mockResolvedValue({ + data: { + success: false, + "error-codes": ["invalid-input-response"], + }, + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "invalid-captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(403); + }); + + it("Should return 500 if an unexpected error occurs", async () => { + mockBetterFetch.mockRejectedValue(new Error("Failed to fetch")); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(500); + }); +}); diff --git a/packages/better-auth/src/plugins/captcha/constants.ts b/packages/better-auth/src/plugins/captcha/constants.ts new file mode 100644 index 0000000000..866e6d34e2 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/constants.ts @@ -0,0 +1,15 @@ +import type { Provider } from "./types"; + +export const defaultEndpoints = ["/sign-up", "/sign-in", "/forget-password"]; + +export const Providers = { + CLOUDFLARE_TURNSTILE: "cloudflare-turnstile", + GOOGLE_RECAPTCHA: "google-recaptcha", +} as const; + +export const siteVerifyMap: Record = { + [Providers.CLOUDFLARE_TURNSTILE]: + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + [Providers.GOOGLE_RECAPTCHA]: + "https://www.google.com/recaptcha/api/siteverify", +}; diff --git a/packages/better-auth/src/plugins/captcha/error-codes.ts b/packages/better-auth/src/plugins/captcha/error-codes.ts new file mode 100644 index 0000000000..68af1955a1 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/error-codes.ts @@ -0,0 +1,6 @@ +export const CAPTCHA_ERROR_CODES = { + MISSING_RESPONSE: "Missing CAPTCHA response", + SERVICE_UNAVAILABLE: "CAPTCHA service unavailable", + VERIFICATION_FAILED: "Captcha verification failed", + UNKNOWN_ERROR: "Something went wrong", +} as const; diff --git a/packages/better-auth/src/plugins/captcha/index.ts b/packages/better-auth/src/plugins/captcha/index.ts new file mode 100644 index 0000000000..d3b769342d --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/index.ts @@ -0,0 +1,67 @@ +import type { BetterAuthPlugin } from "better-auth/plugins"; +import type { Provider } from "./types"; +import { defaultEndpoints, Providers, siteVerifyMap } from "./constants"; +import { CAPTCHA_ERROR_CODES } from "./error-codes"; +import { middlewareResponse } from "../../utils/middleware-response"; +import * as verifyHandlers from "./verify-handlers"; + +/** + * @param {string} provider - The provider to use for captcha verification + * @param {string} secretKey - The secret key provided by the captcha service + * @param {string[]} [protectedEndpoints] - *(optional)* overrides the default protected endpoints: `["/sign-up", "/sign-in", "/forget-password"]` + * @param {string} [siteVerifyURLOverride] - *(optional)* overrides the default site verify URL + */ +export const captcha = (options: { + provider: Provider; + secretKey: string; + endpoints?: string[]; + siteVerifyURLOverride?: string; +}) => + ({ + id: "captcha", + onRequest: async (request) => { + try { + if (request.method !== "POST") return undefined; + + const endpoints = options.endpoints?.length + ? options.endpoints + : defaultEndpoints; + + if (!endpoints.some((endpoint) => request.url.includes(endpoint))) + return; + + const captchaResponse = request.headers.get("x-captcha-response"); + + if (!captchaResponse) { + return middlewareResponse({ + message: CAPTCHA_ERROR_CODES.MISSING_RESPONSE, + status: 400, + }); + } + + const siteVerifyURL = + options.siteVerifyURLOverride || siteVerifyMap[options.provider]; + + if (options.provider === Providers.CLOUDFLARE_TURNSTILE) { + return await verifyHandlers.cloudflareTurnstile({ + secretKey: options.secretKey, + captchaResponse, + siteVerifyURL, + }); + } + + if (options.provider === Providers.GOOGLE_RECAPTCHA) { + return await verifyHandlers.googleReCAPTCHA({ + secretKey: options.secretKey, + captchaResponse, + siteVerifyURL, + }); + } + } catch (_error) { + return middlewareResponse({ + message: CAPTCHA_ERROR_CODES.UNKNOWN_ERROR, + status: 500, + }); + } + }, + }) satisfies BetterAuthPlugin; diff --git a/packages/better-auth/src/plugins/captcha/types.ts b/packages/better-auth/src/plugins/captcha/types.ts new file mode 100644 index 0000000000..8676e4ec74 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/types.ts @@ -0,0 +1,31 @@ +import type { Providers } from "./constants"; +export type Provider = (typeof Providers)[keyof typeof Providers]; + +export type TurnstileSiteVerifyResponse = { + success: boolean; + "error-codes"?: string[]; + challenge_ts?: string; + hostname?: string; + action?: string; + cdata?: string; + metadata?: { + interactive: boolean; + }; + messages?: string[]; +}; + +export type GoogleReCAPTCHASiteVerifyResponse = { + success: boolean; + challenge_ts: string; + hostname: string; + "error-codes": + | Array< + | "missing-input-secret" + | "invalid-input-secret" + | "missing-input-response" + | "invalid-input-response" + | "bad-request" + | "timeout-or-duplicate" + > + | undefined; +}; diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts new file mode 100644 index 0000000000..bfbbc8fdc0 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts @@ -0,0 +1,44 @@ +import { betterFetch } from "@better-fetch/fetch"; +import { middlewareResponse } from "../../../utils/middleware-response"; +import { CAPTCHA_ERROR_CODES } from "../error-codes"; +import type { TurnstileSiteVerifyResponse } from "../types"; + +type Params = { + siteVerifyURL: string; + secretKey: string; + captchaResponse: string; +}; + +export const cloudflareTurnstile = async ({ + siteVerifyURL, + captchaResponse, + secretKey, +}: Params) => { + const response = await betterFetch( + siteVerifyURL, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + secret: secretKey, + response: captchaResponse, + }), + }, + ); + + if (!response.data || response.error) { + return middlewareResponse({ + message: CAPTCHA_ERROR_CODES.SERVICE_UNAVAILABLE, + status: 503, + }); + } + + if (!response.data.success) { + return middlewareResponse({ + message: CAPTCHA_ERROR_CODES.VERIFICATION_FAILED, + status: 403, + }); + } + + return undefined; +}; diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/google-recaptcha.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/google-recaptcha.ts new file mode 100644 index 0000000000..5810bc8116 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/verify-handlers/google-recaptcha.ts @@ -0,0 +1,44 @@ +import { betterFetch } from "@better-fetch/fetch"; +import { middlewareResponse } from "../../../utils/middleware-response"; +import { CAPTCHA_ERROR_CODES } from "../error-codes"; +import type { GoogleReCAPTCHASiteVerifyResponse } from "../types"; + +type Params = { + siteVerifyURL: string; + secretKey: string; + captchaResponse: string; +}; + +export const googleReCAPTCHA = async ({ + siteVerifyURL, + captchaResponse, + secretKey, +}: Params) => { + const response = await betterFetch( + siteVerifyURL, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + secret: secretKey, + response: captchaResponse, + }), + }, + ); + + if (!response.data || response.error) { + return middlewareResponse({ + message: CAPTCHA_ERROR_CODES.SERVICE_UNAVAILABLE, + status: 503, + }); + } + + if (!response.data.success) { + return middlewareResponse({ + message: CAPTCHA_ERROR_CODES.VERIFICATION_FAILED, + status: 403, + }); + } + + return undefined; +}; diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts new file mode 100644 index 0000000000..c3071b0013 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts @@ -0,0 +1,2 @@ +export { cloudflareTurnstile } from "./cloudflare-turnstile"; +export { googleReCAPTCHA } from "./google-recaptcha"; diff --git a/packages/better-auth/src/utils/middleware-response.ts b/packages/better-auth/src/utils/middleware-response.ts new file mode 100644 index 0000000000..768fbd3b40 --- /dev/null +++ b/packages/better-auth/src/utils/middleware-response.ts @@ -0,0 +1,15 @@ +type Params = { + message: string; + status: number; +}; + +export const middlewareResponse = ({ message, status }: Params) => ({ + response: new Response( + JSON.stringify({ + message, + }), + { + status, + }, + ), +});