The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files) so no future functional diff is buried under formatting churn, then gating `cargo fmt --check` in checks.yml so it stays clean. Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat: cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean. This is the deferred cleanup noted when CI's Format step was first left out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.8 KiB
Rust
131 lines
4.8 KiB
Rust
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<AppState> for AuthUser {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
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<AppState> for RequireHost {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
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<AppState> for RequireAdmin {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
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())),
|
|
}
|
|
}
|
|
}
|