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
}