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, 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>>,
}
redacted_debug!(Operator { id, username, active, created_at, password_changed_at, sessions_revoked_at; password_hash });
#[derive(Model, 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>>,
}
redacted_debug!(User { id, username, is_superuser, active, created_at, data, password_changed_at, sessions_revoked_at; password_hash });
#[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_new_password(values, before, "password_hash")?
else {
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 = "admin")]
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_operators",
derive: admin_hash_password,
}
}
#[cfg(feature = "admin")]
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_operators",
derive: crate::admin::derived_fields::revoke_on_deactivate,
}
}
#[cfg(feature = "admin")]
inventory::submit! {
crate::admin::derived_fields::AdminDerivedField {
table: "rustango_users",
derive: crate::admin::derived_fields::revoke_on_deactivate,
}
}
crate::admin::object_permissions::lock_user_credentials!(User, "rustango_users");
#[cfg(feature = "sso")]
crate::register_admin_superuser_fields!(User, Add, [email]);
#[cfg(feature = "sso")]
crate::register_admin_superuser_fields!(User, Change, [email]);
pub(crate) struct NewUser<'a> {
pub(crate) username: &'a str,
pub(crate) password_hash: String,
pub(crate) is_superuser: bool,
pub(crate) email: Option<&'a str>,
}
impl NewUser<'_> {
pub(crate) async fn insert(self, pool: &crate::sql::Pool) -> Result<User, TenancyError> {
#[cfg(not(feature = "sso"))]
let _ = self.email;
let mut user = User {
id: Auto::default(),
username: self.username.to_owned(),
password_hash: self.password_hash,
#[cfg(feature = "sso")]
email: self.email.map(str::to_owned),
is_superuser: self.is_superuser,
active: true,
created_at: chrono::Utc::now(),
data: serde_json::Value::Object(serde_json::Map::new()),
password_changed_at: None,
sessions_revoked_at: None,
};
user.save_pool(pool).await?;
Ok(user)
}
}
#[must_use = "call `complete` once every factor has passed"]
#[derive(Debug)]
pub struct PasswordVerified<T> {
row: T,
upgrade: Option<String>,
}
impl<T> std::ops::Deref for PasswordVerified<T> {
type Target = T;
fn deref(&self) -> &T {
&self.row
}
}
impl<T> PasswordVerified<T> {
#[cfg(feature = "totp")]
pub(crate) fn upgrade(&self) -> Option<&str> {
self.upgrade.as_deref()
}
#[cfg(feature = "totp")]
pub(crate) fn resume(row: T, upgrade: Option<String>) -> Self {
Self { row, upgrade }
}
}
impl PasswordVerified<User> {
#[must_use]
pub async fn complete(self, pool: &crate::sql::Pool) -> User {
let Self { mut row, upgrade } = self;
if let Some(new) = upgrade {
let id = row.id.get().copied().unwrap_or_default();
row.password_hash = crate::passwords::store_rehash(
pool,
<User as crate::core::Model>::SCHEMA,
id,
&row.password_hash,
new,
)
.await;
}
row
}
#[cfg(feature = "postgres")]
#[must_use]
pub async fn complete_on(self, conn: &mut PgConnection) -> User {
let Self { mut row, upgrade } = self;
if let Some(new) = upgrade {
let model = <User as crate::core::Model>::SCHEMA;
let id = row.id.get().copied().unwrap_or_default();
let q = crate::passwords::rehash_update(model, id, &row.password_hash, &new, None);
let applied = crate::sql::update_on(&mut *conn, &q).await;
row.password_hash =
crate::passwords::rehash_applied(applied, model, id, &row.password_hash, new);
}
row
}
}
impl PasswordVerified<Operator> {
#[must_use]
pub async fn complete(self, registry: &crate::sql::Pool) -> Operator {
let Self { mut row, upgrade } = self;
if let Some(new) = upgrade {
let id = row.id.get().copied().unwrap_or_default();
row.password_hash = crate::passwords::store_rehash(
registry,
<Operator as crate::core::Model>::SCHEMA,
id,
&row.password_hash,
new,
)
.await;
}
row
}
}
#[cfg(feature = "postgres")]
pub async fn authenticate_operator(
registry: &PgPool,
username: &str,
password: &str,
) -> Result<Option<PasswordVerified<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<PasswordVerified<Operator>>, TenancyError> {
let op = find_operator(registry, username).await?;
check_operator_password(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(
op: Option<Operator>,
password: &str,
) -> Result<Option<PasswordVerified<Operator>>, TenancyError> {
let Some(op) = op else {
password::verify_dummy_async(password).await?;
return Ok(None);
};
let password_ok = password::verify_async(password, &op.password_hash).await?;
if !op.active || !password_ok {
return Ok(None);
}
let upgrade = crate::passwords::rehash_async(password, &op.password_hash).await;
Ok(Some(PasswordVerified { row: op, upgrade }))
}
#[cfg(feature = "postgres")]
pub async fn authenticate_user(
conn: &mut PgConnection,
username: &str,
password: &str,
) -> Result<Option<PasswordVerified<User>>, TenancyError> {
let rows: Vec<User> = User::objects()
.where_(User::username.eq(username.to_owned()))
.fetch_on(&mut *conn)
.await?;
check_user_password(rows.into_iter().next(), password).await
}
pub async fn authenticate_user_pool(
pool: &crate::sql::Pool,
username: &str,
password: &str,
) -> Result<Option<PasswordVerified<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?;
check_user_password(rows.into_iter().next(), password).await
}
async fn check_user_password(
user: Option<User>,
password: &str,
) -> Result<Option<PasswordVerified<User>>, TenancyError> {
let Some(user) = user 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 upgrade = crate::passwords::rehash_async(password, &user.password_hash).await;
Ok(Some(PasswordVerified { row: user, upgrade }))
}
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 tenancy_registers_user_field_locks() {
use crate::admin::object_permissions::{superuser_fields, AdminWrite};
let add = superuser_fields("rustango_users", AdminWrite::Add);
assert!(add.contains(&"is_superuser"), "{add:?}");
let change = superuser_fields("rustango_users", AdminWrite::Change);
assert!(change.contains(&"password_hash"), "{change:?}");
}
#[cfg(feature = "admin")]
#[tokio::test]
async fn the_admin_hashes_an_operator_password() {
use crate::core::SqlValue;
let mut values = vec![("password_hash", SqlValue::String("typed-pw-1158".into()))];
crate::admin::derived_fields::apply("rustango_operators", &mut values, None)
.await
.unwrap();
let Some((_, SqlValue::String(hash))) = values.iter().find(|(c, _)| *c == "password_hash")
else {
panic!("no password_hash: {values:?}");
};
let ok = password::verify_async("typed-pw-1158", hash).await;
assert!(ok.unwrap(), "{hash}");
}
#[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}");
}
}