use crate::sql::Pool;
use crate::totp::TotpSecret;
use crate::Model;
#[derive(Model, 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>,
#[rustango(max_length = 64)]
pub pending_secret_base32: Option<String>,
}
impl std::fmt::Debug for AdminTotp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdminTotp")
.field("user_id", &self.user_id)
.field("secret_base32", &"<redacted>")
.field("confirmed", &self.confirmed)
.field("created_at", &self.created_at)
.field("last_used_step", &self.last_used_step)
.field(
"pending_secret_base32",
&self.pending_secret_base32.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
impl AdminTotp {
pub fn pending_secret(&self) -> Option<TotpSecret> {
match &self.pending_secret_base32 {
Some(s) => TotpSecret::from_base32(s),
None if !self.confirmed => TotpSecret::from_base32(&self.secret_base32),
None => None,
}
}
}
const STEP_SECS: u64 = 30;
const DIGITS: u32 = 6;
const WINDOW: i64 = 1;
pub async fn ensure_table(pool: &Pool) -> Result<(), sqlx::Error> {
crate::migrate::apply_idempotent(pool, &bootstrap_snapshot()).await?;
add_new_columns(pool).await
}
pub(crate) fn bootstrap_snapshot() -> crate::migrate::SchemaSnapshot {
use crate::core::Model as _;
crate::migrate::SchemaSnapshot::from_models_forced(&[AdminTotp::SCHEMA])
}
async fn add_new_columns(pool: &Pool) -> Result<(), sqlx::Error> {
use crate::core::Model as _;
crate::migrate::add_columns_idempotent(
pool,
&bootstrap_snapshot(),
&[
(AdminTotp::SCHEMA.table, "last_used_step"),
(AdminTotp::SCHEMA.table, "pending_secret_base32"),
],
)
.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> {
redeem_in::<AdminTotp>(pool, user_id, &Stored::plain(secret), code, confirm).await
}
pub(crate) trait DeviceTable: crate::core::Model + Send + 'static {
const OWNER: &'static str;
const SECRET: &'static str;
const PENDING: &'static str;
fn seal(owner: i64, secret: &TotpSecret) -> Result<String, crate::sql::ExecError>;
fn open(owner: i64, stored: &str) -> Option<TotpSecret>;
fn upgrade(pool: &Pool) -> impl std::future::Future<Output = Result<(), sqlx::Error>> + Send;
}
impl DeviceTable for AdminTotp {
const OWNER: &'static str = "user_id";
const SECRET: &'static str = "secret_base32";
const PENDING: &'static str = "pending_secret_base32";
fn seal(_: i64, secret: &TotpSecret) -> Result<String, crate::sql::ExecError> {
Ok(secret.to_base32())
}
fn open(_: i64, stored: &str) -> Option<TotpSecret> {
TotpSecret::from_base32(stored)
}
fn upgrade(pool: &Pool) -> impl std::future::Future<Output = Result<(), sqlx::Error>> + Send {
add_new_columns(pool)
}
}
pub(crate) struct Stored {
pub(crate) secret: TotpSecret,
text: String,
}
impl Stored {
fn plain(secret: &TotpSecret) -> Self {
Self {
secret: secret.clone(),
text: secret.to_base32(),
}
}
fn read<M: DeviceTable>(owner: i64, text: String) -> Option<Self> {
M::open(owner, &text).map(|secret| Self { secret, text })
}
}
pub(crate) async fn redeem_in<M: DeviceTable>(
pool: &Pool,
owner: i64,
secret: &Stored,
code: &str,
confirm: bool,
) -> Result<bool, crate::sql::ExecError> {
let Some(step) = crate::totp::matched_step(&secret.secret, code, STEP_SECS, DIGITS, WINDOW)
else {
return Ok(false);
};
let step = i64::try_from(step).unwrap_or(i64::MAX);
let updated = match write_step::<M>(pool, owner, secret, step, confirm).await {
Ok(n) => n,
Err(first) => match M::upgrade(pool).await {
Ok(()) => write_step::<M>(pool, owner, secret, step, confirm).await?,
Err(_) => return Err(first),
},
};
Ok(updated == 1)
}
async fn write_step<M: DeviceTable>(
pool: &Pool,
owner: i64,
secret: &Stored,
step: i64,
confirm: bool,
) -> Result<u64, crate::sql::ExecError> {
use crate::query::{QuerySet, Q};
use crate::sql::UpdaterPool as _;
if confirm {
let promoted = QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter(M::PENDING, secret.text.clone())
.update()
.set(M::SECRET, secret.text.clone())
.set(M::PENDING, None::<String>)
.set("confirmed", true)
.set("last_used_step", step)
.execute_pool(pool)
.await?;
if promoted == 1 {
return Ok(promoted);
}
}
let q = QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter(M::SECRET, secret.text.clone());
let q = if confirm {
q.filter("confirmed", false)
} else {
q
};
let q = q
.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.set(M::PENDING, None::<String>)
};
q.execute_pool(pool).await
}
pub(crate) async fn confirmed_in<M: DeviceTable>(
pool: &Pool,
owner: i64,
) -> Result<Option<Stored>, crate::sql::ExecError> {
let secrets: Vec<String> = crate::query::QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter("confirmed", true)
.values_list_flat(M::SECRET)
.fetch(pool)
.await?;
secrets
.into_iter()
.next()
.map(|text| {
Stored::read::<M>(owner, text).ok_or_else(|| {
crate::sql::ExecError::Driver(crate::sql::sqlx::Error::Decode(
format!("{}.{} does not decode", M::SCHEMA.table, M::SECRET).into(),
))
})
})
.transpose()
}
#[cfg(feature = "tenancy")]
pub(crate) async fn pending_in<M: DeviceTable>(
pool: &Pool,
owner: i64,
) -> Result<Option<Stored>, crate::sql::ExecError> {
use crate::query::{QuerySet, Q};
let staged: Vec<String> = QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter("confirmed", true)
.where_(Q::is_not_null(M::PENDING))
.values_list_flat(M::PENDING)
.fetch(pool)
.await?;
let text = match staged.into_iter().next() {
Some(t) => Some(t),
None => QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter("confirmed", false)
.values_list_flat(M::SECRET)
.fetch(pool)
.await?
.into_iter()
.next(),
};
Ok(text.and_then(|t| Stored::read::<M>(owner, t)))
}
pub(crate) async fn start_in<M: DeviceTable>(
pool: &Pool,
owner: i64,
secret: &TotpSecret,
) -> Result<(), crate::sql::ExecError> {
use crate::core::SqlValue;
use crate::query::QuerySet;
use crate::sql::UpdaterPool as _;
let stored = M::seal(owner, secret)?;
let staged = QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter("confirmed", true)
.update()
.set(M::PENDING, stored.clone())
.execute_pool(pool)
.await?;
if staged == 1 {
return Ok(());
}
let del = QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter("confirmed", false)
.compile_delete()?;
crate::sql::delete_pool(pool, &del).await?;
let insert = crate::core::InsertQuery::new(
M::SCHEMA,
vec![M::OWNER, M::SECRET, "confirmed", "created_at"],
vec![
SqlValue::I64(owner),
SqlValue::String(stored),
SqlValue::Bool(false),
SqlValue::DateTime(chrono::Utc::now()),
],
);
crate::sql::insert_pool(pool, &insert).await
}
pub(crate) async fn remove_in<M: DeviceTable>(
pool: &Pool,
owner: i64,
secret: &Stored,
) -> Result<bool, crate::sql::ExecError> {
let del = crate::query::QuerySet::<M>::new()
.filter(M::OWNER, owner)
.filter(M::SECRET, secret.text.clone())
.filter("confirmed", true)
.compile_delete()?;
Ok(crate::sql::delete_pool(pool, &del).await? == 1)
}
pub(crate) async fn reset_in<M: DeviceTable>(
pool: &Pool,
owner: i64,
) -> Result<u64, crate::sql::ExecError> {
let del = crate::query::QuerySet::<M>::new()
.filter(M::OWNER, owner)
.compile_delete()?;
crate::sql::delete_pool(pool, &del).await
}
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())
}
#[deprecated(
since = "0.59.17",
note = "a read error looks like no device; use confirmed_secret_checked"
)]
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> {
Ok(confirmed_in::<AdminTotp>(pool, user_id)
.await?
.map(|s| s.secret))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Reenroll {
Verified,
Refused,
NoDevice,
}
pub async fn start_reenrollment(
pool: &Pool,
user_id: i64,
current_code: &str,
secret: &TotpSecret,
) -> Result<Reenroll, crate::sql::ExecError> {
let outcome = match confirmed_secret_checked(pool, user_id).await? {
Some(current) if !redeem_code(pool, user_id, ¤t, current_code).await? => {
return Ok(Reenroll::Refused);
}
Some(_) => Reenroll::Verified,
None => Reenroll::NoDevice,
};
start_enrollment(pool, user_id, secret).await?;
Ok(outcome)
}
pub async fn start_enrollment(
pool: &Pool,
user_id: i64,
secret: &TotpSecret,
) -> Result<(), crate::sql::ExecError> {
start_in::<AdminTotp>(pool, user_id, secret).await
}
pub async fn confirm(pool: &Pool, user_id: i64) -> Result<(), crate::sql::ExecError> {
use crate::sql::UpdaterPool as _;
let q = AdminTotp::objects().filter("user_id", user_id);
let pending = device(pool, user_id)
.await
.and_then(|d| d.pending_secret_base32);
let q = match pending {
Some(p) => q
.filter("pending_secret_base32", p.clone())
.update()
.set("secret_base32", p)
.set("pending_secret_base32", None::<String>),
None => q.update(),
};
q.set("confirmed", true).execute_pool(pool).await?;
Ok(())
}