aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/signup/SignUpForm.tsx42
-rw-r--r--apps/web/lib/clientConfig.tsx1
-rw-r--r--apps/web/package.json1
-rw-r--r--packages/shared/config.ts22
-rw-r--r--packages/shared/types/users.ts1
-rw-r--r--packages/trpc/lib/turnstile.ts71
-rw-r--r--packages/trpc/routers/users.ts13
-rw-r--r--pnpm-lock.yaml14
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