fc-server: add dual authentication support; support user accs
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I3603e4b3cc8b5999fe1edafe8a38efb26a6a6964
This commit is contained in:
parent
f5c54b1e05
commit
37e4575ef7
3 changed files with 199 additions and 34 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue