feat: implement rate limiting across all API endpoints
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>) with per-IP and per-user-id limits on all public endpoint classes: - POST /api/v1/join: 5/min per IP - GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60) - POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10) - GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3) Limits are hot-reloadable via the config table. All 429 responses use German error messages. Client IP is read from X-Forwarded-For (Caddy). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -12,6 +14,7 @@ use crate::error::AppError;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::session::Session;
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -29,8 +32,16 @@ pub struct JoinResponse {
|
||||
|
||||
pub async fn join(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<JoinRequest>,
|
||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let display_name = body.display_name.trim();
|
||||
if display_name.is_empty() || display_name.len() > 50 {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -62,7 +73,7 @@ pub async fn join(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
||||
|
||||
Ok((
|
||||
@@ -134,7 +145,7 @@ pub async fn recover(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
||||
|
||||
return Ok(Json(RecoverResponse {
|
||||
@@ -146,7 +157,7 @@ pub async fn recover(
|
||||
// Wrong PIN — increment failure count
|
||||
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
||||
if attempts >= 3 {
|
||||
let lockout = Utc::now() + Duration::minutes(15);
|
||||
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
||||
User::lock_pin(&state.pool, user.id, lockout).await?;
|
||||
}
|
||||
}
|
||||
@@ -217,7 +228,7 @@ pub async fn admin_login(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + Duration::days(1);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(1);
|
||||
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
|
||||
|
||||
Ok(Json(AdminLoginResponse { jwt: token }))
|
||||
|
||||
Reference in New Issue
Block a user