fix(auth): reconcile role/ban from DB, revoke sessions, fix pin/comment bugs
H1: AuthUser now JOINs session→user and sources role/ban/event from the live DB row instead of trusting JWT claims. Bans take effect on the next request; demotion/promotion is immediate. Adds Session::find_auth_context and rejects tokens whose sub/event_id disagree with the session row. This also closes M2 (banned export) and M3 (banned edit/delete) globally — banned users can no longer pass the AuthUser extractor. Adds Session::delete_by_user_id and revokes all of a user's sessions on ban_user, set_role, and reset_user_pin so existing JWTs die immediately. ban_user also emits a user-banned SSE for forced client logout. H9: reset_user_pin used a non-existent column pin_failed_attempts (runtime sqlx, so it 500'd in prod). Corrected to failed_pin_attempts — the only account-recovery path now works. C4: LightboxModal posted comments to /comment (singular); backend only registers /comments. One-character route fix re-enables the comment feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,16 +38,32 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
|
||||
let token_hash = jwt::hash_token(token);
|
||||
|
||||
let session = Session::find_by_token_hash(&state.pool, &token_hash)
|
||||
// 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 = session.id;
|
||||
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");
|
||||
@@ -55,9 +71,11 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
user_id: claims.sub,
|
||||
event_id: claims.event_id,
|
||||
role: claims.role,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user