mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-25 10:13:54 -05:00
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
This commit is contained in:
@@ -990,6 +990,23 @@ export const contents: Content[] = [
|
||||
icon: () => <Key className="w-4 h-4" />,
|
||||
href: "/docs/plugins/bearer",
|
||||
},
|
||||
{
|
||||
title: "Captcha",
|
||||
href: "/docs/plugins/captcha",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M38.023 18.932A6.3 6.3 0 0 0 42.505 13c0-4.056-2.9-7-8.238-7a14.3 14.3 0 0 0-6.829 1.665.369.369 0 0 0-.171.383v2.732c0 .171.043.212.256.129A11.552 11.552 0 0 1 33.669 9.2c3.756 0 5.336 1.834 5.336 4.224 0 2.732-2.3 4.183-6.061 4.183h-1.58c-.213 0-.256.129-.256.3V20.6c0 .171.086.256.3.256H33.2c4.225 0 7.042 1.537 7.042 4.951 0 2.691-1.878 4.993-6.487 4.993a18.98 18.98 0 0 1-6.655-1.748 10.11 10.11 0 0 0 .882-4.107c0-6.281-4.631-8.511-8.6-8.511A16.789 16.789 0 0 0 12 18.379V3a1 1 0 0 0-1-1l-1.99.007a1 1 0 0 0-.795.4L4.4 5.453a2 2 0 0 0-.4 1.2v.331a1 1 0 0 0 1 1h3v19a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-4.91a14.046 14.046 0 0 1 6.709-2.012c3.4 0 5.469 1.661 5.469 5 0 2.566-1.252 5.06-5.065 9.273a65.711 65.711 0 0 1-6.849 6.719.666.666 0 0 0-.226.558v1.891c0 .43.283.492.451.492H28.2c.317 0 .416-.113.531-.4l.627-2.6a.362.362 0 0 0-.046-.324.479.479 0 0 0-.4-.137h-5.795c-3.224 0-3.886 0-5.152.082a40.482 40.482 0 0 0 4.957-5.367c1-1.222 1.855-2.33 2.586-3.4A22.187 22.187 0 0 0 33.8 34c5.763 0 10.074-2.945 10.074-8.2-.003-4.395-3.374-6.315-5.851-6.868Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Multi Session",
|
||||
icon: () => (
|
||||
|
||||
@@ -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
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### 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]
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### 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).
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## How it works
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
The plugin acts as a middleware: it intercepts all `POST` requests to configured endpoints (see `endpoints`
|
||||
in the [Plugin Options](#plugin-options) section).
|
||||
</Step>
|
||||
<Step>
|
||||
it validates the captcha token on the server, by calling the captcha provider's `/siteverify`.
|
||||
</Step>
|
||||
<Step>
|
||||
- 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.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Provider, string> = {
|
||||
[Providers.CLOUDFLARE_TURNSTILE]:
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
[Providers.GOOGLE_RECAPTCHA]:
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<TurnstileSiteVerifyResponse>(
|
||||
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;
|
||||
};
|
||||
@@ -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<GoogleReCAPTCHASiteVerifyResponse>(
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { cloudflareTurnstile } from "./cloudflare-turnstile";
|
||||
export { googleReCAPTCHA } from "./google-recaptcha";
|
||||
@@ -0,0 +1,15 @@
|
||||
type Params = {
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export const middlewareResponse = ({ message, status }: Params) => ({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
message,
|
||||
}),
|
||||
{
|
||||
status,
|
||||
},
|
||||
),
|
||||
});
|
||||
Reference in New Issue
Block a user