OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / src / db / approvals.rs
5.6 KiB · Rust · last changed A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) 4 days ago source blame history raw
a22e4aec Andy McCall ↶ 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`.
22
pub async fn add_authenticator(pool: &Pool, token_id: i64, account_id: i64, name: &str, public_key: &str) -> sqlx::Result<()> {
23
    sqlx::query(
24
        "INSERT INTO authenticators (token_id, account_id, name, public_key, created_ms) VALUES ($1, $2, $3, $4, $5) \
25
         ON CONFLICT (token_id) DO UPDATE SET name = excluded.name, public_key = excluded.public_key, created_ms = excluded.created_ms",
26
    )
27
    .bind(token_id)
28
    .bind(account_id)
29
    .bind(name)
30
    .bind(public_key)
31
    .bind(now_ms())
32
    .execute(pool)
33
    .await?;
34
    Ok(())
35
}
36
37
pub async fn authenticators(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Authenticator>> {
38
    sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE account_id = $1 ORDER BY created_ms"))
39
        .bind(account_id)
40
        .fetch_all(pool)
41
        .await
42
}
43
44
pub async fn authenticator(pool: &Pool, token_id: i64) -> sqlx::Result<Option<Authenticator>> {
45
    sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE token_id = $1")).bind(token_id).fetch_optional(pool).await
46
}
47
48
/// Remove one of the account's phones; false when it had none by that id.
49
pub async fn remove_authenticator(pool: &Pool, account_id: i64, token_id: i64) -> sqlx::Result<bool> {
50
    Ok(sqlx::query("DELETE FROM authenticators WHERE account_id = $1 AND token_id = $2")
51
        .bind(account_id)
52
        .bind(token_id)
53
        .execute(pool)
54
        .await?
55
        .rows_affected()
56
        > 0)
57
}
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
94
pub async fn create(pool: &Pool, a: NewApproval<'_>) -> sqlx::Result<()> {
95
    sqlx::query(
96
        "INSERT INTO approvals (id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms) \
97
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
98
    )
99
    .bind(a.id)
100
    .bind(a.account_id)
101
    .bind(a.kind)
102
    .bind(a.what)
103
    .bind(a.number)
104
    .bind(a.choices)
105
    .bind(a.session_id)
106
    .bind(now_ms())
107
    .bind(a.expires_ms)
108
    .execute(pool)
109
    .await?;
110
    Ok(())
111
}
112
113
pub async fn get(pool: &Pool, id: &str) -> sqlx::Result<Option<Approval>> {
114
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM approvals WHERE id = $1")).bind(id).fetch_optional(pool).await
115
}
116
117
/// The account's approvals still waiting at `now`, newest first.
118
pub async fn pending(pool: &Pool, account_id: i64, now: i64) -> sqlx::Result<Vec<Approval>> {
119
    sqlx::query_as(&format!(
120
        "SELECT {COLUMNS} FROM approvals WHERE account_id = $1 AND state = 'pending' AND expires_ms > $2 ORDER BY created_ms DESC"
121
    ))
122
    .bind(account_id)
123
    .bind(now)
124
    .fetch_all(pool)
125
    .await
126
}
127
128
/// Decide a pending approval, once: false when it was no longer pending
129
/// or had expired.
130
pub async fn decide(pool: &Pool, id: &str, state: &str, by: i64, now: i64) -> sqlx::Result<bool> {
131
    let done = sqlx::query(
132
        "UPDATE approvals SET state = $1, decided_ms = $2, decided_by = $3 WHERE id = $4 AND state = 'pending' AND expires_ms > $2",
133
    )
134
    .bind(state)
135
    .bind(now)
136
    .bind(by)
137
    .bind(id)
138
    .execute(pool)
139
    .await?
140
    .rows_affected()
141
        > 0;
142
    if done && state == "approved" {
143
        sqlx::query("UPDATE authenticators SET last_used_ms = $1 WHERE token_id = $2").bind(now).bind(by).execute(pool).await?;
144
    }
145
    Ok(done)
146
}
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.
150
pub async fn take(pool: &Pool, id: &str, account_id: i64) -> sqlx::Result<bool> {
151
    Ok(sqlx::query("UPDATE approvals SET state = 'used' WHERE id = $1 AND account_id = $2 AND state = 'approved' AND kind = 'signin'")
152
        .bind(id)
153
        .bind(account_id)
154
        .execute(pool)
155
        .await?
156
        .rows_affected()
157
        > 0)
158
}
159
160
/// Approvals that ended a day ago or more are of no further use.
161
pub async fn sweep(pool: &Pool, now: i64) -> sqlx::Result<u64> {
162
    Ok(sqlx::query("DELETE FROM approvals WHERE expires_ms < $1").bind(now - 86_400_000).execute(pool).await?.rows_affected())
163
}