crates/server: enhance auth middleware and error responses

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I48a780779d884c4a7730347f920b91216a6a6964
This commit is contained in:
raf 2026-02-02 01:26:17 +03:00
commit 92153bf9aa
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
8 changed files with 272 additions and 99 deletions

View file

@ -9,8 +9,8 @@ use sha2::{Digest, Sha256};
use crate::state::AppState;
/// Extract and validate an API key from the Authorization header.
/// Keys use the format: `Bearer fc_xxxx`.
/// 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=<id>`.
/// 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(
@ -33,42 +33,44 @@ pub async fn require_api_key(
.as_deref()
.and_then(|h| h.strip_prefix("Bearer "));
match token {
Some(token) => {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let key_hash = hex::encode(hasher.finalize());
// Try Bearer token first
if let Some(token) = token {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let key_hash = hex::encode(hasher.finalize());
match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await {
Ok(Some(api_key)) => {
// Touch last_used_at (fire and forget)
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 Ok(Some(api_key)) =
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
{
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;
});
request.extensions_mut().insert(api_key);
Ok(next.run(request).await)
}
_ => {
if is_read {
// Invalid token on read is still allowed, just no ApiKey in extensions
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
}
}
None => {
if is_read {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
request.extensions_mut().insert(api_key);
return Ok(next.run(request).await);
}
}
// Fall back to session cookie (so dashboard JS fetches work)
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);
}
// No valid auth found
if is_read {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
/// Extractor that requires an authenticated admin user.
@ -129,9 +131,8 @@ pub async fn extract_session(
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
{
if let Some(session_id) = parse_cookie(cookie_header, "fc_session") {
if let Some(session) = state.sessions.get(&session_id) {
&& 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());
@ -141,12 +142,10 @@ pub async fn extract_session(
state.sessions.remove(&session_id);
}
}
}
}
next.run(request).await
}
fn parse_cookie<'a>(header: &'a str, name: &str) -> Option<String> {
fn parse_cookie(header: &str, name: &str) -> Option<String> {
header
.split(';')
.filter_map(|pair| {