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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user