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

@ -44,6 +44,24 @@ pub fn create_router_with_tls(
.unwrap(),
);
// Rate limit for search: 10 requests/min per IP
let search_governor = Arc::new(
GovernorConfigBuilder::default()
.per_second(6) // replenish one every 6 seconds (10/min)
.burst_size(10)
.finish()
.unwrap(),
);
// Rate limit for streaming: 5 requests per IP (very restrictive for concurrent streams)
let stream_governor = Arc::new(
GovernorConfigBuilder::default()
.per_second(60) // replenish slowly (one per minute)
.burst_size(5) // max 5 concurrent connections
.finish()
.unwrap(),
);
// Login route with strict rate limiting
let login_route = Router::new()
.route("/auth/login", post(routes::auth::login))
@ -58,6 +76,21 @@ pub fn create_router_with_tls(
.route("/health/live", get(routes::health::liveness))
.route("/health/ready", get(routes::health::readiness));
// Search routes with enhanced rate limiting (10 req/min)
let search_routes = Router::new()
.route("/search", get(routes::search::search))
.route("/search", post(routes::search::search_post))
.layer(GovernorLayer {
config: search_governor,
});
// Streaming routes with enhanced rate limiting (5 concurrent)
let streaming_routes = Router::new()
.route("/media/{id}/stream", get(routes::media::stream_media))
.layer(GovernorLayer {
config: stream_governor,
});
// Read-only routes: any authenticated user (Viewer+)
let viewer_routes = Router::new()
.route("/health", get(routes::health::health))
@ -65,11 +98,8 @@ pub fn create_router_with_tls(
.route("/media/count", get(routes::media::get_media_count))
.route("/media", get(routes::media::list_media))
.route("/media/{id}", get(routes::media::get_media))
.route("/media/{id}/stream", get(routes::media::stream_media))
.route("/media/{id}/thumbnail", get(routes::media::get_thumbnail))
.route("/media/{media_id}/tags", get(routes::tags::get_media_tags))
.route("/search", get(routes::search::search))
.route("/search", post(routes::search::search_post))
.route("/tags", get(routes::tags::list_tags))
.route("/tags/{id}", get(routes::tags::get_tag))
.route("/collections", get(routes::collections::list_collections))
@ -107,6 +137,7 @@ pub fn create_router_with_tls(
// Auth endpoints (self-service) — login handled separately with stricter rate limit
.route("/auth/logout", post(routes::auth::logout))
.route("/auth/me", get(routes::auth::me))
.route("/auth/revoke-all", post(routes::auth::revoke_all_sessions))
// Social: ratings & comments (read)
.route(
"/media/{id}/ratings",
@ -374,6 +405,8 @@ pub fn create_router_with_tls(
"/users/{id}/libraries",
delete(routes::users::revoke_library_access),
)
// Session management (admin)
.route("/auth/sessions", get(routes::auth::list_active_sessions))
.layer(middleware::from_fn(auth::require_admin));
// CORS: allow same-origin by default, plus the desktop UI origin
@ -396,6 +429,8 @@ pub fn create_router_with_tls(
// Create protected routes with auth middleware
let protected_api = Router::new()
.merge(viewer_routes)
.merge(search_routes)
.merge(streaming_routes)
.merge(editor_routes)
.merge(admin_routes)
.layer(middleware::from_fn_with_state(

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());
}

View file

@ -98,6 +98,24 @@ async fn main() -> Result<()> {
.validate()
.map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?;
// Warn about authentication configuration
if config.server.authentication_disabled {
tracing::warn!(
"⚠️ AUTHENTICATION IS DISABLED - All requests will be allowed without authentication!"
);
tracing::warn!("⚠️ This is INSECURE and should only be used for development.");
} else {
let has_api_key = config
.server
.api_key
.as_ref()
.map_or(false, |k| !k.is_empty());
let has_accounts = !config.accounts.users.is_empty();
if !has_api_key && !has_accounts {
tracing::error!("⚠️ No authentication method configured!");
}
}
// Apply CLI overrides
if let Some(host) = cli.host {
config.server.host = host;
@ -466,7 +484,6 @@ async fn main() -> Result<()> {
config: config_arc.clone(),
config_path: Some(config_path),
scan_progress: pinakes_core::scan::ScanProgress::new(),
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
job_queue,
cache,
scheduler,
@ -476,14 +493,22 @@ async fn main() -> Result<()> {
// Periodic session cleanup (every 15 minutes)
{
let sessions = state.sessions.clone();
let storage_clone = storage.clone();
let cancel = shutdown_token.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60));
loop {
tokio::select! {
_ = interval.tick() => {
pinakes_server::state::cleanup_expired_sessions(&sessions).await;
match storage_clone.delete_expired_sessions().await {
Ok(count) if count > 0 => {
tracing::info!(count = count, "cleaned up expired sessions");
}
Ok(_) => {} // No sessions to clean up
Err(e) => {
tracing::error!(error = %e, "failed to cleanup expired sessions");
}
}
}
_ = cancel.cancelled() => {
break;

View file

@ -83,17 +83,21 @@ pub async fn login(
let role = user.role;
let username = user.username.clone();
// Store session
{
let mut sessions = state.sessions.write().await;
sessions.insert(
token.clone(),
crate::state::SessionInfo {
username: username.clone(),
role,
created_at: chrono::Utc::now(),
},
);
// Create session in database
let now = chrono::Utc::now();
let session_data = pinakes_core::storage::SessionData {
session_token: token.clone(),
user_id: None, // Could be set if we had user IDs
username: username.clone(),
role: role.to_string(),
created_at: now,
expires_at: now + chrono::Duration::hours(24), // 24 hour sessions
last_accessed: now,
};
if let Err(e) = state.storage.create_session(&session_data).await {
tracing::error!(error = %e, "failed to create session in database");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
tracing::info!(username = %username, role = %role, "login successful");
@ -116,13 +120,17 @@ pub async fn login(
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
if let Some(token) = extract_bearer_token(&headers) {
let sessions = state.sessions.read().await;
let username = sessions.get(token).map(|s| s.username.clone());
drop(sessions);
// Get username before deleting session
let username = match state.storage.get_session(token).await {
Ok(Some(session)) => Some(session.username),
_ => None,
};
let mut sessions = state.sessions.write().await;
sessions.remove(token);
drop(sessions);
// Delete session from database
if let Err(e) = state.storage.delete_session(token).await {
tracing::error!(error = %e, "failed to delete session from database");
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Record logout in audit log
if let Some(user) = username {
@ -153,12 +161,16 @@ pub async fn me(
drop(config);
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let sessions = state.sessions.read().await;
let session = sessions.get(token).ok_or(StatusCode::UNAUTHORIZED)?;
let session = state
.storage
.get_session(token)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(Json(UserInfoResponse {
username: session.username.clone(),
role: session.role.to_string(),
role: session.role.clone(),
}))
}
@ -168,3 +180,89 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
}
/// Revoke all sessions for the current user
pub async fn revoke_all_sessions(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
let token = match extract_bearer_token(&headers) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
};
// Get current session to find username
let session = match state.storage.get_session(token).await {
Ok(Some(s)) => s,
Ok(None) => return StatusCode::UNAUTHORIZED,
Err(e) => {
tracing::error!(error = %e, "failed to get session");
return StatusCode::INTERNAL_SERVER_ERROR;
}
};
let username = session.username.clone();
// Delete all sessions for this user
match state.storage.delete_user_sessions(&username).await {
Ok(count) => {
tracing::info!(username = %username, count = count, "revoked all user sessions");
// Record in audit log
let _ = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::Logout,
Some(format!("revoked all sessions for username: {}", username)),
)
.await;
StatusCode::OK
}
Err(e) => {
tracing::error!(error = %e, "failed to revoke sessions");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
/// List all active sessions (admin only)
#[derive(serde::Serialize)]
pub struct SessionListResponse {
pub sessions: Vec<SessionInfo>,
}
#[derive(serde::Serialize)]
pub struct SessionInfo {
pub username: String,
pub role: String,
pub created_at: String,
pub last_accessed: String,
pub expires_at: String,
}
pub async fn list_active_sessions(
State(state): State<AppState>,
) -> Result<Json<SessionListResponse>, StatusCode> {
// Get all active sessions
let sessions = state
.storage
.list_active_sessions(None)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to list active sessions");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let session_infos = sessions
.into_iter()
.map(|s| SessionInfo {
username: s.username,
role: s.role,
created_at: s.created_at.to_rfc3339(),
last_accessed: s.last_accessed.to_rfc3339(),
expires_at: s.expires_at.to_rfc3339(),
})
.collect();
Ok(Json(SessionListResponse {
sessions: session_infos,
}))
}

View file

@ -1,11 +1,10 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use pinakes_core::cache::CacheLayer;
use pinakes_core::config::{Config, UserRole};
use pinakes_core::config::Config;
use pinakes_core::jobs::JobQueue;
use pinakes_core::plugin::PluginManager;
use pinakes_core::scan::ScanProgress;
@ -13,31 +12,8 @@ use pinakes_core::scheduler::TaskScheduler;
use pinakes_core::storage::DynStorageBackend;
use pinakes_core::transcode::TranscodeService;
/// Default session TTL: 24 hours.
pub const SESSION_TTL_SECS: i64 = 24 * 60 * 60;
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub username: String,
pub role: UserRole,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl SessionInfo {
/// Returns true if this session has exceeded its TTL.
pub fn is_expired(&self) -> bool {
let age = chrono::Utc::now() - self.created_at;
age.num_seconds() > SESSION_TTL_SECS
}
}
pub type SessionStore = Arc<RwLock<HashMap<String, SessionInfo>>>;
/// Remove all expired sessions from the store.
pub async fn cleanup_expired_sessions(sessions: &SessionStore) {
let mut store = sessions.write().await;
store.retain(|_, info| !info.is_expired());
}
// Note: Sessions are now stored in the database via StorageBackend
// See storage::SessionData and related methods
#[derive(Clone)]
pub struct AppState {
@ -45,7 +21,6 @@ pub struct AppState {
pub config: Arc<RwLock<Config>>,
pub config_path: Option<PathBuf>,
pub scan_progress: ScanProgress,
pub sessions: SessionStore,
pub job_queue: Arc<JobQueue>,
pub cache: Arc<CacheLayer>,
pub scheduler: Arc<TaskScheduler>,

View file

@ -113,6 +113,7 @@ fn default_config() -> Config {
port: 3000,
api_key: None,
tls: TlsConfig::default(),
authentication_disabled: true,
},
ui: UiConfig::default(),
accounts: AccountsConfig::default(),
@ -149,7 +150,6 @@ async fn setup_app() -> axum::Router {
config,
config_path: None,
scan_progress: pinakes_core::scan::ScanProgress::new(),
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
job_queue,
cache: Arc::new(CacheLayer::new(60)),
scheduler: Arc::new(scheduler),
@ -187,6 +187,7 @@ async fn setup_app_with_auth() -> (axum::Router, String, String, String) {
}
let mut config = default_config();
config.server.authentication_disabled = false; // Enable authentication for these tests
config.accounts.enabled = true;
config.accounts.users = vec![
UserAccount {
@ -220,7 +221,6 @@ async fn setup_app_with_auth() -> (axum::Router, String, String, String) {
config,
config_path: None,
scan_progress: pinakes_core::scan::ScanProgress::new(),
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
job_queue,
cache: Arc::new(CacheLayer::new(60)),
scheduler: Arc::new(scheduler),

View file

@ -78,6 +78,7 @@ async fn setup_app_with_plugins() -> (axum::Router, Arc<PluginManager>, tempfile
port: 3000,
api_key: None,
tls: TlsConfig::default(),
authentication_disabled: true,
},
ui: UiConfig::default(),
accounts: AccountsConfig::default(),
@ -106,7 +107,6 @@ async fn setup_app_with_plugins() -> (axum::Router, Arc<PluginManager>, tempfile
config,
config_path: None,
scan_progress: pinakes_core::scan::ScanProgress::new(),
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
job_queue,
cache: Arc::new(CacheLayer::new(60)),
scheduler: Arc::new(scheduler),