fc-server: add dual authentication support; support user accs

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3603e4b3cc8b5999fe1edafe8a38efb26a6a6964
This commit is contained in:
raf 2026-02-02 22:38:02 +03:00
commit 37e4575ef7
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 199 additions and 34 deletions

View file

@ -4,15 +4,16 @@ use axum::{
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use fc_common::models::ApiKey; use fc_common::models::{ApiKey, User};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::state::AppState; use crate::state::AppState;
/// Extract and validate an API key from the Authorization header or session /// Extract and validate an API key from the Authorization header or session
/// cookie. Keys use the format: `Bearer fc_xxxx`. Session cookies use /// cookie. Keys use the format: `Bearer fc_xxxx`. Session cookies use
/// `fc_session=<id>`. Write endpoints (POST/PUT/DELETE/PATCH) require a valid /// `fc_session=<id>` for API keys or `fc_user_session=<id>` for users.
/// key. Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for /// Write endpoints (POST/PUT/DELETE/PATCH) require a valid key.
/// Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for
/// dashboard admin UI). /// dashboard admin UI).
pub async fn require_api_key( pub async fn require_api_key(
State(state): State<AppState>, State(state): State<AppState>,
@ -24,6 +25,7 @@ pub async fn require_api_key(
|| method == axum::http::Method::HEAD || method == axum::http::Method::HEAD
|| method == axum::http::Method::OPTIONS; || method == axum::http::Method::OPTIONS;
// Try Bearer token first (API key auth)
let auth_header = request let auth_header = request
.headers() .headers()
.get("authorization") .get("authorization")
@ -34,7 +36,6 @@ pub async fn require_api_key(
.as_deref() .as_deref()
.and_then(|h| h.strip_prefix("Bearer ")); .and_then(|h| h.strip_prefix("Bearer "));
// Try Bearer token first
if let Some(token) = token { if let Some(token) = token {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
@ -43,29 +44,74 @@ pub async fn require_api_key(
if let Ok(Some(api_key)) = if let Ok(Some(api_key)) =
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
{ {
// Update last used timestamp asynchronously
let pool = state.pool.clone(); let pool = state.pool.clone();
let key_id = api_key.id; let key_id = api_key.id;
tokio::spawn(async move { 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); 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 if let Some(cookie_header) = request
.headers() .headers()
.get("cookie") .get("cookie")
.and_then(|v| v.to_str().ok()) .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()); // Try user session first (new fc_user_session cookie)
return Ok(next.run(request).await); 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 // No valid auth found
@ -87,11 +133,29 @@ impl FromRequestParts<AppState> for RequireAdmin {
parts: &mut Parts, parts: &mut Parts,
_state: &AppState, _state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
// Check for user first (new auth)
if let Some(user) = parts.extensions.get::<User>() {
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 let key = parts
.extensions .extensions
.get::<ApiKey>() .get::<ApiKey>()
.cloned() .cloned()
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
if key.role == "admin" { if key.role == "admin" {
Ok(RequireAdmin(key)) Ok(RequireAdmin(key))
} else { } else {
@ -101,21 +165,36 @@ impl FromRequestParts<AppState> for RequireAdmin {
} }
/// Extractor that requires one of the specified roles (admin always passes). /// Extractor that requires one of the specified roles (admin always passes).
/// Use as: `_auth: RequireRole<"cancel-build", "restart-jobs">` /// Use as: `RequireRoles::check(&extensions, &["cancel-build",
/// /// "restart-jobs"])`
/// Since const generics with strings aren't stable, use the helper function pub struct RequireRoles;
/// instead.
pub struct RequireRoles(pub ApiKey);
impl RequireRoles { impl RequireRoles {
pub fn check( pub fn check(
extensions: &axum::http::Extensions, extensions: &axum::http::Extensions,
allowed: &[&str], allowed: &[&str],
) -> Result<ApiKey, StatusCode> { ) -> Result<ApiKey, StatusCode> {
// Check for user first
if let Some(user) = extensions.get::<User>() {
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 let key = extensions
.get::<ApiKey>() .get::<ApiKey>()
.cloned() .cloned()
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
if key.role == "admin" || allowed.contains(&key.role.as_str()) { if key.role == "admin" || allowed.contains(&key.role.as_str()) {
Ok(key) Ok(key)
} else { } else {
@ -125,30 +204,59 @@ impl RequireRoles {
} }
/// Session extraction middleware for dashboard routes. /// 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( pub async fn extract_session(
State(state): State<AppState>, State(state): State<AppState>,
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
if let Some(cookie_header) = request // Extract cookie header first, then clone to end the borrow
let cookie_header = request
.headers() .headers()
.get("cookie") .get("cookie")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session") .map(|s| s.to_string());
&& let Some(session) = state.sessions.get(&session_id)
{ if let Some(cookie_header) = cookie_header {
// Check session expiry (24 hours) // Try user session first
if session.created_at.elapsed() if let Some(session_id) = parse_cookie(&cookie_header, "fc_user_session") {
< std::time::Duration::from_secs(24 * 60 * 60) if let Some(session) = state.sessions.get(&session_id) {
{ // Check session expiry
request.extensions_mut().insert(session.api_key.clone()); if session.created_at.elapsed()
} else { < std::time::Duration::from_secs(24 * 60 * 60)
// Expired, remove it {
drop(session); if let Some(ref user) = session.user {
state.sessions.remove(&session_id); 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 next.run(request).await
} }

View file

@ -81,6 +81,14 @@ impl IntoResponse for ApiError {
format!("IO error: {e}"), 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() { if status.is_server_error() {

View file

@ -1,14 +1,63 @@
use std::{sync::Arc, time::Instant}; use std::{sync::Arc, time::Instant};
use dashmap::DashMap; use dashmap::DashMap;
use fc_common::{config::Config, models::ApiKey}; use fc_common::{
config::Config,
models::{ApiKey, User},
};
use sqlx::PgPool; use sqlx::PgPool;
/// Session data supporting both API key and user authentication
#[derive(Clone)]
pub struct SessionData { pub struct SessionData {
pub api_key: ApiKey, pub api_key: Option<ApiKey>,
pub user: Option<User>,
pub created_at: Instant, 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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub pool: PgPool, pub pool: PgPool,