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:
@@ -58,11 +58,63 @@ impl TestApp {
|
||||
registration,
|
||||
static_dir: None,
|
||||
rates_refresh_minutes: 0,
|
||||
turnstile_site_key: None,
|
||||
turnstile_secret_key: None,
|
||||
trust_proxy: true,
|
||||
};
|
||||
let router = routes::router(AppState::new(db.clone(), config));
|
||||
Self { router, db }
|
||||
}
|
||||
|
||||
/// Bot doğrulaması açık (ağsız sabit sonuç) bir uygulama: giriş/kayıt güvenlik testleri
|
||||
pub async fn with_captcha(pass: bool) -> Self {
|
||||
let app = Self::new().await;
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Config {
|
||||
mongodb_uri: std::env::var("MONGODB_URI").unwrap(),
|
||||
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
|
||||
app_url: "http://localhost".into(),
|
||||
port: 0,
|
||||
registration: RegistrationMode::Open,
|
||||
static_dir: None,
|
||||
rates_refresh_minutes: 0,
|
||||
turnstile_site_key: Some("test-site-key".into()),
|
||||
turnstile_secret_key: Some("test-secret".into()),
|
||||
trust_proxy: true,
|
||||
};
|
||||
let state = AppState::new(app.db.clone(), config)
|
||||
.with_captcha(kakeibo::auth::captcha::Captcha::Fixed(pass));
|
||||
Self {
|
||||
router: routes::router(state),
|
||||
db: app.db,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router_clone(&self) -> Router {
|
||||
self.router.clone()
|
||||
}
|
||||
|
||||
/// Ham istek (başlık eklemek için): (durum, gövde, çerez)
|
||||
pub async fn send_with(
|
||||
&self,
|
||||
req: axum::http::Request<Body>,
|
||||
) -> (StatusCode, Value, Option<String>) {
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let set_cookie = res
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(';').next())
|
||||
.map(String::from);
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
set_cookie,
|
||||
)
|
||||
}
|
||||
|
||||
/// Test sonunda geçici veritabanını siler.
|
||||
pub async fn cleanup(self) {
|
||||
self.db.drop().await.ok();
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Giriş / kayıt güvenliği: IP başına üst üste 3 hatada 15 dk kilit (giriş + kayıt birlikte),
|
||||
//! sahte X-Forwarded-For ile aşılamaz, bot doğrulaması (Turnstile, testte ağsız sabit sonuç).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use common::TestApp;
|
||||
use kakeibo::config::RegistrationMode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Belirli bir IP'den (vekilin eklediği en sağdaki X-Forwarded-For) JSON POST
|
||||
async fn post_from(
|
||||
app: &TestApp,
|
||||
xff: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
) -> (StatusCode, Value, Option<String>) {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-forwarded-for", xff)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
app.send_with(req).await
|
||||
}
|
||||
|
||||
async fn guard_from(app: &TestApp, xff: &str) -> Value {
|
||||
let req = Request::builder()
|
||||
.uri("/api/auth/guard")
|
||||
.header("x-forwarded-for", xff)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
app.send_with(req).await.1
|
||||
}
|
||||
|
||||
fn login(email: &str, password: &str) -> Value {
|
||||
json!({ "email": email, "password": password, "captcha_token": "jeton" })
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn three_wrong_logins_lock_login_and_register_for_that_ip() {
|
||||
let app = TestApp::new().await;
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"10.0.0.1",
|
||||
"/auth/register",
|
||||
json!({ "name": "Sahip", "email": "sahip@ornek.com", "password": "dogru-sifre-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
let ip = "203.0.113.5";
|
||||
for left in [2, 1] {
|
||||
let (st, b, _) =
|
||||
post_from(&app, ip, "/auth/login", login("sahip@ornek.com", "yanlis")).await;
|
||||
assert_eq!(st, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
format!("E-posta veya şifre hatalı. Kalan deneme hakkı: {left}")
|
||||
);
|
||||
}
|
||||
// 3. hata → kilit
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-forwarded-for", ip)
|
||||
.body(Body::from(login("sahip@ornek.com", "yanlis").to_string()))
|
||||
.unwrap();
|
||||
let res = tower::ServiceExt::oneshot(app_router(&app), req)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(res.headers()[header::RETRY_AFTER], "900");
|
||||
|
||||
// Kilitliyken doğru şifre de, kayıt da reddedilir
|
||||
let (st, b, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/login",
|
||||
login("sahip@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(b["error"]["code"], "locked");
|
||||
assert!(
|
||||
b["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("15 dakika")
|
||||
);
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/register",
|
||||
json!({ "name": "Yeni", "email": "yeni@ornek.com", "password": "sifre-12345" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "kayıt da kilitli");
|
||||
let g = guard_from(&app, ip).await;
|
||||
assert!(g["locked_seconds"].as_u64().unwrap() > 890, "{g}");
|
||||
|
||||
// Sahte sol girdi kilidi aşamaz (en sağdaki = vekilin eklediği)
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
&format!("1.1.1.1, {ip}"),
|
||||
"/auth/login",
|
||||
login("sahip@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS);
|
||||
|
||||
// Hane sahibi başka bağlantıdan girebilir (e-postaya göre kilit yok)
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"198.51.100.9",
|
||||
"/auth/login",
|
||||
login("sahip@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(guard_from(&app, "198.51.100.9").await["locked_seconds"], 0);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
fn app_router(app: &TestApp) -> axum::Router {
|
||||
app.router_clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_resets_the_counter() {
|
||||
let app = TestApp::new().await;
|
||||
post_from(
|
||||
&app,
|
||||
"10.0.0.1",
|
||||
"/auth/register",
|
||||
json!({ "name": "A", "email": "a@ornek.com", "password": "dogru-sifre-1" }),
|
||||
)
|
||||
.await;
|
||||
let ip = "203.0.113.6";
|
||||
for _ in 0..2 {
|
||||
post_from(&app, ip, "/auth/login", login("a@ornek.com", "yanlis")).await;
|
||||
}
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/login",
|
||||
login("a@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
for _ in 0..2 {
|
||||
let (st, _, _) = post_from(&app, ip, "/auth/login", login("a@ornek.com", "yanlis")).await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"sayaç sıfırlandı, henüz kilit yok"
|
||||
);
|
||||
}
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invite_code_guessing_is_locked() {
|
||||
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
|
||||
// İlk hane serbest
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"10.0.0.1",
|
||||
"/auth/register",
|
||||
json!({ "name": "Sahip", "email": "sahip@ornek.com", "password": "sifre-12345" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
let ip = "203.0.113.7";
|
||||
let guess = |code: &str, n: u32| json!({ "name": "Tahmin", "email": format!("t{n}@ornek.com"), "password": "sifre-12345", "invite_code": code });
|
||||
for (n, code) in ["AAAA-AAAA", "BBBB-BBBB"].iter().enumerate() {
|
||||
let (st, _, _) = post_from(&app, ip, "/auth/register", guess(code, n as u32)).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
// Davetsiz yeni hane denemesi (kapalı kayıt) da sayılır → 3. hata kilit
|
||||
let (st, b, _) = post_from(
|
||||
&app,
|
||||
ip,
|
||||
"/auth/register",
|
||||
json!({ "name": "X", "email": "x@ornek.com", "password": "sifre-12345" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "{b}");
|
||||
// Form doğrulama hataları sayılmaz (başka IP'de 5 kez kısa şifre)
|
||||
for _ in 0..5 {
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"203.0.113.8",
|
||||
"/auth/register",
|
||||
json!({ "name": "Y", "email": "y@ornek.com", "password": "kisa" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
assert_eq!(guard_from(&app, "203.0.113.8").await["locked_seconds"], 0);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bot_check_is_required_and_failures_count() {
|
||||
// Doğrulama her zaman başarısız
|
||||
let app = TestApp::with_captcha(false).await;
|
||||
let g = guard_from(&app, "203.0.113.9").await;
|
||||
assert_eq!(g["captcha_site_key"], "test-site-key");
|
||||
for _ in 0..2 {
|
||||
let (st, b, _) = post_from(
|
||||
&app,
|
||||
"203.0.113.9",
|
||||
"/auth/login",
|
||||
login("kim@ornek.com", "x"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b["error"]["code"], "captcha");
|
||||
}
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"203.0.113.9",
|
||||
"/auth/login",
|
||||
login("kim@ornek.com", "x"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"bot denemeleri de sayılır"
|
||||
);
|
||||
app.cleanup().await;
|
||||
|
||||
// Doğrulama geçer, ama jeton gönderilmezse red
|
||||
let app = TestApp::with_captcha(true).await;
|
||||
let body = json!({ "name": "A", "email": "a@ornek.com", "password": "dogru-sifre-1" });
|
||||
let (st, b, _) = post_from(&app, "10.0.0.2", "/auth/register", body.clone()).await;
|
||||
assert_eq!(
|
||||
(st, b["error"]["code"].as_str()),
|
||||
(StatusCode::BAD_REQUEST, Some("captcha"))
|
||||
);
|
||||
let mut with_token = body;
|
||||
with_token["captcha_token"] = json!("jeton");
|
||||
let (st, _, _) = post_from(&app, "10.0.0.2", "/auth/register", with_token).await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
let (st, _, _) = post_from(
|
||||
&app,
|
||||
"10.0.0.2",
|
||||
"/auth/login",
|
||||
login("a@ornek.com", "dogru-sifre-1"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user