fix(auth): scanner-proof password reset with button-gated verify and OTP-code fallback (#1100)
Corporate mail scanners (Microsoft Defender SafeLinks) follow links in auth emails and burn the one-shot recovery token before the user sees the mail (#1099, first hit: Deepgrid 2026-07-21, verify from an Azure IP 23s after send). /reset-password now has three entry modes: - set-password: recovery session exists (legacy /auth/callback links keep working unchanged) - confirm-link: the email link carries ?token_hash= and verification runs ONLY on an explicit button click; scanners render pages but do not click buttons, so the token survives scanning - enter-code: email + 6-digit {{ .Token }} code typed manually, the fallback when no link works at all The Supabase recovery email template switches to {{ .SiteURL }}/reset-password?token_hash={{ .TokenHash }} + {{ .Token }} AFTER this deploys (template content in the PR); the link then never touches gotrue's GET /verify endpoint, leaving nothing to detonate. Fixes #1099. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, KeyRound } from 'lucide-react'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
/**
|
||||
* Three entry modes:
|
||||
*
|
||||
* - 'set-password': a recovery session already exists (legacy email links
|
||||
* land via /auth/callback, or a token was just verified below).
|
||||
* - 'confirm-link': the email link points here with ?token_hash=. The
|
||||
* token is verified ONLY on an explicit button click: corporate mail
|
||||
* scanners (Microsoft Defender SafeLinks) render pages and follow
|
||||
* links but do not click buttons, so the one-time token survives
|
||||
* scanning. Never verify in an effect; that re-opens the burn.
|
||||
* - 'enter-code': no session, no token_hash. The email also carries a
|
||||
* 6-digit code the user can type together with their email address.
|
||||
*/
|
||||
type Mode = 'loading' | 'set-password' | 'confirm-link' | 'enter-code'
|
||||
|
||||
function ResetPasswordInner() {
|
||||
const t = useTranslations('reset_password')
|
||||
const searchParams = useSearchParams()
|
||||
const tokenHash = searchParams.get('token_hash')
|
||||
const [mode, setMode] = useState<Mode>('loading')
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (cancelled) return
|
||||
if (session) setMode('set-password')
|
||||
else if (tokenHash) setMode('confirm-link')
|
||||
else setMode('enter-code')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const verifyFailed = (description: string) => {
|
||||
toast({
|
||||
title: t('verify_failed_title'),
|
||||
description,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirmLink = async () => {
|
||||
if (!tokenHash) return
|
||||
setIsLoading(true)
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash: tokenHash,
|
||||
type: 'recovery',
|
||||
})
|
||||
setIsLoading(false)
|
||||
if (error) {
|
||||
// Burned or expired link: fall back to typing the code (a fresh
|
||||
// request may be needed, the hint says so).
|
||||
setMode('enter-code')
|
||||
verifyFailed(t('link_invalid_description'))
|
||||
return
|
||||
}
|
||||
setMode('set-password')
|
||||
}
|
||||
|
||||
const handleVerifyCode = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email: email.trim(),
|
||||
token: code.trim(),
|
||||
type: 'recovery',
|
||||
})
|
||||
setIsLoading(false)
|
||||
if (error) {
|
||||
verifyFailed(t('code_invalid_description'))
|
||||
return
|
||||
}
|
||||
setMode('set-password')
|
||||
}
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -87,6 +165,13 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const subtitle =
|
||||
mode === 'confirm-link'
|
||||
? t('confirm_subtitle')
|
||||
: mode === 'enter-code'
|
||||
? t('code_subtitle')
|
||||
: t('subtitle')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
@@ -97,56 +182,141 @@ export default function ResetPasswordPage() {
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-medium tracking-tight">{t('title')}</h1>
|
||||
<p className="text-muted-foreground text-sm mt-2">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm mt-2">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<form onSubmit={handleResetPassword} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('new_password_label')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('new_password_placeholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
{mode === 'loading' && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('confirm_password_placeholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
)}
|
||||
|
||||
{mode === 'confirm-link' && (
|
||||
<div className="space-y-5">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11"
|
||||
onClick={handleConfirmLink}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('confirm_verifying')}
|
||||
</>
|
||||
) : (
|
||||
t('confirm_button')
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('confirm_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
t('submit')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'enter-code' && (
|
||||
<form onSubmit={handleVerifyCode} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('email_label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t('code_label')}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t('code_placeholder')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
className="h-11 tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('code_verifying')}
|
||||
</>
|
||||
) : (
|
||||
t('code_button')
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('code_hint')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'set-password' && (
|
||||
<form onSubmit={handleResetPassword} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('new_password_label')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('new_password_placeholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('confirm_password_placeholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
t('submit')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ResetPasswordInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-1
@@ -850,7 +850,21 @@
|
||||
"mismatch_title": "Passwords don't match",
|
||||
"mismatch_description": "Make sure you entered the same password in both fields.",
|
||||
"save_failed_title": "Could not save password",
|
||||
"save_failed_description": "Please try again."
|
||||
"save_failed_description": "Please try again.",
|
||||
"confirm_subtitle": "Confirm to continue to the password reset.",
|
||||
"confirm_button": "Continue",
|
||||
"confirm_verifying": "Verifying...",
|
||||
"confirm_hint": "For security, the link is only used when you click the button.",
|
||||
"code_subtitle": "Enter your email and the one-time code from the email.",
|
||||
"email_label": "Email address",
|
||||
"code_label": "One-time code",
|
||||
"code_placeholder": "6-digit code",
|
||||
"code_button": "Verify code",
|
||||
"code_verifying": "Verifying...",
|
||||
"code_hint": "The code is in the password reset email. If no email arrived, request a new reset from the login page.",
|
||||
"verify_failed_title": "Verification failed",
|
||||
"link_invalid_description": "The link is invalid or has already been used. Enter the one-time code from the email instead, or request a new reset.",
|
||||
"code_invalid_description": "The code is invalid or has expired. Check the code or request a new reset from the login page."
|
||||
},
|
||||
"customer_detail": {
|
||||
"back": "Back to customers",
|
||||
|
||||
+15
-1
@@ -850,7 +850,21 @@
|
||||
"mismatch_title": "Lösenorden matchar inte",
|
||||
"mismatch_description": "Kontrollera att du skrev samma lösenord i båda fälten.",
|
||||
"save_failed_title": "Kunde inte spara lösenordet",
|
||||
"save_failed_description": "Försök igen."
|
||||
"save_failed_description": "Försök igen.",
|
||||
"confirm_subtitle": "Bekräfta för att fortsätta till lösenordsåterställningen.",
|
||||
"confirm_button": "Fortsätt",
|
||||
"confirm_verifying": "Verifierar...",
|
||||
"confirm_hint": "Av säkerhetsskäl används länken först när du klickar på knappen.",
|
||||
"code_subtitle": "Ange din e-post och engångskoden från mailet.",
|
||||
"email_label": "E-postadress",
|
||||
"code_label": "Engångskod",
|
||||
"code_placeholder": "6-siffrig kod",
|
||||
"code_button": "Verifiera kod",
|
||||
"code_verifying": "Verifierar...",
|
||||
"code_hint": "Koden finns i mailet om lösenordsåterställning. Hittar du inget mail kan du begära en ny återställning från inloggningssidan.",
|
||||
"verify_failed_title": "Verifieringen misslyckades",
|
||||
"link_invalid_description": "Länken är ogiltig eller har redan använts. Ange engångskoden från mailet i stället, eller begär en ny återställning.",
|
||||
"code_invalid_description": "Koden är ogiltig eller har gått ut. Kontrollera koden eller begär en ny återställning från inloggningssidan."
|
||||
},
|
||||
"customer_detail": {
|
||||
"back": "Tillbaka till kunder",
|
||||
|
||||
Reference in New Issue
Block a user