From 8b9d9162652b7dca566c415b18f0be8308651e8c Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 31 Mar 2026 21:44:03 +0200 Subject: [PATCH] feat: implement authentication flow Backend: - AppConfig, AppError, AppState modules for shared infrastructure - JWT creation/verification with HS256 (jsonwebtoken crate) - Session management: SHA-256 token hashing, DB-backed sessions - Auth middleware: AuthUser, RequireHost, RequireAdmin extractors - POST /api/v1/join: name-only registration, 4-digit PIN + bcrypt hash - POST /api/v1/recover: PIN-based recovery with 3-attempt lockout (15 min) - POST /api/v1/admin/login: bcrypt password verification - DELETE /api/v1/session: logout (session invalidation) - Migration 006: user PIN lockout columns (failed_pin_attempts, pin_locked_until) - Models: Event, User (with role enum), Session with all CRUD methods Frontend: - api.ts: typed fetch wrapper with automatic Bearer token injection - auth.ts: JWT/PIN localStorage management with Svelte store - /join: name entry form with PIN display modal and copy button - /recover: name + PIN recovery form with saved PIN pre-fill - /feed: placeholder gallery page with logout - Root layout: auth initialization on mount - Root page: redirect to /join or /feed based on auth state All responses use German language strings as specified. Co-Authored-By: Claude Opus 4.6 --- backend/Cargo.lock | 2 + backend/Cargo.toml | 2 + .../migrations/006_user_pin_lockout.down.sql | 2 + .../migrations/006_user_pin_lockout.up.sql | 2 + backend/src/auth/handlers.rs | 232 ++++++++++++++++++ backend/src/auth/jwt.rs | 53 ++++ backend/src/auth/middleware.rs | 96 ++++++++ backend/src/auth/mod.rs | 3 + backend/src/config.rs | 43 ++++ backend/src/error.rs | 65 +++++ backend/src/main.rs | 32 ++- backend/src/models/event.rs | 47 ++++ backend/src/models/mod.rs | 3 + backend/src/models/session.rs | 64 +++++ backend/src/models/user.rs | 102 ++++++++ backend/src/state.rs | 28 +++ frontend/src/lib/api.ts | 57 +++++ frontend/src/lib/auth.ts | 44 ++++ frontend/src/routes/+layout.svelte | 6 + frontend/src/routes/+page.svelte | 16 +- frontend/src/routes/feed/+page.svelte | 37 +++ frontend/src/routes/join/+page.svelte | 110 +++++++++ frontend/src/routes/recover/+page.svelte | 83 +++++++ 23 files changed, 1118 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/006_user_pin_lockout.down.sql create mode 100644 backend/migrations/006_user_pin_lockout.up.sql create mode 100644 backend/src/auth/handlers.rs create mode 100644 backend/src/auth/jwt.rs create mode 100644 backend/src/auth/middleware.rs create mode 100644 backend/src/auth/mod.rs create mode 100644 backend/src/config.rs create mode 100644 backend/src/error.rs create mode 100644 backend/src/models/event.rs create mode 100644 backend/src/models/mod.rs create mode 100644 backend/src/models/session.rs create mode 100644 backend/src/models/user.rs create mode 100644 backend/src/state.rs create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/routes/feed/+page.svelte create mode 100644 frontend/src/routes/join/+page.svelte create mode 100644 frontend/src/routes/recover/+page.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0841ea5..cfc01d9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -898,8 +898,10 @@ dependencies = [ "jsonwebtoken", "minijinja", "oxipng", + "rand 0.9.2", "serde", "serde_json", + "sha2", "sqlx", "sysinfo", "tokio", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0a88c0c..800a93b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -16,6 +16,8 @@ jsonwebtoken = "9" bcrypt = "0.15" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10" +rand = "0.9" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/backend/migrations/006_user_pin_lockout.down.sql b/backend/migrations/006_user_pin_lockout.down.sql new file mode 100644 index 0000000..26cb040 --- /dev/null +++ b/backend/migrations/006_user_pin_lockout.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" DROP COLUMN IF EXISTS pin_locked_until; +ALTER TABLE "user" DROP COLUMN IF EXISTS failed_pin_attempts; diff --git a/backend/migrations/006_user_pin_lockout.up.sql b/backend/migrations/006_user_pin_lockout.up.sql new file mode 100644 index 0000000..3c5c10b --- /dev/null +++ b/backend/migrations/006_user_pin_lockout.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN failed_pin_attempts SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE "user" ADD COLUMN pin_locked_until TIMESTAMPTZ; diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs new file mode 100644 index 0000000..a2f934b --- /dev/null +++ b/backend/src/auth/handlers.rs @@ -0,0 +1,232 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use chrono::{Duration, Utc}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::jwt; +use crate::auth::middleware::AuthUser; +use crate::error::AppError; +use crate::models::event::Event; +use crate::models::session::Session; +use crate::models::user::{User, UserRole}; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct JoinRequest { + pub display_name: String, +} + +#[derive(Serialize)] +pub struct JoinResponse { + pub jwt: String, + pub pin: String, + pub user_id: Uuid, + pub is_new: bool, +} + +pub async fn join( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + let display_name = body.display_name.trim(); + if display_name.is_empty() || display_name.len() > 50 { + return Err(AppError::BadRequest( + "Name muss zwischen 1 und 50 Zeichen lang sein.".into(), + )); + } + + let event = Event::find_or_create( + &state.pool, + &state.config.event_slug, + &state.config.event_name, + ) + .await?; + + // Generate a 4-digit PIN + let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); + let pin_hash = + bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?; + + let token = jwt::create_token( + user.id, + event.id, + user.role.clone(), + &state.config.jwt_secret, + state.config.session_expiry_days, + ) + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let token_hash = jwt::hash_token(&token); + let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days); + Session::create(&state.pool, user.id, &token_hash, expires_at).await?; + + Ok(( + StatusCode::CREATED, + Json(JoinResponse { + jwt: token, + pin, + user_id: user.id, + is_new: true, + }), + )) +} + +#[derive(Deserialize)] +pub struct RecoverRequest { + pub display_name: String, + pub pin: String, +} + +#[derive(Serialize)] +pub struct RecoverResponse { + pub jwt: String, + pub user_id: Uuid, +} + +pub async fn recover( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let display_name = body.display_name.trim(); + + let event = Event::find_by_slug(&state.pool, &state.config.event_slug) + .await? + .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; + + let users = + User::find_by_event_and_name(&state.pool, event.id, display_name).await?; + + if users.is_empty() { + return Err(AppError::NotFound( + "Kein Benutzer mit diesem Namen gefunden.".into(), + )); + } + + for user in &users { + // Check PIN lockout + if let Some(locked_until) = user.pin_locked_until { + if Utc::now() < locked_until { + return Err(AppError::TooManyRequests( + "Zu viele Versuche. Bitte warte 15 Minuten.".into(), + )); + } + } + + let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash) + .unwrap_or(false); + + if pin_matches { + // Reset failed attempts on success + User::reset_pin_attempts(&state.pool, user.id).await?; + + let token = jwt::create_token( + user.id, + event.id, + user.role.clone(), + &state.config.jwt_secret, + state.config.session_expiry_days, + ) + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let token_hash = jwt::hash_token(&token); + let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days); + Session::create(&state.pool, user.id, &token_hash, expires_at).await?; + + return Ok(Json(RecoverResponse { + jwt: token, + user_id: user.id, + })); + } + + // Wrong PIN — increment failure count + let attempts = User::increment_failed_pin(&state.pool, user.id).await?; + if attempts >= 3 { + let lockout = Utc::now() + Duration::minutes(15); + User::lock_pin(&state.pool, user.id, lockout).await?; + } + } + + Err(AppError::Unauthorized("PIN ist falsch.".into())) +} + +#[derive(Deserialize)] +pub struct AdminLoginRequest { + pub password: String, +} + +#[derive(Serialize)] +pub struct AdminLoginResponse { + pub jwt: String, +} + +pub async fn admin_login( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + if state.config.admin_password_hash.is_empty() { + return Err(AppError::Forbidden( + "Admin-Login ist nicht konfiguriert.".into(), + )); + } + + let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash) + .unwrap_or(false); + + if !valid { + return Err(AppError::Unauthorized("Falsches Passwort.".into())); + } + + let event = Event::find_or_create( + &state.pool, + &state.config.event_slug, + &state.config.event_name, + ) + .await?; + + // Find or create the admin user for this event + let admin_name = "Admin"; + let users = User::find_by_event_and_name(&state.pool, event.id, admin_name).await?; + let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) { + u + } else { + // Create admin user with a dummy PIN (admin authenticates via password) + let dummy_hash = bcrypt::hash("0000", 4) + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?; + sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1") + .bind(user.id) + .execute(&state.pool) + .await?; + User::find_by_id(&state.pool, user.id) + .await? + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))? + }; + + let token = jwt::create_token( + admin_user.id, + event.id, + UserRole::Admin, + &state.config.jwt_secret, + 1, // Admin sessions expire after 1 day + ) + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let token_hash = jwt::hash_token(&token); + let expires_at = Utc::now() + Duration::days(1); + Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?; + + Ok(Json(AdminLoginResponse { jwt: token })) +} + +pub async fn logout( + State(state): State, + auth: AuthUser, +) -> Result { + Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs new file mode 100644 index 0000000..0af1ac9 --- /dev/null +++ b/backend/src/auth/jwt.rs @@ -0,0 +1,53 @@ +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, +} + +pub fn create_token( + user_id: Uuid, + event_id: Uuid, + role: UserRole, + secret: &str, + expiry_days: i64, +) -> Result { + let now = Utc::now(); + let claims = Claims { + sub: user_id, + event_id, + role, + iat: now.timestamp(), + exp: (now + Duration::days(expiry_days)).timestamp(), + }; + jsonwebtoken::encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) +} + +pub fn verify_token(token: &str, secret: &str) -> Result { + let data = jsonwebtoken::decode::( + 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()) +} diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs new file mode 100644 index 0000000..e85d2a0 --- /dev/null +++ b/backend/src/auth/middleware.rs @@ -0,0 +1,96 @@ +use axum::extract::{FromRequestParts, State}; +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); + + let session = Session::find_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()))?; + + // Update last_seen_at in the background (fire-and-forget) + let pool = state.pool.clone(); + let session_id = session.id; + tokio::spawn(async move { + let _ = Session::touch(&pool, session_id).await; + }); + + Ok(Self { + user_id: claims.sub, + event_id: claims.event_id, + role: claims.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())), + } + } +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs new file mode 100644 index 0000000..cba46e3 --- /dev/null +++ b/backend/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod handlers; +pub mod jwt; +pub mod middleware; diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..6078caf --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +#[derive(Clone, Debug)] +pub struct AppConfig { + pub database_url: String, + pub jwt_secret: String, + pub session_expiry_days: i64, + pub admin_password_hash: String, + pub event_name: String, + pub event_slug: String, + pub media_path: PathBuf, + pub app_port: u16, +} + +impl AppConfig { + pub fn from_env() -> Result { + Ok(Self { + database_url: std::env::var("DATABASE_URL") + .context("DATABASE_URL must be set")?, + jwt_secret: std::env::var("JWT_SECRET") + .context("JWT_SECRET must be set")?, + session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS") + .unwrap_or_else(|_| "30".to_string()) + .parse() + .context("SESSION_EXPIRY_DAYS must be a number")?, + admin_password_hash: std::env::var("ADMIN_PASSWORD_HASH") + .unwrap_or_default(), + event_name: std::env::var("EVENT_NAME") + .unwrap_or_else(|_| "EventSnap".to_string()), + event_slug: std::env::var("EVENT_SLUG") + .context("EVENT_SLUG must be set")?, + media_path: PathBuf::from( + std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()), + ), + app_port: std::env::var("APP_PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse() + .context("APP_PORT must be a number")?, + }) + } +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..933b250 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,65 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +#[derive(Debug)] +pub enum AppError { + BadRequest(String), + Unauthorized(String), + Forbidden(String), + NotFound(String), + TooManyRequests(String), + Internal(anyhow::Error), +} + +impl AppError { + fn status_and_code(&self) -> (StatusCode, &str) { + match self { + Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), + Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), + Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), + Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), + Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), + Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), + } + } + + fn message(&self) -> String { + match self { + Self::BadRequest(msg) + | Self::Unauthorized(msg) + | Self::Forbidden(msg) + | Self::NotFound(msg) + | Self::TooManyRequests(msg) => msg.clone(), + Self::Internal(err) => { + tracing::error!("internal error: {err:#}"); + "Ein interner Fehler ist aufgetreten.".to_string() + } + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, code) = self.status_and_code(); + let message = self.message(); + let body = json!({ + "error": code, + "message": message, + "status": status.as_u16(), + }); + (status, axum::Json(body)).into_response() + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + Self::Internal(err) + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + Self::Internal(err.into()) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 8f862d2..14f9f58 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,7 +1,17 @@ use anyhow::Result; +use axum::routing::{delete, post}; +use axum::Router; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod auth; +mod config; mod db; +mod error; +mod models; +mod state; + +use config::AppConfig; +use state::AppState; #[tokio::main] async fn main() -> Result<()> { @@ -14,18 +24,22 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - let port: u16 = std::env::var("APP_PORT") - .unwrap_or_else(|_| "3000".to_string()) - .parse()?; + let config = AppConfig::from_env()?; + let pool = db::create_pool(&config.database_url).await?; + let state = AppState::new(pool, config.clone()); - let _pool = db::create_pool(&database_url).await?; + let api = Router::new() + .route("/api/v1/join", post(auth::handlers::join)) + .route("/api/v1/recover", post(auth::handlers::recover)) + .route("/api/v1/admin/login", post(auth::handlers::admin_login)) + .route("/api/v1/session", delete(auth::handlers::logout)); - let router = axum::Router::new() - .route("/health", axum::routing::get(|| async { "ok" })); + let router = Router::new() + .route("/health", axum::routing::get(|| async { "ok" })) + .merge(api) + .with_state(state); - let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?; + let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?; tracing::info!("listening on {}", listener.local_addr()?); axum::serve(listener, router).await?; diff --git a/backend/src/models/event.rs b/backend/src/models/event.rs new file mode 100644 index 0000000..f739769 --- /dev/null +++ b/backend/src/models/event.rs @@ -0,0 +1,47 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow)] +pub struct Event { + pub id: Uuid, + pub slug: String, + pub name: String, + pub cover_image_path: Option, + pub is_active: bool, + pub uploads_locked_at: Option>, + pub export_released_at: Option>, + pub export_zip_ready: bool, + pub export_html_ready: bool, + pub created_at: DateTime, +} + +impl Event { + pub async fn find_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>("SELECT * FROM event WHERE slug = $1") + .bind(slug) + .fetch_optional(pool) + .await + } + + pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result { + sqlx::query_as::<_, Self>( + "INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *", + ) + .bind(slug) + .bind(name) + .fetch_one(pool) + .await + } + + pub async fn find_or_create( + pool: &PgPool, + slug: &str, + name: &str, + ) -> Result { + if let Some(event) = Self::find_by_slug(pool, slug).await? { + return Ok(event); + } + Self::create(pool, slug, name).await + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..881e438 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,3 @@ +pub mod event; +pub mod session; +pub mod user; diff --git a/backend/src/models/session.rs b/backend/src/models/session.rs new file mode 100644 index 0000000..280048f --- /dev/null +++ b/backend/src/models/session.rs @@ -0,0 +1,64 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow)] +pub struct Session { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub expires_at: DateTime, + pub last_seen_at: DateTime, + pub created_at: DateTime, +} + +impl Session { + pub async fn create( + pool: &PgPool, + user_id: Uuid, + token_hash: &str, + expires_at: DateTime, + ) -> Result { + sqlx::query_as::<_, Self>( + "INSERT INTO session (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + RETURNING *", + ) + .bind(user_id) + .bind(token_hash) + .bind(expires_at) + .fetch_one(pool) + .await + } + + pub async fn find_by_token_hash( + pool: &PgPool, + token_hash: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>( + "SELECT * FROM session WHERE token_hash = $1 AND expires_at > NOW()", + ) + .bind(token_hash) + .fetch_optional(pool) + .await + } + + pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn delete_by_token_hash( + pool: &PgPool, + token_hash: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM session WHERE token_hash = $1") + .bind(token_hash) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs new file mode 100644 index 0000000..66cbcf6 --- /dev/null +++ b/backend/src/models/user.rs @@ -0,0 +1,102 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "user_role", rename_all = "lowercase")] +pub enum UserRole { + Guest, + Host, + Admin, +} + +#[derive(Debug, sqlx::FromRow)] +pub struct User { + pub id: Uuid, + pub event_id: Uuid, + pub display_name: String, + pub role: UserRole, + pub is_banned: bool, + pub uploads_hidden: bool, + pub recovery_pin_hash: String, + pub total_upload_bytes: i64, + pub failed_pin_attempts: i16, + pub pin_locked_until: Option>, + pub created_at: DateTime, +} + +impl User { + pub async fn create( + pool: &PgPool, + event_id: Uuid, + display_name: &str, + pin_hash: &str, + ) -> Result { + sqlx::query_as::<_, Self>( + "INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash) + VALUES ($1, $2, $3) + RETURNING *", + ) + .bind(event_id) + .bind(display_name) + .bind(pin_hash) + .fetch_one(pool) + .await + } + + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>("SELECT * FROM \"user\" WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn find_by_event_and_name( + pool: &PgPool, + event_id: Uuid, + display_name: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>( + "SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2", + ) + .bind(event_id) + .bind(display_name) + .fetch_all(pool) + .await + } + + pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result { + let row: (i16,) = sqlx::query_as( + "UPDATE \"user\" + SET failed_pin_attempts = failed_pin_attempts + 1 + WHERE id = $1 + RETURNING failed_pin_attempts", + ) + .bind(id) + .fetch_one(pool) + .await?; + Ok(row.0) + } + + pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1", + ) + .bind(id) + .bind(until) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn reset_pin_attempts(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE \"user\" SET failed_pin_attempts = 0, pin_locked_until = NULL WHERE id = $1", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/backend/src/state.rs b/backend/src/state.rs new file mode 100644 index 0000000..5e0556c --- /dev/null +++ b/backend/src/state.rs @@ -0,0 +1,28 @@ +use sqlx::PgPool; +use tokio::sync::broadcast; + +use crate::config::AppConfig; + +#[derive(Clone, Debug)] +pub struct SseEvent { + pub event_type: String, + pub data: String, +} + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub config: AppConfig, + pub sse_tx: broadcast::Sender, +} + +impl AppState { + pub fn new(pool: PgPool, config: AppConfig) -> Self { + let (sse_tx, _) = broadcast::channel(256); + Self { + pool, + config, + sse_tx, + } + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..2ac3b4b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,57 @@ +import { getToken, clearAuth } from './auth'; + +const BASE = '/api/v1'; + +export class ApiError extends Error { + status: number; + code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.status = status; + this.code = code; + } +} + +async function request( + method: string, + path: string, + body?: unknown +): Promise { + const headers: Record = {}; + const token = getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`${BASE}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }); + + if (res.status === 204) { + return undefined as T; + } + + const data = await res.json(); + + if (!res.ok) { + if (res.status === 401) { + clearAuth(); + } + throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler'); + } + + return data as T; +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + patch: (path: string, body?: unknown) => request('PATCH', path, body), + delete: (path: string) => request('DELETE', path) +}; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..fe9f961 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,44 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +const TOKEN_KEY = 'eventsnap_jwt'; +const PIN_KEY = 'eventsnap_pin'; +const USER_ID_KEY = 'eventsnap_user_id'; + +export const isAuthenticated = writable(false); + +export function getToken(): string | null { + if (!browser) return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function getPin(): string | null { + if (!browser) return null; + return localStorage.getItem(PIN_KEY); +} + +export function getUserId(): string | null { + if (!browser) return null; + return localStorage.getItem(USER_ID_KEY); +} + +export function setAuth(jwt: string, pin: string | null, userId: string): void { + if (!browser) return; + localStorage.setItem(TOKEN_KEY, jwt); + if (pin) localStorage.setItem(PIN_KEY, pin); + localStorage.setItem(USER_ID_KEY, userId); + isAuthenticated.set(true); +} + +export function clearAuth(): void { + if (!browser) return; + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_ID_KEY); + // PIN is intentionally kept so the user can recover + isAuthenticated.set(false); +} + +export function initAuth(): void { + if (!browser) return; + isAuthenticated.set(!!getToken()); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 96f5db4..c12e0f2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,8 +1,14 @@ diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index cc88df0..ae4bc09 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,2 +1,14 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte new file mode 100644 index 0000000..47e551e --- /dev/null +++ b/frontend/src/routes/feed/+page.svelte @@ -0,0 +1,37 @@ + + +
+
+
+

Galerie

+ +
+

Die Galerie wird bald hier angezeigt.

+
+
diff --git a/frontend/src/routes/join/+page.svelte b/frontend/src/routes/join/+page.svelte new file mode 100644 index 0000000..e1856c7 --- /dev/null +++ b/frontend/src/routes/join/+page.svelte @@ -0,0 +1,110 @@ + + +
+
+

Willkommen!

+

Gib deinen Namen ein, um dem Event beizutreten.

+ +
{ e.preventDefault(); handleJoin(); }}> + + + {#if error} +

{error}

+ {/if} + + +
+ +

+ Schon dabei? + Mit PIN wiederherstellen +

+
+
+ +{#if showPinModal} +
+
+

Dein Wiederherstellungs-PIN

+

+ Merke dir diesen PIN! Du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. +

+ +
+ {pin} + +
+ + +
+
+{/if} diff --git a/frontend/src/routes/recover/+page.svelte b/frontend/src/routes/recover/+page.svelte new file mode 100644 index 0000000..e9618e0 --- /dev/null +++ b/frontend/src/routes/recover/+page.svelte @@ -0,0 +1,83 @@ + + +
+
+

Konto wiederherstellen

+

Gib deinen Namen und deinen PIN ein.

+ +
{ e.preventDefault(); handleRecover(); }}> + + + + {#if error} +

{error}

+ {/if} + + +
+ +

+ Noch kein Konto? + Neu beitreten +

+
+