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::config; 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, headers: HeaderMap, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { let ip = client_ip(&headers, "unknown"); let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await; if rate_limits_on && join_rate_on && !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(), None, )); } let display_name = body.display_name.trim(); let name_chars = display_name.chars().count(); if name_chars == 0 || name_chars > 50 { return Err(AppError::BadRequest( "Name muss zwischen 1 und 50 Zeichen lang sein.".into(), )); } // Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers // see a clean 400 instead of an internal error. if display_name.contains('\0') { return Err(AppError::BadRequest( "Name enthält ungültige Zeichen.".into(), )); } let event = Event::find_or_create( &state.pool, &state.config.event_slug, &state.config.event_name, ) .await?; // Reject if a user with this name (case-insensitive) already exists if User::name_taken(&state.pool, event.id, display_name).await? { return Err(AppError::Conflict(format!( "Der Name \"{}\" ist bereits vergeben.", display_name ))); } // 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, } /// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used /// to run a constant-time-ish verify on the "unknown display name" branch of /// [`recover`], so that branch costs the same as a real (user-exists) verify and can't /// be told apart by timing. fn dummy_pin_hash() -> &'static str { static HASH: std::sync::OnceLock = std::sync::OnceLock::new(); HASH.get_or_init(|| { bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12) .expect("bcrypt hashing of a static dummy value cannot fail") }) } pub async fn recover( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Result, AppError> { let display_name = body.display_name.trim(); // Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this // an attacker who knows a display name (they're visible on the feed) can // burn through 3 wrong PINs and lock the victim for 15 minutes — repeated // every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name) // softens that into a real cost. let ip = client_ip(&headers, "unknown"); let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await; if rate_limits_on && recover_rate_on { let name_key = display_name.to_lowercase(); if !state.rate_limiter.check( format!("recover:{ip}:{name_key}"), 5, Duration::from_secs(15 * 60), ) { return Err(AppError::TooManyRequests( "Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(), None, )); } } 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() { // No user with this name. Run a throwaway bcrypt verify so this branch takes // the same time as the user-exists path, and return the SAME error as a wrong // PIN — so "no such name" and "wrong PIN" are indistinguishable by response or // timing. Display names are already public on the feed, but this still closes // the /recover enumeration + timing oracle. let _ = bcrypt::verify(&body.pin, dummy_pin_hash()); return Err(AppError::Unauthorized("PIN ist falsch.".into())); } for user in &users { // Check PIN lockout. If the lockout has expired, also reset the failed-attempt // counter so the user gets a fresh 3-strike window — otherwise the counter // stays at 3+ and every subsequent wrong PIN immediately re-locks them, even // after waiting out the cooldown. Without this reset, a once-locked account // is effectively permanently fragile. 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(), None, )); } // Lockout window expired — wipe the counter and the timestamp. User::reset_pin_attempts(&state.pool, user.id).await?; } 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?; tracing::warn!( user_id = %user.id, event_id = %event.id, ip = %ip, attempts, "recover: wrong PIN" ); if attempts >= 3 { let lockout = Utc::now() + chrono::Duration::minutes(15); User::lock_pin(&state.pool, user.id, lockout).await?; tracing::warn!( user_id = %user.id, event_id = %event.id, ip = %ip, "recover: account locked for 15 minutes" ); } } Err(AppError::Unauthorized("PIN ist falsch.".into())) } #[derive(Deserialize)] pub struct AdminLoginRequest { pub password: String, } #[derive(Serialize)] pub struct AdminLoginResponse { pub jwt: String, /// The admin's user id + display name, so the client can populate a real identity /// (own-post affordances, a name on the Account page) instead of a blank session. pub user_id: Uuid, pub display_name: String, } pub async fn admin_login( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Result, AppError> { if state.config.admin_password_hash.is_empty() { return Err(AppError::Forbidden( "Admin-Login ist nicht konfiguriert.".into(), )); } // Throttle password attempts. The admin password is bcrypt-hashed (slow to // verify) but with no IP-level limit a determined attacker can still mount // a long-running guess campaign. 5 attempts / minute / IP is plenty for // honest typos. let ip = client_ip(&headers, "unknown"); let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await; if rate_limits_on && admin_rate_on && !state.rate_limiter.check( format!("admin_login:{ip}"), 5, Duration::from_secs(60), ) { return Err(AppError::TooManyRequests( "Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(), None, )); } let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash) .unwrap_or(false); if !valid { tracing::warn!(ip = %ip, "admin_login: wrong password"); 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 { // Admin authenticates via password, but the schema still requires a PIN // hash. Generate a random unguessable PIN so the recovery path remains // unusable as an escalation route even if the role flag ever got cleared. let dummy_pin: String = (0..32) .map(|_| rand::rng().random_range(b'a'..=b'z') as char) .collect(); let dummy_hash = bcrypt::hash(&dummy_pin, 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")))? }; tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success"); 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, user_id: admin_user.id, display_name: admin_user.display_name, })) } pub async fn logout( State(state): State, auth: AuthUser, ) -> Result { Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?; Ok(StatusCode::NO_CONTENT) }