//! Phones as a second factor (oxygenhub#356): their keys, and the //! approvals they are asked for. use super::{now_ms, Pool}; use sqlx::FromRow; /// A phone that approves for its account: the Keystore key of the sign-in /// (token) it made, as an uncompressed P-256 point in hex. #[derive(Clone, Debug, FromRow)] pub struct Authenticator { pub token_id: i64, pub account_id: i64, pub name: String, pub public_key: String, pub created_ms: i64, pub last_used_ms: i64, } const AUTH_COLUMNS: &str = "token_id, account_id, name, public_key, created_ms, last_used_ms"; /// Register (or replace) the key of the phone signed in with `token_id`. pub async fn add_authenticator(pool: &Pool, token_id: i64, account_id: i64, name: &str, public_key: &str) -> sqlx::Result<()> { sqlx::query( "INSERT INTO authenticators (token_id, account_id, name, public_key, created_ms) VALUES ($1, $2, $3, $4, $5) \ ON CONFLICT (token_id) DO UPDATE SET name = excluded.name, public_key = excluded.public_key, created_ms = excluded.created_ms", ) .bind(token_id) .bind(account_id) .bind(name) .bind(public_key) .bind(now_ms()) .execute(pool) .await?; Ok(()) } pub async fn authenticators(pool: &Pool, account_id: i64) -> sqlx::Result> { sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE account_id = $1 ORDER BY created_ms")) .bind(account_id) .fetch_all(pool) .await } pub async fn authenticator(pool: &Pool, token_id: i64) -> sqlx::Result> { sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE token_id = $1")).bind(token_id).fetch_optional(pool).await } /// Remove one of the account's phones; false when it had none by that id. pub async fn remove_authenticator(pool: &Pool, account_id: i64, token_id: i64) -> sqlx::Result { Ok(sqlx::query("DELETE FROM authenticators WHERE account_id = $1 AND token_id = $2") .bind(account_id) .bind(token_id) .execute(pool) .await? .rows_affected() > 0) } /// An approval asked of the account's phones: what for, the number the /// page shows among the three the phone offers, and what became of it. #[derive(Clone, Debug, FromRow)] pub struct Approval { pub id: String, pub account_id: i64, /// `signin` or `stepup`. pub kind: String, pub what: String, pub number: i64, /// The three numbers the phone offers, comma-separated. pub choices: String, /// For a step-up: the browser session it makes fresh. pub session_id: Option, pub created_ms: i64, pub expires_ms: i64, /// `pending`, `approved`, `denied`, or `used` once a sign-in took it. pub state: String, pub decided_ms: i64, pub decided_by: Option, } const COLUMNS: &str = "id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms, state, decided_ms, decided_by"; pub struct NewApproval<'a> { pub id: &'a str, pub account_id: i64, pub kind: &'a str, pub what: &'a str, pub number: i64, pub choices: &'a str, pub session_id: Option, pub expires_ms: i64, } pub async fn create(pool: &Pool, a: NewApproval<'_>) -> sqlx::Result<()> { sqlx::query( "INSERT INTO approvals (id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) .bind(a.id) .bind(a.account_id) .bind(a.kind) .bind(a.what) .bind(a.number) .bind(a.choices) .bind(a.session_id) .bind(now_ms()) .bind(a.expires_ms) .execute(pool) .await?; Ok(()) } pub async fn get(pool: &Pool, id: &str) -> sqlx::Result> { sqlx::query_as(&format!("SELECT {COLUMNS} FROM approvals WHERE id = $1")).bind(id).fetch_optional(pool).await } /// The account's approvals still waiting at `now`, newest first. pub async fn pending(pool: &Pool, account_id: i64, now: i64) -> sqlx::Result> { sqlx::query_as(&format!( "SELECT {COLUMNS} FROM approvals WHERE account_id = $1 AND state = 'pending' AND expires_ms > $2 ORDER BY created_ms DESC" )) .bind(account_id) .bind(now) .fetch_all(pool) .await } /// Decide a pending approval, once: false when it was no longer pending /// or had expired. pub async fn decide(pool: &Pool, id: &str, state: &str, by: i64, now: i64) -> sqlx::Result { let done = sqlx::query( "UPDATE approvals SET state = $1, decided_ms = $2, decided_by = $3 WHERE id = $4 AND state = 'pending' AND expires_ms > $2", ) .bind(state) .bind(now) .bind(by) .bind(id) .execute(pool) .await? .rows_affected() > 0; if done && state == "approved" { sqlx::query("UPDATE authenticators SET last_used_ms = $1 WHERE token_id = $2").bind(now).bind(by).execute(pool).await?; } Ok(done) } /// Take an approved sign-in approval for the sign-in it was made for, /// once: false when it is not approved, or was taken already. pub async fn take(pool: &Pool, id: &str, account_id: i64) -> sqlx::Result { Ok(sqlx::query("UPDATE approvals SET state = 'used' WHERE id = $1 AND account_id = $2 AND state = 'approved' AND kind = 'signin'") .bind(id) .bind(account_id) .execute(pool) .await? .rows_affected() > 0) } /// Approvals that ended a day ago or more are of no further use. pub async fn sweep(pool: &Pool, now: i64) -> sqlx::Result { Ok(sqlx::query("DELETE FROM approvals WHERE expires_ms < $1").bind(now - 86_400_000).execute(pool).await?.rows_affected()) }