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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user