fix: protect public Auth flows from automated abuse (#1904)

* fix: add Turnstile to public auth flows

* test: isolate Turnstile auth tests
This commit is contained in:
Mattsson
2026-08-25 19:14:06 +02:00
committed by GitHub
parent 743bc82f93
commit 328ccda10d
15 changed files with 619 additions and 10 deletions
+54 -2
View File
@@ -35,7 +35,15 @@ import {
import { buildPasswordResetRedirectTo } from '@/lib/domains/trusted-app-origin'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
} from '@/lib/auth/turnstile'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
@@ -77,8 +85,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
// Consecutive credential failures; from the second one on, the error line
// grows a reset-password action (extra help on repeated errors).
const [failedAttempts, setFailedAttempts] = useState(0)
const [passwordCaptchaToken, setPasswordCaptchaToken] = useState<string | null>(null)
const [resetCaptchaToken, setResetCaptchaToken] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordTurnstileRef = useRef<TurnstileChallengeHandle>(null)
const resetTurnstileRef = useRef<TurnstileChallengeHandle>(null)
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
@@ -145,11 +157,13 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const openResetForm = () => {
setFormError(null)
setResetCaptchaToken(null)
setShowResetPassword(true)
}
const closeResetForm = () => {
setFormError(null)
setResetCaptchaToken(null)
setShowResetPassword(false)
}
@@ -248,6 +262,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
if (isTurnstileSubmissionBlocked(passwordCaptchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -258,6 +278,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const { error } = await supabase.auth.signInWithPassword({
email: emailValue,
password: passwordValue,
options: captchaTokenOptions(passwordCaptchaToken),
})
if (error) {
@@ -317,6 +338,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
passwordTurnstileRef.current?.reset()
setIsLoading(false)
}
}
@@ -324,6 +346,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
if (isTurnstileSubmissionBlocked(resetCaptchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -332,6 +360,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
try {
const { error } = await supabase.auth.resetPasswordForEmail(emailValue, {
redirectTo: buildPasswordResetRedirectTo(window.location.origin),
...captchaTokenOptions(resetCaptchaToken),
})
if (error) {
@@ -357,6 +386,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
resetTurnstileRef.current?.reset()
setIsLoading(false)
}
}
@@ -465,7 +495,20 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
className="h-11"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading || !!resetCooldownUntil}>
<TurnstileChallenge
ref={resetTurnstileRef}
action="accounted_password_reset"
onTokenChange={setResetCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={
isLoading ||
!!resetCooldownUntil ||
isTurnstileSubmissionBlocked(resetCaptchaToken)
}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -653,7 +696,16 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
</p>
)}
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
<TurnstileChallenge
ref={passwordTurnstileRef}
action="accounted_login"
onTokenChange={setPasswordCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading || isTurnstileSubmissionBlocked(passwordCaptchaToken)}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+27 -1
View File
@@ -25,8 +25,16 @@ import {
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
} from '@/lib/auth/turnstile'
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { cn } from '@/lib/utils'
@@ -82,9 +90,11 @@ function RegisterPageContent() {
const [passwordError, setPasswordError] = useState<string | null>(null)
const [confirmError, setConfirmError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const confirmInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const turnstileRef = useRef<TurnstileChallengeHandle>(null)
const { toast } = useToast()
const router = useRouter()
const supabase = createClient()
@@ -316,6 +326,11 @@ function RegisterPageContent() {
return
}
if (isTurnstileSubmissionBlocked(captchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
setIsLoading(true)
try {
@@ -328,6 +343,7 @@ function RegisterPageContent() {
password: passwordValue,
options: {
emailRedirectTo: confirmationCallback.toString(),
...captchaTokenOptions(captchaToken),
},
})
@@ -405,6 +421,7 @@ function RegisterPageContent() {
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
turnstileRef.current?.reset()
setIsLoading(false)
}
}
@@ -755,7 +772,16 @@ function RegisterPageContent() {
</p>
)}
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
<TurnstileChallenge
ref={turnstileRef}
action="accounted_signup"
onTokenChange={setCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading || isTurnstileSubmissionBlocked(captchaToken)}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+34 -5
View File
@@ -1,24 +1,36 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import { Loader2 } from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
} from '@/lib/auth/turnstile'
const branding = getBranding()
export default function SandboxPage() {
const [isLoading, setIsLoading] = useState(false)
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileChallengeHandle>(null)
const { toast } = useToast()
const router = useRouter()
const supabase = createClient()
const tAuth = useTranslations('auth')
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
@@ -27,17 +39,27 @@ export default function SandboxPage() {
}, [supabase.auth])
const handleStartSandbox = async () => {
if (isTurnstileSubmissionBlocked(captchaToken)) {
toast({
title: 'Kunde inte starta sandlådan',
description: tAuth('turnstile_required'),
variant: 'destructive',
})
return
}
setIsLoading(true)
try {
const { error } = await supabase.auth.signInAnonymously()
const { error } = await supabase.auth.signInAnonymously({
options: captchaTokenOptions(captchaToken),
})
if (error) {
toast({
title: 'Kunde inte starta sandlådan',
description: getUserErrorMessage(error),
variant: 'destructive',
})
setIsLoading(false)
return
}
@@ -51,7 +73,6 @@ export default function SandboxPage() {
description: 'Försök igen om en stund.',
variant: 'destructive',
})
setIsLoading(false)
return
}
@@ -63,6 +84,8 @@ export default function SandboxPage() {
description: 'Försök igen om en stund.',
variant: 'destructive',
})
} finally {
turnstileRef.current?.reset()
setIsLoading(false)
}
}
@@ -126,10 +149,16 @@ export default function SandboxPage() {
kräver ett riktigt konto.
</p>
<TurnstileChallenge
ref={turnstileRef}
action="accounted_sandbox"
onTokenChange={setCaptchaToken}
/>
<Button
className="w-full h-11"
onClick={handleStartSandbox}
disabled={isLoading}
disabled={isLoading || isTurnstileSubmissionBlocked(captchaToken)}
>
{isLoading ? (
<>