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>
244 lines
7.2 KiB
Rust
244 lines
7.2 KiB
Rust
use std::time::Duration;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::Json;
|
|
use chrono::Utc;
|
|
use rand::Rng;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::jwt;
|
|
use crate::auth::middleware::AuthUser;
|
|
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)]
|
|
pub struct JoinRequest {
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct JoinResponse {
|
|
pub jwt: String,
|
|
pub pin: String,
|
|
pub user_id: Uuid,
|
|
pub is_new: bool,
|
|
}
|
|
|
|
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(
|
|
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
|
|
));
|
|
}
|
|
|
|
let event = Event::find_or_create(
|
|
&state.pool,
|
|
&state.config.event_slug,
|
|
&state.config.event_name,
|
|
)
|
|
.await?;
|
|
|
|
// Generate a 4-digit PIN
|
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
|
let pin_hash =
|
|
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
|
|
|
|
let token = jwt::create_token(
|
|
user.id,
|
|
event.id,
|
|
user.role.clone(),
|
|
&state.config.jwt_secret,
|
|
state.config.session_expiry_days,
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
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((
|
|
StatusCode::CREATED,
|
|
Json(JoinResponse {
|
|
jwt: token,
|
|
pin,
|
|
user_id: user.id,
|
|
is_new: true,
|
|
}),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RecoverRequest {
|
|
pub display_name: String,
|
|
pub pin: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RecoverResponse {
|
|
pub jwt: String,
|
|
pub user_id: Uuid,
|
|
}
|
|
|
|
pub async fn recover(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<RecoverRequest>,
|
|
) -> Result<Json<RecoverResponse>, AppError> {
|
|
let display_name = body.display_name.trim();
|
|
|
|
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
|
|
let users =
|
|
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
|
|
|
if users.is_empty() {
|
|
return Err(AppError::NotFound(
|
|
"Kein Benutzer mit diesem Namen gefunden.".into(),
|
|
));
|
|
}
|
|
|
|
for user in &users {
|
|
// Check PIN lockout
|
|
if let Some(locked_until) = user.pin_locked_until {
|
|
if Utc::now() < locked_until {
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
|
.unwrap_or(false);
|
|
|
|
if pin_matches {
|
|
// Reset failed attempts on success
|
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
|
|
|
let token = jwt::create_token(
|
|
user.id,
|
|
event.id,
|
|
user.role.clone(),
|
|
&state.config.jwt_secret,
|
|
state.config.session_expiry_days,
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
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 {
|
|
jwt: token,
|
|
user_id: user.id,
|
|
}));
|
|
}
|
|
|
|
// Wrong PIN — increment failure count
|
|
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
|
if attempts >= 3 {
|
|
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
|
User::lock_pin(&state.pool, user.id, lockout).await?;
|
|
}
|
|
}
|
|
|
|
Err(AppError::Unauthorized("PIN ist falsch.".into()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AdminLoginRequest {
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AdminLoginResponse {
|
|
pub jwt: String,
|
|
}
|
|
|
|
pub async fn admin_login(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<AdminLoginRequest>,
|
|
) -> Result<Json<AdminLoginResponse>, AppError> {
|
|
if state.config.admin_password_hash.is_empty() {
|
|
return Err(AppError::Forbidden(
|
|
"Admin-Login ist nicht konfiguriert.".into(),
|
|
));
|
|
}
|
|
|
|
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
|
.unwrap_or(false);
|
|
|
|
if !valid {
|
|
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
|
}
|
|
|
|
let event = Event::find_or_create(
|
|
&state.pool,
|
|
&state.config.event_slug,
|
|
&state.config.event_name,
|
|
)
|
|
.await?;
|
|
|
|
// Find or create the admin user for this event
|
|
let admin_name = "Admin";
|
|
let users = User::find_by_event_and_name(&state.pool, event.id, admin_name).await?;
|
|
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
|
|
u
|
|
} else {
|
|
// Create admin user with a dummy PIN (admin authenticates via password)
|
|
let dummy_hash = bcrypt::hash("0000", 4)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
|
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
|
.bind(user.id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
User::find_by_id(&state.pool, user.id)
|
|
.await?
|
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
|
|
};
|
|
|
|
let token = jwt::create_token(
|
|
admin_user.id,
|
|
event.id,
|
|
UserRole::Admin,
|
|
&state.config.jwt_secret,
|
|
1, // Admin sessions expire after 1 day
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
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 }))
|
|
}
|
|
|
|
pub async fn logout(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> Result<StatusCode, AppError> {
|
|
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|