Compare commits
No commits in common. '24eb878b299fd9f5bbd39b214350f3f4526558cb' and 'e92bbdb62cf0437b6a930a50c4070f25dddf9f09' have entirely different histories.
24eb878b29
...
e92bbdb62c
@ -1,305 +0,0 @@
|
|||||||
//! Authentication — secret generation, credential storage, request verification.
|
|
||||||
//!
|
|
||||||
//! Secret is generated once and stored in a file outside the modules directory.
|
|
||||||
//! Credentials (login/password) are XOR-encrypted with the secret and stored in
|
|
||||||
//! a separate `auth` DB table that modules cannot access through the kv API.
|
|
||||||
|
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use rand::Rng;
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|
||||||
|
|
||||||
use crate::logging;
|
|
||||||
|
|
||||||
struct AuthState {
|
|
||||||
secret: String,
|
|
||||||
credentials: Option<(String, String)>,
|
|
||||||
secret_paths: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
static STATE: OnceLock<Mutex<AuthState>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn state() -> &'static Mutex<AuthState> {
|
|
||||||
STATE.get_or_init(|| Mutex::new(AuthState {
|
|
||||||
secret: String::new(),
|
|
||||||
credentials: None,
|
|
||||||
secret_paths: Vec::new(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lock_state() -> std::sync::MutexGuard<'static, AuthState> {
|
|
||||||
state().lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a 32-byte hex secret.
|
|
||||||
fn generate_secret() -> String {
|
|
||||||
let bytes: [u8; 32] = rand::rng().random();
|
|
||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XOR encrypt/decrypt data with the secret (symmetric).
|
|
||||||
fn xor_with_secret(data: &str, secret: &str) -> String {
|
|
||||||
let secret_bytes = secret.as_bytes();
|
|
||||||
let encrypted: Vec<u8> = data.as_bytes().iter().enumerate()
|
|
||||||
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
|
|
||||||
.collect();
|
|
||||||
BASE64.encode(&encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XOR decrypt base64 data with the secret.
|
|
||||||
fn decrypt_with_secret(encoded: &str, secret: &str) -> Option<String> {
|
|
||||||
let encrypted = BASE64.decode(encoded).ok()?;
|
|
||||||
let secret_bytes = secret.as_bytes();
|
|
||||||
let decrypted: Vec<u8> = encrypted.iter().enumerate()
|
|
||||||
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
|
|
||||||
.collect();
|
|
||||||
String::from_utf8(decrypted).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize auth: load or generate secret, load credentials from DB.
|
|
||||||
pub async fn init(secret_paths: &[String], db_conn: &tokio_rusqlite::Connection) {
|
|
||||||
// Create auth table
|
|
||||||
if let Err(e) = db_conn.call(|conn| {
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE IF NOT EXISTS auth (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
||||||
)?;
|
|
||||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
|
||||||
}).await {
|
|
||||||
logging::log("ERROR", "AUTH", &format!("failed to create auth table: {e}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load or generate secret
|
|
||||||
let secret = load_or_generate_secret(secret_paths).await;
|
|
||||||
logging::log("INFO", "AUTH", "secret ready");
|
|
||||||
|
|
||||||
// Load credentials from DB
|
|
||||||
let creds = db_conn.call(|conn| {
|
|
||||||
let login: Option<String> = conn.query_row(
|
|
||||||
"SELECT value FROM auth WHERE key = 'login'", [], |r| r.get(0),
|
|
||||||
).ok();
|
|
||||||
let password: Option<String> = conn.query_row(
|
|
||||||
"SELECT value FROM auth WHERE key = 'password'", [], |r| r.get(0),
|
|
||||||
).ok();
|
|
||||||
Ok::<_, tokio_rusqlite::rusqlite::Error>((login, password))
|
|
||||||
}).await.unwrap_or((None, None));
|
|
||||||
|
|
||||||
let mut s = lock_state();
|
|
||||||
s.secret_paths = secret_paths.to_vec();
|
|
||||||
|
|
||||||
if let (Some(enc_login), Some(enc_pass)) = creds {
|
|
||||||
if let (Some(login), Some(password)) = (
|
|
||||||
decrypt_with_secret(&enc_login, &secret),
|
|
||||||
decrypt_with_secret(&enc_pass, &secret),
|
|
||||||
) {
|
|
||||||
logging::log("INFO", "AUTH", &format!("credentials loaded for user '{login}'"));
|
|
||||||
s.credentials = Some((login, password));
|
|
||||||
} else {
|
|
||||||
logging::log("WARN", "AUTH", "credentials in DB couldn't be decrypted (secret changed?), auth disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.secret = secret;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_or_generate_secret(paths: &[String]) -> String {
|
|
||||||
for path in paths {
|
|
||||||
if let Ok(content) = tokio::fs::read_to_string(path).await {
|
|
||||||
let trimmed = content.trim().to_string();
|
|
||||||
if trimmed.len() == 64 {
|
|
||||||
logging::log("DEBUG", "AUTH", &format!("secret loaded from {path}"));
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let secret = generate_secret();
|
|
||||||
for path in paths {
|
|
||||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
|
||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
|
||||||
}
|
|
||||||
if tokio::fs::write(path, &secret).await.is_ok() {
|
|
||||||
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
|
|
||||||
return secret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logging::log("WARN", "AUTH", "couldn't persist secret to any path, using ephemeral");
|
|
||||||
secret
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set credentials. Encrypts and stores in DB.
|
|
||||||
pub fn set_credentials(login: &str, password: &str) {
|
|
||||||
let (enc_login, enc_pass) = {
|
|
||||||
let mut s = lock_state();
|
|
||||||
if s.secret.is_empty() { return; }
|
|
||||||
let enc_login = xor_with_secret(login, &s.secret);
|
|
||||||
let enc_pass = xor_with_secret(password, &s.secret);
|
|
||||||
s.credentials = Some((login.to_string(), password.to_string()));
|
|
||||||
(enc_login, enc_pass)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(handle) = crate::server::runtime_handle() {
|
|
||||||
handle.spawn(async move {
|
|
||||||
if let Some(conn) = crate::db::get_connection() {
|
|
||||||
let _ = conn.call(move |conn| {
|
|
||||||
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('login', ?1)", [&enc_login])?;
|
|
||||||
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('password', ?1)", [&enc_pass])?;
|
|
||||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
|
||||||
}).await;
|
|
||||||
logging::log("INFO", "AUTH", "credentials saved");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear credentials (disable auth, keep secret).
|
|
||||||
pub fn clear_credentials() {
|
|
||||||
lock_state().credentials = None;
|
|
||||||
|
|
||||||
if let Some(handle) = crate::server::runtime_handle() {
|
|
||||||
handle.spawn(async move {
|
|
||||||
if let Some(conn) = crate::db::get_connection() {
|
|
||||||
let _ = conn.call(|conn| {
|
|
||||||
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
|
|
||||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
|
||||||
}).await;
|
|
||||||
logging::log("INFO", "AUTH", "credentials cleared");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full reset: clear credentials + regenerate secret.
|
|
||||||
pub fn reset() {
|
|
||||||
let paths = {
|
|
||||||
let mut s = lock_state();
|
|
||||||
s.credentials = None;
|
|
||||||
s.secret = generate_secret();
|
|
||||||
let new_secret = s.secret.clone();
|
|
||||||
let paths = s.secret_paths.clone();
|
|
||||||
drop(s);
|
|
||||||
|
|
||||||
// Write new secret to file and clear DB
|
|
||||||
if let Some(handle) = crate::server::runtime_handle() {
|
|
||||||
let paths_clone = paths.clone();
|
|
||||||
handle.spawn(async move {
|
|
||||||
// Write new secret
|
|
||||||
for path in &paths_clone {
|
|
||||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
|
||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
|
||||||
}
|
|
||||||
if tokio::fs::write(path, &new_secret).await.is_ok() {
|
|
||||||
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear credentials from DB
|
|
||||||
if let Some(conn) = crate::db::get_connection() {
|
|
||||||
let _ = conn.call(|conn| {
|
|
||||||
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
|
|
||||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
logging::log("INFO", "AUTH", "auth fully reset with new secret");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
paths
|
|
||||||
};
|
|
||||||
let _ = paths; // suppress unused warning
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if auth is enabled (credentials are set).
|
|
||||||
pub fn has_auth() -> bool {
|
|
||||||
lock_state().credentials.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check an HTTP request's authorization.
|
|
||||||
pub fn check_auth(auth_header: Option<&str>) -> bool {
|
|
||||||
let s = lock_state();
|
|
||||||
let Some((ref login, ref password)) = s.credentials else {
|
|
||||||
return true; // no auth configured
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(header) = auth_header else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bearer token (secret)
|
|
||||||
if let Some(token) = header.strip_prefix("Bearer ") {
|
|
||||||
return token == s.secret;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic auth
|
|
||||||
if let Some(encoded) = header.strip_prefix("Basic ") {
|
|
||||||
if let Ok(decoded) = BASE64.decode(encoded) {
|
|
||||||
if let Ok(pair) = String::from_utf8(decoded) {
|
|
||||||
if let Some((u, p)) = pair.split_once(':') {
|
|
||||||
return u == login && p == password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the secret token (for external integrations).
|
|
||||||
pub fn get_secret() -> String {
|
|
||||||
lock_state().secret.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_xor_roundtrip() {
|
|
||||||
let secret = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
|
||||||
let data = "hello world";
|
|
||||||
let encrypted = xor_with_secret(data, secret);
|
|
||||||
let decrypted = decrypt_with_secret(&encrypted, secret).unwrap();
|
|
||||||
assert_eq!(decrypted, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_secret_length() {
|
|
||||||
let secret = generate_secret();
|
|
||||||
assert_eq!(secret.len(), 64);
|
|
||||||
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_auth_flow() {
|
|
||||||
// Setup
|
|
||||||
{
|
|
||||||
let mut s = lock_state();
|
|
||||||
s.secret = "a".repeat(64);
|
|
||||||
s.credentials = Some(("admin".to_string(), "pass123".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid basic auth
|
|
||||||
let encoded = BASE64.encode("admin:pass123");
|
|
||||||
assert!(check_auth(Some(&format!("Basic {encoded}"))));
|
|
||||||
|
|
||||||
// Wrong password
|
|
||||||
let wrong = BASE64.encode("admin:wrong");
|
|
||||||
assert!(!check_auth(Some(&format!("Basic {wrong}"))));
|
|
||||||
|
|
||||||
// No header
|
|
||||||
assert!(!check_auth(None));
|
|
||||||
|
|
||||||
// Bearer with secret
|
|
||||||
let secret = get_secret();
|
|
||||||
assert!(check_auth(Some(&format!("Bearer {secret}"))));
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
lock_state().credentials = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_auth_allows_all() {
|
|
||||||
lock_state().credentials = None;
|
|
||||||
assert!(check_auth(None));
|
|
||||||
assert!(check_auth(Some("garbage")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in new issue