use axum::extract::FromRequestParts; use axum::http::request::Parts; use uuid::Uuid; use crate::auth::jwt; use crate::error::AppError; use crate::models::session::Session; use crate::models::user::UserRole; use crate::state::AppState; #[derive(Debug, Clone)] pub struct AuthUser { pub user_id: Uuid, pub event_id: Uuid, pub role: UserRole, pub token_hash: String, } impl FromRequestParts for AuthUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let header = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::Unauthorized("Token fehlt.".into()))?; let token = header .strip_prefix("Bearer ") .ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?; let claims = jwt::verify_token(token, &state.config.jwt_secret) .map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?; let token_hash = jwt::hash_token(token); // Reconcile the session against the live `user` row. We do NOT trust the // JWT claims for role/ban/event — a token can outlive a ban or demotion // (default lifetime 30 days). The single JOIN query is the same number // of round-trips as the old existence check. let ctx = Session::find_auth_context(&state.pool, &token_hash) .await .map_err(|e| AppError::Internal(e.into()))? .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?; // Ban takes effect immediately on the next request, regardless of the // token's remaining lifetime. if ctx.is_banned { return Err(AppError::Forbidden("Du bist gesperrt.".into())); } // Defend against a JWT that was issued for a different user/event than // the session's DB row points at (e.g. a swapped or replayed token). if claims.sub != ctx.user_id || claims.event_id != ctx.event_id { return Err(AppError::Unauthorized("Token passt nicht zur Sitzung.".into())); } // Update last_seen_at in the background (fire-and-forget). Failures are // non-fatal but worth surfacing — silent swallowing hides DB connection // pressure that would otherwise be the first symptom of a real problem. let pool = state.pool.clone(); let session_id = ctx.session_id; tokio::spawn(async move { if let Err(e) = Session::touch(&pool, session_id).await { tracing::warn!(error = ?e, session_id = %session_id, "session touch failed"); } }); Ok(Self { user_id: ctx.user_id, event_id: ctx.event_id, // Live role from the DB, not the claim — demotion/promotion takes // effect on the next request. role: ctx.role, token_hash, }) } } /// Extractor that requires at least Host role. pub struct RequireHost(pub AuthUser); impl FromRequestParts for RequireHost { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let auth = AuthUser::from_request_parts(parts, state).await?; match auth.role { UserRole::Host | UserRole::Admin => Ok(Self(auth)), _ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())), } } } /// Extractor that requires Admin role. pub struct RequireAdmin(pub AuthUser); impl FromRequestParts for RequireAdmin { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let auth = AuthUser::from_request_parts(parts, state).await?; match auth.role { UserRole::Admin => Ok(Self(auth)), _ => Err(AppError::Forbidden("Nur für Admins.".into())), } } }