a2eff1a984
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
4.0 KiB
Rust
131 lines
4.0 KiB
Rust
//! 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");
|
||
}
|
||
}
|