//! A phone as a second factor over the API (oxygenhub#356): register this //! phone's Keystore key, list and remove phones, and answer the approvals //! a browser asks for. Agents' tokens can do none of it. use super::{caller, ApiError, Caller}; use crate::auth::{phone, totp}; use crate::db::{self, accounts, approvals}; use crate::Shared; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use hub_api::{ApprovalAnswer, Authenticator, NewAuthenticator, PendingApproval}; /// The caller as a person's phone: its account and the token it signed in with. fn phone_of(c: &Caller) -> Result<(&accounts::Account, i64), ApiError> { let account = c.require("user:read")?; if c.agent { return Err(ApiError(StatusCode::FORBIDDEN, "an agent cannot be a second factor".into())); } let Some(id) = c.token_id else { return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone uses the token it signed in with".into())); }; Ok((account, id)) } /// `POST /api/v1/user/authenticators`: this phone as a second factor. It /// needs the account's current authenticator-app code, so a token alone /// cannot add one, and the app code stays the way in when the phone is lost. pub async fn add( State(app): State, headers: HeaderMap, Json(r): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let (account, id) = phone_of(&c)?; let Some(secret) = account.totp_secret.as_deref() else { return Err(ApiError( StatusCode::CONFLICT, "set up an authenticator app first (Settings › Security on the hub): it stays the way in if this phone is lost".into(), )); }; if !totp::verify(secret, r.code.trim(), (db::now_ms() / 1000) as u64) { accounts::audit(&app.db, Some(account), "2fa.phone_add_failed", &c.token, "wrong code").await.ok(); return Err(ApiError(StatusCode::FORBIDDEN, "that code is not right".into())); } let Some(point) = phone::public_point(&r.public_key) else { return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is a P-256 key: hex of its SubjectPublicKeyInfo".into())); }; approvals::add_authenticator(&app.db, id, account.id, &c.token, &point).await?; accounts::audit(&app.db, Some(account), "2fa.phone_added", &c.token, "").await.ok(); let a = approvals::authenticator(&app.db, id).await?.expect("just added"); Ok((StatusCode::CREATED, Json(Authenticator { id, name: a.name, created_ms: a.created_ms, last_used_ms: a.last_used_ms, this: true }))) } /// `GET /api/v1/user/authenticators`: the account's phones. pub async fn list(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let account = c.require("user:read")?; let this = c.token_id; Ok(Json( approvals::authenticators(&app.db, account.id) .await? .into_iter() .map(|a| Authenticator { this: Some(a.token_id) == this, id: a.token_id, name: a.name, created_ms: a.created_ms, last_used_ms: a.last_used_ms, }) .collect(), )) } /// `DELETE /api/v1/user/authenticators`: this phone stops being a second factor. pub async fn remove(State(app): State, headers: HeaderMap) -> Result { let c = caller(&app, &headers).await?; let (account, id) = phone_of(&c)?; if approvals::remove_authenticator(&app.db, account.id, id).await? { accounts::audit(&app.db, Some(account), "2fa.phone_removed", &c.token, "").await.ok(); } Ok(StatusCode::NO_CONTENT) } /// `GET /api/v1/user/approvals`: what waits for this phone's answer. pub async fn pending(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (account, id) = phone_of(&c)?; if approvals::authenticator(&app.db, id).await?.is_none() { return Ok(Json(Vec::new())); } Ok(Json( approvals::pending(&app.db, account.id, db::now_ms()) .await? .into_iter() .map(|a| PendingApproval { choices: a.choices.split(',').filter_map(|n| n.parse().ok()).collect(), id: a.id, kind: a.kind, what: a.what, created_ms: a.created_ms, expires_ms: a.expires_ms, }) .collect(), )) } /// `POST /api/v1/user/approvals/{id}`: this phone's answer. Approving takes /// the number the browser shows and the phone key's signature; a wrong /// number denies it, as a guess should. pub async fn answer( State(app): State, headers: HeaderMap, Path(approval): Path, Json(r): Json, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (account, id) = phone_of(&c)?; let Some(key) = approvals::authenticator(&app.db, id).await?.filter(|a| a.account_id == account.id) else { return Err(ApiError(StatusCode::FORBIDDEN, "this phone is not a second factor of the account".into())); }; let now = db::now_ms(); let Some(a) = approvals::get(&app.db, &approval).await?.filter(|a| a.account_id == account.id) else { return Err(ApiError(StatusCode::NOT_FOUND, "no such approval".into())); }; if a.state != "pending" || a.expires_ms <= now { return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into())); } let state = if !r.approve { "denied" } else { let Ok(sig) = hex::decode(r.signature.trim()) else { return Err(ApiError(StatusCode::BAD_REQUEST, "signature is hex".into())); }; if !phone::verify(&key.public_key, &phone::message(&a.id, r.number, true), &sig) { accounts::audit(&app.db, Some(account), "2fa.bad_signature", &c.token, &a.kind).await.ok(); return Err(ApiError(StatusCode::FORBIDDEN, "the signature is not this phone's".into())); } if r.number == a.number { "approved" } else { "denied" } }; if !approvals::decide(&app.db, &a.id, state, id, now).await? { return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into())); } if state == "approved" { if let Some(session) = a.session_id.filter(|_| a.kind == "stepup") { db::sessions::mark_second_factor(&app.db, session, now).await?; } } let detail = if r.approve && state == "denied" { format!("{}: wrong number", a.kind) } else { a.kind.clone() }; accounts::audit(&app.db, Some(account), &format!("2fa.{state}"), &c.token, &detail).await.ok(); Ok(Json(serde_json::json!({ "state": state }))) }