feat(güvenlik): giriş ve kayıtta Cloudflare Turnstile, IP başına 3 hatalı denemede 15 dk kilit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 00:29:00 +03:00
parent 5cbf97d884
commit a2eff1a984
27 changed files with 1247 additions and 164 deletions
+263
View File
@@ -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;
}