BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation
This commit is contained in:
1 parent
743502b183
commit
7dfb8c0f1b
100 files changed
+15588
-309
No files matched your search
@@ -1,5 +1,5 @@
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
use crate::models::User;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
};
|
||||
|
||||
// Extractor: Authenticate API requests using Bearer token
|
||||
pub struct ApiUser(pub User);
|
||||
pub struct ApiUser(pub ApiActor);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for ApiUser
|
||||
@@ -25,9 +25,10 @@ where
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
let conn = app_state.admin_db.lock().unwrap();
|
||||
match authenticate_api_key(&conn, auth_header) {
|
||||
Ok(Some(user)) => Ok(ApiUser(user)),
|
||||
let admin_conn = app_state.admin_db.lock().unwrap();
|
||||
let users_conn = app_state.users_db.lock().unwrap();
|
||||
match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
Ok(Some(actor)) => Ok(ApiUser(actor)),
|
||||
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
|
||||
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,4 +6,6 @@ pub mod session;
|
||||
pub use csrf::{generate_csrf_token, verify_csrf};
|
||||
pub use middleware::ApiUser;
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{authenticate_api_key, authenticate_session, generate_token};
|
||||
pub use session::{
|
||||
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
|
||||
};
|
||||
+255
-17
@@ -1,11 +1,11 @@
|
||||
use crate::db::admin::{
|
||||
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
|
||||
get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
|
||||
};
|
||||
use crate::models::User;
|
||||
use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Generate a secure random token (hex-encoded)
|
||||
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
|
||||
hex::encode(key)
|
||||
}
|
||||
|
||||
// Authenticate session from cookies
|
||||
pub fn authenticate_session(
|
||||
// Authenticate administrator session from cookies
|
||||
pub fn authenticate_admin_session(
|
||||
conn: &Connection,
|
||||
jar: &CookieJar,
|
||||
) -> Result<Option<(User, String)>, rusqlite::Error> {
|
||||
@@ -26,7 +26,33 @@ pub fn authenticate_session(
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
let session = match get_session(conn, session_id)? {
|
||||
let session_opt: Option<AdminSession> = conn
|
||||
.query_row(
|
||||
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
|
||||
[session_id],
|
||||
|row| {
|
||||
let id: String = row.get(0)?;
|
||||
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
|
||||
let user_id_str: String = match row.get::<_, String>(1) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let i: i64 = row.get(1)?;
|
||||
i.to_string()
|
||||
}
|
||||
};
|
||||
let expires_at: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(AdminSession {
|
||||
id,
|
||||
user_id: user_id_str,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let session = match session_opt {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
@@ -41,19 +67,171 @@ pub fn authenticate_session(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
|
||||
// Get user (status must be 'active' and account_type = 'admin')
|
||||
// If the session user_id looks numeric, bind as integer when querying users.db
|
||||
// Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB)
|
||||
// Try the extended lookup; if it errors (legacy schema), perform a fallback lookup.
|
||||
let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::<i64>() {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at
|
||||
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
|
||||
[id_i64],
|
||||
|row| {
|
||||
let id_str = row.get::<_, i64>(0)?.to_string();
|
||||
Ok(User {
|
||||
id: id_str,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at
|
||||
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
|
||||
[session.user_id.as_str()],
|
||||
|row| {
|
||||
// admin DB stores UUID string ids, so read as String
|
||||
let id_str: String = row.get(0)?;
|
||||
Ok(User {
|
||||
id: id_str,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
} {
|
||||
Ok(opt) => (opt, false),
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some((user, session.id)));
|
||||
}
|
||||
|
||||
if extended_failed {
|
||||
// Fallback for legacy admin.db schemas which may not have `status`/`account_type` columns
|
||||
// Try a simpler lookup by id only.
|
||||
let fallback_user_opt = if let Ok(id_i64) = session.user_id.parse::<i64>() {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
|
||||
[id_i64],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get::<_, i64>(0)?.to_string(),
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None)
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
|
||||
[session.user_id.as_str()],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None)
|
||||
};
|
||||
|
||||
if let Some(user) = fallback_user_opt {
|
||||
Ok(Some((user, session.id)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate user session from cookies
|
||||
pub fn authenticate_user_session(
|
||||
users_conn: &Connection,
|
||||
jar: &CookieJar,
|
||||
) -> Result<Option<(TenantUser, String)>, rusqlite::Error> {
|
||||
let cookie = match jar.get("bzod_user_session") {
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
|
||||
// Get session from sessions table in users.db
|
||||
let mut stmt = users_conn
|
||||
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
|
||||
let session_opt: Option<UserSession> = stmt
|
||||
.query_row([session_id], |row| {
|
||||
Ok(UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
let session = match session_opt {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Check expiration
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
||||
if expires.with_timezone(&Utc) < Utc::now() {
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get tenant user (status must be 'active')
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([session.user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
Ok(Some((user, session.id)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate API key from Authorization header
|
||||
// Authenticate API key/token from Authorization header (unified)
|
||||
pub fn authenticate_api_key(
|
||||
conn: &Connection,
|
||||
admin_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
auth_header: &str,
|
||||
) -> Result<Option<User>, rusqlite::Error> {
|
||||
) -> Result<Option<ApiActor>, rusqlite::Error> {
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -68,13 +246,73 @@ pub fn authenticate_api_key(
|
||||
hasher.update(key.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
|
||||
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
|
||||
// Update last used timestamp
|
||||
update_api_key_last_used(conn, &api_key_rec.id)?;
|
||||
// 1. Check user API tokens in users.db
|
||||
let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
|
||||
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
|
||||
return Ok(Some(user));
|
||||
if let Some(user_id) = user_id_opt {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some(ApiActor::User(user)));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check admin system API keys in admin.db
|
||||
if let Some(api_key_rec) = get_api_key_by_hash(admin_conn, &hashed_key)? {
|
||||
// Update last used timestamp
|
||||
update_api_key_last_used(admin_conn, &api_key_rec.id)?;
|
||||
|
||||
// Get admin user from users.db (users_conn)
|
||||
// Try to interpret the api_key user_id as an integer referencing users.db
|
||||
if let Ok(user_id_i64) = api_key_rec.user_id.parse::<i64>() {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, created_at
|
||||
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([user_id_i64], |row| {
|
||||
let id_i64: i64 = row.get(0)?;
|
||||
Ok(User {
|
||||
id: id_i64.to_string(),
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some(ApiActor::Admin(user)));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: admin DB may store users with string UUIDs. Try looking up directly in admin_conn.
|
||||
if let Ok(Some(admin_user)) = get_admin_user_by_id(admin_conn, &api_key_rec.user_id) {
|
||||
return Ok(Some(ApiActor::Admin(User {
|
||||
id: admin_user.id,
|
||||
username: admin_user.username,
|
||||
password_hash: admin_user.password_hash,
|
||||
created_at: admin_user.created_at,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user