OXYGENHUB
◆ main oxygenhub / crates / hub / src / api / approvals.rs
6.9 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) 3 days ago source blame history raw
a22e4aec Andy McCall ↶ 1
//! A phone as a second factor over the API (oxygenhub#356): register this
2
//! phone's Keystore key, list and remove phones, and answer the approvals
3
//! a browser asks for. Agents' tokens can do none of it.
4
5
use super::{caller, ApiError, Caller};
6
use crate::auth::{phone, totp};
7
use crate::db::{self, accounts, approvals};
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::Json;
12
use hub_api::{ApprovalAnswer, Authenticator, NewAuthenticator, PendingApproval};
13
14
/// The caller as a person's phone: its account and the token it signed in with.
15
fn phone_of(c: &Caller) -> Result<(&accounts::Account, i64), ApiError> {
16
    let account = c.require("user:read")?;
17
    if c.agent {
18
        return Err(ApiError(StatusCode::FORBIDDEN, "an agent cannot be a second factor".into()));
19
    }
20
    let Some(id) = c.token_id else {
21
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone uses the token it signed in with".into()));
22
    };
23
    Ok((account, id))
24
}
25
26
/// `POST /api/v1/user/authenticators`: this phone as a second factor. It
27
/// needs the account's current authenticator-app code, so a token alone
28
/// cannot add one, and the app code stays the way in when the phone is lost.
29
pub async fn add(
30
    State(app): State<Shared>,
31
    headers: HeaderMap,
32
    Json(r): Json<NewAuthenticator>,
33
) -> Result<(StatusCode, Json<Authenticator>), ApiError> {
34
    let c = caller(&app, &headers).await?;
35
    let (account, id) = phone_of(&c)?;
36
    let Some(secret) = account.totp_secret.as_deref() else {
37
        return Err(ApiError(
38
            StatusCode::CONFLICT,
39
            "set up an authenticator app first (Settings › Security on the hub): it stays the way in if this phone is lost".into(),
40
        ));
41
    };
42
    if !totp::verify(secret, r.code.trim(), (db::now_ms() / 1000) as u64) {
43
        accounts::audit(&app.db, Some(account), "2fa.phone_add_failed", &c.token, "wrong code").await.ok();
44
        return Err(ApiError(StatusCode::FORBIDDEN, "that code is not right".into()));
45
    }
46
    let Some(point) = phone::public_point(&r.public_key) else {
47
        return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is a P-256 key: hex of its SubjectPublicKeyInfo".into()));
48
    };
49
    approvals::add_authenticator(&app.db, id, account.id, &c.token, &point).await?;
50
    accounts::audit(&app.db, Some(account), "2fa.phone_added", &c.token, "").await.ok();
51
    let a = approvals::authenticator(&app.db, id).await?.expect("just added");
52
    Ok((StatusCode::CREATED, Json(Authenticator { id, name: a.name, created_ms: a.created_ms, last_used_ms: a.last_used_ms, this: true })))
53
}
54
55
/// `GET /api/v1/user/authenticators`: the account's phones.
56
pub async fn list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Authenticator>>, ApiError> {
57
    let c = caller(&app, &headers).await?;
58
    let account = c.require("user:read")?;
59
    let this = c.token_id;
60
    Ok(Json(
61
        approvals::authenticators(&app.db, account.id)
62
            .await?
63
            .into_iter()
64
            .map(|a| Authenticator {
65
                this: Some(a.token_id) == this,
66
                id: a.token_id,
67
                name: a.name,
68
                created_ms: a.created_ms,
69
                last_used_ms: a.last_used_ms,
70
            })
71
            .collect(),
72
    ))
73
}
74
75
/// `DELETE /api/v1/user/authenticators`: this phone stops being a second factor.
76
pub async fn remove(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
77
    let c = caller(&app, &headers).await?;
78
    let (account, id) = phone_of(&c)?;
79
    if approvals::remove_authenticator(&app.db, account.id, id).await? {
80
        accounts::audit(&app.db, Some(account), "2fa.phone_removed", &c.token, "").await.ok();
81
    }
82
    Ok(StatusCode::NO_CONTENT)
83
}
84
85
/// `GET /api/v1/user/approvals`: what waits for this phone's answer.
86
pub async fn pending(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<PendingApproval>>, ApiError> {
87
    let c = caller(&app, &headers).await?;
88
    let (account, id) = phone_of(&c)?;
89
    if approvals::authenticator(&app.db, id).await?.is_none() {
90
        return Ok(Json(Vec::new()));
91
    }
92
    Ok(Json(
93
        approvals::pending(&app.db, account.id, db::now_ms())
94
            .await?
95
            .into_iter()
96
            .map(|a| PendingApproval {
97
                choices: a.choices.split(',').filter_map(|n| n.parse().ok()).collect(),
98
                id: a.id,
99
                kind: a.kind,
100
                what: a.what,
101
                created_ms: a.created_ms,
102
                expires_ms: a.expires_ms,
103
            })
104
            .collect(),
105
    ))
106
}
107
108
/// `POST /api/v1/user/approvals/{id}`: this phone's answer. Approving takes
109
/// the number the browser shows and the phone key's signature; a wrong
110
/// number denies it, as a guess should.
111
pub async fn answer(
112
    State(app): State<Shared>,
113
    headers: HeaderMap,
114
    Path(approval): Path<String>,
115
    Json(r): Json<ApprovalAnswer>,
116
) -> Result<Json<serde_json::Value>, ApiError> {
117
    let c = caller(&app, &headers).await?;
118
    let (account, id) = phone_of(&c)?;
119
    let Some(key) = approvals::authenticator(&app.db, id).await?.filter(|a| a.account_id == account.id) else {
120
        return Err(ApiError(StatusCode::FORBIDDEN, "this phone is not a second factor of the account".into()));
121
    };
122
    let now = db::now_ms();
123
    let Some(a) = approvals::get(&app.db, &approval).await?.filter(|a| a.account_id == account.id) else {
124
        return Err(ApiError(StatusCode::NOT_FOUND, "no such approval".into()));
125
    };
126
    if a.state != "pending" || a.expires_ms <= now {
127
        return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into()));
128
    }
129
    let state = if !r.approve {
130
        "denied"
131
    } else {
132
        let Ok(sig) = hex::decode(r.signature.trim()) else {
133
            return Err(ApiError(StatusCode::BAD_REQUEST, "signature is hex".into()));
134
        };
135
        if !phone::verify(&key.public_key, &phone::message(&a.id, r.number, true), &sig) {
136
            accounts::audit(&app.db, Some(account), "2fa.bad_signature", &c.token, &a.kind).await.ok();
137
            return Err(ApiError(StatusCode::FORBIDDEN, "the signature is not this phone's".into()));
138
        }
139
        if r.number == a.number {
140
            "approved"
141
        } else {
142
            "denied"
143
        }
144
    };
145
    if !approvals::decide(&app.db, &a.id, state, id, now).await? {
146
        return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into()));
147
    }
148
    if state == "approved" {
149
        if let Some(session) = a.session_id.filter(|_| a.kind == "stepup") {
150
            db::sessions::mark_second_factor(&app.db, session, now).await?;
151
        }
152
    }
153
    let detail = if r.approve && state == "denied" { format!("{}: wrong number", a.kind) } else { a.kind.clone() };
154
    accounts::audit(&app.db, Some(account), &format!("2fa.{state}"), &c.token, &detail).await.ok();
155
    Ok(Json(serde_json::json!({ "state": state })))
156
}