From 37e4575ef7c4f1f348f1968b83e198ce3721769c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 2 Feb 2026 22:38:02 +0300 Subject: [PATCH] fc-server: add dual authentication support; support user accs Signed-off-by: NotAShelf Change-Id: I3603e4b3cc8b5999fe1edafe8a38efb26a6a6964 --- crates/server/src/auth_middleware.rs | 172 ++++++++++++++++++++++----- crates/server/src/error.rs | 8 ++ crates/server/src/state.rs | 53 ++++++++- 3 files changed, 199 insertions(+), 34 deletions(-) diff --git a/crates/server/src/auth_middleware.rs b/crates/server/src/auth_middleware.rs index 3ccdc78..7c9a5dd 100644 --- a/crates/server/src/auth_middleware.rs +++ b/crates/server/src/auth_middleware.rs @@ -4,15 +4,16 @@ use axum::{ middleware::Next, response::Response, }; -use fc_common::models::ApiKey; +use fc_common::models::{ApiKey, User}; use sha2::{Digest, Sha256}; use crate::state::AppState; /// Extract and validate an API key from the Authorization header or session /// cookie. Keys use the format: `Bearer fc_xxxx`. Session cookies use -/// `fc_session=`. Write endpoints (POST/PUT/DELETE/PATCH) require a valid -/// key. Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for +/// `fc_session=` for API keys or `fc_user_session=` for users. +/// Write endpoints (POST/PUT/DELETE/PATCH) require a valid key. +/// Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for /// dashboard admin UI). pub async fn require_api_key( State(state): State, @@ -24,6 +25,7 @@ pub async fn require_api_key( || method == axum::http::Method::HEAD || method == axum::http::Method::OPTIONS; + // Try Bearer token first (API key auth) let auth_header = request .headers() .get("authorization") @@ -34,7 +36,6 @@ pub async fn require_api_key( .as_deref() .and_then(|h| h.strip_prefix("Bearer ")); - // Try Bearer token first if let Some(token) = token { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); @@ -43,29 +44,74 @@ pub async fn require_api_key( if let Ok(Some(api_key)) = fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await { + // Update last used timestamp asynchronously let pool = state.pool.clone(); let key_id = api_key.id; tokio::spawn(async move { - let _ = fc_common::repo::api_keys::touch_last_used(&pool, key_id).await; + if let Err(e) = + fc_common::repo::api_keys::touch_last_used(&pool, key_id).await + { + tracing::warn!(error = %e, "Failed to update API key last_used timestamp"); + } }); - request.extensions_mut().insert(api_key); + request.extensions_mut().insert(api_key.clone()); + request.extensions_mut().insert(crate::state::SessionData { + api_key: Some(api_key), + user: None, + created_at: std::time::Instant::now(), + }); return Ok(next.run(request).await); } } - // Fall back to session cookie (so dashboard JS fetches work) + // Fall back to session cookie if let Some(cookie_header) = request .headers() .get("cookie") .and_then(|v| v.to_str().ok()) - && let Some(session_id) = parse_cookie(cookie_header, "fc_session") - && let Some(session) = state.sessions.get(&session_id) - && session.created_at.elapsed() - < std::time::Duration::from_secs(24 * 60 * 60) { - request.extensions_mut().insert(session.api_key.clone()); - return Ok(next.run(request).await); + // Try user session first (new fc_user_session cookie) + if let Some(session_id) = parse_cookie(cookie_header, "fc_user_session") { + if let Some(session) = state.sessions.get(&session_id) { + // Check session expiry (24 hours) + if session.created_at.elapsed() + < std::time::Duration::from_secs(24 * 60 * 60) + { + // Insert both user and session data + if let Some(ref user) = session.user { + request.extensions_mut().insert(user.clone()); + } + if let Some(ref api_key) = session.api_key { + request.extensions_mut().insert(api_key.clone()); + } + return Ok(next.run(request).await); + } else { + // Expired, remove it + drop(session); + state.sessions.remove(&session_id); + } + } + } + + // Try legacy API key session (fc_session cookie) + if let Some(session_id) = parse_cookie(cookie_header, "fc_session") { + if let Some(session) = state.sessions.get(&session_id) { + // Check session expiry (24 hours) + if session.created_at.elapsed() + < std::time::Duration::from_secs(24 * 60 * 60) + { + if let Some(ref api_key) = session.api_key { + request.extensions_mut().insert(api_key.clone()); + } + return Ok(next.run(request).await); + } else { + // Expired, remove it + drop(session); + state.sessions.remove(&session_id); + } + } + } } // No valid auth found @@ -87,11 +133,29 @@ impl FromRequestParts for RequireAdmin { parts: &mut Parts, _state: &AppState, ) -> Result { + // Check for user first (new auth) + if let Some(user) = parts.extensions.get::() { + if user.role == "admin" { + // Create a synthetic API key for compatibility + return Ok(RequireAdmin(ApiKey { + id: user.id, + name: user.username.clone(), + key_hash: String::new(), + role: user.role.clone(), + created_at: user.created_at, + last_used_at: user.last_login_at, + user_id: Some(user.id), + })); + } + } + + // Fall back to API key let key = parts .extensions .get::() .cloned() .ok_or(StatusCode::UNAUTHORIZED)?; + if key.role == "admin" { Ok(RequireAdmin(key)) } else { @@ -101,21 +165,36 @@ impl FromRequestParts for RequireAdmin { } /// Extractor that requires one of the specified roles (admin always passes). -/// Use as: `_auth: RequireRole<"cancel-build", "restart-jobs">` -/// -/// Since const generics with strings aren't stable, use the helper function -/// instead. -pub struct RequireRoles(pub ApiKey); +/// Use as: `RequireRoles::check(&extensions, &["cancel-build", +/// "restart-jobs"])` +pub struct RequireRoles; impl RequireRoles { pub fn check( extensions: &axum::http::Extensions, allowed: &[&str], ) -> Result { + // Check for user first + if let Some(user) = extensions.get::() { + if user.role == "admin" || allowed.contains(&user.role.as_str()) { + return Ok(ApiKey { + id: user.id, + name: user.username.clone(), + key_hash: String::new(), + role: user.role.clone(), + created_at: user.created_at, + last_used_at: user.last_login_at, + user_id: Some(user.id), + }); + } + } + + // Fall back to API key let key = extensions .get::() .cloned() .ok_or(StatusCode::UNAUTHORIZED)?; + if key.role == "admin" || allowed.contains(&key.role.as_str()) { Ok(key) } else { @@ -125,30 +204,59 @@ impl RequireRoles { } /// Session extraction middleware for dashboard routes. -/// Reads `fc_session` cookie and inserts ApiKey into extensions if valid. +/// Reads `fc_user_session` or `fc_session` cookie and inserts User/ApiKey into +/// extensions if valid. pub async fn extract_session( State(state): State, mut request: Request, next: Next, ) -> Response { - if let Some(cookie_header) = request + // Extract cookie header first, then clone to end the borrow + let cookie_header = request .headers() .get("cookie") .and_then(|v| v.to_str().ok()) - && let Some(session_id) = parse_cookie(cookie_header, "fc_session") - && let Some(session) = state.sessions.get(&session_id) - { - // Check session expiry (24 hours) - if session.created_at.elapsed() - < std::time::Duration::from_secs(24 * 60 * 60) - { - request.extensions_mut().insert(session.api_key.clone()); - } else { - // Expired, remove it - drop(session); - state.sessions.remove(&session_id); + .map(|s| s.to_string()); + + if let Some(cookie_header) = cookie_header { + // Try user session first + if let Some(session_id) = parse_cookie(&cookie_header, "fc_user_session") { + if let Some(session) = state.sessions.get(&session_id) { + // Check session expiry + if session.created_at.elapsed() + < std::time::Duration::from_secs(24 * 60 * 60) + { + if let Some(ref user) = session.user { + request.extensions_mut().insert(user.clone()); + } + if let Some(ref api_key) = session.api_key { + request.extensions_mut().insert(api_key.clone()); + } + } else { + drop(session); + state.sessions.remove(&session_id); + } + } + } + + // Try legacy API key session + if let Some(session_id) = parse_cookie(&cookie_header, "fc_session") { + if let Some(session) = state.sessions.get(&session_id) { + // Check session expiry + if session.created_at.elapsed() + < std::time::Duration::from_secs(24 * 60 * 60) + { + if let Some(ref api_key) = session.api_key { + request.extensions_mut().insert(api_key.clone()); + } + } else { + drop(session); + state.sessions.remove(&session_id); + } + } } } + next.run(request).await } diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 39ec54a..e8a4f49 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -81,6 +81,14 @@ impl IntoResponse for ApiError { format!("IO error: {e}"), ) }, + CiError::Internal(msg) => { + tracing::error!(message = %msg, "Internal error in API handler"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + msg.clone(), + ) + }, }; if status.is_server_error() { diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index e506dac..d3b7d10 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -1,14 +1,63 @@ use std::{sync::Arc, time::Instant}; use dashmap::DashMap; -use fc_common::{config::Config, models::ApiKey}; +use fc_common::{ + config::Config, + models::{ApiKey, User}, +}; use sqlx::PgPool; +/// Session data supporting both API key and user authentication +#[derive(Clone)] pub struct SessionData { - pub api_key: ApiKey, + pub api_key: Option, + pub user: Option, pub created_at: Instant, } +impl SessionData { + /// Check if the session has admin role + pub fn is_admin(&self) -> bool { + if let Some(ref user) = self.user { + user.role == "admin" + } else if let Some(ref key) = self.api_key { + key.role == "admin" + } else { + false + } + } + + /// Check if the session has a specific role + pub fn has_role(&self, role: &str) -> bool { + if self.is_admin() { + return true; + } + if let Some(ref user) = self.user { + user.role == role + } else if let Some(ref key) = self.api_key { + key.role == role + } else { + false + } + } + + /// Get the display name for the session (username or api key name) + pub fn display_name(&self) -> String { + if let Some(ref user) = self.user { + user.username.clone() + } else if let Some(ref key) = self.api_key { + key.name.clone() + } else { + "Anonymous".to_string() + } + } + + /// Check if this is a user session (not just API key) + pub fn is_user_session(&self) -> bool { + self.user.is_some() + } +} + #[derive(Clone)] pub struct AppState { pub pool: PgPool,