feat(güvenlik): giriş ve kayıtta Cloudflare Turnstile, IP başına 3 hatalı denemede 15 dk kilit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 00:29:00 +03:00
parent 5cbf97d884
commit a2eff1a984
27 changed files with 1247 additions and 164 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from 'react'
import { Turnstile, type TurnstileHandle } from '@/components/turnstile'
import { ApiError } from '@/lib/api'
import { useAuthGuard } from '@/lib/auth'
import { t } from '@/lib/strings'
const s = t.auth.guard
/**
* Giriş / kayıt güvenliği (ortak): bot doğrulama jetonu + IP kilidi.
* Sunucu: üst üste 3 hatalı deneme → bu bağlantıya giriş ve kayıt 15 dk kapalı.
*/
export function useAuthSecurity() {
const guard = useAuthGuard()
const widget = useRef<TurnstileHandle>(null)
const [token, setToken] = useState<string | null>(null)
const [scriptFailed, setScriptFailed] = useState(false)
const siteKey = guard.data?.captcha_site_key ?? null
// Bitiş anı: yanıtın alındığı an + sunucunun bildirdiği kalan süre
const lockedSecs = guard.data?.locked_seconds ?? 0
const until = lockedSecs > 0 ? guard.dataUpdatedAt + lockedSecs * 1000 : 0
const locked = useCountdown(until, () => void guard.refetch())
return {
loading: guard.isPending,
locked,
/** Gönder düğmesi: doğrulama açıksa jeton gelmeden olmaz */
ready: !guard.isPending && locked === 0 && (!siteKey || !!token),
token: token ?? undefined,
/** Her başarısız gönderimden sonra: jeton tek kullanımlık; kilit geldiyse sayaç başlar */
afterError(e: unknown) {
widget.current?.reset()
if (e instanceof ApiError && (e.code === 'locked' || e.code === 'invalid_credentials'))
void guard.refetch()
},
field: siteKey ? (
<div className="space-y-1.5">
<Turnstile
ref={widget}
siteKey={siteKey}
onToken={setToken}
onLoadError={() => setScriptFailed(true)}
/>
{scriptFailed && <p className="text-sm text-expense">{s.scriptFailed}</p>}
</div>
) : null,
}
}
/** `until` anına (ms) kadar kalan saniye, saniye saniye; bitince `onDone` */
function useCountdown(until: number, onDone: () => void): number {
const [now, setNow] = useState(() => Date.now())
const done = useRef(onDone)
useEffect(() => {
done.current = onDone
})
useEffect(() => {
if (!until) return
const id = setInterval(() => {
const n = Date.now()
setNow(n)
if (n >= until) {
clearInterval(id)
done.current()
}
}, 1000)
return () => clearInterval(id)
}, [until])
// `now` bileşen açılırken alındı; yanıt daha sonra geldiyse süre 15 dk'yı aşmasın
return until ? Math.max(0, Math.ceil((until - Math.max(now, until - 15 * 60_000)) / 1000)) : 0
}
@@ -0,0 +1,22 @@
import { Lock } from 'lucide-react'
import { t } from '@/lib/strings'
const s = t.auth.guard
/** Kilitliyken formun yerine: neden + geri sayım */
export function LockNotice({ seconds }: { seconds: number }) {
const mm = Math.floor(seconds / 60)
const ss = String(seconds % 60).padStart(2, '0')
return (
<div role="alert" className="flex gap-3 rounded-xl border border-expense/30 bg-expense/5 p-4">
<Lock className="mt-0.5 size-5 shrink-0 text-expense" aria-hidden />
<div className="space-y-1">
<p className="font-medium">{s.lockedTitle}</p>
<p className="text-sm text-muted-foreground">{s.lockedText}</p>
<p className="money text-lg font-semibold" aria-live="off">
{mm}:{ss}
</p>
</div>
</div>
)
}
+59 -44
View File
@@ -12,6 +12,8 @@ import { ApiError } from '@/lib/api'
import { useLogin } from '@/lib/auth'
import { t } from '@/lib/strings'
import { AuthShell } from './auth-shell'
import { useAuthSecurity } from './guard'
import { LockNotice } from './lock-notice'
import { loginSchema, type LoginForm } from './schemas'
const s = t.auth.login
@@ -31,12 +33,15 @@ export function LoginPage() {
defaultValues: { email: '', password: '', remember_me: true },
})
const security = useAuthSecurity()
const onSubmit = handleSubmit(async (values) => {
try {
const me = await login.mutateAsync(values)
const me = await login.mutateAsync({ ...values, captcha_token: security.token })
toast.success(s.welcome(me.user.name))
// Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya)
} catch (e) {
security.afterError(e)
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
@@ -63,52 +68,62 @@ export function LoginPage() {
</>
}
>
<form onSubmit={onSubmit} noValidate className="space-y-5">
<FormField label={s.email} error={errors.email}>
{(f) => (
<Input
{...f}
{...register('email')}
type="email"
inputMode="email"
autoComplete="email"
autoCapitalize="none"
autoFocus
/>
)}
</FormField>
<FormField label={s.password} error={errors.password}>
{(f) => (
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
)}
</FormField>
<Controller
control={control}
name="remember_me"
render={({ field }) => (
<label className="flex min-h-11 cursor-pointer items-start gap-3">
<Checkbox
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
className="mt-0.5"
{security.locked > 0 ? (
<LockNotice seconds={security.locked} />
) : (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<FormField label={s.email} error={errors.email}>
{(f) => (
<Input
{...f}
{...register('email')}
type="email"
inputMode="email"
autoComplete="email"
autoCapitalize="none"
autoFocus
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium">{s.remember}</span>
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
</span>
</label>
)}
/>
)}
</FormField>
<FormError message={errors.root?.message} />
<FormField label={s.password} error={errors.password}>
{(f) => (
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
)}
</FormField>
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
{isSubmitting ? s.submitting : s.submit}
</Button>
</form>
<Controller
control={control}
name="remember_me"
render={({ field }) => (
<label className="flex min-h-11 cursor-pointer items-start gap-3">
<Checkbox
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
className="mt-0.5"
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium">{s.remember}</span>
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
</span>
</label>
)}
/>
{security.field}
<FormError message={errors.root?.message} />
<Button
type="submit"
size="lg"
className="w-full"
disabled={isSubmitting || !security.ready}
>
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
{isSubmitting ? s.submitting : s.submit}
</Button>
</form>
)}
</AuthShell>
)
}
+114 -98
View File
@@ -15,6 +15,8 @@ import { queryKeys } from '@/lib/query'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import { AuthShell } from './auth-shell'
import { useAuthSecurity } from './guard'
import { LockNotice } from './lock-notice'
import type { RegistrationInfo } from '@/types/RegistrationInfo'
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
@@ -61,9 +63,12 @@ export function RegisterPage() {
const mode = useWatch({ control, name: 'mode' })
const name = useWatch({ control, name: 'name' }).trim()
const security = useAuthSecurity()
const onSubmit = handleSubmit(async (v) => {
try {
const me = await registerAccount.mutateAsync({
captcha_token: security.token,
name: v.name,
email: v.email,
password: v.password,
@@ -73,6 +78,7 @@ export function RegisterPage() {
})
toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name))
} catch (e) {
security.afterError(e)
if (!(e instanceof ApiError)) return setError('root', { message: String(e) })
// Sunucu hatasını ilgili alana bağla
if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true })
@@ -105,110 +111,120 @@ export function RegisterPage() {
</>
}
>
<form onSubmit={onSubmit} noValidate className="space-y-5">
<FormField label={s.name} error={errors.name}>
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
</FormField>
{security.locked > 0 ? (
<LockNotice seconds={security.locked} />
) : (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<FormField label={s.name} error={errors.name}>
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
</FormField>
<FormField label={s.email} error={errors.email}>
{(f) => (
<Input
{...f}
{...register('email')}
type="email"
inputMode="email"
autoComplete="email"
autoCapitalize="none"
/>
)}
</FormField>
<FormField label={s.email} error={errors.email}>
{(f) => (
<Input
{...f}
{...register('email')}
type="email"
inputMode="email"
autoComplete="email"
autoCapitalize="none"
/>
)}
</FormField>
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
</FormField>
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
</FormField>
{/* Yeni hane mi, davetle katılma mı? */}
<fieldset className="space-y-3">
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
{!newAllowed && (
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
{s.closedNote}
</p>
)}
{newAllowed && (
<Controller
control={control}
name="mode"
render={({ field }) => (
<div
role="radiogroup"
className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1"
>
{(
[
['new', s.modeNew, Home],
['join', s.modeJoin, Users],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={field.value === value}
onClick={() => field.onChange(value)}
className={cn(
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
field.value === value && 'bg-background text-foreground shadow-sm',
)}
>
<Icon className="size-4" aria-hidden />
{label}
</button>
))}
</div>
)}
/>
)}
{/* Yeni hane mi, davetle katılma mı? */}
<fieldset className="space-y-3">
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
{!newAllowed && (
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
{s.closedNote}
</p>
)}
{newAllowed && (
<Controller
control={control}
name="mode"
render={({ field }) => (
<div
role="radiogroup"
className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1"
>
{(
[
['new', s.modeNew, Home],
['join', s.modeJoin, Users],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={field.value === value}
onClick={() => field.onChange(value)}
className={cn(
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
field.value === value && 'bg-background text-foreground shadow-sm',
)}
>
<Icon className="size-4" aria-hidden />
{label}
</button>
))}
</div>
)}
/>
)}
{mode === 'new' ? (
<FormField
label={s.householdName}
error={errors.household_name}
hint={s.householdHint(name)}
>
{(f) => (
<Input
{...f}
{...register('household_name')}
placeholder={name ? `${name} Hanesi` : ''}
/>
)}
</FormField>
) : (
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
{(f) => (
<Input
{...f}
{...register('invite_code')}
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
maxLength={12}
className="font-mono tracking-widest uppercase"
/>
)}
</FormField>
)}
</fieldset>
{mode === 'new' ? (
<FormField
label={s.householdName}
error={errors.household_name}
hint={s.householdHint(name)}
>
{(f) => (
<Input
{...f}
{...register('household_name')}
placeholder={name ? `${name} Hanesi` : ''}
/>
)}
</FormField>
) : (
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
{(f) => (
<Input
{...f}
{...register('invite_code')}
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
maxLength={12}
className="font-mono tracking-widest uppercase"
/>
)}
</FormField>
)}
</fieldset>
<FormError message={errors.root?.message} />
{security.field}
<FormError message={errors.root?.message} />
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
{isSubmitting ? s.submitting : s.submit}
</Button>
</form>
<Button
type="submit"
size="lg"
className="w-full"
disabled={isSubmitting || !security.ready}
>
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
{isSubmitting ? s.submitting : s.submit}
</Button>
</form>
)}
</AuthShell>
)
}