use crate::core::Column as _;
#[cfg(feature = "postgres")]
use crate::sql::sqlx::{PgConnection, PgPool};
#[allow(unused_imports)]
use crate::sql::Auto;
use crate::Model;
use base64::Engine;
use super::error::TenancyError;
use super::password;
#[derive(Model, Debug, Clone)]
#[rustango(table = "rustango_operators", display = "username", scope = "registry")]
#[allow(dead_code)]
pub struct Operator {
#[rustango(primary_key)]
pub id: rustango::sql::Auto<i64>,
#[rustango(max_length = 64, unique)]
pub username: String,
#[rustango(max_length = 255)]
pub password_hash: String,
pub active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
pub sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Model, Debug, Clone)]
#[rustango(
table = "rustango_users",
display = "username",
admin(
list_display = "username, is_superuser, active, created_at",
search_fields = "username",
ordering = "username",
readonly_fields = "created_at, password_changed_at, sessions_revoked_at",
formfield_overrides = "password_hash: password",
)
)]
#[allow(dead_code)]
pub struct User {
#[rustango(primary_key)]
pub id: rustango::sql::Auto<i64>,
#[rustango(max_length = 64, unique)]
pub username: String,
#[rustango(max_length = 255)]
pub password_hash: String,
#[cfg(feature = "sso")]
#[rustango(max_length = 254, unique)]
pub email: Option<String>,
pub is_superuser: bool,
pub active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
pub password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
pub sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[cfg(feature = "admin")]
fn admin_hash_password<'a>(
values: &'a mut Vec<(&'static str, crate::core::SqlValue)>,
before: Option<&'a serde_json::Value>,
) -> crate::admin::derived_fields::DeriveFuture<'a> {
use crate::core::SqlValue;
Box::pin(async move {
let Some(plain) = crate::admin::derived_fields::take_secret(values, "password_hash") else {
return Ok(());
};
if before.and_then(|r| r.get("password_hash")?.as_str()) == Some(plain.as_str()) {
return Ok(());
}
let hash = password::hash_async(&plain)
.await
.map_err(|e| e.to_string())?;
values.push(("password_hash", SqlValue::String(hash)));
if before.is_some() {
values.retain(|(c, _)| *c != "password_changed_at");
values.push((
"password_changed_at",
SqlValue::DateTime(chrono::Utc::now()),
));
}
Ok::<(), String>(())
})
}
#[cfg(feature = "admin")]
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_users",
derive: admin_hash_password,
}
}
#[cfg(feature = "postgres")]
pub async fn authenticate_operator(
registry: &PgPool,
username: &str,
password: &str,
) -> Result<Option<Operator>, TenancyError> {
authenticate_operator_pool(
&crate::sql::Pool::Postgres(registry.clone()),
username,
password,
)
.await
}
pub async fn authenticate_operator_pool(
registry: &crate::sql::Pool,
username: &str,
password: &str,
) -> Result<Option<Operator>, TenancyError> {
let op = find_operator(registry, username).await?;
check_operator_password(registry, op, password).await
}
pub(crate) async fn find_operator(
registry: &crate::sql::Pool,
username: &str,
) -> Result<Option<Operator>, TenancyError> {
use crate::sql::FetcherPool as _;
let rows: Vec<Operator> = Operator::objects()
.where_(Operator::username.eq(username.to_owned()))
.fetch(registry)
.await?;
Ok(rows.into_iter().next())
}
pub(crate) async fn check_operator_password(
registry: &crate::sql::Pool,
op: Option<Operator>,
password: &str,
) -> Result<Option<Operator>, TenancyError> {
let Some(op) = op else {
password::verify_dummy_async(password).await?;
return Ok(None);
};
let mut op = op;
let password_ok = password::verify_async(password, &op.password_hash).await?;
if !op.active || !password_ok {
return Ok(None);
}
let id = op.id.get().copied().unwrap_or_default();
op.password_hash = crate::passwords::upgrade_stored_hash(
registry,
<Operator as crate::core::Model>::SCHEMA,
id,
password,
&op.password_hash,
)
.await;
Ok(Some(op))
}
#[cfg(feature = "postgres")]
pub async fn authenticate_user(
conn: &mut PgConnection,
username: &str,
password: &str,
) -> Result<Option<User>, TenancyError> {
let rows: Vec<User> = User::objects()
.where_(User::username.eq(username.to_owned()))
.fetch_on(&mut *conn)
.await?;
let Some(mut user) = rows.into_iter().next() else {
password::verify_dummy_async(password).await?;
return Ok(None);
};
let password_ok = password::verify_async(password, &user.password_hash).await?;
if !user.active || !password_ok {
return Ok(None);
}
if let Some(new) = crate::passwords::rehash_async(password, &user.password_hash).await {
let id = user.id.get().copied().unwrap_or_default();
let q = crate::passwords::rehash_update(
<User as crate::core::Model>::SCHEMA,
id,
&user.password_hash,
&new,
);
let applied = crate::sql::update_on(&mut *conn, &q).await;
user.password_hash = crate::passwords::rehash_applied(
applied,
<User as crate::core::Model>::SCHEMA,
id,
&user.password_hash,
new,
);
}
Ok(Some(user))
}
pub async fn authenticate_user_pool(
pool: &crate::sql::Pool,
username: &str,
password: &str,
) -> Result<Option<User>, TenancyError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let rows: Vec<User> = User::objects()
.where_(User::username.eq(username.to_owned()))
.fetch(pool)
.await?;
let Some(mut user) = rows.into_iter().next() else {
password::verify_dummy_async(password).await?;
return Ok(None);
};
let password_ok = password::verify_async(password, &user.password_hash).await?;
if !user.active || !password_ok {
return Ok(None);
}
let id = user.id.get().copied().unwrap_or_default();
user.password_hash = crate::passwords::upgrade_stored_hash(
pool,
<User as crate::core::Model>::SCHEMA,
id,
password,
&user.password_hash,
)
.await;
Ok(Some(user))
}
pub trait TenantUserModel: crate::core::Model {}
impl TenantUserModel for User {}
pub const REQUIRED_USER_COLUMNS: &[&str] = &[
"id",
"username",
"password_hash",
"is_superuser",
"active",
"created_at",
"data",
"password_changed_at",
"sessions_revoked_at",
];
pub fn validate_tenant_user_schema(schema: &crate::core::ModelSchema) -> Result<(), TenancyError> {
if schema.table != "rustango_users" {
return Err(TenancyError::Validation(format!(
"TenantUserModel must point at table \"rustango_users\", got \"{}\"",
schema.table
)));
}
for required in REQUIRED_USER_COLUMNS {
if !schema.fields.iter().any(|f| f.column == *required) {
return Err(TenancyError::Validation(format!(
"TenantUserModel \"{}\" is missing required column \"{}\"",
schema.name, required
)));
}
}
Ok(())
}
#[must_use]
pub fn parse_basic_auth(header_value: Option<&str>) -> Option<(String, String)> {
let raw = header_value?;
let encoded = raw.strip_prefix("Basic ")?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.ok()?;
let s = String::from_utf8(decoded).ok()?;
let (user, pass) = s.split_once(':')?;
Some((user.to_owned(), pass.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_auth_decodes_standard_format() {
let v = "Basic YWxpY2U6aHVudGVyMg==";
let (u, p) = parse_basic_auth(Some(v)).unwrap();
assert_eq!(u, "alice");
assert_eq!(p, "hunter2");
}
#[test]
fn parse_basic_auth_rejects_non_basic_scheme() {
assert!(parse_basic_auth(Some("Bearer tokenhere")).is_none());
assert!(parse_basic_auth(Some("Digest qop=auth")).is_none());
}
#[test]
fn parse_basic_auth_rejects_missing_colon() {
let v = "Basic bm8tY29sb24taGVyZQ==";
assert!(parse_basic_auth(Some(v)).is_none());
}
#[test]
fn parse_basic_auth_handles_none_header() {
assert!(parse_basic_auth(None).is_none());
}
#[test]
fn validate_accepts_default_user() {
use crate::core::Model as _;
validate_tenant_user_schema(&User::SCHEMA).unwrap();
}
#[test]
fn validate_rejects_wrong_table() {
use crate::core::Model as _;
let err = validate_tenant_user_schema(&Operator::SCHEMA).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("rustango_users"), "{msg}");
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "rustango_users")]
#[allow(dead_code)]
pub struct MissingDataColumn {
#[rustango(primary_key)]
pub id: rustango::sql::Auto<i64>,
#[rustango(max_length = 64, unique)]
pub username: String,
#[rustango(max_length = 255)]
pub password_hash: String,
pub is_superuser: bool,
pub active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl TenantUserModel for MissingDataColumn {}
#[cfg(feature = "manage")]
#[test]
#[should_panic(expected = "missing required column")]
fn user_model_refuses_a_model_missing_a_column() {
let _ = crate::manage::Cli::new().user_model::<MissingDataColumn>();
}
#[test]
fn validate_rejects_missing_required_column() {
use crate::core::Model as _;
let err = validate_tenant_user_schema(&MissingDataColumn::SCHEMA).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("data"), "{msg}");
}
}