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,130 @@
|
||||
//! Giriş / kayıt deneme kilidi (IP başına, kullanıcı kararı 2026-11-14).
|
||||
//!
|
||||
//! Üst üste `max` hatalı deneme (yanlış şifre, geçersiz davet kodu, bot doğrulaması…) →
|
||||
//! o IP için giriş **ve** kayıt `lock` süresince kapanır. Başarılı girişte sayaç sıfırlanır.
|
||||
//! IP'ye göre olması bilinçli: e-postaya göre kilit, adresi bilen birinin hane sahibini
|
||||
//! dışarıda bırakmasına izin verirdi. Bellek içi (tek sunucu); yeniden başlatınca sıfırlanır.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub const MAX_FAILURES: u32 = 3;
|
||||
pub const LOCK_FOR: Duration = Duration::from_secs(15 * 60);
|
||||
/// Kilitsiz ve bu kadar süredir dokunulmamış kayıtlar silinir (bellek büyümesin)
|
||||
const FORGET_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// Kilide kalan deneme
|
||||
Remaining(u32),
|
||||
/// Kilitlendi; kalan süre (sn)
|
||||
Locked(u64),
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
failures: u32,
|
||||
locked_until: Option<Instant>,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
pub struct AttemptGuard {
|
||||
map: Mutex<HashMap<String, Entry>>,
|
||||
max: u32,
|
||||
lock: Duration,
|
||||
}
|
||||
|
||||
impl Default for AttemptGuard {
|
||||
fn default() -> Self {
|
||||
Self::new(MAX_FAILURES, LOCK_FOR)
|
||||
}
|
||||
}
|
||||
|
||||
impl AttemptGuard {
|
||||
pub fn new(max: u32, lock: Duration) -> Self {
|
||||
Self {
|
||||
map: Mutex::new(HashMap::new()),
|
||||
max,
|
||||
lock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kilitliyse kalan süre (sn, en az 1)
|
||||
pub fn locked_for(&self, key: &str) -> Option<u64> {
|
||||
let map = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
map.get(key)
|
||||
.and_then(|e| e.locked_until)
|
||||
.and_then(|until| until.checked_duration_since(Instant::now()))
|
||||
.map(|left| left.as_secs().max(1))
|
||||
}
|
||||
|
||||
pub fn failure(&self, key: &str) -> Outcome {
|
||||
let now = Instant::now();
|
||||
let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
map.retain(|_, e| {
|
||||
e.locked_until.is_some_and(|u| u > now) || now.duration_since(e.last) < FORGET_AFTER
|
||||
});
|
||||
let e = map.entry(key.to_string()).or_insert(Entry {
|
||||
failures: 0,
|
||||
locked_until: None,
|
||||
last: now,
|
||||
});
|
||||
// Süresi biten kilit: sayaç baştan
|
||||
if e.locked_until.is_some_and(|u| u <= now) {
|
||||
e.locked_until = None;
|
||||
e.failures = 0;
|
||||
}
|
||||
e.failures += 1;
|
||||
e.last = now;
|
||||
if e.failures >= self.max {
|
||||
e.locked_until = Some(now + self.lock);
|
||||
e.failures = 0;
|
||||
Outcome::Locked(self.lock.as_secs())
|
||||
} else {
|
||||
Outcome::Remaining(self.max - e.failures)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success(&self, key: &str) {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn three_consecutive_failures_lock() {
|
||||
let g = AttemptGuard::default();
|
||||
assert_eq!(g.failure("1.2.3.4"), Outcome::Remaining(2));
|
||||
assert_eq!(g.failure("1.2.3.4"), Outcome::Remaining(1));
|
||||
assert!(g.locked_for("1.2.3.4").is_none());
|
||||
assert_eq!(g.failure("1.2.3.4"), Outcome::Locked(900));
|
||||
assert!(g.locked_for("1.2.3.4").is_some_and(|s| s > 890));
|
||||
assert!(g.locked_for("5.6.7.8").is_none(), "başka IP etkilenmez");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_resets_and_lock_expires() {
|
||||
let g = AttemptGuard::new(3, Duration::from_millis(50));
|
||||
g.failure("ip");
|
||||
g.failure("ip");
|
||||
g.success("ip");
|
||||
assert_eq!(
|
||||
g.failure("ip"),
|
||||
Outcome::Remaining(2),
|
||||
"başarı sayacı sıfırlar"
|
||||
);
|
||||
g.failure("ip");
|
||||
assert!(matches!(g.failure("ip"), Outcome::Locked(_)));
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
assert!(g.locked_for("ip").is_none(), "süre dolunca açılır");
|
||||
assert_eq!(g.failure("ip"), Outcome::Remaining(2), "yeni tur");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user