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
+90
View File
@@ -0,0 +1,90 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useTheme } from '@/lib/theme'
/**
* Cloudflare Turnstile (bot doğrulaması). Betik yalnızca giriş/kayıt sayfalarında, bir kez
* yüklenir. Jeton tek kullanımlıktır: her gönderimden sonra `reset()` ile yenisi alınır.
*/
type TurnstileApi = {
render: (el: HTMLElement, opts: Record<string, unknown>) => string
reset: (id: string) => void
remove: (id: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
}
}
const SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
let loading: Promise<TurnstileApi> | null = null
function loadTurnstile(): Promise<TurnstileApi> {
if (window.turnstile) return Promise.resolve(window.turnstile)
loading ??= new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = SRC
s.async = true
s.onload = () => (window.turnstile ? resolve(window.turnstile) : reject(new Error('turnstile')))
s.onerror = () => {
loading = null
reject(new Error('turnstile'))
}
document.head.append(s)
})
return loading
}
export type TurnstileHandle = { reset: () => void }
export const Turnstile = forwardRef<
TurnstileHandle,
{
siteKey: string
/** Geçerli jeton ya da `null` (süresi doldu / hata) */
onToken: (token: string | null) => void
/** Betik yüklenemedi (ağ / reklam engelleyici) */
onLoadError?: () => void
}
>(function Turnstile({ siteKey, onToken, onLoadError }, ref) {
const el = useRef<HTMLDivElement>(null)
const widget = useRef<string | null>(null)
const { theme } = useTheme()
// Geri çağrılar her çizimde değişebilir; kutu yeniden oluşturulmasın
const cb = useRef({ onToken, onLoadError })
useEffect(() => {
cb.current = { onToken, onLoadError }
})
useImperativeHandle(ref, () => ({
reset: () => {
cb.current.onToken(null)
if (widget.current && window.turnstile) window.turnstile.reset(widget.current)
},
}))
useEffect(() => {
let cancelled = false
loadTurnstile()
.then((ts) => {
if (cancelled || !el.current) return
widget.current = ts.render(el.current, {
sitekey: siteKey,
theme: theme === 'system' ? 'auto' : theme,
language: 'tr',
size: 'flexible',
callback: (t: string) => cb.current.onToken(t),
'expired-callback': () => cb.current.onToken(null),
'error-callback': () => cb.current.onToken(null),
})
})
.catch(() => cb.current.onLoadError?.())
return () => {
cancelled = true
if (widget.current && window.turnstile) window.turnstile.remove(widget.current)
widget.current = null
}
}, [siteKey, theme])
return <div ref={el} className="min-h-[65px]" />
})
+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>
)
}
+10
View File
@@ -7,6 +7,7 @@
* - `MeResponse`→ giriş yapılmış
*/
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import type { AuthGuardInfo } from '@/types/AuthGuardInfo'
import type { LoginRequest } from '@/types/LoginRequest'
import type { MeResponse } from '@/types/MeResponse'
import type { RegisterRequest } from '@/types/RegisterRequest'
@@ -69,3 +70,12 @@ export function useLogout() {
},
})
}
/** Giriş/kayıt sayfası: bot doğrulama site anahtarı ve bu bağlantının kilidi (her açılışta taze) */
export function useAuthGuard() {
return useQuery({
queryKey: queryKeys.authGuard,
queryFn: () => api.get<AuthGuardInfo>('/auth/guard'),
staleTime: 0,
})
}
+2
View File
@@ -8,6 +8,8 @@ import { ApiError } from './api'
export const queryKeys = {
me: ['me'] as const,
registration: ['registration'] as const,
/** Giriş/kayıt: bot doğrulama anahtarı + bu bağlantının kilidi */
authGuard: ['auth-guard'] as const,
health: ['health'] as const,
household: ['household'] as const,
invites: ['household', 'invites'] as const,
+8
View File
@@ -52,6 +52,14 @@ export const t = {
},
auth: {
guard: {
lockedTitle: 'Çok fazla hatalı deneme',
lockedText:
'Güvenliğin için bu bağlantıdan giriş ve kayıt geçici olarak kapatıldı. Süre dolunca tekrar deneyebilirsin.',
verifying: 'Robot olmadığın doğrulanıyor…',
scriptFailed:
'Doğrulama kutusu yüklenemedi. İnternet bağlantını ya da reklam engelleyiciyi kontrol edip sayfayı yenile.',
},
login: {
title: 'Tekrar hoş geldin',
description: 'Defterine devam etmek için giriş yap.',
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Giriş/kayıt sayfası: bot doğrulaması açık mı, bu IP kilitli mi?
*/
export type AuthGuardInfo = {
/**
* Turnstile site anahtarı (herkese açık); `null` → doğrulama kapalı
*/
captcha_site_key: string | null,
/**
* Kilit varsa kalan süre (sn); yoksa 0
*/
locked_seconds: number, };
+5 -1
View File
@@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LoginRequest = { email: string, password: string, remember_me: boolean, };
export type LoginRequest = { email: string, password: string, remember_me: boolean,
/**
* Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
*/
captcha_token?: string, };
+5 -1
View File
@@ -4,4 +4,8 @@ export type RegisterRequest = { name: string, email: string, password: string, h
/**
* Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
*/
invite_code?: string, };
invite_code?: string,
/**
* Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
*/
captcha_token?: string, };