Files
EventSnap/backend/src/auth/jwt.rs
MechaCat02 05f76514a2 fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint
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>
2026-05-16 19:01:34 +02:00

62 lines
1.7 KiB
Rust

use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::models::user::UserRole;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid,
pub event_id: Uuid,
pub role: UserRole,
pub exp: i64,
pub iat: i64,
/// Random per-token identifier. Without it, two `create_token` calls in the
/// same wall-clock second for the same (sub, role, event) produce identical
/// JWT bytes — and identical sha256(token) hashes — which then collide on
/// the `session.token_hash` UNIQUE constraint. The jti is ignored by the
/// verifier but breaks the collision.
#[serde(default)]
pub jti: Uuid,
}
pub fn create_token(
user_id: Uuid,
event_id: Uuid,
role: UserRole,
secret: &str,
expiry_days: i64,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
let claims = Claims {
sub: user_id,
event_id,
role,
iat: now.timestamp(),
exp: (now + Duration::days(expiry_days)).timestamp(),
jti: Uuid::new_v4(),
};
jsonwebtoken::encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
Ok(data.claims)
}
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}