use crate::sql::Pool;
use crate::totp::TotpSecret;
use crate::Model;
#[derive(Model, Debug, Clone)]
#[rustango(table = "rustango_admin_totp", managed = false)]
#[allow(dead_code)]
pub struct AdminTotp {
#[rustango(primary_key)]
pub user_id: i64,
#[rustango(max_length = 64)]
pub secret_base32: String,
#[rustango(default = "false")]
pub confirmed: bool,
#[rustango(default = "now()")]
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_step: Option<i64>,
}
const STEP_SECS: u64 = 30;
const DIGITS: u32 = 6;
const WINDOW: i64 = 1;
pub async fn ensure_table(pool: &Pool) -> Result<(), sqlx::Error> {
use crate::core::Model as _;
let snapshot = crate::migrate::SchemaSnapshot::from_models_forced(&[AdminTotp::SCHEMA]);
crate::migrate::apply_idempotent(pool, &snapshot).await?;
add_new_columns(pool).await
}
async fn add_new_columns(pool: &Pool) -> Result<(), sqlx::Error> {
use crate::core::Model as _;
let snapshot = crate::migrate::SchemaSnapshot::from_models_forced(&[AdminTotp::SCHEMA]);
crate::migrate::add_columns_idempotent(
pool,
&snapshot,
&[(AdminTotp::SCHEMA.table, "last_used_step")],
)
.await
}
pub async fn redeem_code(
pool: &Pool,
user_id: i64,
secret: &TotpSecret,
code: &str,
) -> Result<bool, crate::sql::ExecError> {
redeem(pool, user_id, secret, code, false).await
}
pub async fn confirm_with_code(
pool: &Pool,
user_id: i64,
secret: &TotpSecret,
code: &str,
) -> Result<bool, crate::sql::ExecError> {
redeem(pool, user_id, secret, code, true).await
}
async fn redeem(
pool: &Pool,
user_id: i64,
secret: &TotpSecret,
code: &str,
confirm: bool,
) -> Result<bool, crate::sql::ExecError> {
use crate::query::Q;
use crate::sql::UpdaterPool as _;
let Some(step) = crate::totp::matched_step(secret, code, STEP_SECS, DIGITS, WINDOW) else {
return Ok(false);
};
let step = i64::try_from(step).unwrap_or(i64::MAX);
let update = || {
let q = AdminTotp::objects()
.filter("user_id", user_id)
.filter("secret_base32", secret.to_base32())
.where_(Q::is_null("last_used_step") | Q::lt("last_used_step", step))
.update()
.set("last_used_step", step);
let q = if confirm { q.set("confirmed", true) } else { q };
q.execute_pool(pool)
};
let updated = match update().await {
Ok(n) => n,
Err(first) => match add_new_columns(pool).await {
Ok(()) => update().await?,
Err(_) => return Err(first),
},
};
Ok(updated == 1)
}
pub async fn device(pool: &Pool, user_id: i64) -> Option<AdminTotp> {
use crate::sql::FetcherPool as _;
AdminTotp::objects()
.filter("user_id", user_id)
.fetch(pool)
.await
.ok()
.and_then(|rows| rows.into_iter().next())
}
pub async fn confirmed_secret(pool: &Pool, user_id: i64) -> Option<TotpSecret> {
confirmed_secret_checked(pool, user_id).await.ok().flatten()
}
pub async fn confirmed_secret_checked(
pool: &Pool,
user_id: i64,
) -> Result<Option<TotpSecret>, crate::sql::ExecError> {
let secrets: Vec<String> = AdminTotp::objects()
.filter("user_id", user_id)
.filter("confirmed", true)
.values_list_flat("secret_base32")
.fetch(pool)
.await?;
Ok(secrets.first().and_then(|s| TotpSecret::from_base32(s)))
}
pub async fn start_enrollment(
pool: &Pool,
user_id: i64,
secret: &TotpSecret,
) -> Result<(), crate::sql::ExecError> {
let del = AdminTotp::objects()
.filter("user_id", user_id)
.compile_delete()?;
crate::sql::delete_pool(pool, &del).await?;
let row = AdminTotp {
user_id,
secret_base32: secret.to_base32(),
confirmed: false,
created_at: chrono::Utc::now(),
last_used_step: None,
};
row.insert_pool(pool).await?;
Ok(())
}
pub async fn confirm(pool: &Pool, user_id: i64) -> Result<(), crate::sql::ExecError> {
use crate::sql::UpdaterPool as _;
AdminTotp::objects()
.filter("user_id", user_id)
.update()
.set("confirmed", true)
.execute_pool(pool)
.await?;
Ok(())
}