OXYGENHUB

Coverage / crates/hub/src/api/approvals.rs 79.7%

98 of 123 lines run, 14 of 34 functions · run #541 on main · run missed

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.
1510×
fn phone_of(c: &Caller) -> Result<(&accounts::Account, i64), ApiError> {
1610×
    let account = c.require("user:read")?;
1710×
    if c.agent {
180×
        return Err(ApiError(StatusCode::FORBIDDEN, "an agent cannot be a second factor".into()));
1910×
    }
2010×
    let Some(id) = c.token_id else {
210×
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone uses the token it signed in with".into()));
22
    };
2310×
    Ok((account, id))
2410×
}
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.
293×
pub async fn add(
303×
    State(app): State<Shared>,
313×
    headers: HeaderMap,
323×
    Json(r): Json<NewAuthenticator>,
333×
) -> Result<(StatusCode, Json<Authenticator>), ApiError> {
343×
    let c = caller(&app, &headers).await?;
353×
    let (account, id) = phone_of(&c)?;
363×
    let Some(secret) = account.totp_secret.as_deref() else {
371×
        return Err(ApiError(
381×
            StatusCode::CONFLICT,
391×
            "set up an authenticator app first (Settings › Security on the hub): it stays the way in if this phone is lost".into(),
401×
        ));
41
    };
422×
    if !totp::verify(secret, r.code.trim(), (db::now_ms() / 1000) as u64) {
431×
        accounts::audit(&app.db, Some(account), "2fa.phone_add_failed", &c.token, "wrong code").await.ok();
441×
        return Err(ApiError(StatusCode::FORBIDDEN, "that code is not right".into()));
451×
    }
461×
    let Some(point) = phone::public_point(&r.public_key) else {
470×
        return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is a P-256 key: hex of its SubjectPublicKeyInfo".into()));
48
    };
491×
    approvals::add_authenticator(&app.db, id, account.id, &c.token, &point).await?;
501×
    accounts::audit(&app.db, Some(account), "2fa.phone_added", &c.token, "").await.ok();
511×
    let a = approvals::authenticator(&app.db, id).await?.expect("just added");
521×
    Ok((StatusCode::CREATED, Json(Authenticator { id, name: a.name, created_ms: a.created_ms, last_used_ms: a.last_used_ms, this: true })))
533×
}
54
55
/// `GET /api/v1/user/authenticators`: the account's phones.
561×
pub async fn list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Authenticator>>, ApiError> {
571×
    let c = caller(&app, &headers).await?;
581×
    let account = c.require("user:read")?;
591×
    let this = c.token_id;
60
    Ok(Json(
611×
        approvals::authenticators(&app.db, account.id)
621×
            .await?
631×
            .into_iter()
641×
            .map(|a| Authenticator {
650×
                this: Some(a.token_id) == this,
660×
                id: a.token_id,
670×
                name: a.name,
680×
                created_ms: a.created_ms,
690×
                last_used_ms: a.last_used_ms,
700×
            })
711×
            .collect(),
72
    ))
731×
}
74
75
/// `DELETE /api/v1/user/authenticators`: this phone stops being a second factor.
760×
pub async fn remove(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
770×
    let c = caller(&app, &headers).await?;
780×
    let (account, id) = phone_of(&c)?;
790×
    if approvals::remove_authenticator(&app.db, account.id, id).await? {
800×
        accounts::audit(&app.db, Some(account), "2fa.phone_removed", &c.token, "").await.ok();
810×
    }
820×
    Ok(StatusCode::NO_CONTENT)
830×
}
84
85
/// `GET /api/v1/user/approvals`: what waits for this phone's answer.
863×
pub async fn pending(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<PendingApproval>>, ApiError> {
873×
    let c = caller(&app, &headers).await?;
883×
    let (account, id) = phone_of(&c)?;
893×
    if approvals::authenticator(&app.db, id).await?.is_none() {
900×
        return Ok(Json(Vec::new()));
913×
    }
92
    Ok(Json(
933×
        approvals::pending(&app.db, account.id, db::now_ms())
943×
            .await?
953×
            .into_iter()
963×
            .map(|a| PendingApproval {
979×
                choices: a.choices.split(',').filter_map(|n| n.parse().ok()).collect(),
983×
                id: a.id,
993×
                kind: a.kind,
1003×
                what: a.what,
1013×
                created_ms: a.created_ms,
1023×
                expires_ms: a.expires_ms,
1033×
            })
1043×
            .collect(),
105
    ))
1063×
}
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.
1114×
pub async fn answer(
1124×
    State(app): State<Shared>,
1134×
    headers: HeaderMap,
1144×
    Path(approval): Path<String>,
1154×
    Json(r): Json<ApprovalAnswer>,
1164×
) -> Result<Json<serde_json::Value>, ApiError> {
1174×
    let c = caller(&app, &headers).await?;
1184×
    let (account, id) = phone_of(&c)?;
1194×
    let Some(key) = approvals::authenticator(&app.db, id).await?.filter(|a| a.account_id == account.id) else {
1200×
        return Err(ApiError(StatusCode::FORBIDDEN, "this phone is not a second factor of the account".into()));
121
    };
1224×
    let now = db::now_ms();
1234×
    let Some(a) = approvals::get(&app.db, &approval).await?.filter(|a| a.account_id == account.id) else {
1240×
        return Err(ApiError(StatusCode::NOT_FOUND, "no such approval".into()));
125
    };
1264×
    if a.state != "pending" || a.expires_ms <= now {
1270×
        return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into()));
1284×
    }
1294×
    let state = if !r.approve {
1300×
        "denied"
131
    } else {
1324×
        let Ok(sig) = hex::decode(r.signature.trim()) else {
1330×
            return Err(ApiError(StatusCode::BAD_REQUEST, "signature is hex".into()));
134
        };
1354×
        if !phone::verify(&key.public_key, &phone::message(&a.id, r.number, true), &sig) {
1361×
            accounts::audit(&app.db, Some(account), "2fa.bad_signature", &c.token, &a.kind).await.ok();
1371×
            return Err(ApiError(StatusCode::FORBIDDEN, "the signature is not this phone's".into()));
1383×
        }
1393×
        if r.number == a.number {
1402×
            "approved"
141
        } else {
1421×
            "denied"
143
        }
144
    };
1453×
    if !approvals::decide(&app.db, &a.id, state, id, now).await? {
1460×
        return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into()));
1473×
    }
1483×
    if state == "approved" {
1492×
        if let Some(session) = a.session_id.filter(|_| a.kind == "stepup") {
1501×
            db::sessions::mark_second_factor(&app.db, session, now).await?;
1511×
        }
1521×
    }
1533×
    let detail = if r.approve && state == "denied" { format!("{}: wrong number", a.kind) } else { a.kind.clone() };
1543×
    accounts::audit(&app.db, Some(account), &format!("2fa.{state}"), &c.token, &detail).await.ok();
1553×
    Ok(Json(serde_json::json!({ "state": state })))
1564×
}