pinakes-server: TLS support; session persistence and security polish

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If2c9c3e3af62bbf9f33a97be89ac40bc6a6a6964
This commit is contained in:
raf 2026-01-31 15:20:27 +03:00
commit 87a4482576
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
19 changed files with 1835 additions and 111 deletions

View file

@ -21,7 +21,7 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
/// Axum middleware that checks for a valid Bearer token.
///
/// If `accounts.enabled == true`: look up bearer token in session store.
/// 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(
@ -38,8 +38,19 @@ pub async fn require_auth(
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 {
// Session-based auth
drop(config);
// Session-based auth using database
let token = request
.headers()
.get("authorization")
@ -47,32 +58,63 @@ pub async fn require_auth(
.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");
// 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
if session.is_expired() {
let now = chrono::Utc::now();
if session.expires_at < now {
let username = session.username.clone();
drop(sessions);
// Remove expired session
let mut sessions_mut = state.sessions.write().await;
sessions_mut.remove(&token);
// 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");
}
// 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(session.role);
request.extensions_mut().insert(role);
request.extensions_mut().insert(session.username.clone());
} else {
// Legacy API key auth
@ -81,35 +123,38 @@ pub async fn require_auth(
.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);
request.extensions_mut().insert("admin".to_string());
return next.run(request).await;
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");
}
}
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>",
);
}
_ => {
return unauthorized(
"missing or malformed Authorization header, expected: Bearer <api_key>",
);
}
}
// When no api_key is configured, or key matches, grant admin
// API key matches, grant admin
request.extensions_mut().insert(UserRole::Admin);
request.extensions_mut().insert("admin".to_string());
}