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
+115
View File
@@ -0,0 +1,115 @@
//! Bot doğrulaması: Cloudflare Turnstile (kullanıcı kararı 2026-11-14).
//!
//! Tarayıcı Cloudflare'den tek kullanımlık bir jeton alır; sunucu bunu gizli anahtarla
//! `siteverify` uç noktasına sorar. Anahtar tanımlı değilse (geliştirme, testler) kapalıdır.
//! Cloudflare'e ulaşılamazsa güvenlik için **reddedilir** (deneme hakkından düşmez).
use std::time::Duration;
use serde::Deserialize;
use crate::error::AppError;
const VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
pub enum Captcha {
Disabled,
Turnstile {
secret: String,
client: reqwest::Client,
},
/// Testler için: ağ olmadan her zaman geçer (`true`) ya da kalır (`false`)
Fixed(bool),
}
/// Doğrulama sonucu: `Rejected` deneme hakkından düşer, `Unavailable` düşmez
#[derive(Debug)]
pub enum CaptchaError {
Rejected,
Unavailable,
}
impl CaptchaError {
pub fn into_app_error(self) -> AppError {
AppError::Captcha(match self {
Self::Rejected => {
"Robot olmadığını doğrulayamadık. Doğrulama kutusunu yeniden tamamlayıp tekrar dene."
.into()
}
Self::Unavailable => {
"Doğrulama servisine şu an ulaşılamıyor. Birkaç dakika sonra tekrar dene.".into()
}
})
}
}
#[derive(Deserialize)]
struct VerifyResponse {
success: bool,
#[serde(default, rename = "error-codes")]
error_codes: Vec<String>,
}
impl Captcha {
pub fn from_secret(secret: Option<&str>) -> Self {
match secret {
Some(s) => Self::Turnstile {
secret: s.to_string(),
client: reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.unwrap_or_default(),
},
None => Self::Disabled,
}
}
pub fn enabled(&self) -> bool {
!matches!(self, Self::Disabled)
}
pub async fn verify(&self, token: Option<&str>, ip: &str) -> Result<(), CaptchaError> {
let token = token.map(str::trim).filter(|t| !t.is_empty());
match self {
Self::Disabled => Ok(()),
Self::Fixed(ok) => {
if *ok && token.is_some() {
Ok(())
} else {
Err(CaptchaError::Rejected)
}
}
Self::Turnstile { secret, client } => {
// Jeton en fazla 2048 karakter (Cloudflare belgesi)
let Some(token) = token.filter(|t| t.len() <= 2048) else {
return Err(CaptchaError::Rejected);
};
let mut form = vec![("secret", secret.as_str()), ("response", token)];
if ip.parse::<std::net::IpAddr>().is_ok() {
form.push(("remoteip", ip));
}
let res = client.post(VERIFY_URL).form(&form).send().await;
let body: VerifyResponse = match res {
Ok(r) => r.json().await.map_err(|e| {
tracing::error!("Turnstile yanıtı okunamadı: {e}");
CaptchaError::Unavailable
})?,
Err(e) => {
tracing::error!("Turnstile'a ulaşılamadı: {e}");
return Err(CaptchaError::Unavailable);
}
};
if body.success {
Ok(())
} else {
tracing::warn!(ip, codes = ?body.error_codes, "Turnstile doğrulaması başarısız");
if body.error_codes.iter().any(|c| c == "internal-error") {
Err(CaptchaError::Unavailable)
} else {
Err(CaptchaError::Rejected)
}
}
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
//! İstemcinin IP adresi (deneme kilidi ve bot doğrulaması için).
//!
//! Canlıda uygulama Dokploy'un Traefik vekilinin arkasındadır: bağlantı adresi vekilinkidir.
//! `TRUST_PROXY` açıkken (varsayılan) gerçek adres `X-Forwarded-For`'un **en sağdaki** girdisidir —
//! onu vekil ekler; istemcinin kendi yazdığı (sahte) girdiler solda kalır ve dikkate alınmaz.
//! Vekil yoksa `TRUST_PROXY=false`: bağlantı adresi kullanılır.
use std::net::{IpAddr, SocketAddr};
use axum::{
extract::{ConnectInfo, FromRequestParts},
http::{HeaderMap, request::Parts},
};
use crate::state::AppState;
#[derive(Debug, Clone)]
pub struct ClientIp(pub String);
/// `X-Forwarded-For: sahte, gerçek` → `gerçek`
pub fn from_forwarded(headers: &HeaderMap) -> Option<IpAddr> {
headers
.get_all("x-forwarded-for")
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.filter_map(|p| p.trim().parse::<IpAddr>().ok())
.next_back()
}
impl FromRequestParts<AppState> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let forwarded = state
.config
.trust_proxy
.then(|| from_forwarded(&parts.headers))
.flatten();
let socket = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip());
Ok(Self(forwarded.or(socket).map_or_else(
|| "bilinmiyor".to_string(),
|ip| ip.to_string(),
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rightmost_forwarded_address_wins() {
let mut h = HeaderMap::new();
assert_eq!(from_forwarded(&h), None);
h.insert("x-forwarded-for", "6.6.6.6, 203.0.113.7".parse().unwrap());
assert_eq!(from_forwarded(&h), Some("203.0.113.7".parse().unwrap()));
h.append("x-forwarded-for", "198.51.100.1".parse().unwrap());
assert_eq!(from_forwarded(&h), Some("198.51.100.1".parse().unwrap()));
let mut bad = HeaderMap::new();
bad.insert("x-forwarded-for", "sahte".parse().unwrap());
assert_eq!(from_forwarded(&bad), None);
}
}
+130
View File
@@ -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");
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, giriş deneme sınırı.
//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, deneme kilidi (IP), bot doğrulaması.
pub mod captcha;
pub mod client_ip;
pub mod guard;
pub mod jwt;
pub mod limiter;
pub mod password;