initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
raf 2026-01-30 22:05:46 +03:00
commit 6a73d11c4b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
124 changed files with 34856 additions and 0 deletions

View file

@ -0,0 +1,164 @@
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::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
}
/// Axum middleware that checks for a valid Bearer token.
///
/// If `accounts.enabled == true`: look up bearer token in 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,
) -> Response {
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;
}
let config = state.config.read().await;
if config.accounts.enabled {
// Session-based auth
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());
drop(config);
let Some(token) = token else {
tracing::debug!(path = %path, "rejected: missing Authorization header");
return unauthorized("missing Authorization header");
};
let sessions = state.sessions.read().await;
let Some(session) = sessions.get(&token) else {
tracing::debug!(path = %path, "rejected: invalid session token");
return unauthorized("invalid or expired session token");
};
// Check session expiry
if session.is_expired() {
let username = session.username.clone();
drop(sessions);
// Remove expired session
let mut sessions_mut = state.sessions.write().await;
sessions_mut.remove(&token);
tracing::info!(username = %username, "session expired");
return unauthorized("session expired");
}
// Inject role and username into request extensions
request.extensions_mut().insert(session.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);
if let Some(ref expected_key) = api_key {
if expected_key.is_empty() {
// Empty key means no auth required
request.extensions_mut().insert(UserRole::Admin);
return next.run(request).await;
}
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>",
);
}
}
}
// When no api_key is configured, or key matches, grant admin
request.extensions_mut().insert(UserRole::Admin);
}
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")
}
}
/// 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")
}
}
fn unauthorized(message: &str) -> 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()
}