diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-30 00:01:07 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-11-30 00:01:07 +0000 |
| commit | b12c1c3a82941f2767ade8f497db56933415b94d (patch) | |
| tree | b1fa65f111c21bfb996c8b99ebff2d60c11a5876 | |
| parent | 4898b6be87c6edec8c74d69317899ce918c550ad (diff) | |
| download | karakeep-b12c1c3a82941f2767ade8f497db56933415b94d.tar.zst | |
feat: add support for turnstile on signup
| -rw-r--r-- | apps/web/components/signup/SignUpForm.tsx | 42 | ||||
| -rw-r--r-- | apps/web/lib/clientConfig.tsx | 1 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | packages/shared/config.ts | 22 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 1 | ||||
| -rw-r--r-- | packages/trpc/lib/turnstile.ts | 71 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 13 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 14 |
8 files changed, 165 insertions, 0 deletions
diff --git a/apps/web/components/signup/SignUpForm.tsx b/apps/web/components/signup/SignUpForm.tsx index a1e8dadf..bd4a1cf2 100644 --- a/apps/web/components/signup/SignUpForm.tsx +++ b/apps/web/components/signup/SignUpForm.tsx @@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input"; import { useClientConfig } from "@/lib/clientConfig"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Turnstile } from "@marsidev/react-turnstile"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, UserX } from "lucide-react"; import { signIn } from "next-auth/react"; @@ -43,11 +44,13 @@ export default function SignUpForm() { name: "", password: "", confirmPassword: "", + turnstileToken: "", }, }); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); const clientConfig = useClientConfig(); + const turnstileSiteKey = clientConfig.turnstile?.siteKey; const createUserMutation = api.users.create.useMutation(); @@ -97,6 +100,14 @@ export default function SignUpForm() { <Form {...form}> <form onSubmit={form.handleSubmit(async (value) => { + if (turnstileSiteKey && !value.turnstileToken) { + form.setError("turnstileToken", { + type: "manual", + message: "Please complete the verification challenge", + }); + return; + } + form.clearErrors("turnstileToken"); try { await createUserMutation.mutateAsync(value); } catch (e) { @@ -205,6 +216,37 @@ export default function SignUpForm() { )} /> + {turnstileSiteKey && ( + <FormField + control={form.control} + name="turnstileToken" + render={({ field }) => ( + <FormItem> + <FormLabel>Verification</FormLabel> + <FormControl> + <Turnstile + siteKey={turnstileSiteKey} + onSuccess={(token) => { + field.onChange(token); + form.clearErrors("turnstileToken"); + }} + onExpire={() => field.onChange("")} + onError={() => { + field.onChange(""); + form.setError("turnstileToken", { + type: "manual", + message: + "Verification failed, please reload the challenge", + }); + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + <ActionButton type="submit" loading={ diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx index 03089e49..9331a7af 100644 --- a/apps/web/lib/clientConfig.tsx +++ b/apps/web/lib/clientConfig.tsx @@ -10,6 +10,7 @@ export const ClientConfigCtx = createContext<ClientConfig>({ disableSignups: false, disablePasswordAuth: false, }, + turnstile: null, inference: { isConfigured: false, inferredTagLang: "english", diff --git a/apps/web/package.json b/apps/web/package.json index 67ce2560..0c53490d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@lexical/plain-text": "^0.20.2", "@lexical/react": "^0.20.2", "@lexical/rich-text": "^0.20.2", + "@marsidev/react-turnstile": "^1.3.1", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 634f083b..60beae1e 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -54,6 +54,8 @@ const allEnv = z.object({ OAUTH_TIMEOUT: z.coerce.number().optional().default(3500), OAUTH_SCOPE: z.string().default("openid email profile"), OAUTH_PROVIDER_NAME: z.string().default("Custom Provider"), + TURNSTILE_SITE_KEY: z.string().optional(), + TURNSTILE_SECRET_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_BASE_URL: z.string().url().optional(), OLLAMA_BASE_URL: z.string().url().optional(), @@ -237,6 +239,11 @@ const serverConfigSchema = allEnv.transform((val, ctx) => { name: val.OAUTH_PROVIDER_NAME, timeout: val.OAUTH_TIMEOUT, }, + turnstile: { + enabled: val.TURNSTILE_SITE_KEY !== undefined, + siteKey: val.TURNSTILE_SITE_KEY, + secretKey: val.TURNSTILE_SECRET_KEY, + }, }, email: { smtp: val.SMTP_HOST @@ -401,6 +408,15 @@ const serverConfigSchema = allEnv.transform((val, ctx) => { }); return z.NEVER; } + if (obj.auth.turnstile.enabled && !obj.auth.turnstile.secretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "TURNSTILE_SECRET_KEY is required when TURNSTILE_SITE_KEY is set", + fatal: true, + }); + return z.NEVER; + } return obj; }); @@ -416,6 +432,12 @@ export const clientConfig = { disableSignups: serverConfig.auth.disableSignups, disablePasswordAuth: serverConfig.auth.disablePasswordAuth, }, + turnstile: + serverConfig.auth.turnstile.enabled && serverConfig.auth.turnstile.siteKey + ? { + siteKey: serverConfig.auth.turnstile.siteKey, + } + : null, inference: { isConfigured: serverConfig.inference.isConfigured, inferredTagLang: serverConfig.inference.inferredTagLang, diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 2fad4f83..9f020d52 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -11,6 +11,7 @@ export const zSignUpSchema = z email: z.string().email(), password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), confirmPassword: z.string(), + turnstileToken: z.string().optional(), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", diff --git a/packages/trpc/lib/turnstile.ts b/packages/trpc/lib/turnstile.ts new file mode 100644 index 00000000..3ba25f05 --- /dev/null +++ b/packages/trpc/lib/turnstile.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +import serverConfig from "@karakeep/shared/config"; +import logger from "@karakeep/shared/logger"; + +const TurnstileVerifyResponseSchema = z.object({ + success: z.boolean(), + challenge_ts: z.string().optional(), + hostname: z.string().optional(), + "error-codes": z.array(z.string()).optional(), +}); + +export async function verifyTurnstileToken( + token: string, + remoteIp?: string | null, +) { + if (!serverConfig.auth.turnstile.enabled) { + return { success: true }; + } + + if (!token) { + return { success: false, "error-codes": ["missing-input-response"] }; + } + + const body = new URLSearchParams(); + body.append("secret", serverConfig.auth.turnstile.secretKey!); + body.append("response", token); + if (remoteIp) { + body.append("remoteip", remoteIp); + } + + try { + const response = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + body, + }, + ); + + if (!response.ok) { + logger.warn( + `[Turnstile] Verification request failed with status ${response.status}`, + ); + return { success: false, "error-codes": ["request-not-ok"] }; + } + + const json = await response.json(); + const parseResult = TurnstileVerifyResponseSchema.safeParse(json); + + if (!parseResult.success) { + logger.warn("[Turnstile] Invalid response format", { + error: parseResult.error, + remoteIp, + }); + return { success: false, "error-codes": ["invalid-response"] }; + } + + const parsed = parseResult.data; + if (!parsed.success) { + logger.warn("[Turnstile] Verification failed", { + errorCodes: parsed["error-codes"], + remoteIp, + }); + } + return parsed; + } catch (error) { + logger.warn("[Turnstile] Verification threw", { error, remoteIp }); + return { success: false, "error-codes": ["internal-error"] }; + } +} diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index 5ce9c67e..d3bc06d9 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -18,6 +18,7 @@ import { publicProcedure, router, } from "../index"; +import { verifyTurnstileToken } from "../lib/turnstile"; import { User } from "../models/users"; export const usersAppRouter = router({ @@ -51,6 +52,18 @@ export const usersAppRouter = router({ message: errorMessage, }); } + if (serverConfig.auth.turnstile.enabled) { + const result = await verifyTurnstileToken( + input.turnstileToken ?? "", + ctx.req.ip, + ); + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Turnstile verification failed", + }); + } + } const user = await User.create(ctx, input); return { id: user.id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22e69d0..adc2495f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: '@lexical/rich-text': specifier: ^0.20.2 version: 0.20.2 + '@marsidev/react-turnstile': + specifier: ^1.3.1 + version: 1.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-collapsible': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -3935,6 +3938,12 @@ packages: peerDependencies: yjs: '>=13.5.22' + '@marsidev/react-turnstile@1.3.1': + resolution: {integrity: sha512-h2THG/75k4Y049hgjSGPIcajxXnh+IZAiXVbryQyVmagkboN7pJtBgR16g8akjwUBSfRrg6jw6KvPDjscQflog==} + peerDependencies: + react: ^17.0.2 || ^18.0.0 || ^19.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} @@ -18937,6 +18946,11 @@ snapshots: lexical: 0.20.2 yjs: 13.6.27 + '@marsidev/react-turnstile@1.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@mdx-js/mdx@3.1.0(acorn@8.15.0)': dependencies: '@types/estree': 1.0.8 |
