Skip to content

Commit 698e19d

Browse files
committed
Turnstile implementation
1 parent 83b334c commit 698e19d

File tree

17 files changed

+607
-10
lines changed

17 files changed

+607
-10
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ DISCORD_PUBLIC_KEY=dummy_discord_public_key
3333
DISCORD_BOT_TOKEN=dummy_discord_bot_token
3434
DISCORD_APPLICATION_ID=dummy_discord_app_id
3535

36+
# Cloudflare Turnstile (optional — bot protection on signup)
37+
TURNSTILE_SECRET_KEY=dummy_turnstile_secret_key
38+
NEXT_PUBLIC_TURNSTILE_SITE_KEY=dummy_turnstile_site_key
39+
3640
# Frontend/Public Variables
3741
NEXT_PUBLIC_CB_ENVIRONMENT=dev
3842
NEXT_PUBLIC_CODEBUFF_APP_URL=http://localhost:3000

common/src/env-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const clientEnvSchema = z.object({
1111
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
1212
NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1),
1313
NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(),
14+
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
1415
NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000),
1516
} satisfies Record<`${typeof CLIENT_ENV_PREFIX}${string}`, any>)
1617
export const clientEnvVars = clientEnvSchema.keyof().options
@@ -33,5 +34,6 @@ export const clientProcessEnv: ClientInput = {
3334
process.env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL,
3435
NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID:
3536
process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID,
37+
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
3638
NEXT_PUBLIC_WEB_PORT: process.env.NEXT_PUBLIC_WEB_PORT,
3739
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import crypto from 'crypto'
2+
3+
import { env } from '@codebuff/internal/env'
4+
import { NextResponse } from 'next/server'
5+
import { z } from 'zod/v4'
6+
7+
import type { NextRequest } from 'next/server'
8+
9+
import { logger } from '@/util/logger'
10+
11+
const TURNSTILE_VERIFY_URL =
12+
'https://challenges.cloudflare.com/turnstile/v0/siteverify'
13+
14+
export async function POST(request: NextRequest) {
15+
const secretKey = env.TURNSTILE_SECRET_KEY
16+
if (!secretKey) {
17+
return NextResponse.json({ success: true })
18+
}
19+
20+
const body = await request.json()
21+
const parsed = z.object({ token: z.string().min(1) }).safeParse(body)
22+
23+
if (!parsed.success) {
24+
return NextResponse.json(
25+
{ success: false, error: 'Invalid token' },
26+
{ status: 400 },
27+
)
28+
}
29+
30+
const formData = new FormData()
31+
formData.append('secret', secretKey)
32+
formData.append('response', parsed.data.token)
33+
34+
const ip =
35+
request.headers.get('CF-Connecting-IP') ??
36+
request.headers.get('X-Forwarded-For') ??
37+
''
38+
if (ip) {
39+
formData.append('remoteip', ip)
40+
}
41+
42+
const result = await fetch(TURNSTILE_VERIFY_URL, {
43+
method: 'POST',
44+
body: formData,
45+
})
46+
const outcome = (await result.json()) as {
47+
success: boolean
48+
'error-codes'?: string[]
49+
}
50+
51+
if (!outcome.success) {
52+
logger.warn(
53+
{ errorCodes: outcome['error-codes'] },
54+
'Turnstile verification failed',
55+
)
56+
return NextResponse.json(
57+
{ success: false, error: 'Verification failed' },
58+
{ status: 403 },
59+
)
60+
}
61+
62+
const timestamp = Date.now().toString()
63+
const signature = crypto
64+
.createHmac('sha256', env.NEXTAUTH_SECRET)
65+
.update(timestamp)
66+
.digest('hex')
67+
68+
const response = NextResponse.json({ success: true })
69+
response.cookies.set('turnstile_verified', `${timestamp}.${signature}`, {
70+
httpOnly: true,
71+
secure: process.env.NODE_ENV === 'production',
72+
sameSite: 'lax',
73+
maxAge: 300,
74+
path: '/',
75+
})
76+
77+
return response
78+
}

freebuff/web/src/components/login/login-card.tsx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import Image from 'next/image'
44
import { useSearchParams } from 'next/navigation'
55
import { useSession, signIn } from 'next-auth/react'
6-
import { Suspense } from 'react'
6+
import { Suspense, useCallback, useRef, useState } from 'react'
77

88
import { SignInCardFooter } from '@/components/sign-in/sign-in-card-footer'
9+
import { TurnstileWidget } from '@/components/turnstile-widget'
910
import { Button } from '@/components/ui/button'
1011
import {
1112
Card,
@@ -15,9 +16,48 @@ import {
1516
CardFooter,
1617
} from '@/components/ui/card'
1718

19+
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
20+
1821
export function LoginCard({ authCode }: { authCode?: string | null }) {
1922
const { data: session } = useSession()
2023
const searchParams = useSearchParams() ?? new URLSearchParams()
24+
const [turnstileVerified, setTurnstileVerified] = useState(
25+
!TURNSTILE_SITE_KEY,
26+
)
27+
const [turnstileError, setTurnstileError] = useState<string | null>(null)
28+
const turnstileErrorShownRef = useRef(false)
29+
30+
const handleTurnstileVerify = useCallback(async (token: string) => {
31+
try {
32+
const response = await fetch('/api/auth/verify-turnstile', {
33+
method: 'POST',
34+
headers: { 'Content-Type': 'application/json' },
35+
body: JSON.stringify({ token }),
36+
})
37+
const result = await response.json()
38+
if (result.success) {
39+
setTurnstileVerified(true)
40+
setTurnstileError(null)
41+
turnstileErrorShownRef.current = false
42+
} else {
43+
setTurnstileError('Verification failed. Please refresh and try again.')
44+
}
45+
} catch {
46+
setTurnstileError('Verification failed. Please refresh and try again.')
47+
}
48+
}, [])
49+
50+
const handleTurnstileError = useCallback((errorCode: string) => {
51+
console.error('Turnstile error:', errorCode)
52+
if (!turnstileErrorShownRef.current) {
53+
turnstileErrorShownRef.current = true
54+
setTurnstileError('Verification error. Please refresh and try again.')
55+
}
56+
}, [])
57+
58+
const handleTurnstileExpired = useCallback(() => {
59+
setTurnstileVerified(false)
60+
}, [])
2161

2262
const handleContinueAsUser = () => {
2363
const referralCode = searchParams.get('referral_code')
@@ -129,7 +169,22 @@ export function LoginCard({ authCode }: { authCode?: string | null }) {
129169
</CardFooter>
130170
</>
131171
) : (
132-
<SignInCardFooter />
172+
<>
173+
{TURNSTILE_SITE_KEY && (
174+
<CardContent className="flex flex-col items-center gap-2">
175+
<TurnstileWidget
176+
siteKey={TURNSTILE_SITE_KEY}
177+
onVerify={handleTurnstileVerify}
178+
onError={handleTurnstileError}
179+
onExpired={handleTurnstileExpired}
180+
/>
181+
{turnstileError && (
182+
<p className="text-sm text-red-400">{turnstileError}</p>
183+
)}
184+
</CardContent>
185+
)}
186+
<SignInCardFooter disabled={!turnstileVerified} />
187+
</>
133188
)}
134189
</Card>
135190
</Suspense>

freebuff/web/src/components/sign-in/sign-in-button.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import type { OAuthProviderType } from 'next-auth/providers/oauth-types'
1212
export function SignInButton({
1313
providerName,
1414
providerDomain,
15+
disabled,
1516
}: {
1617
providerName: OAuthProviderType
1718
providerDomain: string
19+
disabled?: boolean
1820
}) {
1921
const [isPending, startTransition] = useTransition()
2022
const pathname = usePathname()
@@ -52,7 +54,7 @@ export function SignInButton({
5254
return (
5355
<Button
5456
onClick={handleSignIn}
55-
disabled={isPending}
57+
disabled={isPending || disabled}
5658
className="flex items-center gap-2 w-full bg-zinc-900 border border-zinc-700 text-white hover:bg-zinc-800 hover:border-acid-matrix/60 hover:shadow-[0_0_20px_rgba(124,255,63,0.15)] transition-all duration-300"
5759
>
5860
{isPending ? (
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { SignInButton } from './sign-in-button'
22
import { CardFooter } from '../ui/card'
33

4-
export function SignInCardFooter() {
4+
export function SignInCardFooter({ disabled }: { disabled?: boolean }) {
55
return (
66
<CardFooter className="flex flex-col space-y-3 pb-8">
7-
<SignInButton providerDomain="github.com" providerName="github" />
7+
<SignInButton providerDomain="github.com" providerName="github" disabled={disabled} />
88
</CardFooter>
99
)
1010
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import Script from 'next/script'
4+
import { useEffect, useRef, useState } from 'react'
5+
6+
export function TurnstileWidget({
7+
siteKey,
8+
onVerify,
9+
onError,
10+
onExpired,
11+
}: {
12+
siteKey: string
13+
onVerify: (token: string) => void
14+
onError: (errorCode: string) => void
15+
onExpired: () => void
16+
}) {
17+
const containerRef = useRef<HTMLDivElement>(null)
18+
const widgetIdRef = useRef<string | undefined>(undefined)
19+
const onVerifyRef = useRef(onVerify)
20+
const onErrorRef = useRef(onError)
21+
const onExpiredRef = useRef(onExpired)
22+
onVerifyRef.current = onVerify
23+
onErrorRef.current = onError
24+
onExpiredRef.current = onExpired
25+
26+
const [scriptLoaded, setScriptLoaded] = useState(false)
27+
28+
useEffect(() => {
29+
if (
30+
!scriptLoaded ||
31+
!containerRef.current ||
32+
!window.turnstile ||
33+
widgetIdRef.current !== undefined
34+
) {
35+
return
36+
}
37+
38+
widgetIdRef.current = window.turnstile.render(containerRef.current, {
39+
sitekey: siteKey,
40+
callback: (token: string) => onVerifyRef.current(token),
41+
'error-callback': (errorCode: string) => onErrorRef.current(errorCode),
42+
'expired-callback': () => onExpiredRef.current(),
43+
})
44+
45+
return () => {
46+
if (widgetIdRef.current !== undefined && window.turnstile) {
47+
window.turnstile.remove(widgetIdRef.current)
48+
widgetIdRef.current = undefined
49+
}
50+
}
51+
}, [scriptLoaded, siteKey])
52+
53+
return (
54+
<>
55+
<link rel="preconnect" href="https://challenges.cloudflare.com" />
56+
<Script
57+
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
58+
onLoad={() => setScriptLoaded(true)}
59+
/>
60+
<div ref={containerRef} />
61+
</>
62+
)
63+
}

freebuff/web/src/middleware.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NextResponse } from 'next/server'
2+
3+
import type { NextRequest } from 'next/server'
4+
5+
const COOKIE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes
6+
7+
async function verifyHmac(
8+
timestamp: string,
9+
signature: string,
10+
secret: string,
11+
): Promise<boolean> {
12+
const encoder = new TextEncoder()
13+
const key = await crypto.subtle.importKey(
14+
'raw',
15+
encoder.encode(secret),
16+
{ name: 'HMAC', hash: 'SHA-256' },
17+
false,
18+
['sign'],
19+
)
20+
const signed = await crypto.subtle.sign('HMAC', key, encoder.encode(timestamp))
21+
const expected = Array.from(new Uint8Array(signed))
22+
.map((b) => b.toString(16).padStart(2, '0'))
23+
.join('')
24+
return expected === signature
25+
}
26+
27+
export async function middleware(request: NextRequest) {
28+
const turnstileSecret = process.env.TURNSTILE_SECRET_KEY
29+
const nextauthSecret = process.env.NEXTAUTH_SECRET
30+
31+
if (!turnstileSecret || !nextauthSecret) {
32+
return NextResponse.next()
33+
}
34+
35+
const cookie = request.cookies.get('turnstile_verified')?.value
36+
if (!cookie) {
37+
const loginUrl = new URL('/login', request.url)
38+
request.nextUrl.searchParams.forEach((value, key) => {
39+
loginUrl.searchParams.set(key, value)
40+
})
41+
return NextResponse.redirect(loginUrl)
42+
}
43+
44+
const [timestamp, signature] = cookie.split('.')
45+
if (!timestamp || !signature) {
46+
const loginUrl = new URL('/login', request.url)
47+
return NextResponse.redirect(loginUrl)
48+
}
49+
50+
const age = Date.now() - parseInt(timestamp, 10)
51+
if (isNaN(age) || age > COOKIE_MAX_AGE_MS) {
52+
const loginUrl = new URL('/login', request.url)
53+
return NextResponse.redirect(loginUrl)
54+
}
55+
56+
const valid = await verifyHmac(timestamp, signature, nextauthSecret)
57+
if (!valid) {
58+
const loginUrl = new URL('/login', request.url)
59+
return NextResponse.redirect(loginUrl)
60+
}
61+
62+
return NextResponse.next()
63+
}
64+
65+
export const config = {
66+
matcher: ['/api/auth/signin/:path*'],
67+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
interface TurnstileRenderOptions {
2+
sitekey: string
3+
callback: (token: string) => void
4+
'error-callback'?: (errorCode: string) => void
5+
'expired-callback'?: () => void
6+
theme?: 'light' | 'dark' | 'auto'
7+
size?: 'normal' | 'flexible' | 'compact'
8+
action?: string
9+
execution?: 'render' | 'execute'
10+
appearance?: 'always' | 'execute' | 'interaction-only'
11+
}
12+
13+
interface TurnstileInstance {
14+
render: (container: HTMLElement | string, options: TurnstileRenderOptions) => string
15+
reset: (widgetId: string) => void
16+
remove: (widgetId: string) => void
17+
getResponse: (widgetId: string) => string | undefined
18+
isExpired: (widgetId: string) => boolean
19+
ready: (callback: () => void) => void
20+
execute: (container: HTMLElement | string) => void
21+
}
22+
23+
interface Window {
24+
turnstile?: TurnstileInstance
25+
}

packages/internal/src/env-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const serverEnvSchema = clientEnvSchema.extend({
3030
DISCORD_PUBLIC_KEY: z.string().min(1),
3131
DISCORD_BOT_TOKEN: z.string().min(1),
3232
DISCORD_APPLICATION_ID: z.string().min(1),
33+
TURNSTILE_SECRET_KEY: z.string().optional(),
3334
})
3435
export const serverEnvVars = serverEnvSchema.keyof().options
3536
export type ServerEnvVar = (typeof serverEnvVars)[number]
@@ -75,4 +76,5 @@ export const serverProcessEnv: ServerInput = {
7576
DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY,
7677
DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN,
7778
DISCORD_APPLICATION_ID: process.env.DISCORD_APPLICATION_ID,
79+
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
7880
}

0 commit comments

Comments
 (0)