OXYGENHUB

Coverage / crates/hub/src/db/approvals.rs 95.9%

94 of 98 lines run, 18 of 40 functions · run #544 on main · run missed

1
//! Phones as a second factor (oxygenhub#356): their keys, and the
2
//! approvals they are asked for.
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
/// A phone that approves for its account: the Keystore key of the sign-in
8
/// (token) it made, as an uncompressed P-256 point in hex.
9
#[derive(Clone, Debug, FromRow)]
10
pub struct Authenticator {
11
    pub token_id: i64,
12
    pub account_id: i64,
13
    pub name: String,
14
    pub public_key: String,
15
    pub created_ms: i64,
16
    pub last_used_ms: i64,
17
}
18
19
const AUTH_COLUMNS: &str = "token_id, account_id, name, public_key, created_ms, last_used_ms";
20
21
/// Register (or replace) the key of the phone signed in with `token_id`.
221×
pub async fn add_authenticator(pool: &Pool, token_id: i64, account_id: i64, name: &str, public_key: &str) -> sqlx::Result<()> {
231×
    sqlx::query(
241×
        "INSERT INTO authenticators (token_id, account_id, name, public_key, created_ms) VALUES ($1, $2, $3, $4, $5) \
251×
         ON CONFLICT (token_id) DO UPDATE SET name = excluded.name, public_key = excluded.public_key, created_ms = excluded.created_ms",
261×
    )
271×
    .bind(token_id)
281×
    .bind(account_id)
291×
    .bind(name)
301×
    .bind(public_key)
311×
    .bind(now_ms())
321×
    .execute(pool)
331×
    .await?;
341×
    Ok(())
351×
}
36
3725×
pub async fn authenticators(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Authenticator>> {
3825×
    sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE account_id = $1 ORDER BY created_ms"))
3925×
        .bind(account_id)
4025×
        .fetch_all(pool)
4125×
        .await
4225×
}
43
448×
pub async fn authenticator(pool: &Pool, token_id: i64) -> sqlx::Result<Option<Authenticator>> {
458×
    sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE token_id = $1")).bind(token_id).fetch_optional(pool).await
468×
}
47
48
/// Remove one of the account's phones; false when it had none by that id.
491×
pub async fn remove_authenticator(pool: &Pool, account_id: i64, token_id: i64) -> sqlx::Result<bool> {
501×
    Ok(sqlx::query("DELETE FROM authenticators WHERE account_id = $1 AND token_id = $2")
511×
        .bind(account_id)
521×
        .bind(token_id)
531×
        .execute(pool)
541×
        .await?
551×
        .rows_affected()
56
        > 0)
571×
}
58
59
/// An approval asked of the account's phones: what for, the number the
60
/// page shows among the three the phone offers, and what became of it.
61
#[derive(Clone, Debug, FromRow)]
62
pub struct Approval {
63
    pub id: String,
64
    pub account_id: i64,
65
    /// `signin` or `stepup`.
66
    pub kind: String,
67
    pub what: String,
68
    pub number: i64,
69
    /// The three numbers the phone offers, comma-separated.
70
    pub choices: String,
71
    /// For a step-up: the browser session it makes fresh.
72
    pub session_id: Option<i64>,
73
    pub created_ms: i64,
74
    pub expires_ms: i64,
75
    /// `pending`, `approved`, `denied`, or `used` once a sign-in took it.
76
    pub state: String,
77
    pub decided_ms: i64,
78
    pub decided_by: Option<i64>,
79
}
80
81
const COLUMNS: &str = "id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms, state, decided_ms, decided_by";
82
83
pub struct NewApproval<'a> {
84
    pub id: &'a str,
85
    pub account_id: i64,
86
    pub kind: &'a str,
87
    pub what: &'a str,
88
    pub number: i64,
89
    pub choices: &'a str,
90
    pub session_id: Option<i64>,
91
    pub expires_ms: i64,
92
}
93
943×
pub async fn create(pool: &Pool, a: NewApproval<'_>) -> sqlx::Result<()> {
953×
    sqlx::query(
963×
        "INSERT INTO approvals (id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms) \
973×
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
983×
    )
993×
    .bind(a.id)
1003×
    .bind(a.account_id)
1013×
    .bind(a.kind)
1023×
    .bind(a.what)
1033×
    .bind(a.number)
1043×
    .bind(a.choices)
1053×
    .bind(a.session_id)
1063×
    .bind(now_ms())
1073×
    .bind(a.expires_ms)
1083×
    .execute(pool)
1093×
    .await?;
1103×
    Ok(())
1113×
}
112
11313×
pub async fn get(pool: &Pool, id: &str) -> sqlx::Result<Option<Approval>> {
11413×
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM approvals WHERE id = $1")).bind(id).fetch_optional(pool).await
11513×
}
116
117
/// The account's approvals still waiting at `now`, newest first.
1183×
pub async fn pending(pool: &Pool, account_id: i64, now: i64) -> sqlx::Result<Vec<Approval>> {
1193×
    sqlx::query_as(&format!(
1203×
        "SELECT {COLUMNS} FROM approvals WHERE account_id = $1 AND state = 'pending' AND expires_ms > $2 ORDER BY created_ms DESC"
1213×
    ))
1223×
    .bind(account_id)
1233×
    .bind(now)
1243×
    .fetch_all(pool)
1253×
    .await
1263×
}
127
128
/// Decide a pending approval, once: false when it was no longer pending
129
/// or had expired.
1303×
pub async fn decide(pool: &Pool, id: &str, state: &str, by: i64, now: i64) -> sqlx::Result<bool> {
1313×
    let done = sqlx::query(
1323×
        "UPDATE approvals SET state = $1, decided_ms = $2, decided_by = $3 WHERE id = $4 AND state = 'pending' AND expires_ms > $2",
1333×
    )
1343×
    .bind(state)
1353×
    .bind(now)
1363×
    .bind(by)
1373×
    .bind(id)
1383×
    .execute(pool)
1393×
    .await?
1403×
    .rows_affected()
141
        > 0;
1423×
    if done && state == "approved" {
1432×
        sqlx::query("UPDATE authenticators SET last_used_ms = $1 WHERE token_id = $2").bind(now).bind(by).execute(pool).await?;
1441×
    }
1453×
    Ok(done)
1463×
}
147
148
/// Take an approved sign-in approval for the sign-in it was made for,
149
/// once: false when it is not approved, or was taken already.
1503×
pub async fn take(pool: &Pool, id: &str, account_id: i64) -> sqlx::Result<bool> {
1513×
    Ok(sqlx::query("UPDATE approvals SET state = 'used' WHERE id = $1 AND account_id = $2 AND state = 'approved' AND kind = 'signin'")
1523×
        .bind(id)
1533×
        .bind(account_id)
1543×
        .execute(pool)
1553×
        .await?
1563×
        .rows_affected()
157
        > 0)
1583×
}
159
160
/// Approvals that ended a day ago or more are of no further use.
1610×
pub async fn sweep(pool: &Pool, now: i64) -> sqlx::Result<u64> {
1620×
    Ok(sqlx::query("DELETE FROM approvals WHERE expires_ms < $1").bind(now - 86_400_000).execute(pool).await?.rows_affected())
1630×
}