Files
EventSnap/backend/src/auth/handlers.rs
MechaCat02 05f76514a2 fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint
Two bugs surfaced while running the new E2E suite, plus a small test hook:

- jwt.rs: add a per-token `jti: Uuid` claim. Without it, two `create_token`
  calls in the same wall-clock second for the same (sub, role, event_id)
  produced identical JWT bytes — and identical sha256(token) hashes —
  which then collided on `session.token_hash UNIQUE` with a 500. Manifests
  in real use when an admin clicks "Anmelden" twice fast.

- auth/handlers.rs: reject display names containing 0x00. Postgres rejects
  NUL in TEXT columns with `invalid byte sequence for encoding "UTF8"` and
  the request leaks back as a 500. Now returns 400 with a clean message.

- handlers/test_admin.rs + main.rs: new POST /api/v1/admin/__truncate route,
  compiled in always but only **registered** when EVENTSNAP_TEST_MODE=1 is
  set on startup. Truncates every event-scoped table, reseeds config from
  migration defaults, wipes media on disk, and clears the in-memory rate
  limiter. RequireAdmin-gated so it's not anonymous even in test mode. In
  production builds (no env var) the route returns 404 — verified by the
  startup log message.

- services/rate_limiter.rs: add `clear()` so the truncate handler can wipe
  the in-memory window map between tests.

- Dockerfile: bump rust:1.87 → rust:1.88 (current dep tree needs it) and
  COPY ./migrations into the build context so the `sqlx::migrate!()` macro
  can resolve at compile time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:34 +02:00

272 lines
8.5 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::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<AppState>,
headers: HeaderMap,
Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.pool, "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();
if display_name.is_empty() || display_name.len() > 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,
}
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 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?;
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)
}