OXYGENHUB

Coverage / crates/hub/src/web/device.rs 88.4%

76 of 86 lines run, 12 of 32 functions · run #469 on main · run missed

1
//! `/device`: approving an app's sign-in code (oxygenhub#67). The app shows
2
//! a code; the user, signed in here, enters it, sees what the app asked
3
//! for, and approves it with the scopes they choose, or denies it.
4
5
use super::settings::{csrf, require, SCOPES};
6
use super::{filters, Error, Page, Shell};
7
use crate::api::device::normalize_user_code;
8
use crate::auth::Viewer;
9
use crate::db::{self, accounts};
10
use crate::throttle::{self, Key};
11
use crate::Shared;
12
use askama::Template;
13
use axum::extract::{Query, State};
14
use serde::Deserialize;
15
16
#[derive(Template)]
17
#[template(path = "device.html")]
18
pub struct DevicePage {
19
    shell: Shell,
20
    code: String,
21
    /// The pending request the code names, with each scope it asked for.
22
    pending: Option<(accounts::DeviceCode, Vec<(&'static str, &'static str)>)>,
23
    /// The answer just given: the page then says so and shows no code
24
    /// form, so nobody thinks the code is needed again (oxygenhub#83).
25
    done: Option<Done>,
26
    error: Option<String>,
27
}
28
29
pub struct Done {
30
    pub approved: bool,
31
    /// The app, as it named itself ("oxy on saturn").
32
    pub client: String,
33
    pub username: String,
34
}
35
36
/// A code still waiting for an answer.
376×
async fn pending(app: &Shared, code: &str) -> Result<Option<accounts::DeviceCode>, sqlx::Error> {
386×
    Ok(accounts::device_by_user_code(&app.db, code).await?.filter(|d| d.account_id.is_none() && !d.denied && d.expires_ms >= db::now_ms()))
396×
}
40
416×
fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<Done>, error: Option<String>) -> Page<DevicePage> {
426×
    let pending = d.map(|d| {
4316×
        let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect();
442×
        (d, asked)
452×
    });
466×
    Page(DevicePage { shell, code, pending, done, error })
476×
}
48
49
#[derive(Deserialize)]
50
pub struct CodeQuery {
51
    #[serde(default)]
52
    code: String,
53
}
54
554×
pub async fn form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<CodeQuery>) -> Result<Page<DevicePage>, Error> {
564×
    let shell = Shell::new(&app, "/device", &viewer);
574×
    require(&viewer, &shell)?;
583×
    let code = normalize_user_code(&q.code);
593×
    if code.is_empty() {
600×
        return Ok(page(shell, code, None, None, None));
613×
    }
623×
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
633×
    let error = d.is_none().then(|| "No app is waiting for that code: it may have expired. Ask the app for a new one.".to_string());
643×
    Ok(page(shell, code, d, None, error))
654×
}
66
67
#[derive(Deserialize)]
68
pub struct DeviceForm {
69
    #[serde(rename = "_csrf")]
70
    csrf_token: String,
71
    code: String,
72
    #[serde(default)]
73
    action: String,
74
    #[serde(default)]
75
    scopes: Vec<String>,
76
}
77
783×
pub async fn answer(
793×
    State(app): State<Shared>,
803×
    viewer: Viewer,
813×
    headers: axum::http::HeaderMap,
823×
    extensions: axum::http::Extensions,
833×
    axum_extra::extract::Form(f): axum_extra::extract::Form<DeviceForm>,
843×
) -> Result<Page<DevicePage>, Error> {
853×
    let shell = Shell::new(&app, "/device", &viewer);
863×
    let me = require(&viewer, &shell)?;
873×
    csrf(&viewer, &f.csrf_token, &shell)?;
883×
    let code = normalize_user_code(&f.code);
89
    // Guessing codes is slowed per account and address (oxygenhub#221).
903×
    let addr = crate::api::client_addr(&app, &headers, &extensions);
913×
    let keys = [Key::account("device", &me.account.username), Key::address("device", &addr)];
923×
    let now = db::now_ms();
933×
    if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now) {
940×
        return Ok(page(shell, code, None, None, Some(throttle::message(wait))));
953×
    }
963×
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
973×
    let Some(d) = d else {
981×
        let started = app.throttle.lock().unwrap().failed(&keys, now);
991×
        if let Some(wait) = started {
1000×
            accounts::audit(&app.db, Some(&me.account), "device.throttled", &addr, &format!("for {}s", wait / 1000)).await.ok();
1011×
        }
1021×
        let e = "No app is waiting for that code: it may have expired. Ask the app for a new one.";
1031×
        return Ok(page(shell, code, None, None, Some(e.into())));
104
    };
1052×
    match f.action.as_str() {
1062×
        "approve" => {
107
            // Only scopes the app asked for, and at least one.
1081×
            let asked = d.scope_list();
1091×
            let chosen: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| asked.contains(s)).collect();
1101×
            if chosen.is_empty() {
1110×
                return Ok(page(shell, code, Some(d), None, Some("Allow at least one of what it asks for, or deny it.".into())));
1121×
            }
1131×
            let ok = accounts::approve_device(&app.db, d.id, me.account.id, &chosen.join(","))
1141×
                .await
1151×
                .map_err(|e| Error::from_db(shell.clone(), e))?;
1161×
            if !ok {
1170×
                return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into())));
1181×
            }
1191×
            accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok();
1201×
            let done = Done { approved: true, client: d.client.clone(), username: me.account.username.clone() };
1211×
            Ok(page(shell, String::new(), None, Some(done), None))
122
        }
1231×
        "deny" => {
1241×
            accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1251×
            accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok();
1261×
            Ok(page(
1271×
                shell,
1281×
                String::new(),
1291×
                None,
1301×
                Some(Done { approved: false, client: d.client.clone(), username: me.account.username.clone() }),
1311×
                None,
1321×
            ))
133
        }
134
        // Looking a typed code up.
1350×
        _ => Ok(page(shell, code, Some(d), None, None)),
136
    }
1373×
}