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, /// Live ban flag. Banned users keep *read* access (per USER_JOURNEYS §10), so /// the base extractor does NOT reject them — write handlers and the /// Require{Host,Admin} extractors enforce the ban instead. pub is_banned: bool, 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()))?; // Verify the JWT's signature. Expiry is deliberately NOT enforced here (see // `jwt::verify_token`) — the authoritative, sliding session lifetime lives in the // `session` row read below. We also don't trust the token's role/ban claims; the // live user row is authoritative, so the decoded claims aren't needed beyond this. 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); // Single round-trip: resolve the session token to its *live* user row. A // role/ban stored in the token would survive a demote/ban for the full session // lifetime (up to 30d), so we always re-read the user (a demoted host loses host // powers immediately). We do NOT reject banned users here — they retain read // access by design; writes and host/admin actions enforce the ban downstream. let user = Session::find_user_by_token_hash(&state.pool, &token_hash) .await .map_err(|e| AppError::Internal(e.into()))? .ok_or_else(|| { AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()) })?; // Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so // an active client's session renews instead of hitting the fixed 30-day cliff. // Admin sessions keep their tighter 1-day window (they renew on activity but still // lapse a day after the admin goes idle). 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 touch_hash = token_hash.clone(); let expiry_days = if user.role == UserRole::Admin { 1 } else { state.config.session_expiry_days }; tokio::spawn(async move { if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await { tracing::warn!(error = ?e, "session touch/renew failed"); } }); Ok(Self { user_id: user.id, event_id: user.event_id, role: user.role, is_banned: user.is_banned, 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?; if auth.is_banned { return Err(AppError::Forbidden("Du bist gesperrt.".into())); } 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?; if auth.is_banned { return Err(AppError::Forbidden("Du bist gesperrt.".into())); } match auth.role { UserRole::Admin => Ok(Self(auth)), _ => Err(AppError::Forbidden("Nur für Admins.".into())), } } }