mirror of
https://github.com/karakeep-app/karakeep.git
synced 2025-12-12 20:35:52 +01:00
feat: add support for turnstile on signup
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ClientConfigCtx = createContext<ClientConfig>({
|
||||
disableSignups: false,
|
||||
disablePasswordAuth: false,
|
||||
},
|
||||
turnstile: null,
|
||||
inference: {
|
||||
isConfigured: false,
|
||||
inferredTagLang: "english",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
71
packages/trpc/lib/turnstile.ts
Normal file
71
packages/trpc/lib/turnstile.ts
Normal file
@@ -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"] };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user