mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-26 17:06:41 -05:00
feat: (captcha plugin) adding support for Google ReCAPTCHA v3 and hCaptcha (#1836)
* feat: Adding support for HCaptcha and Google ReCAPTCHA v3 * docs: Update captcha plugin documentation with reCAPTCHA v3 and hCaptcha * fix: Restrict captcha verification to email sign-in and sign-up + updated documentation
This commit is contained in:
@@ -3,7 +3,14 @@ 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/).
|
||||
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. The following providers are currently supported:
|
||||
- [Google reCAPTCHA](https://developers.google.com/recaptcha)
|
||||
- [Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/)
|
||||
- [hCaptcha](https://www.hcaptcha.com/)
|
||||
|
||||
<Callout type="info">
|
||||
This plugin works out of the box with <Link href="/docs/authentication/email-password">Email & Password</Link> authentication. To use it with other authentication methods, you will need to configure the <Link href="/docs/plugins/captcha#plugin-options">endpoints</Link> array in the plugin options.
|
||||
</Callout>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -18,7 +25,7 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
|
||||
export const auth = betterAuth({
|
||||
plugins: [ // [!code highlight]
|
||||
captcha({ // [!code highlight]
|
||||
provider: "cloudflare-turnstile", // or "google-recaptcha" // [!code highlight]
|
||||
provider: "cloudflare-turnstile", // or google-recaptcha, hcaptcha // [!code highlight]
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY!, // [!code highlight]
|
||||
}), // [!code highlight]
|
||||
], // [!code highlight]
|
||||
@@ -38,14 +45,15 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
|
||||
fetchOptions: { // [!code highlight]
|
||||
headers: { // [!code highlight]
|
||||
"x-captcha-response": turnstileToken, // [!code highlight]
|
||||
"x-captcha-user-remote-ip": userIp, // optional: forwards the user's IP address to the captcha service // [!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).
|
||||
|
||||
- To implement Cloudflare Turnstile on the client side, follow 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, follow the official [Google reCAPTCHA documentation](https://developers.google.com/recaptcha/intro) or use libraries like [react-google-recaptcha](https://www.npmjs.com/package/react-google-recaptcha) (v2) and [react-google-recaptcha-v3](https://www.npmjs.com/package/react-google-recaptcha-v3) (v3).
|
||||
- To implement hCaptcha on the client side, follow the official [hCaptcha documentation](https://docs.hcaptcha.com/#add-the-hcaptcha-widget-to-your-webpage) or use libraries like [@hcaptcha/react-hcaptcha](https://www.npmjs.com/package/@hcaptcha/react-hcaptcha)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -69,11 +77,9 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
|
||||
|
||||
## 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.
|
||||
- **`provider` (required)**: your captcha provider.
|
||||
- **`secretKey` (required)**: your provider's secret key used for the server-side validation.
|
||||
- `endpoints` (optional): overrides the default array of paths where captcha validation is enforced. Default is: `["/sign-up/email", "/sign-in/email", "/forget-password",]`.
|
||||
- `minScore` (optional - only *Google ReCAPTCHA v3*): minimum score threshold. Default is `0.5`.
|
||||
- `siteKey` (optional - only *hCaptcha*): prevents tokens issued on one sitekey from being redeemed elsewhere.
|
||||
- `siteVerifyURLOverride` (optional): overrides endpoint URL for the captcha verification request.
|
||||
@@ -11,171 +11,21 @@ vi.mock("@better-fetch/fetch", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("cloudflare-turnstile", async (it) => {
|
||||
describe("captcha", 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 successful 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",
|
||||
},
|
||||
},
|
||||
it("Should ignore non-protected endpoints", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
endpoints: ["/sign-up"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -192,11 +42,63 @@ describe("google-recaptcha", async (it) => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.error?.status).toBe(403);
|
||||
expect(res.data?.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("Should return a 500 when missing secret key", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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(500);
|
||||
});
|
||||
|
||||
it("Should return 400 if no captcha token is found in the request headers", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const res = await client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "test123456",
|
||||
fetchOptions: {
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
expect(res.error?.status).toBe(400);
|
||||
});
|
||||
|
||||
it("Should return 500 if an unexpected error occurs", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
mockBetterFetch.mockRejectedValue(new Error("Failed to fetch"));
|
||||
|
||||
const res = await client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "test123456",
|
||||
@@ -209,4 +111,244 @@ describe("google-recaptcha", async (it) => {
|
||||
|
||||
expect(res.error?.status).toBe(500);
|
||||
});
|
||||
|
||||
describe("cloudflare-turnstile", async (it) => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const headers = new Headers();
|
||||
|
||||
it("Should successful sign in 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 500 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(500);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("google-recaptcha", async (it) => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({ provider: "google-recaptcha", secretKey: "xx-secret-key" }),
|
||||
],
|
||||
});
|
||||
const headers = new Headers();
|
||||
|
||||
it("Should successfuly sign in 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 500 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(500);
|
||||
});
|
||||
|
||||
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 403 in case of a too low score (ReCAPTCHA v3)", async () => {
|
||||
mockBetterFetch.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
score: 0.4, // Default minScore is 0.5
|
||||
action: "yourAction",
|
||||
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": "low-score-captcha-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
// TODO: Adding tests for hCaptcha
|
||||
});
|
||||
describe("hcaptcha", async (it) => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "hcaptcha",
|
||||
secretKey: "xx-secret-key",
|
||||
siteKey: "xx-site-key",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const headers = new Headers();
|
||||
|
||||
it("Should successfuly sign in 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 500 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(500);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// TODO: Adding tests for hCaptcha
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { Provider } from "./types";
|
||||
|
||||
export const defaultEndpoints = ["/sign-up", "/sign-in", "/forget-password"];
|
||||
export const defaultEndpoints = [
|
||||
"/sign-up/email",
|
||||
"/sign-in/email",
|
||||
"/forget-password",
|
||||
];
|
||||
|
||||
export const Providers = {
|
||||
CLOUDFLARE_TURNSTILE: "cloudflare-turnstile",
|
||||
GOOGLE_RECAPTCHA: "google-recaptcha",
|
||||
HCAPTCHA: "hcaptcha",
|
||||
} as const;
|
||||
|
||||
export const siteVerifyMap: Record<Provider, string> = {
|
||||
@@ -12,4 +17,5 @@ export const siteVerifyMap: Record<Provider, string> = {
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
[Providers.GOOGLE_RECAPTCHA]:
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
[Providers.HCAPTCHA]: "https://api.hcaptcha.com/siteverify",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export const CAPTCHA_ERROR_CODES = {
|
||||
MISSING_RESPONSE: "Missing CAPTCHA response",
|
||||
SERVICE_UNAVAILABLE: "CAPTCHA service unavailable",
|
||||
// These error codes are returned by the API
|
||||
export const EXTERNAL_ERROR_CODES = {
|
||||
VERIFICATION_FAILED: "Captcha verification failed",
|
||||
MISSING_RESPONSE: "Missing CAPTCHA response",
|
||||
UNKNOWN_ERROR: "Something went wrong",
|
||||
} as const;
|
||||
|
||||
// These error codes are only visible in the server logs
|
||||
export const INTERNAL_ERROR_CODES = {
|
||||
MISSING_SECRET_KEY: "Missing secret key",
|
||||
SERVICE_UNAVAILABLE: "CAPTCHA service unavailable",
|
||||
} as const;
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import type { BetterAuthPlugin } from "../../plugins";
|
||||
import type { Provider } from "./types";
|
||||
import type { CaptchaOptions } from "./types";
|
||||
import { defaultEndpoints, Providers, siteVerifyMap } from "./constants";
|
||||
import { CAPTCHA_ERROR_CODES } from "./error-codes";
|
||||
import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "./error-codes";
|
||||
import { middlewareResponse } from "../../utils/middleware-response";
|
||||
import * as verifyHandlers from "./verify-handlers";
|
||||
|
||||
export interface CaptchaOptions {
|
||||
provider: Provider;
|
||||
secretKey: string;
|
||||
endpoints?: string[];
|
||||
siteVerifyURLOverride?: string;
|
||||
}
|
||||
|
||||
export const captcha = (options: CaptchaOptions) =>
|
||||
({
|
||||
id: "captcha",
|
||||
onRequest: async (request) => {
|
||||
onRequest: async (request, ctx) => {
|
||||
try {
|
||||
if (request.method !== "POST") return undefined;
|
||||
|
||||
const endpoints = options.endpoints?.length
|
||||
? options.endpoints
|
||||
: defaultEndpoints;
|
||||
|
||||
if (!endpoints.some((endpoint) => request.url.includes(endpoint)))
|
||||
return;
|
||||
return undefined;
|
||||
|
||||
if (!options.secretKey) {
|
||||
throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY);
|
||||
}
|
||||
|
||||
const captchaResponse = request.headers.get("x-captcha-response");
|
||||
const remoteUserIP =
|
||||
request.headers.get("x-captcha-user-remote-ip") ?? undefined;
|
||||
|
||||
if (!captchaResponse) {
|
||||
return middlewareResponse({
|
||||
message: CAPTCHA_ERROR_CODES.MISSING_RESPONSE,
|
||||
message: EXTERNAL_ERROR_CODES.MISSING_RESPONSE,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
@@ -38,24 +35,41 @@ export const captcha = (options: CaptchaOptions) =>
|
||||
const siteVerifyURL =
|
||||
options.siteVerifyURLOverride || siteVerifyMap[options.provider];
|
||||
|
||||
const handlerParams = {
|
||||
siteVerifyURL,
|
||||
captchaResponse,
|
||||
secretKey: options.secretKey,
|
||||
remoteIP: remoteUserIP,
|
||||
};
|
||||
|
||||
if (options.provider === Providers.CLOUDFLARE_TURNSTILE) {
|
||||
return await verifyHandlers.cloudflareTurnstile({
|
||||
secretKey: options.secretKey,
|
||||
captchaResponse,
|
||||
siteVerifyURL,
|
||||
});
|
||||
return await verifyHandlers.cloudflareTurnstile(handlerParams);
|
||||
}
|
||||
|
||||
if (options.provider === Providers.GOOGLE_RECAPTCHA) {
|
||||
return await verifyHandlers.googleReCAPTCHA({
|
||||
secretKey: options.secretKey,
|
||||
captchaResponse,
|
||||
siteVerifyURL,
|
||||
return await verifyHandlers.googleRecaptcha({
|
||||
...handlerParams,
|
||||
minScore: options.minScore,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.provider === Providers.HCAPTCHA) {
|
||||
return await verifyHandlers.hCaptcha({
|
||||
...handlerParams,
|
||||
siteKey: options.siteKey,
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
const errorMessage =
|
||||
_error instanceof Error ? _error.message : undefined;
|
||||
|
||||
ctx.logger.error(errorMessage ?? "Unknown error", {
|
||||
endpoint: request.url,
|
||||
message: _error,
|
||||
});
|
||||
|
||||
return middlewareResponse({
|
||||
message: CAPTCHA_ERROR_CODES.UNKNOWN_ERROR,
|
||||
message: EXTERNAL_ERROR_CODES.UNKNOWN_ERROR,
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
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 interface BaseCaptchaOptions {
|
||||
secretKey: string;
|
||||
endpoints?: string[];
|
||||
siteVerifyURLOverride?: 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;
|
||||
};
|
||||
export interface GoogleRecaptchaOptions extends BaseCaptchaOptions {
|
||||
provider: typeof Providers.GOOGLE_RECAPTCHA;
|
||||
minScore?: number;
|
||||
}
|
||||
|
||||
export interface CloudflareTurnstileOptions extends BaseCaptchaOptions {
|
||||
provider: typeof Providers.CLOUDFLARE_TURNSTILE;
|
||||
}
|
||||
|
||||
export interface HCaptchaOptions extends BaseCaptchaOptions {
|
||||
provider: typeof Providers.HCAPTCHA;
|
||||
siteKey?: string;
|
||||
}
|
||||
|
||||
export type CaptchaOptions =
|
||||
| GoogleRecaptchaOptions
|
||||
| CloudflareTurnstileOptions
|
||||
| HCaptchaOptions;
|
||||
|
||||
15
packages/better-auth/src/plugins/captcha/utils.ts
Normal file
15
packages/better-auth/src/plugins/captcha/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const encodeToURLParams = (obj: Record<string, any>): string => {
|
||||
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
||||
throw new Error("Input must be a non-null object.");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
@@ -1,41 +1,50 @@
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { middlewareResponse } from "../../../utils/middleware-response";
|
||||
import { CAPTCHA_ERROR_CODES } from "../error-codes";
|
||||
import type { TurnstileSiteVerifyResponse } from "../types";
|
||||
import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes";
|
||||
|
||||
type Params = {
|
||||
siteVerifyURL: string;
|
||||
secretKey: string;
|
||||
captchaResponse: string;
|
||||
remoteIP?: string;
|
||||
};
|
||||
|
||||
type SiteVerifyResponse = {
|
||||
success: boolean;
|
||||
"error-codes"?: string[];
|
||||
challenge_ts?: string;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
cdata?: string;
|
||||
metadata?: {
|
||||
interactive: boolean;
|
||||
};
|
||||
messages?: string[];
|
||||
};
|
||||
|
||||
export const cloudflareTurnstile = async ({
|
||||
siteVerifyURL,
|
||||
captchaResponse,
|
||||
secretKey,
|
||||
remoteIP,
|
||||
}: Params) => {
|
||||
const response = await betterFetch<TurnstileSiteVerifyResponse>(
|
||||
siteVerifyURL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
secret: secretKey,
|
||||
response: captchaResponse,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await betterFetch<SiteVerifyResponse>(siteVerifyURL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
secret: secretKey,
|
||||
response: captchaResponse,
|
||||
...(remoteIP && { remoteip: remoteIP }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.data || response.error) {
|
||||
return middlewareResponse({
|
||||
message: CAPTCHA_ERROR_CODES.SERVICE_UNAVAILABLE,
|
||||
status: 503,
|
||||
});
|
||||
throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
if (!response.data.success) {
|
||||
return middlewareResponse({
|
||||
message: CAPTCHA_ERROR_CODES.VERIFICATION_FAILED,
|
||||
message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED,
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +1,72 @@
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { middlewareResponse } from "../../../utils/middleware-response";
|
||||
import { CAPTCHA_ERROR_CODES } from "../error-codes";
|
||||
import type { GoogleReCAPTCHASiteVerifyResponse } from "../types";
|
||||
import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes";
|
||||
import { encodeToURLParams } from "../utils";
|
||||
|
||||
type Params = {
|
||||
siteVerifyURL: string;
|
||||
secretKey: string;
|
||||
captchaResponse: string;
|
||||
minScore?: number;
|
||||
remoteIP?: string;
|
||||
};
|
||||
|
||||
export const googleReCAPTCHA = async ({
|
||||
type SiteVerifyResponse = {
|
||||
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;
|
||||
};
|
||||
|
||||
type SiteVerifyV3Response = SiteVerifyResponse & {
|
||||
score: number;
|
||||
};
|
||||
|
||||
const isV3 = (
|
||||
response: SiteVerifyResponse | SiteVerifyV3Response,
|
||||
): response is SiteVerifyV3Response => {
|
||||
return "score" in response && typeof response.score === "number";
|
||||
};
|
||||
|
||||
export const googleRecaptcha = async ({
|
||||
siteVerifyURL,
|
||||
captchaResponse,
|
||||
secretKey,
|
||||
minScore = 0.5,
|
||||
remoteIP,
|
||||
}: Params) => {
|
||||
const response = await betterFetch<GoogleReCAPTCHASiteVerifyResponse>(
|
||||
const response = await betterFetch<SiteVerifyResponse | SiteVerifyV3Response>(
|
||||
siteVerifyURL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: encodeToURLParams({
|
||||
secret: secretKey,
|
||||
response: captchaResponse,
|
||||
...(remoteIP && { remoteip: remoteIP }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.data || response.error) {
|
||||
return middlewareResponse({
|
||||
message: CAPTCHA_ERROR_CODES.SERVICE_UNAVAILABLE,
|
||||
status: 503,
|
||||
});
|
||||
throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
if (!response.data.success) {
|
||||
if (
|
||||
!response.data.success ||
|
||||
(isV3(response.data) && response.data.score < minScore)
|
||||
) {
|
||||
return middlewareResponse({
|
||||
message: CAPTCHA_ERROR_CODES.VERIFICATION_FAILED,
|
||||
message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED,
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { middlewareResponse } from "../../../utils/middleware-response";
|
||||
import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes";
|
||||
import { encodeToURLParams } from "../utils";
|
||||
|
||||
type Params = {
|
||||
siteVerifyURL: string;
|
||||
secretKey: string;
|
||||
captchaResponse: string;
|
||||
siteKey?: string;
|
||||
remoteIP?: string;
|
||||
};
|
||||
|
||||
type SiteVerifyResponse = {
|
||||
success: boolean;
|
||||
challenge_ts: number;
|
||||
hostname: string;
|
||||
credit: true | false | undefined;
|
||||
"error-codes":
|
||||
| Array<
|
||||
| "missing-input-secret"
|
||||
| "invalid-input-secret"
|
||||
| "missing-input-response"
|
||||
| "invalid-input-response"
|
||||
| "expired-input-response"
|
||||
| "already-seen-response"
|
||||
| "bad-request"
|
||||
| "missing-remoteip"
|
||||
| "invalid-remoteip"
|
||||
| "not-using-dummy-passcode"
|
||||
| "sitekey-secret-mismatch"
|
||||
>
|
||||
| undefined;
|
||||
score: number | undefined; // ENTERPRISE feature: a score denoting malicious activity.
|
||||
score_reason: Array<unknown> | undefined; // ENTERPRISE feature: reason(s) for score.
|
||||
};
|
||||
|
||||
export const hCaptcha = async ({
|
||||
siteVerifyURL,
|
||||
captchaResponse,
|
||||
secretKey,
|
||||
siteKey,
|
||||
remoteIP,
|
||||
}: Params) => {
|
||||
const response = await betterFetch<SiteVerifyResponse>(siteVerifyURL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: encodeToURLParams({
|
||||
secret: secretKey,
|
||||
response: captchaResponse,
|
||||
...(siteKey && { sitekey: siteKey }),
|
||||
...(remoteIP && { remoteip: remoteIP }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.data || response.error) {
|
||||
throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
if (!response.data.success) {
|
||||
return middlewareResponse({
|
||||
message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED,
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export { cloudflareTurnstile } from "./cloudflare-turnstile";
|
||||
export { googleReCAPTCHA } from "./google-recaptcha";
|
||||
export { googleRecaptcha } from "./google-recaptcha";
|
||||
export { hCaptcha } from "./h-captcha";
|
||||
|
||||
Reference in New Issue
Block a user