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;
|
||||
|
||||
@@ -14,6 +14,11 @@ pub struct Config {
|
||||
pub static_dir: Option<PathBuf>,
|
||||
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
||||
pub rates_refresh_minutes: u64,
|
||||
/// Cloudflare Turnstile (`TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`); ikisi de yoksa kapalı
|
||||
pub turnstile_site_key: Option<String>,
|
||||
pub turnstile_secret_key: Option<String>,
|
||||
/// Ters vekil arkasında (Dokploy/Traefik) gerçek IP `X-Forwarded-For`'dan (`TRUST_PROXY`, varsayılan açık)
|
||||
pub trust_proxy: bool,
|
||||
}
|
||||
|
||||
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
|
||||
@@ -78,6 +83,31 @@ impl Config {
|
||||
_ => 60,
|
||||
};
|
||||
|
||||
let opt = |k: &str| {
|
||||
env::var(k)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
let turnstile_site_key = opt("TURNSTILE_SITE_KEY");
|
||||
let turnstile_secret_key = opt("TURNSTILE_SECRET_KEY");
|
||||
if turnstile_site_key.is_some() != turnstile_secret_key.is_some() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY",
|
||||
"ikisi birlikte tanımlanmalı (ya da ikisi de boş)",
|
||||
));
|
||||
}
|
||||
let trust_proxy = match env::var("TRUST_PROXY").as_deref().map(str::trim) {
|
||||
Err(_) | Ok("") | Ok("true") | Ok("1") => true,
|
||||
Ok("false") | Ok("0") => false,
|
||||
Ok(_) => {
|
||||
return Err(ConfigError::Invalid(
|
||||
"TRUST_PROXY",
|
||||
"\"true\" veya \"false\" olmalı",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mongodb_uri,
|
||||
jwt_secret,
|
||||
@@ -86,6 +116,9 @@ impl Config {
|
||||
registration,
|
||||
static_dir,
|
||||
rates_refresh_minutes,
|
||||
turnstile_site_key,
|
||||
turnstile_secret_key,
|
||||
trust_proxy,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -35,6 +35,15 @@ pub enum AppError {
|
||||
Unauthorized,
|
||||
#[error("E-posta veya şifre hatalı")]
|
||||
InvalidCredentials,
|
||||
/// Hatalı giriş; kilide kalan deneme sayısı mesajda
|
||||
#[error("E-posta veya şifre hatalı. Kalan deneme hakkı: {0}")]
|
||||
InvalidCredentialsLeft(u32),
|
||||
/// IP kilidi (üst üste hatalı deneme); saniye cinsinden kalan süre
|
||||
#[error("Çok fazla hatalı deneme yapıldı. Güvenlik için giriş ve kayıt {} dakika kapalı.", .0.div_ceil(60))]
|
||||
Locked(u64),
|
||||
/// Bot doğrulaması (Turnstile) geçilemedi
|
||||
#[error("{0}")]
|
||||
Captcha(String),
|
||||
#[error("Bu işlem için yetkiniz yok")]
|
||||
Forbidden,
|
||||
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
|
||||
@@ -55,7 +64,11 @@ impl AppError {
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
|
||||
Self::InvalidCredentials | Self::InvalidCredentialsLeft(_) => {
|
||||
(StatusCode::UNAUTHORIZED, "invalid_credentials")
|
||||
}
|
||||
Self::Locked(_) => (StatusCode::TOO_MANY_REQUESTS, "locked"),
|
||||
Self::Captcha(_) => (StatusCode::BAD_REQUEST, "captcha"),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
@@ -70,6 +83,10 @@ impl AppError {
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = self.status_and_code();
|
||||
let retry_after = match &self {
|
||||
Self::Locked(secs) => Some(*secs),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let message = if status.is_server_error() {
|
||||
// Teknik detay sadece logda; kullanıcıya genel mesaj.
|
||||
@@ -85,7 +102,12 @@ impl IntoResponse for AppError {
|
||||
message,
|
||||
},
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
let mut res = (status, Json(body)).into_response();
|
||||
if let Some(secs) = retry_after {
|
||||
res.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, secs.into());
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -44,6 +44,22 @@ async fn main() -> ExitCode {
|
||||
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
|
||||
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
|
||||
|
||||
if config.turnstile_secret_key.is_some() {
|
||||
tracing::info!("Bot doğrulaması (Cloudflare Turnstile) açık");
|
||||
} else if config.is_https() {
|
||||
tracing::warn!(
|
||||
"Bot doğrulaması KAPALI: TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY tanımlayın (docs/DEPLOY.md)"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"İstemci IP'si: {}",
|
||||
if config.trust_proxy {
|
||||
"X-Forwarded-For (vekil arkasında, TRUST_PROXY=true)"
|
||||
} else {
|
||||
"bağlantı adresi (TRUST_PROXY=false)"
|
||||
}
|
||||
);
|
||||
|
||||
let port = config.port;
|
||||
let state = AppState::new(db, config);
|
||||
let app = routes::app(state);
|
||||
@@ -60,9 +76,13 @@ async fn main() -> ExitCode {
|
||||
};
|
||||
|
||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
// ConnectInfo: vekil yoksa istemci IP'si bağlantı adresinden (deneme kilidi)
|
||||
if let Err(e) = axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Sunucu hatası: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
|
||||
+90
-12
@@ -1,6 +1,11 @@
|
||||
//! Kimlik doğrulama uç noktaları:
|
||||
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
|
||||
//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır)
|
||||
//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır),
|
||||
//! `GET /api/auth/guard` (bot doğrulama anahtarı + bu IP'nin kilidi).
|
||||
//!
|
||||
//! **Güvenlik (giriş + kayıt):** Cloudflare Turnstile + IP başına üst üste 3 hatada 15 dk kilit
|
||||
//! (`auth::guard`). Doğrulama hataları (`ValidJson`) sayılmaz; yanlış şifre, geçersiz davet,
|
||||
//! kapalı kayıt, kayıtlı e-posta ve başarısız bot doğrulaması sayılır.
|
||||
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
@@ -11,7 +16,11 @@ use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthUser, password,
|
||||
AuthUser,
|
||||
captcha::CaptchaError,
|
||||
client_ip::ClientIp,
|
||||
guard::Outcome,
|
||||
password,
|
||||
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
|
||||
},
|
||||
db::col,
|
||||
@@ -42,6 +51,8 @@ pub struct RegisterRequest {
|
||||
/// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
|
||||
#[validate(length(max = 20, message = "Davet kodu geçersiz"))]
|
||||
invite_code: Option<String>,
|
||||
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||
captcha_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
@@ -53,6 +64,10 @@ pub struct LoginRequest {
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
remember_me: bool,
|
||||
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
captcha_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -94,13 +109,67 @@ pub struct PasswordChangeRequest {
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
/// Giriş/kayıt sayfası: bot doğrulaması açık mı, bu IP kilitli mi?
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct AuthGuardInfo {
|
||||
/// Turnstile site anahtarı (herkese açık); `null` → doğrulama kapalı
|
||||
captcha_site_key: Option<String>,
|
||||
/// Kilit varsa kalan süre (sn); yoksa 0
|
||||
#[ts(type = "number")]
|
||||
locked_seconds: u64,
|
||||
}
|
||||
|
||||
// ---------- Deneme kilidi + bot doğrulaması (giriş ve kayıt ortak) ----------
|
||||
|
||||
/// Kilitliyse 429; değilse bot doğrulaması (başarısızsa hata sayılır)
|
||||
async fn precheck(state: &AppState, ip: &str, token: Option<&str>) -> AppResult<()> {
|
||||
if let Some(secs) = state.guard.locked_for(ip) {
|
||||
return Err(AppError::Locked(secs));
|
||||
}
|
||||
match state.captcha.verify(token, ip).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e @ CaptchaError::Rejected) => {
|
||||
if let Outcome::Locked(secs) = state.guard.failure(ip) {
|
||||
tracing::warn!(ip, "deneme kilidi: bot doğrulaması");
|
||||
return Err(AppError::Locked(secs));
|
||||
}
|
||||
Err(e.into_app_error())
|
||||
}
|
||||
Err(e) => Err(e.into_app_error()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hatalı denemeyi say; kilide ulaşıldıysa kilit hatası, değilse kalan hakla birlikte döner
|
||||
fn count_failure(state: &AppState, ip: &str, what: &str) -> Result<u32, AppError> {
|
||||
match state.guard.failure(ip) {
|
||||
Outcome::Remaining(n) => Ok(n),
|
||||
Outcome::Locked(secs) => {
|
||||
tracing::warn!(ip, "deneme kilidi: {what}");
|
||||
Err(AppError::Locked(secs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Uç noktalar ----------
|
||||
|
||||
pub async fn guard_info(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
) -> Json<AuthGuardInfo> {
|
||||
Json(AuthGuardInfo {
|
||||
captcha_site_key: state.config.turnstile_site_key.clone(),
|
||||
locked_seconds: state.guard.locked_for(&ip).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
jar: CookieJar,
|
||||
ValidJson(req): ValidJson<RegisterRequest>,
|
||||
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
|
||||
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
|
||||
let name = req.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("Ad boş olamaz".into()));
|
||||
@@ -118,7 +187,7 @@ pub async fn register(
|
||||
.filter(|c| !c.is_empty());
|
||||
|
||||
let password_hash = password::hash(req.password).await?;
|
||||
let created = account::register(
|
||||
let created = match account::register(
|
||||
&state.db,
|
||||
state.config.registration,
|
||||
NewAccount {
|
||||
@@ -129,7 +198,19 @@ pub async fn register(
|
||||
invite_code,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
// Davet kodu tahmini, kapalı kayıt denemesi, kayıtlı e-posta yoklama → hata sayılır
|
||||
Err(
|
||||
e @ (AppError::RegistrationClosed | AppError::BadRequest(_) | AppError::Conflict(_)),
|
||||
) => {
|
||||
count_failure(&state, &ip, "kayıt")?;
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
state.guard.success(&ip);
|
||||
|
||||
let token = state.jwt.issue(
|
||||
created.user_id,
|
||||
@@ -145,16 +226,13 @@ pub async fn register(
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
jar: CookieJar,
|
||||
ValidJson(req): ValidJson<LoginRequest>,
|
||||
) -> AppResult<(CookieJar, Json<MeResponse>)> {
|
||||
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
|
||||
let email = normalize_email(&req.email);
|
||||
|
||||
if !state.login_limiter.is_allowed(&email) {
|
||||
tracing::warn!(%email, "giriş deneme sınırı aşıldı");
|
||||
return Err(AppError::TooManyRequests);
|
||||
}
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.collection::<User>(col::USERS)
|
||||
@@ -163,12 +241,12 @@ pub async fn login(
|
||||
|
||||
let hash = user.as_ref().map(|u| u.password_hash.clone());
|
||||
if !password::verify(req.password, hash).await {
|
||||
state.login_limiter.record_failure(&email);
|
||||
return Err(AppError::InvalidCredentials);
|
||||
let left = count_failure(&state, &ip, "giriş")?;
|
||||
return Err(AppError::InvalidCredentialsLeft(left));
|
||||
}
|
||||
// verify() true döndüyse kullanıcı kesin vardır
|
||||
let user = user.ok_or(AppError::InvalidCredentials)?;
|
||||
state.login_limiter.reset(&email);
|
||||
state.guard.success(&ip);
|
||||
|
||||
state
|
||||
.db
|
||||
|
||||
@@ -47,6 +47,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/health", get(health::health))
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/registration", get(auth::registration_info))
|
||||
.route("/auth/guard", get(auth::guard_info))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me).patch(auth::update_me))
|
||||
|
||||
+15
-2
@@ -5,7 +5,7 @@ use std::{sync::Arc, time::Duration};
|
||||
use mongodb::Database;
|
||||
|
||||
use crate::{
|
||||
auth::{jwt::Jwt, limiter::LoginLimiter},
|
||||
auth::{captcha::Captcha, guard::AttemptGuard, jwt::Jwt, limiter::LoginLimiter},
|
||||
config::Config,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,11 @@ pub struct AppState {
|
||||
pub db: Database,
|
||||
pub config: Arc<Config>,
|
||||
pub jwt: Arc<Jwt>,
|
||||
/// Şifre değiştirmede mevcut şifre denemeleri (kullanıcı başına)
|
||||
pub login_limiter: Arc<LoginLimiter>,
|
||||
/// Giriş / kayıt: IP başına üst üste 3 hata → 15 dk kilit
|
||||
pub guard: Arc<AttemptGuard>,
|
||||
pub captcha: Arc<Captcha>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -23,9 +27,18 @@ impl AppState {
|
||||
Self {
|
||||
db,
|
||||
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
|
||||
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
|
||||
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
|
||||
guard: Arc::new(AttemptGuard::default()),
|
||||
captcha: Arc::new(Captcha::from_secret(config.turnstile_secret_key.as_deref())),
|
||||
config: Arc::new(config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Testler: bot doğrulamasını ağsız sabit sonuçla değiştir
|
||||
pub fn with_captcha(mut self, captcha: Captcha) -> Self {
|
||||
self.captcha = Arc::new(captcha);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,63 @@ impl TestApp {
|
||||
registration,
|
||||
static_dir: None,
|
||||
rates_refresh_minutes: 0,
|
||||
turnstile_site_key: None,
|
||||
turnstile_secret_key: None,
|
||||
trust_proxy: true,
|
||||
};
|
||||
let router = routes::router(AppState::new(db.clone(), config));
|
||||
Self { router, db }
|
||||
}
|
||||
|
||||
/// Bot doğrulaması açık (ağsız sabit sonuç) bir uygulama: giriş/kayıt güvenlik testleri
|
||||
pub async fn with_captcha(pass: bool) -> Self {
|
||||
let app = Self::new().await;
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Config {
|
||||
mongodb_uri: std::env::var("MONGODB_URI").unwrap(),
|
||||
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
|
||||
app_url: "http://localhost".into(),
|
||||
port: 0,
|
||||
registration: RegistrationMode::Open,
|
||||
static_dir: None,
|
||||
rates_refresh_minutes: 0,
|
||||
turnstile_site_key: Some("test-site-key".into()),
|
||||
turnstile_secret_key: Some("test-secret".into()),
|
||||
trust_proxy: true,
|
||||
};
|
||||
let state = AppState::new(app.db.clone(), config)
|
||||
.with_captcha(kakeibo::auth::captcha::Captcha::Fixed(pass));
|
||||
Self {
|
||||
router: routes::router(state),
|
||||
db: app.db,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router_clone(&self) -> Router {
|
||||
self.router.clone()
|
||||
}
|
||||
|
||||
/// Ham istek (başlık eklemek için): (durum, gövde, çerez)
|
||||
pub async fn send_with(
|
||||
&self,
|
||||
req: axum::http::Request<Body>,
|
||||
) -> (StatusCode, Value, Option<String>) {
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let set_cookie = res
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(';').next())
|
||||
.map(String::from);
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
set_cookie,
|
||||
)
|
||||
}
|
||||
|
||||
/// Test sonunda geçici veritabanını siler.
|
||||
pub async fn cleanup(self) {
|
||||
self.db.drop().await.ok();
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Giriş / kayıt güvenliği: IP başına üst üste 3 hatada 15 dk kilit (giriş + kayıt birlikte),
|
||||
//! sahte X-Forwarded-For ile aşılamaz, bot doğrulaması (Turnstile, testte ağsız sabit sonuç).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use common::TestApp;
|
||||
use kakeibo::config::RegistrationMode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Belirli bir IP'den (vekilin eklediği en sağdaki X-Forwarded-For) JSON POST
|
||||
async fn post_from(
|
||||
app: &TestApp,
|
||||
xff: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
) -> (StatusCode, Value, Option<String>) {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-forwarded-for", xff)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
app.send_with(req).await
|
||||
}
|
||||
|
||||
async fn guard_from(app: &TestApp, xff: &str) -> Value {
|
||||
let req = Request::builder()
|
||||
.uri("/api/auth/guard")
|
||||
.header("x-forwarded-for", xff)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
app.send_with(req).await.1
|
||||
}
|
||||
|
||||
fn login(email: &str, password: &str) -> Value {
|
||||
json!({ "email": email, "password": password, "captcha_token": "jeton" })
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn three_wrong_logins_lock_login_and_register_for_that_ip() {
|
||||
let app = TestApp::new().await;
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"10.0.0.1",
|
||||
"/auth/register",
|
||||
json!({ "name": "Sahip", "email": "sahip@ornek.com", "password": "dogru-sifre-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
let ip = "203.0.113.5";
|
||||
for left in [2, 1] {
|
||||
let (st, b, _) =
|
||||
post_from(&app, ip, "/auth/login", login("sahip@ornek.com", "yanlis")).await;
|
||||
assert_eq!(st, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
format!("E-posta veya şifre hatalı. Kalan deneme hakkı: {left}")
|
||||
);
|
||||
}
|
||||
// 3. hata → kilit
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-forwarded-for", ip)
|
||||
.body(Body::from(login("sahip@ornek.com", "yanlis").to_string()))
|
||||
.unwrap();
|
||||
let res = tower::ServiceExt::oneshot(app_router(&app), req)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(res.headers()[header::RETRY_AFTER], "900");
|
||||
|
||||
// Kilitliyken doğru şifre de, kayıt da reddedilir
|
||||
let (st, b, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/login",
|
||||
login("sahip@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(b["error"]["code"], "locked");
|
||||
assert!(
|
||||
b["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("15 dakika")
|
||||
);
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/register",
|
||||
json!({ "name": "Yeni", "email": "yeni@ornek.com", "password": "sifre-12345" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "kayıt da kilitli");
|
||||
let g = guard_from(&app, ip).await;
|
||||
assert!(g["locked_seconds"].as_u64().unwrap() > 890, "{g}");
|
||||
|
||||
// Sahte sol girdi kilidi aşamaz (en sağdaki = vekilin eklediği)
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
&format!("1.1.1.1, {ip}"),
|
||||
"/auth/login",
|
||||
login("sahip@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS);
|
||||
|
||||
// Hane sahibi başka bağlantıdan girebilir (e-postaya göre kilit yok)
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"198.51.100.9",
|
||||
"/auth/login",
|
||||
login("sahip@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(guard_from(&app, "198.51.100.9").await["locked_seconds"], 0);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
fn app_router(app: &TestApp) -> axum::Router {
|
||||
app.router_clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_resets_the_counter() {
|
||||
let app = TestApp::new().await;
|
||||
post_from(
|
||||
&app,
|
||||
"10.0.0.1",
|
||||
"/auth/register",
|
||||
json!({ "name": "A", "email": "a@ornek.com", "password": "dogru-sifre-1" }),
|
||||
)
|
||||
.await;
|
||||
let ip = "203.0.113.6";
|
||||
for _ in 0..2 {
|
||||
post_from(&app, ip, "/auth/login", login("a@ornek.com", "yanlis")).await;
|
||||
}
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/login",
|
||||
login("a@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
for _ in 0..2 {
|
||||
let (st, _, _) = post_from(&app, ip, "/auth/login", login("a@ornek.com", "yanlis")).await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"sayaç sıfırlandı, henüz kilit yok"
|
||||
);
|
||||
}
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invite_code_guessing_is_locked() {
|
||||
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
|
||||
// İlk hane serbest
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"10.0.0.1",
|
||||
"/auth/register",
|
||||
json!({ "name": "Sahip", "email": "sahip@ornek.com", "password": "sifre-12345" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
let ip = "203.0.113.7";
|
||||
let guess = |code: &str, n: u32| json!({ "name": "Tahmin", "email": format!("t{n}@ornek.com"), "password": "sifre-12345", "invite_code": code });
|
||||
for (n, code) in ["AAAA-AAAA", "BBBB-BBBB"].iter().enumerate() {
|
||||
let (st, _, _) = post_from(&app, ip, "/auth/register", guess(code, n as u32)).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
// Davetsiz yeni hane denemesi (kapalı kayıt) da sayılır → 3. hata kilit
|
||||
let (st, b, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/register",
|
||||
json!({ "name": "X", "email": "x@ornek.com", "password": "sifre-12345" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "{b}");
|
||||
// Form doğrulama hataları sayılmaz (başka IP'de 5 kez kısa şifre)
|
||||
for _ in 0..5 {
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"203.0.113.8",
|
||||
"/auth/register",
|
||||
json!({ "name": "Y", "email": "y@ornek.com", "password": "kisa" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
assert_eq!(guard_from(&app, "203.0.113.8").await["locked_seconds"], 0);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bot_check_is_required_and_failures_count() {
|
||||
// Doğrulama her zaman başarısız
|
||||
let app = TestApp::with_captcha(false).await;
|
||||
let g = guard_from(&app, "203.0.113.9").await;
|
||||
assert_eq!(g["captcha_site_key"], "test-site-key");
|
||||
for _ in 0..2 {
|
||||
let (st, b, _) = post_from(
|
||||
&app,
|
||||
"203.0.113.9",
|
||||
"/auth/login",
|
||||
login("kim@ornek.com", "x"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b["error"]["code"], "captcha");
|
||||
}
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"203.0.113.9",
|
||||
"/auth/login",
|
||||
login("kim@ornek.com", "x"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"bot denemeleri de sayılır"
|
||||
);
|
||||
app.cleanup().await;
|
||||
|
||||
// Doğrulama geçer, ama jeton gönderilmezse red
|
||||
let app = TestApp::with_captcha(true).await;
|
||||
let body = json!({ "name": "A", "email": "a@ornek.com", "password": "dogru-sifre-1" });
|
||||
let (st, b, _) = post_from(&app, "10.0.0.2", "/auth/register", body.clone()).await;
|
||||
assert_eq!(
|
||||
(st, b["error"]["code"].as_str()),
|
||||
(StatusCode::BAD_REQUEST, Some("captcha"))
|
||||
);
|
||||
let mut with_token = body;
|
||||
with_token["captcha_token"] = json!("jeton");
|
||||
let (st, _, _) = post_from(&app, "10.0.0.2", "/auth/register", with_token).await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"10.0.0.2",
|
||||
"/auth/login",
|
||||
login("a@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user