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>
272 lines
8.5 KiB
Rust
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)
|
|
}
|