treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
parent
764aafa88d
commit
3ccddce7fd
178 changed files with 58342 additions and 54241 deletions
|
|
@ -1,229 +1,237 @@
|
|||
use axum::extract::{Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use pinakes_core::config::UserRole;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Constant-time string comparison to prevent timing attacks on API keys.
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.as_bytes()
|
||||
.iter()
|
||||
.zip(b.as_bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.as_bytes()
|
||||
.iter()
|
||||
.zip(b.as_bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
/// Axum middleware that checks for a valid Bearer token.
|
||||
///
|
||||
/// If `accounts.enabled == true`: look up bearer token in database session store.
|
||||
/// If `accounts.enabled == false`: use existing api_key logic (unchanged behavior).
|
||||
/// Skips authentication for the `/health` and `/auth/login` path suffixes.
|
||||
/// If `accounts.enabled == true`: look up bearer token in database session
|
||||
/// store. If `accounts.enabled == false`: use existing api_key logic (unchanged
|
||||
/// behavior). Skips authentication for the `/health` and `/auth/login` path
|
||||
/// suffixes.
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path().to_string();
|
||||
let path = request.uri().path().to_string();
|
||||
|
||||
// Always allow health and login endpoints
|
||||
if path.ends_with("/health") || path.ends_with("/auth/login") {
|
||||
return next.run(request).await;
|
||||
// Always allow health and login endpoints
|
||||
if path.ends_with("/health") || path.ends_with("/auth/login") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let config = state.config.read().await;
|
||||
|
||||
// Check if authentication is explicitly disabled
|
||||
if config.server.authentication_disabled {
|
||||
drop(config);
|
||||
tracing::warn!("authentication is disabled - allowing all requests");
|
||||
request.extensions_mut().insert(UserRole::Admin);
|
||||
request.extensions_mut().insert("admin".to_string());
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
if config.accounts.enabled {
|
||||
drop(config);
|
||||
|
||||
// Session-based auth using database
|
||||
let token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let Some(token) = token else {
|
||||
tracing::debug!(path = %path, "rejected: missing Authorization header");
|
||||
return unauthorized("missing Authorization header");
|
||||
};
|
||||
|
||||
// Look up session in database
|
||||
let session_result = state.storage.get_session(&token).await;
|
||||
let session = match session_result {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
tracing::debug!(path = %path, "rejected: invalid session token");
|
||||
return unauthorized("invalid or expired session token");
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to query session from database");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "database error")
|
||||
.into_response();
|
||||
},
|
||||
};
|
||||
|
||||
// Check session expiry
|
||||
let now = chrono::Utc::now();
|
||||
if session.expires_at < now {
|
||||
let username = session.username.clone();
|
||||
// Delete expired session asynchronously (fire-and-forget)
|
||||
let storage = state.storage.clone();
|
||||
let token_owned = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = storage.delete_session(&token_owned).await {
|
||||
tracing::error!(error = %e, "failed to delete expired session");
|
||||
}
|
||||
});
|
||||
tracing::info!(username = %username, "session expired");
|
||||
return unauthorized("session expired");
|
||||
}
|
||||
|
||||
let config = state.config.read().await;
|
||||
// Update last_accessed timestamp asynchronously (fire-and-forget)
|
||||
let storage = state.storage.clone();
|
||||
let token_owned = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = storage.touch_session(&token_owned).await {
|
||||
tracing::warn!(error = %e, "failed to update session last_accessed");
|
||||
}
|
||||
});
|
||||
|
||||
// Check if authentication is explicitly disabled
|
||||
if config.server.authentication_disabled {
|
||||
drop(config);
|
||||
tracing::warn!("authentication is disabled - allowing all requests");
|
||||
request.extensions_mut().insert(UserRole::Admin);
|
||||
request.extensions_mut().insert("admin".to_string());
|
||||
return next.run(request).await;
|
||||
// Parse role from string
|
||||
let role = match session.role.as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
"editor" => UserRole::Editor,
|
||||
"viewer" => UserRole::Viewer,
|
||||
_ => {
|
||||
tracing::warn!(role = %session.role, "unknown role, defaulting to viewer");
|
||||
UserRole::Viewer
|
||||
},
|
||||
};
|
||||
|
||||
// Inject role and username into request extensions
|
||||
request.extensions_mut().insert(role);
|
||||
request.extensions_mut().insert(session.username.clone());
|
||||
} else {
|
||||
// Legacy API key auth
|
||||
let api_key = std::env::var("PINAKES_API_KEY")
|
||||
.ok()
|
||||
.or_else(|| config.server.api_key.clone());
|
||||
drop(config);
|
||||
|
||||
let Some(ref expected_key) = api_key else {
|
||||
tracing::error!("no authentication configured");
|
||||
return unauthorized("authentication not configured");
|
||||
};
|
||||
|
||||
if expected_key.is_empty() {
|
||||
// Empty key is not allowed - must use authentication_disabled flag
|
||||
tracing::error!(
|
||||
"empty api_key rejected, use authentication_disabled flag instead"
|
||||
);
|
||||
return unauthorized("authentication not properly configured");
|
||||
}
|
||||
|
||||
if config.accounts.enabled {
|
||||
drop(config);
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// Session-based auth using database
|
||||
let token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let Some(token) = token else {
|
||||
tracing::debug!(path = %path, "rejected: missing Authorization header");
|
||||
return unauthorized("missing Authorization header");
|
||||
};
|
||||
|
||||
// Look up session in database
|
||||
let session_result = state.storage.get_session(&token).await;
|
||||
let session = match session_result {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
tracing::debug!(path = %path, "rejected: invalid session token");
|
||||
return unauthorized("invalid or expired session token");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to query session from database");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Check session expiry
|
||||
let now = chrono::Utc::now();
|
||||
if session.expires_at < now {
|
||||
let username = session.username.clone();
|
||||
// Delete expired session asynchronously (fire-and-forget)
|
||||
let storage = state.storage.clone();
|
||||
let token_owned = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = storage.delete_session(&token_owned).await {
|
||||
tracing::error!(error = %e, "failed to delete expired session");
|
||||
}
|
||||
});
|
||||
tracing::info!(username = %username, "session expired");
|
||||
return unauthorized("session expired");
|
||||
match auth_header {
|
||||
Some(header) if header.starts_with("Bearer ") => {
|
||||
let token = &header[7..];
|
||||
if !constant_time_eq(token, expected_key.as_str()) {
|
||||
tracing::warn!(path = %path, "rejected: invalid API key");
|
||||
return unauthorized("invalid api key");
|
||||
}
|
||||
|
||||
// Update last_accessed timestamp asynchronously (fire-and-forget)
|
||||
let storage = state.storage.clone();
|
||||
let token_owned = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = storage.touch_session(&token_owned).await {
|
||||
tracing::warn!(error = %e, "failed to update session last_accessed");
|
||||
}
|
||||
});
|
||||
|
||||
// Parse role from string
|
||||
let role = match session.role.as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
"editor" => UserRole::Editor,
|
||||
"viewer" => UserRole::Viewer,
|
||||
_ => {
|
||||
tracing::warn!(role = %session.role, "unknown role, defaulting to viewer");
|
||||
UserRole::Viewer
|
||||
}
|
||||
};
|
||||
|
||||
// Inject role and username into request extensions
|
||||
request.extensions_mut().insert(role);
|
||||
request.extensions_mut().insert(session.username.clone());
|
||||
} else {
|
||||
// Legacy API key auth
|
||||
let api_key = std::env::var("PINAKES_API_KEY")
|
||||
.ok()
|
||||
.or_else(|| config.server.api_key.clone());
|
||||
drop(config);
|
||||
|
||||
let Some(ref expected_key) = api_key else {
|
||||
tracing::error!("no authentication configured");
|
||||
return unauthorized("authentication not configured");
|
||||
};
|
||||
|
||||
if expected_key.is_empty() {
|
||||
// Empty key is not allowed - must use authentication_disabled flag
|
||||
tracing::error!("empty api_key rejected, use authentication_disabled flag instead");
|
||||
return unauthorized("authentication not properly configured");
|
||||
}
|
||||
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
match auth_header {
|
||||
Some(header) if header.starts_with("Bearer ") => {
|
||||
let token = &header[7..];
|
||||
if !constant_time_eq(token, expected_key.as_str()) {
|
||||
tracing::warn!(path = %path, "rejected: invalid API key");
|
||||
return unauthorized("invalid api key");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return unauthorized(
|
||||
"missing or malformed Authorization header, expected: Bearer <api_key>",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// API key matches, grant admin
|
||||
request.extensions_mut().insert(UserRole::Admin);
|
||||
request.extensions_mut().insert("admin".to_string());
|
||||
},
|
||||
_ => {
|
||||
return unauthorized(
|
||||
"missing or malformed Authorization header, expected: Bearer \
|
||||
<api_key>",
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
// API key matches, grant admin
|
||||
request.extensions_mut().insert(UserRole::Admin);
|
||||
request.extensions_mut().insert("admin".to_string());
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Middleware: requires Editor or Admin role.
|
||||
pub async fn require_editor(request: Request, next: Next) -> Response {
|
||||
let role = request
|
||||
.extensions()
|
||||
.get::<UserRole>()
|
||||
.copied()
|
||||
.unwrap_or(UserRole::Viewer);
|
||||
if role.can_write() {
|
||||
next.run(request).await
|
||||
} else {
|
||||
forbidden("editor role required")
|
||||
}
|
||||
let role = request
|
||||
.extensions()
|
||||
.get::<UserRole>()
|
||||
.copied()
|
||||
.unwrap_or(UserRole::Viewer);
|
||||
if role.can_write() {
|
||||
next.run(request).await
|
||||
} else {
|
||||
forbidden("editor role required")
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware: requires Admin role.
|
||||
pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||
let role = request
|
||||
.extensions()
|
||||
.get::<UserRole>()
|
||||
.copied()
|
||||
.unwrap_or(UserRole::Viewer);
|
||||
if role.can_admin() {
|
||||
next.run(request).await
|
||||
} else {
|
||||
forbidden("admin role required")
|
||||
}
|
||||
let role = request
|
||||
.extensions()
|
||||
.get::<UserRole>()
|
||||
.copied()
|
||||
.unwrap_or(UserRole::Viewer);
|
||||
if role.can_admin() {
|
||||
next.run(request).await
|
||||
} else {
|
||||
forbidden("admin role required")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the authenticated username (from request extensions) to a UserId.
|
||||
///
|
||||
/// Returns an error if the user cannot be found.
|
||||
pub async fn resolve_user_id(
|
||||
storage: &pinakes_core::storage::DynStorageBackend,
|
||||
username: &str,
|
||||
storage: &pinakes_core::storage::DynStorageBackend,
|
||||
username: &str,
|
||||
) -> Result<pinakes_core::users::UserId, crate::error::ApiError> {
|
||||
match storage.get_user_by_username(username).await {
|
||||
Ok(user) => Ok(user.id),
|
||||
Err(e) => {
|
||||
tracing::warn!(username = %username, error = ?e, "failed to resolve user");
|
||||
Err(crate::error::ApiError(
|
||||
pinakes_core::error::PinakesError::Authentication("user not found".into()),
|
||||
))
|
||||
}
|
||||
}
|
||||
match storage.get_user_by_username(username).await {
|
||||
Ok(user) => Ok(user.id),
|
||||
Err(e) => {
|
||||
tracing::warn!(username = %username, error = ?e, "failed to resolve user");
|
||||
Err(crate::error::ApiError(
|
||||
pinakes_core::error::PinakesError::Authentication(
|
||||
"user not found".into(),
|
||||
),
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized(message: &str) -> Response {
|
||||
let body = format!(r#"{{"error":"{message}"}}"#);
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("content-type", "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
let body = format!(r#"{{"error":"{message}"}}"#);
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("content-type", "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn forbidden(message: &str) -> Response {
|
||||
let body = format!(r#"{{"error":"{message}"}}"#);
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
[("content-type", "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
let body = format!(r#"{{"error":"{message}"}}"#);
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
[("content-type", "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue