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:
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user