pinakes-server: TLS support; session persistence and security polish
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If2c9c3e3af62bbf9f33a97be89ac40bc6a6a6964
This commit is contained in:
parent
758aba0f7a
commit
87a4482576
19 changed files with 1835 additions and 111 deletions
|
|
@ -31,6 +31,18 @@ pub struct DatabaseStats {
|
|||
pub backend_name: String,
|
||||
}
|
||||
|
||||
/// Session data for database-backed session storage.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionData {
|
||||
pub session_token: String,
|
||||
pub user_id: Option<String>,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait StorageBackend: Send + Sync + 'static {
|
||||
// Migrations
|
||||
|
|
@ -412,6 +424,28 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
progress: f32,
|
||||
) -> Result<()>;
|
||||
async fn cleanup_expired_transcodes(&self, before: DateTime<Utc>) -> Result<u64>;
|
||||
|
||||
// ===== Session Management =====
|
||||
/// Create a new session in the database
|
||||
async fn create_session(&self, session: &SessionData) -> Result<()>;
|
||||
|
||||
/// Get a session by its token, returns None if not found or expired
|
||||
async fn get_session(&self, session_token: &str) -> Result<Option<SessionData>>;
|
||||
|
||||
/// Update the last_accessed timestamp for a session
|
||||
async fn touch_session(&self, session_token: &str) -> Result<()>;
|
||||
|
||||
/// Delete a specific session
|
||||
async fn delete_session(&self, session_token: &str) -> Result<()>;
|
||||
|
||||
/// Delete all sessions for a specific user
|
||||
async fn delete_user_sessions(&self, username: &str) -> Result<u64>;
|
||||
|
||||
/// Delete all expired sessions (where expires_at < now)
|
||||
async fn delete_expired_sessions(&self) -> Result<u64>;
|
||||
|
||||
/// List all active sessions (optionally filtered by username)
|
||||
async fn list_active_sessions(&self, username: Option<&str>) -> Result<Vec<SessionData>>;
|
||||
}
|
||||
|
||||
/// Comprehensive library statistics.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use std::path::PathBuf;
|
|||
|
||||
use chrono::Utc;
|
||||
use deadpool_postgres::{Config as PoolConfig, Pool, Runtime};
|
||||
use native_tls::TlsConnector;
|
||||
use postgres_native_tls::MakeTlsConnector;
|
||||
use tokio_postgres::types::ToSql;
|
||||
use tokio_postgres::{NoTls, Row};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -27,19 +29,72 @@ impl PostgresBackend {
|
|||
pool_config.user = Some(config.username.clone());
|
||||
pool_config.password = Some(config.password.clone());
|
||||
|
||||
let pool = pool_config
|
||||
.create_pool(Some(Runtime::Tokio1), NoTls)
|
||||
.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to create connection pool: {e}"))
|
||||
if config.tls_enabled {
|
||||
// Build TLS connector
|
||||
let mut tls_builder = TlsConnector::builder();
|
||||
|
||||
// Load custom CA certificate if provided
|
||||
if let Some(ref ca_cert_path) = config.tls_ca_cert_path {
|
||||
let cert_bytes = std::fs::read(ca_cert_path).map_err(|e| {
|
||||
PinakesError::Config(format!(
|
||||
"failed to read CA certificate file {}: {e}",
|
||||
ca_cert_path.display()
|
||||
))
|
||||
})?;
|
||||
let cert = native_tls::Certificate::from_pem(&cert_bytes).map_err(|e| {
|
||||
PinakesError::Config(format!(
|
||||
"failed to parse CA certificate {}: {e}",
|
||||
ca_cert_path.display()
|
||||
))
|
||||
})?;
|
||||
tls_builder.add_root_certificate(cert);
|
||||
}
|
||||
|
||||
// Configure certificate validation
|
||||
if !config.tls_verify_ca {
|
||||
tracing::warn!(
|
||||
"PostgreSQL TLS certificate verification disabled - this is insecure!"
|
||||
);
|
||||
tls_builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
|
||||
let connector = tls_builder.build().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to build TLS connector: {e}"))
|
||||
})?;
|
||||
let tls = MakeTlsConnector::new(connector);
|
||||
|
||||
let pool = pool_config
|
||||
.create_pool(Some(Runtime::Tokio1), tls)
|
||||
.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to create connection pool: {e}"))
|
||||
})?;
|
||||
|
||||
// Verify connectivity
|
||||
let _ = pool.get().await.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to connect to postgres: {e}"))
|
||||
})?;
|
||||
|
||||
// Verify connectivity
|
||||
let _ = pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("failed to connect to postgres: {e}")))?;
|
||||
tracing::info!("PostgreSQL connection established with TLS");
|
||||
Ok(Self { pool })
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"PostgreSQL TLS is disabled - connection is unencrypted. \
|
||||
Set postgres.tls_enabled = true to enable encryption."
|
||||
);
|
||||
|
||||
Ok(Self { pool })
|
||||
let pool = pool_config
|
||||
.create_pool(Some(Runtime::Tokio1), NoTls)
|
||||
.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to create connection pool: {e}"))
|
||||
})?;
|
||||
|
||||
// Verify connectivity
|
||||
let _ = pool.get().await.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to connect to postgres: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3229,6 +3284,167 @@ impl StorageBackend for PostgresBackend {
|
|||
.await?;
|
||||
Ok(affected)
|
||||
}
|
||||
|
||||
// ===== Session Management =====
|
||||
|
||||
async fn create_session(&self, session: &crate::storage::SessionData) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO sessions (session_token, user_id, username, role, created_at, expires_at, last_accessed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
&[
|
||||
&session.session_token,
|
||||
&session.user_id,
|
||||
&session.username,
|
||||
&session.role,
|
||||
&session.created_at,
|
||||
&session.expires_at,
|
||||
&session.last_accessed,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_session(
|
||||
&self,
|
||||
session_token: &str,
|
||||
) -> Result<Option<crate::storage::SessionData>> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed
|
||||
FROM sessions WHERE session_token = $1",
|
||||
&[&session_token],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| crate::storage::SessionData {
|
||||
session_token: r.get(0),
|
||||
user_id: r.get(1),
|
||||
username: r.get(2),
|
||||
role: r.get(3),
|
||||
created_at: r.get(4),
|
||||
expires_at: r.get(5),
|
||||
last_accessed: r.get(6),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn touch_session(&self, session_token: &str) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
client
|
||||
.execute(
|
||||
"UPDATE sessions SET last_accessed = $1 WHERE session_token = $2",
|
||||
&[&now, &session_token],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_token: &str) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
client
|
||||
.execute(
|
||||
"DELETE FROM sessions WHERE session_token = $1",
|
||||
&[&session_token],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_user_sessions(&self, username: &str) -> Result<u64> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let affected = client
|
||||
.execute("DELETE FROM sessions WHERE username = $1", &[&username])
|
||||
.await?;
|
||||
Ok(affected)
|
||||
}
|
||||
|
||||
async fn delete_expired_sessions(&self) -> Result<u64> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let affected = client
|
||||
.execute("DELETE FROM sessions WHERE expires_at < $1", &[&now])
|
||||
.await?;
|
||||
Ok(affected)
|
||||
}
|
||||
|
||||
async fn list_active_sessions(
|
||||
&self,
|
||||
username: Option<&str>,
|
||||
) -> Result<Vec<crate::storage::SessionData>> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let rows = if let Some(user) = username {
|
||||
client
|
||||
.query(
|
||||
"SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed
|
||||
FROM sessions WHERE expires_at > $1 AND username = $2
|
||||
ORDER BY last_accessed DESC",
|
||||
&[&now, &user],
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
client
|
||||
.query(
|
||||
"SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed
|
||||
FROM sessions WHERE expires_at > $1
|
||||
ORDER BY last_accessed DESC",
|
||||
&[&now],
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| crate::storage::SessionData {
|
||||
session_token: r.get(0),
|
||||
user_id: r.get(1),
|
||||
username: r.get(2),
|
||||
role: r.get(3),
|
||||
created_at: r.get(4),
|
||||
expires_at: r.get(5),
|
||||
last_accessed: r.get(6),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresBackend {
|
||||
|
|
|
|||
|
|
@ -3580,6 +3580,227 @@ impl StorageBackend for SqliteBackend {
|
|||
.map_err(|_| PinakesError::Database("cleanup_expired_transcodes timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
// ===== Session Management =====
|
||||
|
||||
async fn create_session(&self, session: &crate::storage::SessionData) -> Result<()> {
|
||||
let conn = self.conn.clone();
|
||||
let session_token = session.session_token.clone();
|
||||
let user_id = session.user_id.clone();
|
||||
let username = session.username.clone();
|
||||
let role = session.role.clone();
|
||||
let created_at = session.created_at.to_rfc3339();
|
||||
let expires_at = session.expires_at.to_rfc3339();
|
||||
let last_accessed = session.last_accessed.to_rfc3339();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
db.execute(
|
||||
"INSERT INTO sessions (session_token, user_id, username, role, created_at, expires_at, last_accessed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
&session_token,
|
||||
&user_id,
|
||||
&username,
|
||||
&role,
|
||||
&created_at,
|
||||
&expires_at,
|
||||
&last_accessed
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("create_session timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn get_session(
|
||||
&self,
|
||||
session_token: &str,
|
||||
) -> Result<Option<crate::storage::SessionData>> {
|
||||
let conn = self.conn.clone();
|
||||
let token = session_token.to_string();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
|
||||
let result = db
|
||||
.query_row(
|
||||
"SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed
|
||||
FROM sessions WHERE session_token = ?",
|
||||
[&token],
|
||||
|row| {
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let expires_at_str: String = row.get(5)?;
|
||||
let last_accessed_str: String = row.get(6)?;
|
||||
|
||||
Ok(crate::storage::SessionData {
|
||||
session_token: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
username: row.get(2)?,
|
||||
role: row.get(3)?,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
last_accessed: chrono::DateTime::parse_from_rfc3339(&last_accessed_str)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(result)
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("get_session timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn touch_session(&self, session_token: &str) -> Result<()> {
|
||||
let conn = self.conn.clone();
|
||||
let token = session_token.to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
db.execute(
|
||||
"UPDATE sessions SET last_accessed = ? WHERE session_token = ?",
|
||||
params![&now, &token],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("touch_session timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_token: &str) -> Result<()> {
|
||||
let conn = self.conn.clone();
|
||||
let token = session_token.to_string();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
db.execute("DELETE FROM sessions WHERE session_token = ?", [&token])?;
|
||||
Ok(())
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("delete_session timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn delete_user_sessions(&self, username: &str) -> Result<u64> {
|
||||
let conn = self.conn.clone();
|
||||
let user = username.to_string();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
let affected = db.execute("DELETE FROM sessions WHERE username = ?", [&user])?;
|
||||
Ok(affected as u64)
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("delete_user_sessions timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn delete_expired_sessions(&self) -> Result<u64> {
|
||||
let conn = self.conn.clone();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
let affected = db.execute("DELETE FROM sessions WHERE expires_at < ?", [&now])?;
|
||||
Ok(affected as u64)
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("delete_expired_sessions timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn list_active_sessions(
|
||||
&self,
|
||||
username: Option<&str>,
|
||||
) -> Result<Vec<crate::storage::SessionData>> {
|
||||
let conn = self.conn.clone();
|
||||
let user_filter = username.map(|s| s.to_string());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let fut = tokio::task::spawn_blocking(move || {
|
||||
let db = conn.lock().map_err(|e| {
|
||||
PinakesError::Database(format!("failed to acquire database lock: {}", e))
|
||||
})?;
|
||||
|
||||
let (query, params): (&str, Vec<String>) = if let Some(user) = user_filter {
|
||||
(
|
||||
"SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed
|
||||
FROM sessions WHERE expires_at > ? AND username = ?
|
||||
ORDER BY last_accessed DESC",
|
||||
vec![now, user],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed
|
||||
FROM sessions WHERE expires_at > ?
|
||||
ORDER BY last_accessed DESC",
|
||||
vec![now],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = db.prepare(query)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
|
||||
let rows = stmt.query_map(¶m_refs[..], |row| {
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let expires_at_str: String = row.get(5)?;
|
||||
let last_accessed_str: String = row.get(6)?;
|
||||
|
||||
Ok(crate::storage::SessionData {
|
||||
session_token: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
username: row.get(2)?,
|
||||
role: row.get(3)?,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
last_accessed: chrono::DateTime::parse_from_rfc3339(&last_accessed_str)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
|
||||
.with_timezone(&chrono::Utc),
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.into())
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
.map_err(|_| PinakesError::Database("list_active_sessions timed out".into()))?
|
||||
.map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for `query_row(...).optional()`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue