use crate::error::DbError;
use crate::server::authorization::{Role, UserRole};
use crate::storage::StorageEngine;
use crate::sync::log::SyncLog;
use crate::sync::{LogEntry, Operation};
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use dashmap::DashMap;
use lru::LruCache;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
static MAX_LOGIN_ATTEMPTS: Lazy<usize> = Lazy::new(|| {
std::env::var("SOLIDB_MAX_LOGIN_ATTEMPTS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(20)
});
static RATE_LIMIT_WINDOW_SECS: Lazy<u64> = Lazy::new(|| {
std::env::var("SOLIDB_LOGIN_RATE_WINDOW_SECS")
.ok()
.and_then(|value| value.parse().ok())
.filter(|secs| *secs > 0)
.unwrap_or(60)
});
const BASIC_AUTH_CACHE_TTL_SECS: u64 = 60;
const RATE_LIMITER_CAPACITY: usize = 50_000;
const BASIC_AUTH_CACHE_CAPACITY: usize = 10_000;
struct AuthCacheEntry {
claims: Claims,
expires_at: Instant,
}
static LOGIN_RATE_LIMITER: Lazy<Mutex<LruCache<String, Vec<Instant>>>> = Lazy::new(|| {
Mutex::new(LruCache::new(
NonZeroUsize::new(RATE_LIMITER_CAPACITY).unwrap(),
))
});
static BASIC_AUTH_CACHE: Lazy<Mutex<LruCache<String, AuthCacheEntry>>> = Lazy::new(|| {
Mutex::new(LruCache::new(
NonZeroUsize::new(BASIC_AUTH_CACHE_CAPACITY).unwrap(),
))
});
static TRUST_PROXY_HEADERS: Lazy<bool> = Lazy::new(|| {
std::env::var("SOLIDB_TRUST_PROXY_HEADERS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
});
pub fn trust_proxy_headers() -> bool {
*TRUST_PROXY_HEADERS
}
pub fn check_rate_limit(bucket: &str) -> Result<(), crate::error::DbError> {
let now = Instant::now();
let window = std::time::Duration::from_secs(*RATE_LIMIT_WINDOW_SECS);
let mut limiter = LOGIN_RATE_LIMITER.lock();
let Some(attempts) = limiter.get_mut(bucket) else {
return Ok(());
};
attempts.retain(|t| now.duration_since(*t) < window);
if attempts.len() >= *MAX_LOGIN_ATTEMPTS {
let retry_after_secs = attempts
.first()
.map(|oldest| window.saturating_sub(now.duration_since(*oldest)).as_secs() + 1)
.unwrap_or(*RATE_LIMIT_WINDOW_SECS);
return Err(crate::error::DbError::RateLimited(
format!(
"Too many failed login attempts. Please wait {} seconds before trying again.",
retry_after_secs
),
retry_after_secs,
));
}
Ok(())
}
pub fn record_login_failure(bucket: &str) {
let now = Instant::now();
let window = std::time::Duration::from_secs(*RATE_LIMIT_WINDOW_SECS);
let mut limiter = LOGIN_RATE_LIMITER.lock();
let attempts = limiter.get_or_insert_mut(bucket.to_string(), Vec::new);
attempts.retain(|t| now.duration_since(*t) < window);
attempts.push(now);
}
pub fn clear_login_failures(bucket: &str) {
LOGIN_RATE_LIMITER.lock().pop(bucket);
}
fn get_cached_basic_auth(cache_key: &str) -> Option<Claims> {
let mut cache = BASIC_AUTH_CACHE.lock();
if let Some(entry) = cache.get(cache_key) {
if Instant::now() < entry.expires_at {
return Some(entry.claims.clone());
}
cache.pop(cache_key);
}
None
}
fn cache_basic_auth(cache_key: String, claims: Claims) {
let mut cache = BASIC_AUTH_CACHE.lock();
cache.push(
cache_key,
AuthCacheEntry {
claims,
expires_at: Instant::now() + std::time::Duration::from_secs(BASIC_AUTH_CACHE_TTL_SECS),
},
);
}
const ADMIN_DB: &str = "_system";
pub const ADMIN_COLL: &str = "_admins";
pub const API_KEYS_COLL: &str = "_api_keys";
pub const ROLES_COLL: &str = "_roles";
pub const USER_ROLES_COLL: &str = "_user_roles";
const DEFAULT_USER: &str = "admin";
const RBAC_CONFIG_KEY: &str = "rbac_migrated";
static JWT_SECRET: Lazy<String> = Lazy::new(|| {
match std::env::var("JWT_SECRET") {
Ok(secret) => {
if secret.len() < 32 {
tracing::warn!(
"⚠️ JWT_SECRET is less than 32 characters - consider using a longer secret"
);
}
secret
}
Err(_) => {
let mut key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut key_bytes);
let generated = hex::encode(key_bytes);
tracing::warn!("╔══════════════════════════════════════════════════════════════════╗");
tracing::warn!("║ ⚠️ JWT_SECRET environment variable is not set! ║");
tracing::warn!("║ A random secret has been generated for this session. ║");
tracing::warn!("║ All tokens will be INVALID after server restart. ║");
tracing::warn!("║ ║");
tracing::warn!("║ For production, set JWT_SECRET to a secure 32+ character value: ║");
tracing::warn!(
"║ export JWT_SECRET=\"your-secure-random-secret-here\" ║"
);
tracing::warn!("╚══════════════════════════════════════════════════════════════════╝");
generated
}
}
});
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, pub exp: usize, #[serde(skip_serializing_if = "Option::is_none")]
pub livequery: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")]
pub roles: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scoped_databases: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
#[serde(rename = "_key")]
pub username: String,
pub password_hash: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiKey {
#[serde(rename = "_key")]
pub id: String,
pub name: String,
pub key_hash: String,
pub created_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scoped_databases: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ApiKeyResponse {
pub id: String,
pub name: String,
pub key: String, pub created_at: String,
}
#[derive(Debug, Serialize)]
pub struct ApiKeyListItem {
pub id: String,
pub name: String,
pub created_at: String,
pub roles: Vec<String>,
pub scoped_databases: Option<Vec<String>>,
}
pub struct AuthService;
impl AuthService {
pub fn init(
storage: &StorageEngine,
replication_log: Option<&SyncLog>,
data_dir: &str,
) -> Result<(), DbError> {
let _ = JWT_SECRET.len();
let db = storage.get_database(ADMIN_DB)?;
let is_joining_cluster = storage
.cluster_config()
.map(|c| !c.peers.is_empty())
.unwrap_or(false);
let has_override_password = std::env::var("SOLIDB_ADMIN_PASSWORD")
.map(|p| !p.is_empty())
.unwrap_or(false);
let should_skip_defaults = is_joining_cluster && !has_override_password;
if let Err(DbError::CollectionNotFound(_)) = db.system_collection(ADMIN_COLL) {
if should_skip_defaults {
tracing::info!(
"Cluster join detected: Skipping {} creation (waiting for sync)",
ADMIN_COLL
);
} else {
tracing::info!("Creating {} collection", ADMIN_COLL);
db.create_collection(ADMIN_COLL.to_string(), None)?;
}
}
if let Err(DbError::CollectionNotFound(_)) = db.system_collection(API_KEYS_COLL) {
if should_skip_defaults {
tracing::info!(
"Cluster join detected: Skipping {} creation (waiting for sync)",
API_KEYS_COLL
);
} else {
tracing::info!("Creating {} collection", API_KEYS_COLL);
db.create_collection(API_KEYS_COLL.to_string(), None)?;
}
}
if let Ok(collection) = db.system_collection(ADMIN_COLL) {
if collection.count() == 0 {
if should_skip_defaults {
tracing::warn!(
"No admin user, and none will be created: this node has peers, so it \
expects to receive one by sync. If every node was started at once \
nothing will ever arrive and the cluster stays unusable — start the \
first node with no --peer, or set SOLIDB_ADMIN_PASSWORD."
);
} else {
let (password, is_override) = match std::env::var("SOLIDB_ADMIN_PASSWORD") {
Ok(pwd) if !pwd.is_empty() => (pwd, true),
_ => {
let mut password_bytes = [0u8; 16];
OsRng.fill_bytes(&mut password_bytes);
(hex::encode(password_bytes), false)
}
};
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| DbError::InternalError(format!("Hashing error: {}", e)))?
.to_string();
let user = User {
username: DEFAULT_USER.to_string(),
password_hash,
};
let doc_value = serde_json::to_value(user).map_err(|e| {
DbError::InternalError(format!("Serialization error: {}", e))
})?;
collection.insert(doc_value.clone())?;
if let Some(log) = replication_log {
let entry = LogEntry {
sequence: 0,
node_id: "".to_string(), database: ADMIN_DB.to_string(),
collection: ADMIN_COLL.to_string(),
operation: Operation::Insert,
key: DEFAULT_USER.to_string(),
data: serde_json::to_vec(&doc_value).ok(),
timestamp: chrono::Utc::now().timestamp_millis() as u64,
origin_sequence: None,
};
let _ = log.append(entry);
}
if is_override {
tracing::info!(
"Admin user created with password from SOLIDB_ADMIN_PASSWORD env var"
);
} else {
let password_file = format!("{}/.admin_password", data_dir);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&password_file)?;
writeln!(file, "{}", password)?;
}
#[cfg(not(unix))]
{
std::fs::write(&password_file, format!("{}\n", password))?;
}
tracing::warn!(
"╔══════════════════════════════════════════════════════════════════╗"
);
tracing::warn!(
"║ INITIAL ADMIN ACCOUNT CREATED ║"
);
tracing::warn!(
"╠══════════════════════════════════════════════════════════════════╣"
);
tracing::warn!(
"║ Username: admin ║"
);
tracing::warn!(
"║ ║"
);
tracing::warn!("║ ⚠️ PASSWORD SAVED TO: {}", password_file);
tracing::warn!(
"║ ║"
);
tracing::warn!(
"║ ⚠️ SAVE THIS PASSWORD! It will not be shown again. ║"
);
tracing::warn!(
"║ Change it after first login via the API. ║"
);
tracing::warn!(
"╚══════════════════════════════════════════════════════════════════╝"
);
}
}
}
}
Self::init_rbac(storage, replication_log, should_skip_defaults)?;
if let Err(e) = Self::load_api_key_cache(storage) {
tracing::warn!("Failed to pre-warm API key cache: {}", e);
} else {
let (hits, misses, len) = api_key_cache().stats();
tracing::info!(
"API key cache pre-warmed: {} keys loaded (hits={}, misses={})",
len,
hits,
misses
);
}
Ok(())
}
fn init_rbac(
storage: &StorageEngine,
replication_log: Option<&SyncLog>,
should_skip_defaults: bool,
) -> Result<(), DbError> {
let db = storage.get_database(ADMIN_DB)?;
if let Err(DbError::CollectionNotFound(_)) = db.get_collection(ROLES_COLL) {
if should_skip_defaults {
tracing::info!(
"Cluster join detected: Skipping {} creation (waiting for sync)",
ROLES_COLL
);
} else {
tracing::info!("Creating {} collection for RBAC", ROLES_COLL);
db.create_collection(ROLES_COLL.to_string(), None)?;
}
}
if let Err(DbError::CollectionNotFound(_)) = db.get_collection(USER_ROLES_COLL) {
if should_skip_defaults {
tracing::info!(
"Cluster join detected: Skipping {} creation (waiting for sync)",
USER_ROLES_COLL
);
} else {
tracing::info!("Creating {} collection for RBAC", USER_ROLES_COLL);
db.create_collection(USER_ROLES_COLL.to_string(), None)?;
}
}
let config_coll = "_config";
if let Err(DbError::CollectionNotFound(_)) = db.get_collection(config_coll) {
if !should_skip_defaults {
tracing::info!(
"Creating {} collection for system configuration",
config_coll
);
db.create_collection(config_coll.to_string(), None)?;
}
}
if should_skip_defaults {
return Ok(());
}
let already_migrated = if let Ok(config) = db.get_collection(config_coll) {
config.get(RBAC_CONFIG_KEY).is_ok()
} else {
false
};
if already_migrated {
tracing::debug!("RBAC already initialized, skipping migration");
return Ok(());
}
if let Ok(roles_coll) = db.get_collection(ROLES_COLL) {
for role in Role::builtin_roles() {
if roles_coll.get(&role.name).is_err() {
let role_value = serde_json::to_value(&role).map_err(|e| {
DbError::InternalError(format!("Serialization error: {}", e))
})?;
roles_coll.insert(role_value.clone())?;
tracing::info!("Created builtin role: {}", role.name);
if let Some(log) = replication_log {
let entry = LogEntry {
sequence: 0,
node_id: "".to_string(),
database: ADMIN_DB.to_string(),
collection: ROLES_COLL.to_string(),
operation: Operation::Insert,
key: role.name.clone(),
data: serde_json::to_vec(&role_value).ok(),
timestamp: chrono::Utc::now().timestamp_millis() as u64,
origin_sequence: None,
};
let _ = log.append(entry);
}
}
}
}
Self::migrate_existing_users_to_admin(storage, replication_log)?;
Self::migrate_existing_api_keys_to_admin(storage, replication_log)?;
if let Ok(config) = db.get_collection(config_coll) {
let migration_record = serde_json::json!({
"_key": RBAC_CONFIG_KEY,
"migrated_at": chrono::Utc::now().to_rfc3339(),
"version": "1.0"
});
config.insert(migration_record)?;
tracing::info!("RBAC migration completed successfully");
}
Ok(())
}
fn migrate_existing_users_to_admin(
storage: &StorageEngine,
replication_log: Option<&SyncLog>,
) -> Result<(), DbError> {
let db = storage.get_database(ADMIN_DB)?;
let admins_coll = db.system_collection(ADMIN_COLL)?;
let user_roles_coll = db.get_collection(USER_ROLES_COLL)?;
for doc in admins_coll.scan(None) {
let user: User = serde_json::from_value(doc.to_value())
.map_err(|e| DbError::InternalError(format!("Invalid user data: {}", e)))?;
let mut existing_assignment = false;
for d in user_roles_coll.scan(None) {
if let Ok(ur) = serde_json::from_value::<UserRole>(d.to_value()) {
if ur.username == user.username {
existing_assignment = true;
break;
}
}
}
if !existing_assignment {
let user_role = UserRole::new_global(&user.username, "admin", "migration");
let user_role_value = serde_json::to_value(&user_role)
.map_err(|e| DbError::InternalError(format!("Serialization error: {}", e)))?;
user_roles_coll.insert(user_role_value.clone())?;
tracing::info!("Migrated user '{}' to admin role", user.username);
if let Some(log) = replication_log {
let entry = LogEntry {
sequence: 0,
node_id: "".to_string(),
database: ADMIN_DB.to_string(),
collection: USER_ROLES_COLL.to_string(),
operation: Operation::Insert,
key: user_role.id.clone(),
data: serde_json::to_vec(&user_role_value).ok(),
timestamp: chrono::Utc::now().timestamp_millis() as u64,
origin_sequence: None,
};
let _ = log.append(entry);
}
}
}
Ok(())
}
fn migrate_existing_api_keys_to_admin(
storage: &StorageEngine,
replication_log: Option<&SyncLog>,
) -> Result<(), DbError> {
let db = storage.get_database(ADMIN_DB)?;
let api_keys_coll = db.system_collection(API_KEYS_COLL)?;
for doc in api_keys_coll.scan(None) {
let api_key: ApiKey = serde_json::from_value(doc.to_value())
.map_err(|e| DbError::InternalError(format!("Invalid API key data: {}", e)))?;
if api_key.roles.is_empty() {
let mut updated_key = api_key.clone();
updated_key.roles = vec!["admin".to_string()];
let updated_value = serde_json::to_value(&updated_key)
.map_err(|e| DbError::InternalError(format!("Serialization error: {}", e)))?;
api_keys_coll.update(&api_key.id, updated_value.clone())?;
tracing::info!("Migrated API key '{}' to admin role", api_key.name);
if let Some(log) = replication_log {
let entry = LogEntry {
sequence: 0,
node_id: "".to_string(),
database: ADMIN_DB.to_string(),
collection: API_KEYS_COLL.to_string(),
operation: Operation::Update,
key: api_key.id.clone(),
data: serde_json::to_vec(&updated_value).ok(),
timestamp: chrono::Utc::now().timestamp_millis() as u64,
origin_sequence: None,
};
let _ = log.append(entry);
}
}
}
Ok(())
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed_hash = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
}
pub fn hash_password(password: &str) -> Result<String, DbError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| DbError::InternalError(format!("Hashing error: {}", e)))
}
pub fn create_jwt(username: &str) -> Result<String, DbError> {
Self::create_jwt_with_roles(username, None, None)
}
pub fn create_jwt_with_roles(
username: &str,
roles: Option<Vec<String>>,
scoped_databases: Option<Vec<String>>,
) -> Result<String, DbError> {
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| DbError::InternalError("System clock before UNIX epoch".to_string()))?
.as_secs() as usize
+ 24 * 3600;
let claims = Claims {
sub: username.to_owned(),
exp: expiration,
livequery: None,
roles,
scoped_databases,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.map_err(|e| DbError::InternalError(format!("Token creation failed: {}", e)))
}
pub fn create_livequery_jwt(
sub: &str,
roles: Option<Vec<String>>,
scoped_databases: Option<Vec<String>>,
) -> Result<String, DbError> {
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| DbError::InternalError("System clock before UNIX epoch".to_string()))?
.as_secs() as usize
+ 2;
let claims = Claims {
sub: sub.to_owned(),
exp: expiration,
livequery: Some(true),
roles,
scoped_databases,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.map_err(|e| DbError::InternalError(format!("Token creation failed: {}", e)))
}
pub fn validate_token(token: &str) -> Result<Claims, DbError> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(JWT_SECRET.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.map_err(|_| DbError::BadRequest("Invalid token".to_string()))?;
Ok(token_data.claims)
}
pub fn generate_api_key() -> (String, String) {
let mut key_bytes = [0u8; 32];
use rand_core::RngCore;
OsRng.fill_bytes(&mut key_bytes);
let raw_key = format!("sk_{}", hex::encode(key_bytes));
let key_hash = Self::hash_api_key(&raw_key);
(raw_key, key_hash)
}
pub fn hash_api_key(key: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
hex::encode(hasher.finalize())
}
pub fn lookup_api_key(
storage: &StorageEngine,
raw_key: &str,
) -> Option<std::sync::Arc<ApiKey>> {
let incoming_hash = Self::hash_api_key(raw_key);
if let Some(api_key) = api_key_cache().lookup(&incoming_hash) {
return Some(api_key);
}
if !api_key_cache().is_loaded() {
let _ = Self::load_api_key_cache(storage);
}
api_key_cache().lookup(&incoming_hash)
}
pub fn validate_api_key(storage: &StorageEngine, raw_key: &str) -> Result<Claims, DbError> {
let incoming_hash = Self::hash_api_key(raw_key);
if let Some(api_key) = api_key_cache().lookup(&incoming_hash) {
return api_key_to_claims(&api_key);
}
if !api_key_cache().is_loaded() {
let _ = Self::load_api_key_cache(storage);
}
let db = match storage.get_database(ADMIN_DB) {
Ok(db) => db,
Err(_) => return Err(DbError::BadRequest("Invalid API key".to_string())),
};
let collection = match db.system_collection(API_KEYS_COLL) {
Ok(c) => c,
Err(DbError::CollectionNotFound(_)) => {
return Err(DbError::BadRequest("Invalid API key".to_string()));
}
Err(_) => return Err(DbError::BadRequest("Invalid API key".to_string())),
};
for doc in collection.scan(None) {
let api_key: ApiKey = serde_json::from_value(doc.to_value())
.map_err(|_| DbError::InternalError("Corrupted API key data".to_string()))?;
api_key_cache().insert(api_key.clone());
if constant_time_eq(incoming_hash.as_bytes(), api_key.key_hash.as_bytes()) {
return api_key_to_claims(&api_key);
}
}
Err(DbError::BadRequest("Invalid API key".to_string()))
}
pub fn load_api_key_cache(storage: &StorageEngine) -> Result<usize, DbError> {
let mut loaded = 0;
if let Ok(db) = storage.get_database(ADMIN_DB) {
if let Ok(collection) = db.system_collection(API_KEYS_COLL) {
for doc in collection.scan(None) {
if let Ok(api_key) = serde_json::from_value::<ApiKey>(doc.to_value()) {
api_key_cache().insert(api_key);
loaded += 1;
}
}
}
}
api_key_cache().mark_loaded();
Ok(loaded)
}
pub fn get_user_roles(storage: &StorageEngine, username: &str) -> Option<Vec<String>> {
const TTL: std::time::Duration = std::time::Duration::from_secs(30);
if let Some(entry) = USER_ROLES_CACHE.get(username) {
let (roles, at) = entry.value();
if at.elapsed() < TTL {
return roles.clone();
}
}
let db = match storage.get_database(ADMIN_DB) {
Ok(db) => db,
Err(_) => return None,
};
let user_roles_coll = match db.get_collection(USER_ROLES_COLL) {
Ok(coll) => coll,
Err(_) => return None,
};
let mut roles = Vec::new();
for doc in user_roles_coll.scan(None) {
if let Ok(user_role) = serde_json::from_value::<UserRole>(doc.to_value()) {
if user_role.username == username {
roles.push(user_role.role);
}
}
}
let result = if roles.is_empty() { None } else { Some(roles) };
USER_ROLES_CACHE.insert(
username.to_string(),
(result.clone(), std::time::Instant::now()),
);
result
}
pub fn invalidate_user_roles_cache(username: &str) {
USER_ROLES_CACHE.remove(username);
}
}
type CachedUserRoles = (Option<Vec<String>>, std::time::Instant);
static USER_ROLES_CACHE: Lazy<DashMap<String, CachedUserRoles>> = Lazy::new(DashMap::new);
pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.ct_eq(b).unwrap_u8() == 1
}
fn api_key_to_claims(api_key: &ApiKey) -> Result<Claims, DbError> {
if let Some(ref expires_at) = api_key.expires_at {
if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires_at) {
if expiry < chrono::Utc::now() {
return Err(DbError::BadRequest("API key has expired".to_string()));
}
}
}
Ok(Claims {
sub: format!("api-key:{}", api_key.name),
exp: usize::MAX,
livequery: None,
roles: if api_key.roles.is_empty() {
None
} else {
Some(api_key.roles.clone())
},
scoped_databases: api_key.scoped_databases.clone(),
})
}
pub struct ApiKeyCache {
by_hash: DashMap<String, std::sync::Arc<ApiKey>>,
by_id: DashMap<String, String>,
loaded: std::sync::atomic::AtomicBool,
hits: AtomicU64,
misses: AtomicU64,
}
impl ApiKeyCache {
pub fn new() -> Self {
Self {
by_hash: DashMap::new(),
by_id: DashMap::new(),
loaded: std::sync::atomic::AtomicBool::new(false),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
pub fn lookup(&self, key_hash: &str) -> Option<std::sync::Arc<ApiKey>> {
if let Some(v) = self.by_hash.get(key_hash) {
self.hits.fetch_add(1, Ordering::Relaxed);
Some(v.value().clone())
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
pub fn insert(&self, api_key: ApiKey) {
let hash = api_key.key_hash.clone();
let id = api_key.id.clone();
self.by_hash
.insert(hash.clone(), std::sync::Arc::new(api_key));
self.by_id.insert(id, hash);
}
pub fn remove_by_id(&self, id: &str) {
if let Some((_, hash)) = self.by_id.remove(id) {
self.by_hash.remove(&hash);
}
}
pub fn is_loaded(&self) -> bool {
self.loaded.load(Ordering::Acquire)
}
pub fn mark_loaded(&self) {
self.loaded.store(true, Ordering::Release);
}
pub fn clear(&self) {
self.by_hash.clear();
self.by_id.clear();
self.loaded.store(false, Ordering::Release);
}
pub fn stats(&self) -> (u64, u64, usize) {
(
self.hits.load(Ordering::Relaxed),
self.misses.load(Ordering::Relaxed),
self.by_hash.len(),
)
}
}
static API_KEY_CACHE: Lazy<ApiKeyCache> = Lazy::new(ApiKeyCache::new);
impl Default for ApiKeyCache {
fn default() -> Self {
Self::new()
}
}
pub fn api_key_cache() -> &'static ApiKeyCache {
&API_KEY_CACHE
}
pub fn note_replicated_api_key_upsert(doc: &serde_json::Value) {
match serde_json::from_value::<ApiKey>(doc.clone()) {
Ok(api_key) => api_key_cache().insert(api_key),
Err(e) => tracing::warn!("Replicated _api_keys doc did not parse as ApiKey: {}", e),
}
}
pub fn note_replicated_api_key_delete(id: &str) {
api_key_cache().remove_by_id(id);
}
pub(crate) async fn verify_password_blocking(password: &str, hash: &str) -> bool {
let password = password.to_string();
let hash = hash.to_string();
tokio::task::spawn_blocking(move || AuthService::verify_password(&password, &hash))
.await
.unwrap_or(false)
}
pub(crate) async fn hash_password_blocking(password: &str) -> Result<String, DbError> {
let password = password.to_string();
tokio::task::spawn_blocking(move || AuthService::hash_password(&password))
.await
.map_err(|e| DbError::InternalError(format!("hash task failed: {}", e)))?
}
fn livequery_path_allowed(path: &str) -> bool {
path.starts_with("/_api/ws/changefeed") || path.starts_with("/_api/livequery")
}
fn reject_livequery_token(claims: &Claims, path: &str) -> bool {
if claims.livequery == Some(true) && !livequery_path_allowed(path) {
tracing::warn!("livequery token used on non-whitelisted path: {}", path);
return true;
}
false
}
fn refresh_jwt_roles(mut claims: Claims, storage: &StorageEngine) -> Claims {
if claims.livequery == Some(true) {
return claims;
}
let Ok(db) = storage.get_database(ADMIN_DB) else {
return claims;
};
let Ok(coll) = db.system_collection(ADMIN_COLL) else {
return claims;
};
if coll.get(&claims.sub).is_err() {
return claims;
}
claims.roles = AuthService::get_user_roles(storage, &claims.sub);
claims
}
pub async fn auth_middleware(
State(state): State<crate::server::handlers::AppState>,
mut req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let is_internal_cluster_request = req.headers().contains_key("X-Shard-Direct")
|| req.headers().contains_key("X-Scatter-Gather");
if is_internal_cluster_request {
let cluster_secret = state
.storage
.cluster_config()
.and_then(|c| c.keyfile.clone())
.unwrap_or_default();
let provided_secret = req
.headers()
.get("X-Cluster-Secret")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
if cluster_secret.is_empty() {
tracing::warn!(
"CLUSTER AUTH REJECTED: Internal request but no keyfile configured on this node."
);
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
if constant_time_eq(cluster_secret.as_bytes(), provided_secret.as_bytes()) {
let claims = Claims {
sub: "cluster-internal".to_string(),
exp: usize::MAX,
livequery: None,
roles: Some(vec!["admin".to_string()]), scoped_databases: None,
};
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
} else {
tracing::warn!("CLUSTER AUTH FAILURE: Secret mismatch for internal request. Ensure all nodes use the same keyfile.");
}
}
if let Some(api_key) = req.headers().get("X-API-Key").and_then(|h| h.to_str().ok()) {
match AuthService::validate_api_key(&state.storage, api_key) {
Ok(claims) => {
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
Err(_) => return Err(StatusCode::UNAUTHORIZED),
}
}
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(header) = auth_header {
if let Some(api_key) = header.strip_prefix("ApiKey ") {
match AuthService::validate_api_key(&state.storage, api_key) {
Ok(claims) => {
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
Err(_) => return Err(StatusCode::UNAUTHORIZED),
}
}
if let Some(token) = header.strip_prefix("Bearer ") {
match AuthService::validate_token(token) {
Ok(claims) => {
if reject_livequery_token(&claims, req.uri().path()) {
return Err(StatusCode::FORBIDDEN);
}
let claims = refresh_jwt_roles(claims, &state.storage);
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
Err(_) => return Err(StatusCode::UNAUTHORIZED),
}
}
if let Some(encoded) = header.strip_prefix("Basic ") {
if let Ok(decoded) =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
{
if let Ok(credentials) = String::from_utf8(decoded) {
if let Some((username, password)) = credentials.split_once(':') {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
credentials.hash(&mut hasher);
let cache_key = format!("{}:{}", username, hasher.finish());
if let Some(claims) = get_cached_basic_auth(&cache_key) {
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
if let Ok(db) = state.storage.get_database("_system") {
if let Ok(collection) = db.system_collection("_admins") {
if let Ok(doc) = collection.get(username) {
if let Ok(user) = serde_json::from_value::<User>(doc.to_value())
{
if verify_password_blocking(password, &user.password_hash)
.await
{
let roles = AuthService::get_user_roles(
&state.storage,
username,
);
let claims = Claims {
sub: username.to_string(),
exp: usize::MAX,
livequery: None,
roles,
scoped_databases: None,
};
cache_basic_auth(cache_key, claims.clone());
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
}
}
}
}
}
}
}
return Err(StatusCode::UNAUTHORIZED);
}
}
if let Some(query) = req.uri().query() {
if let Ok(params) = serde_urlencoded::from_str::<HashMap<String, String>>(query) {
if let Some(token) = params.get("token") {
let path = req.uri().path();
if !query_token_path_allowed(path) {
tracing::warn!(
"auth token in query string rejected on non-WebSocket path: {}",
path
);
return Err(StatusCode::UNAUTHORIZED);
}
if let Ok(claims) = AuthService::validate_token(token) {
if reject_livequery_token(&claims, path) {
return Err(StatusCode::FORBIDDEN);
}
let claims = refresh_jwt_roles(claims, &state.storage);
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
}
}
}
Err(StatusCode::UNAUTHORIZED)
}
fn query_token_path_allowed(path: &str) -> bool {
matches!(
path,
"/_api/ws/changefeed" | "/_api/cluster/status/ws" | "/_api/monitoring/ws"
)
}
pub async fn permissive_auth_middleware(
State(state): State<crate::server::handlers::AppState>,
mut req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
if let Some(api_key) = req.headers().get("X-API-Key").and_then(|h| h.to_str().ok()) {
match AuthService::validate_api_key(&state.storage, api_key) {
Ok(claims) => {
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
Err(_) => return Err(StatusCode::UNAUTHORIZED),
}
}
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(header) = auth_header {
if let Some(api_key) = header.strip_prefix("ApiKey ") {
match AuthService::validate_api_key(&state.storage, api_key) {
Ok(claims) => {
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
Err(_) => return Err(StatusCode::UNAUTHORIZED),
}
}
if let Some(token) = header.strip_prefix("Bearer ") {
match AuthService::validate_token(token) {
Ok(claims) => {
if reject_livequery_token(&claims, req.uri().path()) {
return Err(StatusCode::FORBIDDEN);
}
let claims = refresh_jwt_roles(claims, &state.storage);
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
Err(_) => return Err(StatusCode::UNAUTHORIZED),
}
}
if let Some(encoded) = header.strip_prefix("Basic ") {
if let Ok(decoded) =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
{
if let Ok(credentials) = String::from_utf8(decoded) {
if let Some((username, password)) = credentials.split_once(':') {
if let Ok(db) = state.storage.get_database("_system") {
if let Ok(collection) = db.system_collection("_admins") {
if let Ok(doc) = collection.get(username) {
if let Ok(user) = serde_json::from_value::<User>(doc.to_value())
{
if verify_password_blocking(password, &user.password_hash)
.await
{
let roles = AuthService::get_user_roles(
&state.storage,
username,
);
let claims = Claims {
sub: username.to_string(),
exp: usize::MAX,
livequery: None,
roles,
scoped_databases: None,
};
req.extensions_mut().insert(claims);
return Ok(next.run(req).await);
}
}
}
}
}
}
}
}
return Err(StatusCode::UNAUTHORIZED);
}
}
let method = req.method().clone();
let path = req.uri().path().to_string();
let peer = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.or_else(|| {
req.headers()
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "unknown".to_string());
tracing::warn!(
target: "audit",
event = "anonymous_access",
method = %method,
path = %path,
peer = %peer,
"permissive_auth: anonymous request to script endpoint"
);
Ok(next.run(req).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let password = "test_password_123";
let hash = AuthService::hash_password(password).unwrap();
assert!(!hash.is_empty());
assert!(AuthService::verify_password(password, &hash));
assert!(!AuthService::verify_password("wrong_password", &hash));
}
#[test]
fn test_verify_password_invalid_hash() {
assert!(!AuthService::verify_password("password", "invalid_hash"));
}
#[test]
fn test_create_and_validate_jwt() {
let token = AuthService::create_jwt("testuser").unwrap();
assert!(!token.is_empty());
let claims = AuthService::validate_token(&token).unwrap();
assert_eq!(claims.sub, "testuser");
assert!(claims.exp > 0);
assert!(claims.livequery.is_none());
}
#[test]
fn test_validate_invalid_token() {
let result = AuthService::validate_token("invalid.token.here");
assert!(result.is_err());
}
#[test]
fn query_token_only_on_websocket_upgrade_paths() {
assert!(query_token_path_allowed("/_api/ws/changefeed"));
assert!(query_token_path_allowed("/_api/cluster/status/ws"));
assert!(query_token_path_allowed("/_api/monitoring/ws"));
assert!(!query_token_path_allowed("/_api/livequery/token"));
assert!(!query_token_path_allowed("/_api/livequery"));
assert!(!query_token_path_allowed("/_api/databases"));
}
#[test]
fn test_create_livequery_jwt() {
let token =
AuthService::create_livequery_jwt("alice", Some(vec!["viewer".to_string()]), None)
.unwrap();
let claims = AuthService::validate_token(&token).unwrap();
assert_eq!(claims.sub, "alice");
assert_eq!(claims.roles, Some(vec!["viewer".to_string()]));
assert_eq!(claims.livequery, Some(true));
}
#[test]
fn test_generate_api_key() {
let (raw_key, hash) = AuthService::generate_api_key();
assert!(raw_key.starts_with("sk_"));
assert_eq!(raw_key.len(), 67);
assert_eq!(hash.len(), 64);
let hash2 = AuthService::hash_api_key(&raw_key);
assert_eq!(hash, hash2);
}
#[test]
fn test_api_key_uniqueness() {
let (key1, _) = AuthService::generate_api_key();
let (key2, _) = AuthService::generate_api_key();
assert_ne!(key1, key2);
}
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq(b"test", b"test"));
assert!(!constant_time_eq(b"test", b"Test"));
assert!(!constant_time_eq(b"test", b"testing"));
assert!(!constant_time_eq(b"short", b"longer_string"));
}
#[test]
fn test_claims_struct() {
let claims = Claims {
sub: "user1".to_string(),
exp: 12345,
livequery: Some(true),
roles: Some(vec!["admin".to_string()]),
scoped_databases: None,
};
assert_eq!(claims.sub, "user1");
assert_eq!(claims.exp, 12345);
assert_eq!(claims.livequery, Some(true));
assert_eq!(claims.roles, Some(vec!["admin".to_string()]));
assert_eq!(claims.scoped_databases, None);
}
#[test]
fn test_user_struct() {
let user = User {
username: "admin".to_string(),
password_hash: "hash123".to_string(),
};
assert_eq!(user.username, "admin");
assert_eq!(user.password_hash, "hash123");
}
#[test]
fn test_api_key_struct() {
let api_key = ApiKey {
id: "key1".to_string(),
name: "My Key".to_string(),
key_hash: "hash123".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
roles: vec!["admin".to_string()],
scoped_databases: Some(vec!["db1".to_string()]),
expires_at: None,
};
assert_eq!(api_key.id, "key1");
assert_eq!(api_key.name, "My Key");
assert_eq!(api_key.roles, vec!["admin".to_string()]);
assert_eq!(api_key.scoped_databases, Some(vec!["db1".to_string()]));
}
#[test]
fn test_claims_serialization() {
let claims = Claims {
sub: "user".to_string(),
exp: 1000,
livequery: None,
roles: None,
scoped_databases: None,
};
let json = serde_json::to_string(&claims).unwrap();
assert!(json.contains("user"));
assert!(json.contains("1000"));
assert!(!json.contains("livequery"));
assert!(!json.contains("roles"));
assert!(!json.contains("scoped_databases"));
let deserialized: Claims = serde_json::from_str(&json).unwrap();
assert_eq!(claims.sub, deserialized.sub);
let claims_with_roles = Claims {
sub: "user".to_string(),
exp: 1000,
livequery: None,
roles: Some(vec!["admin".to_string(), "editor".to_string()]),
scoped_databases: Some(vec!["db1".to_string()]),
};
let json = serde_json::to_string(&claims_with_roles).unwrap();
assert!(json.contains("roles"));
assert!(json.contains("admin"));
assert!(json.contains("scoped_databases"));
}
#[test]
fn test_check_rate_limit_initial() {
let result = check_rate_limit("192.168.1.1_test|admin");
assert!(result.is_ok());
}
#[test]
fn test_rate_limit_only_counts_failures() {
let bucket = "10.0.0.1_test|alice";
for _ in 0..(*MAX_LOGIN_ATTEMPTS * 10) {
assert!(check_rate_limit(bucket).is_ok());
}
}
#[test]
fn test_rate_limit_blocks_after_max_failures_then_clears() {
let bucket = "10.0.0.2_test|bob";
for _ in 0..*MAX_LOGIN_ATTEMPTS {
assert!(check_rate_limit(bucket).is_ok());
record_login_failure(bucket);
}
match check_rate_limit(bucket) {
Err(crate::error::DbError::RateLimited(msg, retry_after_secs)) => {
assert!(msg.contains("Too many failed login attempts"));
assert!(retry_after_secs >= 1);
assert!(retry_after_secs <= *RATE_LIMIT_WINDOW_SECS + 1);
}
other => panic!("expected RateLimited, got {:?}", other),
}
clear_login_failures(bucket);
assert!(check_rate_limit(bucket).is_ok());
}
#[test]
fn test_password_hash_different_each_time() {
let password = "same_password";
let hash1 = AuthService::hash_password(password).unwrap();
let hash2 = AuthService::hash_password(password).unwrap();
assert_ne!(hash1, hash2);
assert!(AuthService::verify_password(password, &hash1));
assert!(AuthService::verify_password(password, &hash2));
}
}