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:
@@ -27,3 +27,11 @@ RUST_LOG=kakeibo=debug,tower_http=info
|
|||||||
|
|
||||||
# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme)
|
# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme)
|
||||||
# RATES_REFRESH_MINUTES=60
|
# 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
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--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.
|
- 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.
|
- 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.
|
- 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.
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
|
|||||||
@@ -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 jwt;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ pub struct Config {
|
|||||||
pub static_dir: Option<PathBuf>,
|
pub static_dir: Option<PathBuf>,
|
||||||
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
||||||
pub rates_refresh_minutes: u64,
|
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).
|
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
|
||||||
@@ -78,6 +83,31 @@ impl Config {
|
|||||||
_ => 60,
|
_ => 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 {
|
Ok(Self {
|
||||||
mongodb_uri,
|
mongodb_uri,
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
@@ -86,6 +116,9 @@ impl Config {
|
|||||||
registration,
|
registration,
|
||||||
static_dir,
|
static_dir,
|
||||||
rates_refresh_minutes,
|
rates_refresh_minutes,
|
||||||
|
turnstile_site_key,
|
||||||
|
turnstile_secret_key,
|
||||||
|
trust_proxy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-2
@@ -35,6 +35,15 @@ pub enum AppError {
|
|||||||
Unauthorized,
|
Unauthorized,
|
||||||
#[error("E-posta veya şifre hatalı")]
|
#[error("E-posta veya şifre hatalı")]
|
||||||
InvalidCredentials,
|
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")]
|
#[error("Bu işlem için yetkiniz yok")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
|
#[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::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
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::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
|
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
@@ -70,6 +83,10 @@ impl AppError {
|
|||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, code) = self.status_and_code();
|
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() {
|
let message = if status.is_server_error() {
|
||||||
// Teknik detay sadece logda; kullanıcıya genel mesaj.
|
// Teknik detay sadece logda; kullanıcıya genel mesaj.
|
||||||
@@ -85,7 +102,12 @@ impl IntoResponse for AppError {
|
|||||||
message,
|
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
|
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
|
||||||
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
|
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 port = config.port;
|
||||||
let state = AppState::new(db, config);
|
let state = AppState::new(db, config);
|
||||||
let app = routes::app(state);
|
let app = routes::app(state);
|
||||||
@@ -60,9 +76,13 @@ async fn main() -> ExitCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||||
if let Err(e) = axum::serve(listener, app)
|
// ConnectInfo: vekil yoksa istemci IP'si bağlantı adresinden (deneme kilidi)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
if let Err(e) = axum::serve(
|
||||||
.await
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Sunucu hatası: {e}");
|
tracing::error!("Sunucu hatası: {e}");
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
|
|||||||
+90
-12
@@ -1,6 +1,11 @@
|
|||||||
//! Kimlik doğrulama uç noktaları:
|
//! Kimlik doğrulama uç noktaları:
|
||||||
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
|
//! `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::{Json, extract::State, http::StatusCode};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
@@ -11,7 +16,11 @@ use validator::Validate;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{
|
||||||
AuthUser, password,
|
AuthUser,
|
||||||
|
captcha::CaptchaError,
|
||||||
|
client_ip::ClientIp,
|
||||||
|
guard::Outcome,
|
||||||
|
password,
|
||||||
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
|
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
|
||||||
},
|
},
|
||||||
db::col,
|
db::col,
|
||||||
@@ -42,6 +51,8 @@ pub struct RegisterRequest {
|
|||||||
/// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
|
/// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
|
||||||
#[validate(length(max = 20, message = "Davet kodu geçersiz"))]
|
#[validate(length(max = 20, message = "Davet kodu geçersiz"))]
|
||||||
invite_code: Option<String>,
|
invite_code: Option<String>,
|
||||||
|
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||||
|
captcha_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Validate, TS)]
|
#[derive(Deserialize, Validate, TS)]
|
||||||
@@ -53,6 +64,10 @@ pub struct LoginRequest {
|
|||||||
password: String,
|
password: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
remember_me: bool,
|
remember_me: bool,
|
||||||
|
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
captcha_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
#[derive(Serialize, TS)]
|
||||||
@@ -94,13 +109,67 @@ pub struct PasswordChangeRequest {
|
|||||||
new_password: String,
|
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 ----------
|
// ---------- 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(
|
pub async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ClientIp(ip): ClientIp,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ValidJson(req): ValidJson<RegisterRequest>,
|
ValidJson(req): ValidJson<RegisterRequest>,
|
||||||
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
|
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
|
||||||
|
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
|
||||||
let name = req.name.trim().to_string();
|
let name = req.name.trim().to_string();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(AppError::BadRequest("Ad boş olamaz".into()));
|
return Err(AppError::BadRequest("Ad boş olamaz".into()));
|
||||||
@@ -118,7 +187,7 @@ pub async fn register(
|
|||||||
.filter(|c| !c.is_empty());
|
.filter(|c| !c.is_empty());
|
||||||
|
|
||||||
let password_hash = password::hash(req.password).await?;
|
let password_hash = password::hash(req.password).await?;
|
||||||
let created = account::register(
|
let created = match account::register(
|
||||||
&state.db,
|
&state.db,
|
||||||
state.config.registration,
|
state.config.registration,
|
||||||
NewAccount {
|
NewAccount {
|
||||||
@@ -129,7 +198,19 @@ pub async fn register(
|
|||||||
invite_code,
|
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(
|
let token = state.jwt.issue(
|
||||||
created.user_id,
|
created.user_id,
|
||||||
@@ -145,16 +226,13 @@ pub async fn register(
|
|||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ClientIp(ip): ClientIp,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ValidJson(req): ValidJson<LoginRequest>,
|
ValidJson(req): ValidJson<LoginRequest>,
|
||||||
) -> AppResult<(CookieJar, Json<MeResponse>)> {
|
) -> AppResult<(CookieJar, Json<MeResponse>)> {
|
||||||
|
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
|
||||||
let email = normalize_email(&req.email);
|
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
|
let user = state
|
||||||
.db
|
.db
|
||||||
.collection::<User>(col::USERS)
|
.collection::<User>(col::USERS)
|
||||||
@@ -163,12 +241,12 @@ pub async fn login(
|
|||||||
|
|
||||||
let hash = user.as_ref().map(|u| u.password_hash.clone());
|
let hash = user.as_ref().map(|u| u.password_hash.clone());
|
||||||
if !password::verify(req.password, hash).await {
|
if !password::verify(req.password, hash).await {
|
||||||
state.login_limiter.record_failure(&email);
|
let left = count_failure(&state, &ip, "giriş")?;
|
||||||
return Err(AppError::InvalidCredentials);
|
return Err(AppError::InvalidCredentialsLeft(left));
|
||||||
}
|
}
|
||||||
// verify() true döndüyse kullanıcı kesin vardır
|
// verify() true döndüyse kullanıcı kesin vardır
|
||||||
let user = user.ok_or(AppError::InvalidCredentials)?;
|
let user = user.ok_or(AppError::InvalidCredentials)?;
|
||||||
state.login_limiter.reset(&email);
|
state.guard.success(&ip);
|
||||||
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/health", get(health::health))
|
.route("/health", get(health::health))
|
||||||
.route("/auth/register", post(auth::register))
|
.route("/auth/register", post(auth::register))
|
||||||
.route("/auth/registration", get(auth::registration_info))
|
.route("/auth/registration", get(auth::registration_info))
|
||||||
|
.route("/auth/guard", get(auth::guard_info))
|
||||||
.route("/auth/login", post(auth::login))
|
.route("/auth/login", post(auth::login))
|
||||||
.route("/auth/logout", post(auth::logout))
|
.route("/auth/logout", post(auth::logout))
|
||||||
.route("/auth/me", get(auth::me).patch(auth::update_me))
|
.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 mongodb::Database;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{jwt::Jwt, limiter::LoginLimiter},
|
auth::{captcha::Captcha, guard::AttemptGuard, jwt::Jwt, limiter::LoginLimiter},
|
||||||
config::Config,
|
config::Config,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,7 +15,11 @@ pub struct AppState {
|
|||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
pub jwt: Arc<Jwt>,
|
pub jwt: Arc<Jwt>,
|
||||||
|
/// Şifre değiştirmede mevcut şifre denemeleri (kullanıcı başına)
|
||||||
pub login_limiter: Arc<LoginLimiter>,
|
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 {
|
impl AppState {
|
||||||
@@ -23,9 +27,18 @@ impl AppState {
|
|||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
|
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))),
|
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),
|
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,
|
registration,
|
||||||
static_dir: None,
|
static_dir: None,
|
||||||
rates_refresh_minutes: 0,
|
rates_refresh_minutes: 0,
|
||||||
|
turnstile_site_key: None,
|
||||||
|
turnstile_secret_key: None,
|
||||||
|
trust_proxy: true,
|
||||||
};
|
};
|
||||||
let router = routes::router(AppState::new(db.clone(), config));
|
let router = routes::router(AppState::new(db.clone(), config));
|
||||||
Self { router, db }
|
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.
|
/// Test sonunda geçici veritabanını siler.
|
||||||
pub async fn cleanup(self) {
|
pub async fn cleanup(self) {
|
||||||
self.db.drop().await.ok();
|
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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
|
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
|
||||||
| `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi |
|
| `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 |
|
| `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 |
|
| `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin |
|
||||||
| `STATIC_DIR` | – | `/app/static` | İ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.
|
- 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.
|
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.<alan-adı>`; 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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 ✅.
|
**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
|
## 📌 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).
|
- **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).
|
- **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).
|
- **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 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 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.
|
- **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).
|
||||||
|
|||||||
@@ -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, unknown>) => 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<TurnstileApi> | null = null
|
||||||
|
|
||||||
|
function loadTurnstile(): Promise<TurnstileApi> {
|
||||||
|
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<HTMLDivElement>(null)
|
||||||
|
const widget = useRef<string | null>(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 <div ref={el} className="min-h-[65px]" />
|
||||||
|
})
|
||||||
@@ -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<TurnstileHandle>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(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 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Turnstile
|
||||||
|
ref={widget}
|
||||||
|
siteKey={siteKey}
|
||||||
|
onToken={setToken}
|
||||||
|
onLoadError={() => setScriptFailed(true)}
|
||||||
|
/>
|
||||||
|
{scriptFailed && <p className="text-sm text-expense">{s.scriptFailed}</p>}
|
||||||
|
</div>
|
||||||
|
) : 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
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div role="alert" className="flex gap-3 rounded-xl border border-expense/30 bg-expense/5 p-4">
|
||||||
|
<Lock className="mt-0.5 size-5 shrink-0 text-expense" aria-hidden />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{s.lockedTitle}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.lockedText}</p>
|
||||||
|
<p className="money text-lg font-semibold" aria-live="off">
|
||||||
|
{mm}:{ss}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { ApiError } from '@/lib/api'
|
|||||||
import { useLogin } from '@/lib/auth'
|
import { useLogin } from '@/lib/auth'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { AuthShell } from './auth-shell'
|
import { AuthShell } from './auth-shell'
|
||||||
|
import { useAuthSecurity } from './guard'
|
||||||
|
import { LockNotice } from './lock-notice'
|
||||||
import { loginSchema, type LoginForm } from './schemas'
|
import { loginSchema, type LoginForm } from './schemas'
|
||||||
|
|
||||||
const s = t.auth.login
|
const s = t.auth.login
|
||||||
@@ -31,12 +33,15 @@ export function LoginPage() {
|
|||||||
defaultValues: { email: '', password: '', remember_me: true },
|
defaultValues: { email: '', password: '', remember_me: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const security = useAuthSecurity()
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (values) => {
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
try {
|
try {
|
||||||
const me = await login.mutateAsync(values)
|
const me = await login.mutateAsync({ ...values, captcha_token: security.token })
|
||||||
toast.success(s.welcome(me.user.name))
|
toast.success(s.welcome(me.user.name))
|
||||||
// Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya)
|
// Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
security.afterError(e)
|
||||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -63,52 +68,62 @@ export function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
{security.locked > 0 ? (
|
||||||
<FormField label={s.email} error={errors.email}>
|
<LockNotice seconds={security.locked} />
|
||||||
{(f) => (
|
) : (
|
||||||
<Input
|
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||||
{...f}
|
<FormField label={s.email} error={errors.email}>
|
||||||
{...register('email')}
|
{(f) => (
|
||||||
type="email"
|
<Input
|
||||||
inputMode="email"
|
{...f}
|
||||||
autoComplete="email"
|
{...register('email')}
|
||||||
autoCapitalize="none"
|
type="email"
|
||||||
autoFocus
|
inputMode="email"
|
||||||
/>
|
autoComplete="email"
|
||||||
)}
|
autoCapitalize="none"
|
||||||
</FormField>
|
autoFocus
|
||||||
|
|
||||||
<FormField label={s.password} error={errors.password}>
|
|
||||||
{(f) => (
|
|
||||||
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="remember_me"
|
|
||||||
render={({ field }) => (
|
|
||||||
<label className="flex min-h-11 cursor-pointer items-start gap-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(v) => field.onChange(v === true)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
/>
|
||||||
<span className="space-y-0.5">
|
)}
|
||||||
<span className="block text-sm font-medium">{s.remember}</span>
|
</FormField>
|
||||||
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormError message={errors.root?.message} />
|
<FormField label={s.password} error={errors.password}>
|
||||||
|
{(f) => (
|
||||||
|
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
<Controller
|
||||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
control={control}
|
||||||
{isSubmitting ? s.submitting : s.submit}
|
name="remember_me"
|
||||||
</Button>
|
render={({ field }) => (
|
||||||
</form>
|
<label className="flex min-h-11 cursor-pointer items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(v) => field.onChange(v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-0.5">
|
||||||
|
<span className="block text-sm font-medium">{s.remember}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{security.field}
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting || !security.ready}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||||
|
{isSubmitting ? s.submitting : s.submit}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { queryKeys } from '@/lib/query'
|
|||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { AuthShell } from './auth-shell'
|
import { AuthShell } from './auth-shell'
|
||||||
|
import { useAuthSecurity } from './guard'
|
||||||
|
import { LockNotice } from './lock-notice'
|
||||||
import type { RegistrationInfo } from '@/types/RegistrationInfo'
|
import type { RegistrationInfo } from '@/types/RegistrationInfo'
|
||||||
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
|
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
|
||||||
|
|
||||||
@@ -61,9 +63,12 @@ export function RegisterPage() {
|
|||||||
const mode = useWatch({ control, name: 'mode' })
|
const mode = useWatch({ control, name: 'mode' })
|
||||||
const name = useWatch({ control, name: 'name' }).trim()
|
const name = useWatch({ control, name: 'name' }).trim()
|
||||||
|
|
||||||
|
const security = useAuthSecurity()
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (v) => {
|
const onSubmit = handleSubmit(async (v) => {
|
||||||
try {
|
try {
|
||||||
const me = await registerAccount.mutateAsync({
|
const me = await registerAccount.mutateAsync({
|
||||||
|
captcha_token: security.token,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
email: v.email,
|
email: v.email,
|
||||||
password: v.password,
|
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))
|
toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
security.afterError(e)
|
||||||
if (!(e instanceof ApiError)) return setError('root', { message: String(e) })
|
if (!(e instanceof ApiError)) return setError('root', { message: String(e) })
|
||||||
// Sunucu hatasını ilgili alana bağla
|
// Sunucu hatasını ilgili alana bağla
|
||||||
if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true })
|
if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true })
|
||||||
@@ -105,110 +111,120 @@ export function RegisterPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
{security.locked > 0 ? (
|
||||||
<FormField label={s.name} error={errors.name}>
|
<LockNotice seconds={security.locked} />
|
||||||
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
|
) : (
|
||||||
</FormField>
|
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||||
|
<FormField label={s.name} error={errors.name}>
|
||||||
|
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label={s.email} error={errors.email}>
|
<FormField label={s.email} error={errors.email}>
|
||||||
{(f) => (
|
{(f) => (
|
||||||
<Input
|
<Input
|
||||||
{...f}
|
{...f}
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
type="email"
|
type="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
|
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
|
||||||
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
|
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* Yeni hane mi, davetle katılma mı? */}
|
{/* Yeni hane mi, davetle katılma mı? */}
|
||||||
<fieldset className="space-y-3">
|
<fieldset className="space-y-3">
|
||||||
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
|
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
|
||||||
{!newAllowed && (
|
{!newAllowed && (
|
||||||
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
|
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
|
||||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
{s.closedNote}
|
{s.closedNote}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{newAllowed && (
|
{newAllowed && (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="mode"
|
name="mode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1"
|
className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1"
|
||||||
>
|
>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['new', s.modeNew, Home],
|
['new', s.modeNew, Home],
|
||||||
['join', s.modeJoin, Users],
|
['join', s.modeJoin, Users],
|
||||||
] as const
|
] as const
|
||||||
).map(([value, label, Icon]) => (
|
).map(([value, label, Icon]) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={field.value === value}
|
aria-checked={field.value === value}
|
||||||
onClick={() => field.onChange(value)}
|
onClick={() => field.onChange(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
|
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
|
||||||
field.value === value && 'bg-background text-foreground shadow-sm',
|
field.value === value && 'bg-background text-foreground shadow-sm',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" aria-hidden />
|
<Icon className="size-4" aria-hidden />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'new' ? (
|
{mode === 'new' ? (
|
||||||
<FormField
|
<FormField
|
||||||
label={s.householdName}
|
label={s.householdName}
|
||||||
error={errors.household_name}
|
error={errors.household_name}
|
||||||
hint={s.householdHint(name)}
|
hint={s.householdHint(name)}
|
||||||
>
|
>
|
||||||
{(f) => (
|
{(f) => (
|
||||||
<Input
|
<Input
|
||||||
{...f}
|
{...f}
|
||||||
{...register('household_name')}
|
{...register('household_name')}
|
||||||
placeholder={name ? `${name} Hanesi` : ''}
|
placeholder={name ? `${name} Hanesi` : ''}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
) : (
|
) : (
|
||||||
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
|
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
|
||||||
{(f) => (
|
{(f) => (
|
||||||
<Input
|
<Input
|
||||||
{...f}
|
{...f}
|
||||||
{...register('invite_code')}
|
{...register('invite_code')}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
maxLength={12}
|
maxLength={12}
|
||||||
className="font-mono tracking-widest uppercase"
|
className="font-mono tracking-widest uppercase"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<FormError message={errors.root?.message} />
|
{security.field}
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
|
||||||
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
<Button
|
||||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
type="submit"
|
||||||
{isSubmitting ? s.submitting : s.submit}
|
size="lg"
|
||||||
</Button>
|
className="w-full"
|
||||||
</form>
|
disabled={isSubmitting || !security.ready}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
||||||
|
{isSubmitting ? s.submitting : s.submit}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - `MeResponse`→ giriş yapılmış
|
* - `MeResponse`→ giriş yapılmış
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||||
|
import type { AuthGuardInfo } from '@/types/AuthGuardInfo'
|
||||||
import type { LoginRequest } from '@/types/LoginRequest'
|
import type { LoginRequest } from '@/types/LoginRequest'
|
||||||
import type { MeResponse } from '@/types/MeResponse'
|
import type { MeResponse } from '@/types/MeResponse'
|
||||||
import type { RegisterRequest } from '@/types/RegisterRequest'
|
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<AuthGuardInfo>('/auth/guard'),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { ApiError } from './api'
|
|||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
me: ['me'] as const,
|
me: ['me'] as const,
|
||||||
registration: ['registration'] 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,
|
health: ['health'] as const,
|
||||||
household: ['household'] as const,
|
household: ['household'] as const,
|
||||||
invites: ['household', 'invites'] as const,
|
invites: ['household', 'invites'] as const,
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export const t = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
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: {
|
login: {
|
||||||
title: 'Tekrar hoş geldin',
|
title: 'Tekrar hoş geldin',
|
||||||
description: 'Defterine devam etmek için giriş yap.',
|
description: 'Defterine devam etmek için giriş yap.',
|
||||||
|
|||||||
@@ -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, };
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
||||||
|
|||||||
@@ -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.
|
* 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, };
|
||||||
|
|||||||
Reference in New Issue
Block a user