From a2eff1a984b6ac394c5e0080037040b52478129b Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 14 Nov 2026 00:29:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(g=C3=BCvenlik):=20giri=C5=9F=20ve=20kay?= =?UTF-8?q?=C4=B1tta=20Cloudflare=20Turnstile,=20IP=20ba=C5=9F=C4=B1na=203?= =?UTF-8?q?=20hatal=C4=B1=20denemede=2015=20dk=20kilit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 + CLAUDE.md | 1 + backend/src/auth/captcha.rs | 115 ++++++++ backend/src/auth/client_ip.rs | 70 +++++ backend/src/auth/guard.rs | 130 +++++++++ backend/src/auth/mod.rs | 5 +- backend/src/config.rs | 33 +++ backend/src/error.rs | 26 +- backend/src/main.rs | 26 +- backend/src/routes/auth.rs | 102 ++++++- backend/src/routes/mod.rs | 1 + backend/src/state.rs | 17 +- backend/tests/common/mod.rs | 52 ++++ backend/tests/security.rs | 263 +++++++++++++++++++ docs/DEPLOY.md | 16 ++ docs/MEMORY.md | 2 + frontend/src/components/turnstile.tsx | 90 +++++++ frontend/src/features/auth/guard.tsx | 71 +++++ frontend/src/features/auth/lock-notice.tsx | 22 ++ frontend/src/features/auth/login-page.tsx | 103 ++++---- frontend/src/features/auth/register-page.tsx | 212 ++++++++------- frontend/src/lib/auth.ts | 10 + frontend/src/lib/query.ts | 2 + frontend/src/lib/strings.ts | 8 + frontend/src/types/AuthGuardInfo.ts | 14 + frontend/src/types/LoginRequest.ts | 6 +- frontend/src/types/RegisterRequest.ts | 6 +- 27 files changed, 1247 insertions(+), 164 deletions(-) create mode 100644 backend/src/auth/captcha.rs create mode 100644 backend/src/auth/client_ip.rs create mode 100644 backend/src/auth/guard.rs create mode 100644 backend/tests/security.rs create mode 100644 frontend/src/components/turnstile.tsx create mode 100644 frontend/src/features/auth/guard.tsx create mode 100644 frontend/src/features/auth/lock-notice.tsx create mode 100644 frontend/src/types/AuthGuardInfo.ts diff --git a/.env.example b/.env.example index 65249f2..3264586 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,11 @@ RUST_LOG=kakeibo=debug,tower_http=info # Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme) # RATES_REFRESH_MINUTES=60 + +# Bot doğrulaması (Cloudflare Turnstile). İkisi de boşsa kapalı (geliştirme). +# Yerel deneme için Cloudflare test anahtarları (her zaman geçer): +# TURNSTILE_SITE_KEY=1x00000000000000000000AA +# TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA + +# Ters vekil (Dokploy/Traefik) arkasında gerçek IP X-Forwarded-For'dan; vekil yoksa false +# TRUST_PROXY=true diff --git a/CLAUDE.md b/CLAUDE.md index ce724fe..ffca8ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,7 @@ npm run db:restore -- [--force] - Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez. - Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok. - PWA: `vite.config.ts` `VitePWA` (registerType `prompt`, `/api` asla önbelleğe alınmaz, `navigateFallbackDenylist /api`, xlsx + yabancı font alt kümeleri ön önbellekte yok); `lib/pwa.tsx` (`PwaUpdater` "Yeni sürüm hazır → Yenile", saatlik yoklama; `OfflineBanner`), `lib/online.ts`. `main.tsx` `onlineManager.setOnline(navigator.onLine)`. Oturum sorgusu ağ hatası/duraklama → `RequireAuth` giriş sayfasına **atmaz**, "Tekrar dene" gösterir. Geliştirmede SW yok. DEPLOY §7. +- Giriş güvenliği: `auth/guard.rs` (IP başına üst üste 3 hata → giriş+kayıt 15 dk kilit, bellek içi; e-postaya göre kilit YOK), `auth/captcha.rs` (Cloudflare Turnstile; anahtar yoksa kapalı; testte `Captcha::Fixed`), `auth/client_ip.rs` (`TRUST_PROXY` → en sağdaki X-Forwarded-For). Hata kodları `locked` (429 + Retry-After), `captcha` (400), `invalid_credentials` (kalan hak mesajda). `GET /auth/guard`. Arayüz `features/auth/guard.tsx` (`useAuthSecurity`), `lock-notice.tsx`, `components/turnstile.tsx`. Testler `TestApp::with_captcha`, `send_with` (X-Forwarded-For). DEPLOY §8. - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/backend/src/auth/captcha.rs b/backend/src/auth/captcha.rs new file mode 100644 index 0000000..4be64b1 --- /dev/null +++ b/backend/src/auth/captcha.rs @@ -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, +} + +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::().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) + } + } + } + } + } +} diff --git a/backend/src/auth/client_ip.rs b/backend/src/auth/client_ip.rs new file mode 100644 index 0000000..6df1715 --- /dev/null +++ b/backend/src/auth/client_ip.rs @@ -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 { + 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::().ok()) + .next_back() +} + +impl FromRequestParts for ClientIp { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let forwarded = state + .config + .trust_proxy + .then(|| from_forwarded(&parts.headers)) + .flatten(); + let socket = parts + .extensions + .get::>() + .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); + } +} diff --git a/backend/src/auth/guard.rs b/backend/src/auth/guard.rs new file mode 100644 index 0000000..b4d44ea --- /dev/null +++ b/backend/src/auth/guard.rs @@ -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, + last: Instant, +} + +pub struct AttemptGuard { + map: Mutex>, + 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 { + 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"); + } +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index d66acda..54fec43 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -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; diff --git a/backend/src/config.rs b/backend/src/config.rs index c0450d9..3bd5079 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -14,6 +14,11 @@ pub struct Config { pub static_dir: Option, /// 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, + pub turnstile_secret_key: Option, + /// 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, }) } diff --git a/backend/src/error.rs b/backend/src/error.rs index cc7c4ca..ca9fd89 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -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 } } diff --git a/backend/src/main.rs b/backend/src/main.rs index cde0275..7da7f00 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await { tracing::error!("Sunucu hatası: {e}"); return ExitCode::FAILURE; diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index b6eaa99..9016edd 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -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, + /// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu) + captcha_token: Option, } #[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, } #[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, + /// 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 { + 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, + ClientIp(ip): ClientIp, +) -> Json { + 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, + ClientIp(ip): ClientIp, jar: CookieJar, ValidJson(req): ValidJson, ) -> AppResult<(StatusCode, CookieJar, Json)> { + 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, + ClientIp(ip): ClientIp, jar: CookieJar, ValidJson(req): ValidJson, ) -> AppResult<(CookieJar, Json)> { + 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::(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 diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 34e0630..1c86b2e 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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)) diff --git a/backend/src/state.rs b/backend/src/state.rs index da1a4e0..9cecbe1 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -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, pub jwt: Arc, + /// Şifre değiştirmede mevcut şifre denemeleri (kullanıcı başına) pub login_limiter: Arc, + /// Giriş / kayıt: IP başına üst üste 3 hata → 15 dk kilit + pub guard: Arc, + pub captcha: Arc, } 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 + } +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 9a5a978..3f99f36 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -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, + ) -> (StatusCode, Value, Option) { + 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(); diff --git a/backend/tests/security.rs b/backend/tests/security.rs new file mode 100644 index 0000000..4a6e2af --- /dev/null +++ b/backend/tests/security.rs @@ -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) { + 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; +} diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index d0b51e1..3c9ab63 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -25,6 +25,9 @@ | `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes | | `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi | | `RATES_REFRESH_MINUTES` | – | `60` | Otomatik kur yenileme aralığı (dk); `0` → kapalı. Konteynerin internete çıkışı gerekir: `www.tcmb.gov.tr` (döviz), `xaus.com` (altın). Erişilemezse son kur kalır, elle kur girilebilir | +| `TURNSTILE_SITE_KEY` | önerilir | `0x4AAAA…` | Cloudflare Turnstile **site anahtarı** (herkese açık). Giriş/kayıtta bot doğrulaması; ikisi de boşsa kapalı (§8) | +| `TURNSTILE_SECRET_KEY` | önerilir | `0x4AAAA…` | Turnstile **gizli anahtarı** — yalnızca sunucuda, kimseyle paylaşmayın. Konteynerin `challenges.cloudflare.com`'a çıkışı gerekir | +| `TRUST_PROXY` | – | `true` | Dokploy/Traefik arkasında gerçek istemci IP'si `X-Forwarded-For`'dan (giriş kilidi IP'ye göre). Vekil **yoksa** `false` | | `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin | | `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin | @@ -187,3 +190,16 @@ Nasıl çalışır: - Sunucu: `sw.js` ve `manifest.webmanifest` hiç önbelleğe alınmaz (`routes/spa.rs`), böylece güncelleme hemen fark edilir. Sorun olursa (eski sürüm takılı kaldı vb.): tarayıcı ayarlarından site verilerini temizlemek service worker'ı da kaldırır. + +## 8. Giriş güvenliği: bot doğrulaması + deneme kilidi + +**Deneme kilidi (her zaman açık):** aynı bağlantıdan (IP) üst üste **3 hatalı** deneme → o bağlantıya giriş **ve** kayıt **15 dakika** kapanır. Sayılanlar: yanlış şifre, geçersiz/kullanılmış davet kodu, kapalı kayda deneme, zaten kayıtlı e-posta, başarısız bot doğrulaması. Form hataları (kısa şifre vb.) sayılmaz; başarılı giriş sayacı sıfırlar. Kilit e-postaya değil IP'ye göredir: adresinizi bilen biri sizi dışarıda bırakamaz. Sayaçlar bellektedir (yeni sürüm yayınlanınca sıfırlanır). + +**Bot doğrulaması (Cloudflare Turnstile) — kurulum:** +1. Ücretsiz bir Cloudflare hesabı açın (alan adınızın Cloudflare'de olması gerekmez). +2. Panel → **Turnstile** → **Add widget**: ad `Kakeibo`, **Hostname**: canlı alan adınız (ör. `kakeibo.`; yerel deneme için `localhost` da eklenebilir), **Widget Mode: Managed**. +3. Verilen **Site Key** ve **Secret Key**'i Dokploy → uygulama → **Environment**'a `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` olarak girin, yeniden dağıtın. +4. Doğrulama: loglarda `Bot doğrulaması (Cloudflare Turnstile) açık`; giriş sayfasında "Başarılı ✓" kutusu. + +Notlar: Gizli anahtarı arayüze, git'e ya da sohbete yazmayın. Cloudflare'e ulaşılamazsa giriş güvenlik için reddedilir (hata hakkından düşmez). Yerelde denemek için Cloudflare'in herkese açık test anahtarları: site `1x00000000000000000000AA`, gizli `1x0000000000000000000000000000000AA` (her zaman geçer; kutuda "yalnızca test" uyarısı görünür). + diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 69ca4de..57a7f53 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -5,6 +5,7 @@ **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. ## 📌 Alınan Kararlar +- **Giriş güvenliği (kullanıcı, 2026-11-14):** reCAPTCHA istendi → seçeneklerden **Cloudflare Turnstile** seçildi (KVKK dostu); kilit **IP'ye göre** (e-postaya göre değil: sahibi kilitleme saldırısı olmasın). Üst üste 3 hata → giriş + kayıt 15 dk. - **Tauri / mobil + masaüstü uygulamalar (kullanıcı, 2026-11-13):** Faz 11 (Windows, Linux, Android) **kullanıcının geniş kapsamlı testinden sonra, kullanıcı prompt verdiğinde** yapılır; kendiliğinden başlanmaz. Şimdi Faz 10: web uygulamasının eksikleri + arayüz cilası (PWA web kurulumu Faz 10'da kalır). - **Aylık rapor tasarımı (kullanıcı onayı, 2026-11-13):** 7 bölüm — Bir bakışta (4 soru + önceki dönem) · Ne yaptım (gruplar ✅≤%90/⚠️≤%100/❌, ilk 5 kategori + ortalamaya göre, haftalar, ödeme türü, birikim) · İyi gitti · Zorladı · Ne yapılabilirdi ("olsaydı" hesapları) · Kendi notların (plan notları + yansıtma, yorumsuz) · Gelecek dönem (önerilen bütçe = son ≤4 dönem ortalaması 50 ₺ yukarı, ≤3 somut adım, devir, boş hedef kutuları) + Ek: tüm kategoriler. Eşik: kategori %20 **ve** 250 ₺. Karşılaştırma: önceki ≤3 kapalı/arşiv dönem. "Sen" dili. Açık dönem → ara rapor (hedef yargısı yok, bitmemiş hafta yok). - **Hafta kuralı değişti (kullanıcı, 2026-11-13):** Pazartesi başlangıçlı haftalar yerine dönemin 1–8, 9–15, 16–22, 23–son günleri (ay = 4 hafta). Kasım 2026'da 6 hafta (01.11 tek gün) çıkması üzerine. Genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan (6–12). Defter her açılışta bugünün haftası; hafta artık adrese yazılmaz (sekme geri yüklemede eski hafta açılıyordu). @@ -153,3 +154,4 @@ - **Faz 10 – Adım 3 (2026-11-13):** Tarama betiği (scratchpad `e2e/scan.mjs`): 21 sayfa × telefon açık/koyu + masaüstü (+ boş hane, giriş/kayıt); sayfa içi denetim (adsız denetim, <44 px hedef, canvas ile oklch→sRGB kontrast, tek h1, alt, lang, taşma) + Tab ile odak halkası. İlk sonuç 89 kontrast / 75 küçük hedef / 27 adsız / 3 h1 → benzersiz: muted yazı 4,35 (→ token 0.52), yeşil 4,37 (→ #166534), `sm` düğmeler 36 px (→ pointer-coarse 44), GFM görev kutusu adsız (→ "Tamamlandı/Yapılacak"), rapor 2 h1 (→ `shiftHeadings`). **Ekran görüntüsünden:** telefonda Defter formu taşıyordu (ızgara öğesi `min-width:auto` 425 px) — otomatik kontrol kaçırdı çünkü mobil öykünmede `innerWidth` da büyür → kontrol `screen.width`/390 ile; Raporlar grafik kartları da taşıyordu → `Section` + rapor bölümüne `min-w-0`. Eklenen: %80 bütçe uyarısı (`lib/budget-level.ts`), Raporlar boş durumu (dönem yok → Dönemi başlat / Ekstreden aktar; filtre boş → Filtreleri temizle), tutarlarda bölünmez boşluk. Sonra: kontrast/adsız/h1/telefon hedef 0. Frontend 142 test. - **Faz 10 – Adım 4 (2026-11-13):** Önbellek başlıkları zaten doğruydu (Faz 5: `/assets/*` 1 yıl immutable, HTML no-cache, sıkıştırma). Ana JS 950 KB (gz 284) tek paket → sayfa bazlı `lazy` route: ana 402 KB (gz 128), sayfalar 2–23 KB; Markdown (49 KB gz) ve zod/form (40 KB gz) yalnız kullanan sayfada. İlk sürümde Defter yavaşlamıştı (lazy route → `/auth/me` sıralı) → `prefetchMe` ile paralel; `HydrateFallback: Splash`. Boşta alt menü sayfalarını önden indirme + `useNavigation` ilerleme çizgisi. Ölçüm (scratchpad `e2e/perf.mjs`: yavaş 4G 1,6 Mbps/150 ms + CPU 4×, soğuk; eski sürüm `git worktree` ile ayrı derlenip aynı koşulda, 2 tekrar ort.): Giriş 2,97→2,23 sn, Panel 2,42→2,03, Raporlar 2,36→2,11, Defter 2,33→2,37 (aynı); hazırda JS 295→171–221 KB. Regresyon: katalog (25) + rapor (21) e2e geçti. Not: e2e rapor başlığı artık `.md-h1`. - **Faz 10 – Adım 5 (2026-11-13) → Faz 10 ✅:** `vite-plugin-pwa` 2.0 + `workbox-window` (dev): manifest (tr, standalone, ikonlar + maskable, kısayollar Harcama ekle/Panel), Workbox precache 98–101 dosya (xlsx ve vietnamca/yunanca/kiril font alt kümeleri hariç → runtime CacheFirst `/assets/`), `navigateFallback` index.html (`/api` hariç). `registerType: 'prompt'` → `PwaUpdater` toast + saatlik `reg.update()`. `OfflineBanner` (useSyncExternalStore). Backend `spa.rs` `cache_policy` (sw.js, manifest, registerSW → no-cache) + birim testi. **Bulunan hata:** çevrimdışı açılışta `/auth/me` ağ hatası → RequireAuth kullanıcıyı giriş sayfasına atıyordu; ayrıca TanStack onlineManager açılış durumunu bilmediği için birkaç sn boş açılış → `SessionUnknown` ("Oturum bilgisi alınamadı… Tekrar dene") + `onlineManager.setOnline(navigator.onLine)`; bağlantı gelince kendiliğinden toparlanır. E2E 14 kontrol (CDP `Page.getInstallabilityErrors` boş, önbellekte /api 0, çevrimdışı, güncelleme: dist kopyası + sw.js index revizyonu değiştirilip sunucu yeniden başlatıldı → bildirim → Yenile → yeni sürüm, oturum/sayfa korunur). npm audit: yeni `glob` (orta) yalnız derleme zamanı; diğer uyarılar mevcut `shadcn` CLI'dan. Docker web aşaması geçti. DEPLOY §7 kurulum. +- **Giriş güvenliği (2026-11-14):** Backend `auth/guard.rs` (AttemptGuard, ardışık sayaç, başarıda sıfır, süre bitince yeni tur, 1 sa dokunulmayan kayıt silinir), `auth/captcha.rs` (siteverify, 8 sn zaman aşımı, ağ hatası → `Unavailable` sayılmaz ama reddedilir, `internal-error` de öyle), `auth/client_ip.rs` (en sağ XFF, yoksa ConnectInfo; `main.rs` `into_make_service_with_connect_info`). Login/register `precheck` (kilit → captcha) + sayılan hatalar (yanlış şifre `InvalidCredentialsLeft(n)`, RegistrationClosed / BadRequest davet / Conflict e-posta). E-postaya göre giriş limiter'ı kaldırıldı (şifre değiştirmede kullanıcı başına kaldı). Config `TURNSTILE_SITE_KEY`/`SECRET_KEY` (ikisi birden), `TRUST_PROXY` (varsayılan true: Traefik arkasında herkes aynı IP olmasın). Testler `tests/security.rs` (4) + guard/XFF birim; 2 bilinçli bozma yakalandı; backend 214. Frontend `useAuthSecurity` (jeton, her hatada widget reset, kilit → `useAuthGuard` refetch, `dataUpdatedAt`+saniye geri sayım), Turnstile explicit render (tr, tema, flexible), betik yüklenemezse açıklama + düğme kapalı. E2E: Cloudflare test anahtarlarıyla gerçek widget + siteverify; kalan hak 2/1, kilit 14:57 sayaç, yenileme/kayıt/API kilitli; Cloudflare engelli → düğme kapalı, jetonsuz API 400 captcha. **Kullanıcıda:** Cloudflare'den anahtar alıp Dokploy'a girmek (DEPLOY §8). diff --git a/frontend/src/components/turnstile.tsx b/frontend/src/components/turnstile.tsx new file mode 100644 index 0000000..c140446 --- /dev/null +++ b/frontend/src/components/turnstile.tsx @@ -0,0 +1,90 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { useTheme } from '@/lib/theme' + +/** + * Cloudflare Turnstile (bot doğrulaması). Betik yalnızca giriş/kayıt sayfalarında, bir kez + * yüklenir. Jeton tek kullanımlıktır: her gönderimden sonra `reset()` ile yenisi alınır. + */ +type TurnstileApi = { + render: (el: HTMLElement, opts: Record) => string + reset: (id: string) => void + remove: (id: string) => void +} +declare global { + interface Window { + turnstile?: TurnstileApi + } +} + +const SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' +let loading: Promise | null = null + +function loadTurnstile(): Promise { + if (window.turnstile) return Promise.resolve(window.turnstile) + loading ??= new Promise((resolve, reject) => { + const s = document.createElement('script') + s.src = SRC + s.async = true + s.onload = () => (window.turnstile ? resolve(window.turnstile) : reject(new Error('turnstile'))) + s.onerror = () => { + loading = null + reject(new Error('turnstile')) + } + document.head.append(s) + }) + return loading +} + +export type TurnstileHandle = { reset: () => void } + +export const Turnstile = forwardRef< + TurnstileHandle, + { + siteKey: string + /** Geçerli jeton ya da `null` (süresi doldu / hata) */ + onToken: (token: string | null) => void + /** Betik yüklenemedi (ağ / reklam engelleyici) */ + onLoadError?: () => void + } +>(function Turnstile({ siteKey, onToken, onLoadError }, ref) { + const el = useRef(null) + const widget = useRef(null) + const { theme } = useTheme() + // Geri çağrılar her çizimde değişebilir; kutu yeniden oluşturulmasın + const cb = useRef({ onToken, onLoadError }) + useEffect(() => { + cb.current = { onToken, onLoadError } + }) + + useImperativeHandle(ref, () => ({ + reset: () => { + cb.current.onToken(null) + if (widget.current && window.turnstile) window.turnstile.reset(widget.current) + }, + })) + + useEffect(() => { + let cancelled = false + loadTurnstile() + .then((ts) => { + if (cancelled || !el.current) return + widget.current = ts.render(el.current, { + sitekey: siteKey, + theme: theme === 'system' ? 'auto' : theme, + language: 'tr', + size: 'flexible', + callback: (t: string) => cb.current.onToken(t), + 'expired-callback': () => cb.current.onToken(null), + 'error-callback': () => cb.current.onToken(null), + }) + }) + .catch(() => cb.current.onLoadError?.()) + return () => { + cancelled = true + if (widget.current && window.turnstile) window.turnstile.remove(widget.current) + widget.current = null + } + }, [siteKey, theme]) + + return
+}) diff --git a/frontend/src/features/auth/guard.tsx b/frontend/src/features/auth/guard.tsx new file mode 100644 index 0000000..47d833e --- /dev/null +++ b/frontend/src/features/auth/guard.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from 'react' +import { Turnstile, type TurnstileHandle } from '@/components/turnstile' +import { ApiError } from '@/lib/api' +import { useAuthGuard } from '@/lib/auth' +import { t } from '@/lib/strings' + +const s = t.auth.guard + +/** + * Giriş / kayıt güvenliği (ortak): bot doğrulama jetonu + IP kilidi. + * Sunucu: üst üste 3 hatalı deneme → bu bağlantıya giriş ve kayıt 15 dk kapalı. + */ +export function useAuthSecurity() { + const guard = useAuthGuard() + const widget = useRef(null) + const [token, setToken] = useState(null) + const [scriptFailed, setScriptFailed] = useState(false) + const siteKey = guard.data?.captcha_site_key ?? null + // Bitiş anı: yanıtın alındığı an + sunucunun bildirdiği kalan süre + const lockedSecs = guard.data?.locked_seconds ?? 0 + const until = lockedSecs > 0 ? guard.dataUpdatedAt + lockedSecs * 1000 : 0 + const locked = useCountdown(until, () => void guard.refetch()) + + return { + loading: guard.isPending, + locked, + /** Gönder düğmesi: doğrulama açıksa jeton gelmeden olmaz */ + ready: !guard.isPending && locked === 0 && (!siteKey || !!token), + token: token ?? undefined, + /** Her başarısız gönderimden sonra: jeton tek kullanımlık; kilit geldiyse sayaç başlar */ + afterError(e: unknown) { + widget.current?.reset() + if (e instanceof ApiError && (e.code === 'locked' || e.code === 'invalid_credentials')) + void guard.refetch() + }, + field: siteKey ? ( +
+ setScriptFailed(true)} + /> + {scriptFailed &&

{s.scriptFailed}

} +
+ ) : null, + } +} + +/** `until` anına (ms) kadar kalan saniye, saniye saniye; bitince `onDone` */ +function useCountdown(until: number, onDone: () => void): number { + const [now, setNow] = useState(() => Date.now()) + const done = useRef(onDone) + useEffect(() => { + done.current = onDone + }) + useEffect(() => { + if (!until) return + const id = setInterval(() => { + const n = Date.now() + setNow(n) + if (n >= until) { + clearInterval(id) + done.current() + } + }, 1000) + return () => clearInterval(id) + }, [until]) + // `now` bileşen açılırken alındı; yanıt daha sonra geldiyse süre 15 dk'yı aşmasın + return until ? Math.max(0, Math.ceil((until - Math.max(now, until - 15 * 60_000)) / 1000)) : 0 +} diff --git a/frontend/src/features/auth/lock-notice.tsx b/frontend/src/features/auth/lock-notice.tsx new file mode 100644 index 0000000..773414e --- /dev/null +++ b/frontend/src/features/auth/lock-notice.tsx @@ -0,0 +1,22 @@ +import { Lock } from 'lucide-react' +import { t } from '@/lib/strings' + +const s = t.auth.guard + +/** Kilitliyken formun yerine: neden + geri sayım */ +export function LockNotice({ seconds }: { seconds: number }) { + const mm = Math.floor(seconds / 60) + const ss = String(seconds % 60).padStart(2, '0') + return ( +
+ +
+

{s.lockedTitle}

+

{s.lockedText}

+

+ {mm}:{ss} +

+
+
+ ) +} diff --git a/frontend/src/features/auth/login-page.tsx b/frontend/src/features/auth/login-page.tsx index 076e81e..6c7966e 100644 --- a/frontend/src/features/auth/login-page.tsx +++ b/frontend/src/features/auth/login-page.tsx @@ -12,6 +12,8 @@ import { ApiError } from '@/lib/api' import { useLogin } from '@/lib/auth' import { t } from '@/lib/strings' import { AuthShell } from './auth-shell' +import { useAuthSecurity } from './guard' +import { LockNotice } from './lock-notice' import { loginSchema, type LoginForm } from './schemas' const s = t.auth.login @@ -31,12 +33,15 @@ export function LoginPage() { defaultValues: { email: '', password: '', remember_me: true }, }) + const security = useAuthSecurity() + const onSubmit = handleSubmit(async (values) => { try { - const me = await login.mutateAsync(values) + const me = await login.mutateAsync({ ...values, captcha_token: security.token }) toast.success(s.welcome(me.user.name)) // Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya) } catch (e) { + security.afterError(e) setError('root', { message: e instanceof ApiError ? e.message : String(e) }) } }) @@ -63,52 +68,62 @@ export function LoginPage() { } > -
- - {(f) => ( - - )} - - - - {(f) => ( - - )} - - - ( - - )} - /> + )} + - + + {(f) => ( + + )} + - - + ( + + )} + /> + + {security.field} + + + + + )} ) } diff --git a/frontend/src/features/auth/register-page.tsx b/frontend/src/features/auth/register-page.tsx index 7fad6cc..3a86cbb 100644 --- a/frontend/src/features/auth/register-page.tsx +++ b/frontend/src/features/auth/register-page.tsx @@ -15,6 +15,8 @@ import { queryKeys } from '@/lib/query' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' import { AuthShell } from './auth-shell' +import { useAuthSecurity } from './guard' +import { LockNotice } from './lock-notice' import type { RegistrationInfo } from '@/types/RegistrationInfo' import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas' @@ -61,9 +63,12 @@ export function RegisterPage() { const mode = useWatch({ control, name: 'mode' }) const name = useWatch({ control, name: 'name' }).trim() + const security = useAuthSecurity() + const onSubmit = handleSubmit(async (v) => { try { const me = await registerAccount.mutateAsync({ + captcha_token: security.token, name: v.name, email: v.email, password: v.password, @@ -73,6 +78,7 @@ export function RegisterPage() { }) toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name)) } catch (e) { + security.afterError(e) if (!(e instanceof ApiError)) return setError('root', { message: String(e) }) // Sunucu hatasını ilgili alana bağla if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true }) @@ -105,110 +111,120 @@ export function RegisterPage() { } > -
- - {(f) => } - + {security.locked > 0 ? ( + + ) : ( + + + {(f) => } + - - {(f) => ( - - )} - + + {(f) => ( + + )} + - - {(f) => } - + + {(f) => } + - {/* Yeni hane mi, davetle katılma mı? */} -
- {s.modeLabel} - {!newAllowed && ( -

- - {s.closedNote} -

- )} - {newAllowed && ( - ( -
- {( - [ - ['new', s.modeNew, Home], - ['join', s.modeJoin, Users], - ] as const - ).map(([value, label, Icon]) => ( - - ))} -
- )} - /> - )} + {/* Yeni hane mi, davetle katılma mı? */} +
+ {s.modeLabel} + {!newAllowed && ( +

+ + {s.closedNote} +

+ )} + {newAllowed && ( + ( +
+ {( + [ + ['new', s.modeNew, Home], + ['join', s.modeJoin, Users], + ] as const + ).map(([value, label, Icon]) => ( + + ))} +
+ )} + /> + )} - {mode === 'new' ? ( - - {(f) => ( - - )} - - ) : ( - - {(f) => ( - - )} - - )} -
+ {mode === 'new' ? ( + + {(f) => ( + + )} + + ) : ( + + {(f) => ( + + )} + + )} +
- + {security.field} + - - + + + )} ) } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index b9838e1..17c741e 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -7,6 +7,7 @@ * - `MeResponse`→ giriş yapılmış */ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' +import type { AuthGuardInfo } from '@/types/AuthGuardInfo' import type { LoginRequest } from '@/types/LoginRequest' import type { MeResponse } from '@/types/MeResponse' import type { RegisterRequest } from '@/types/RegisterRequest' @@ -69,3 +70,12 @@ export function useLogout() { }, }) } + +/** Giriş/kayıt sayfası: bot doğrulama site anahtarı ve bu bağlantının kilidi (her açılışta taze) */ +export function useAuthGuard() { + return useQuery({ + queryKey: queryKeys.authGuard, + queryFn: () => api.get('/auth/guard'), + staleTime: 0, + }) +} diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index b585129..d6f6c63 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -8,6 +8,8 @@ import { ApiError } from './api' export const queryKeys = { me: ['me'] as const, registration: ['registration'] as const, + /** Giriş/kayıt: bot doğrulama anahtarı + bu bağlantının kilidi */ + authGuard: ['auth-guard'] as const, health: ['health'] as const, household: ['household'] as const, invites: ['household', 'invites'] as const, diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 876d3ee..3c90ba8 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -52,6 +52,14 @@ export const t = { }, auth: { + guard: { + lockedTitle: 'Çok fazla hatalı deneme', + lockedText: + 'Güvenliğin için bu bağlantıdan giriş ve kayıt geçici olarak kapatıldı. Süre dolunca tekrar deneyebilirsin.', + verifying: 'Robot olmadığın doğrulanıyor…', + scriptFailed: + 'Doğrulama kutusu yüklenemedi. İnternet bağlantını ya da reklam engelleyiciyi kontrol edip sayfayı yenile.', + }, login: { title: 'Tekrar hoş geldin', description: 'Defterine devam etmek için giriş yap.', diff --git a/frontend/src/types/AuthGuardInfo.ts b/frontend/src/types/AuthGuardInfo.ts new file mode 100644 index 0000000..04b9346 --- /dev/null +++ b/frontend/src/types/AuthGuardInfo.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Giriş/kayıt sayfası: bot doğrulaması açık mı, bu IP kilitli mi? + */ +export type AuthGuardInfo = { +/** + * Turnstile site anahtarı (herkese açık); `null` → doğrulama kapalı + */ +captcha_site_key: string | null, +/** + * Kilit varsa kalan süre (sn); yoksa 0 + */ +locked_seconds: number, }; diff --git a/frontend/src/types/LoginRequest.ts b/frontend/src/types/LoginRequest.ts index bdcbd52..ab9bd08 100644 --- a/frontend/src/types/LoginRequest.ts +++ b/frontend/src/types/LoginRequest.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LoginRequest = { email: string, password: string, remember_me: boolean, }; +export type LoginRequest = { email: string, password: string, remember_me: boolean, +/** + * Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu) + */ +captcha_token?: string, }; diff --git a/frontend/src/types/RegisterRequest.ts b/frontend/src/types/RegisterRequest.ts index 22c88fb..f4b9b53 100644 --- a/frontend/src/types/RegisterRequest.ts +++ b/frontend/src/types/RegisterRequest.ts @@ -4,4 +4,8 @@ export type RegisterRequest = { name: string, email: string, password: string, h /** * Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır. */ -invite_code?: string, }; +invite_code?: string, +/** + * Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu) + */ +captcha_token?: string, };