@ -4,17 +4,30 @@
//! Credentials (login/password) are XOR-encrypted with the secret and stored in
//! 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.
//! a separate `auth` DB table that modules cannot access through the kv API.
use std ::sync ::OnceLock ;
use std ::sync ::{ Mutex , OnceLock } ;
use rand ::Rng ;
use rand ::Rng ;
use base64 ::{ Engine as _ , engine ::general_purpose ::STANDARD as BASE64 } ;
use base64 ::{ Engine as _ , engine ::general_purpose ::STANDARD as BASE64 } ;
use crate ::logging ;
use crate ::logging ;
static SECRET : OnceLock < String > = OnceLock ::new ( ) ;
struct AuthState {
static CREDENTIALS : OnceLock < std ::sync ::Mutex < Option < ( String , String ) > > > = OnceLock ::new ( ) ;
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 credentials ( ) -> & ' static std ::sync ::Mutex < Option < ( String , String ) > > {
fn lock_state( ) -> std ::sync ::MutexGuard < ' static , AuthState > {
CREDENTIALS . get_or_init ( | | std ::sync ::Mutex ::new ( None ) )
state( ) . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) )
}
}
/// Generate a 32-byte hex secret.
/// Generate a 32-byte hex secret.
@ -43,8 +56,6 @@ fn decrypt_with_secret(encoded: &str, secret: &str) -> Option<String> {
}
}
/// Initialize auth: load or generate secret, load credentials from DB.
/// Initialize auth: load or generate secret, load credentials from DB.
/// Must be called after DB is initialized.
/// `secret_paths`: list of paths to try for secret file (first writable wins).
pub async fn init ( secret_paths : & [ String ] , db_conn : & tokio_rusqlite ::Connection ) {
pub async fn init ( secret_paths : & [ String ] , db_conn : & tokio_rusqlite ::Connection ) {
// Create auth table
// Create auth table
if let Err ( e ) = db_conn . call ( | conn | {
if let Err ( e ) = db_conn . call ( | conn | {
@ -72,23 +83,25 @@ pub async fn init(secret_paths: &[String], db_conn: &tokio_rusqlite::Connection)
Ok ::< _ , tokio_rusqlite ::rusqlite ::Error > ( ( login , password ) )
Ok ::< _ , tokio_rusqlite ::rusqlite ::Error > ( ( login , password ) )
} ) . await . unwrap_or ( ( None , None ) ) ;
} ) . 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 ( enc_login ) , Some ( enc_pass ) ) = creds {
if let ( Some ( login ) , Some ( password ) ) = (
if let ( Some ( login ) , Some ( password ) ) = (
decrypt_with_secret ( & enc_login , & secret ) ,
decrypt_with_secret ( & enc_login , & secret ) ,
decrypt_with_secret ( & enc_pass , & secret ) ,
decrypt_with_secret ( & enc_pass , & secret ) ,
) {
) {
* credentials ( ) . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) = Some ( ( login . clone ( ) , password ) ) ;
logging ::log ( "INFO" , "AUTH" , & format! ( "credentials loaded for user '{login}'" ) ) ;
logging ::log ( "INFO" , "AUTH" , & format! ( "credentials loaded for user '{login}'" ) ) ;
s . credentials = Some ( ( login , password ) ) ;
} else {
} else {
logging ::log ( "WARN" , "AUTH" , "credentials in DB couldn't be decrypted (secret changed?), auth disabled" ) ;
logging ::log ( "WARN" , "AUTH" , "credentials in DB couldn't be decrypted (secret changed?), auth disabled" ) ;
}
}
}
}
SECRET. set ( secret ) . ok ( ) ;
s. secret = secret ;
}
}
async fn load_or_generate_secret ( paths : & [ String ] ) -> String {
async fn load_or_generate_secret ( paths : & [ String ] ) -> String {
// Try to read existing secret from any path
for path in paths {
for path in paths {
if let Ok ( content ) = tokio ::fs ::read_to_string ( path ) . await {
if let Ok ( content ) = tokio ::fs ::read_to_string ( path ) . await {
let trimmed = content . trim ( ) . to_string ( ) ;
let trimmed = content . trim ( ) . to_string ( ) ;
@ -99,7 +112,6 @@ async fn load_or_generate_secret(paths: &[String]) -> String {
}
}
}
}
// Generate new secret and try to write it
let secret = generate_secret ( ) ;
let secret = generate_secret ( ) ;
for path in paths {
for path in paths {
if let Some ( parent ) = std ::path ::Path ::new ( path ) . parent ( ) {
if let Some ( parent ) = std ::path ::Path ::new ( path ) . parent ( ) {
@ -115,31 +127,23 @@ async fn load_or_generate_secret(paths: &[String]) -> String {
secret
secret
}
}
/// Set credentials (called from Lua FFI) . Encrypts and stores in DB.
/// Set credentials . Encrypts and stores in DB.
pub fn set_credentials ( login : & str , password : & str ) {
pub fn set_credentials ( login : & str , password : & str ) {
let Some ( secret ) = SECRET . get ( ) else { return } ;
let ( enc_login , enc_pass ) = {
let mut s = lock_state ( ) ;
let enc_login = xor_with_secret ( login , secret ) ;
if s . secret . is_empty ( ) { return ; }
let enc_pass = xor_with_secret ( password , secret ) ;
let enc_login = xor_with_secret ( login , & s . secret ) ;
let enc_pass = xor_with_secret ( password , & s . secret ) ;
* credentials ( ) . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) =
s . credentials = Some ( ( login . to_string ( ) , password . to_string ( ) ) ) ;
Some ( ( login . to_string ( ) , password . to_string ( ) ) ) ;
( enc_login , enc_pass )
} ;
// Store in DB async
let enc_login = enc_login . clone ( ) ;
let enc_pass = enc_pass . clone ( ) ;
if let Some ( handle ) = crate ::server ::runtime_handle ( ) {
if let Some ( handle ) = crate ::server ::runtime_handle ( ) {
handle . spawn ( async move {
handle . spawn ( async move {
if let Some ( conn ) = crate ::db ::get_connection ( ) {
if let Some ( conn ) = crate ::db ::get_connection ( ) {
let _ = conn . call ( move | conn | {
let _ = conn . call ( move | conn | {
conn . execute (
conn . execute ( "INSERT OR REPLACE INTO auth (key, value) VALUES ('login', ?1)" , [ & enc_login ] ) ? ;
"INSERT OR REPLACE INTO auth (key, value) VALUES ('login', ?1)" ,
conn . execute ( "INSERT OR REPLACE INTO auth (key, value) VALUES ('password', ?1)" , [ & enc_pass ] ) ? ;
[ & enc_login ] ,
) ? ;
conn . execute (
"INSERT OR REPLACE INTO auth (key, value) VALUES ('password', ?1)" ,
[ & enc_pass ] ,
) ? ;
Ok ::< _ , tokio_rusqlite ::rusqlite ::Error > ( ( ) )
Ok ::< _ , tokio_rusqlite ::rusqlite ::Error > ( ( ) )
} ) . await ;
} ) . await ;
logging ::log ( "INFO" , "AUTH" , "credentials saved" ) ;
logging ::log ( "INFO" , "AUTH" , "credentials saved" ) ;
@ -148,9 +152,9 @@ pub fn set_credentials(login: &str, password: &str) {
}
}
}
}
/// Clear credentials .
/// Clear credentials (disable auth, keep secret) .
pub fn clear_credentials ( ) {
pub fn clear_credentials ( ) {
* credentials ( ) . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) = None ;
lock_state ( ) . credentials = None ;
if let Some ( handle ) = crate ::server ::runtime_handle ( ) {
if let Some ( handle ) = crate ::server ::runtime_handle ( ) {
handle . spawn ( async move {
handle . spawn ( async move {
@ -165,18 +169,55 @@ pub fn clear_credentials() {
}
}
}
}
/// 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).
/// Check if auth is enabled (credentials are set).
pub fn has_auth ( ) -> bool {
pub fn has_auth ( ) -> bool {
credentials ( ) . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) . is_some ( )
lock_state( ) . credentials . is_some ( )
}
}
/// Check an HTTP request's authorization.
/// Check an HTTP request's authorization.
/// Returns true if authorized (no auth configured, or valid credentials/token).
pub fn check_auth ( auth_header : Option < & str > ) -> bool {
pub fn check_auth ( auth_header : Option < & str > ) -> bool {
// No auth configured → allow all
let s = lock_state ( ) ;
let creds = credentials ( ) . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) ;
let Some ( ( ref login , ref password ) ) = s . credentials else {
let Some ( ( ref login , ref password ) ) = * creds else {
return true ; // no auth configured
return true ;
} ;
} ;
let Some ( header ) = auth_header else {
let Some ( header ) = auth_header else {
@ -185,9 +226,7 @@ pub fn check_auth(auth_header: Option<&str>) -> bool {
// Bearer token (secret)
// Bearer token (secret)
if let Some ( token ) = header . strip_prefix ( "Bearer " ) {
if let Some ( token ) = header . strip_prefix ( "Bearer " ) {
if let Some ( secret ) = SECRET . get ( ) {
return token = = s . secret ;
return token = = secret ;
}
}
}
// Basic auth
// Basic auth
@ -204,9 +243,9 @@ pub fn check_auth(auth_header: Option<&str>) -> bool {
false
false
}
}
/// Get the secret token (for WebSocket query param auth ).
/// Get the secret token (for external integrations ).
pub fn get_secret ( ) -> Option< & ' static String> {
pub fn get_secret ( ) -> String {
SECRET. get ( )
lock_state( ) . secret . clone ( )
}
}
#[ cfg(test) ]
#[ cfg(test) ]
@ -222,27 +261,21 @@ mod tests {
assert_eq! ( decrypted , data ) ;
assert_eq! ( decrypted , data ) ;
}
}
#[ test ]
fn test_check_auth_no_config ( ) {
// When no credentials set, everything passes
// (credentials() defaults to None)
// Note: in test environment, CREDENTIALS may have state from other tests
// This test verifies the logic path
assert! ( check_auth ( None ) | | has_auth ( ) ) ;
}
#[ test ]
#[ test ]
fn test_generate_secret_length ( ) {
fn test_generate_secret_length ( ) {
let secret = generate_secret ( ) ;
let secret = generate_secret ( ) ;
assert_eq! ( secret . len ( ) , 64 ) ; // 32 bytes * 2 hex chars
assert_eq! ( secret . len ( ) , 64 ) ;
assert! ( secret . chars ( ) . all ( | c | c . is_ascii_hexdigit ( ) ) ) ;
assert! ( secret . chars ( ) . all ( | c | c . is_ascii_hexdigit ( ) ) ) ;
}
}
#[ test ]
#[ test ]
fn test_basic_auth_parse ( ) {
fn test_auth_flow ( ) {
// Set up test state
// Setup
let _ = SECRET . set ( "a" . repeat ( 64 ) ) ;
{
* credentials ( ) . lock ( ) . unwrap ( ) = Some ( ( "admin" . to_string ( ) , "pass123" . to_string ( ) ) ) ;
let mut s = lock_state ( ) ;
s . secret = "a" . repeat ( 64 ) ;
s . credentials = Some ( ( "admin" . to_string ( ) , "pass123" . to_string ( ) ) ) ;
}
// Valid basic auth
// Valid basic auth
let encoded = BASE64 . encode ( "admin:pass123" ) ;
let encoded = BASE64 . encode ( "admin:pass123" ) ;
@ -256,10 +289,17 @@ mod tests {
assert! ( ! check_auth ( None ) ) ;
assert! ( ! check_auth ( None ) ) ;
// Bearer with secret
// Bearer with secret
let secret = SECRET. get ( ) . unwrap ( ) ;
let secret = get_secret ( ) ;
assert! ( check_auth ( Some ( & format! ( "Bearer {secret}" ) ) ) ) ;
assert! ( check_auth ( Some ( & format! ( "Bearer {secret}" ) ) ) ) ;
// Clean up
// Clean up
* credentials ( ) . lock ( ) . unwrap ( ) = None ;
lock_state ( ) . credentials = None ;
}
#[ test ]
fn test_no_auth_allows_all ( ) {
lock_state ( ) . credentials = None ;
assert! ( check_auth ( None ) ) ;
assert! ( check_auth ( Some ( "garbage" ) ) ) ;
}
}
}
}