OXYGENHUB
# de4467cb1eca oxygenhub / crates / hub / src / web / device.rs
5.7 KiB · Rust · last changed Sign-in, 2FA codes, device codes and reset requests slow guessing 20 days ago source blame history raw
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.
37
async fn pending(app: &Shared, code: &str) -> Result<Option<accounts::DeviceCode>, sqlx::Error> {
38
    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()))
39
}
40
41
fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<Done>, error: Option<String>) -> Page<DevicePage> {
42
    let pending = d.map(|d| {
43
        let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect();
44
        (d, asked)
45
    });
46
    Page(DevicePage { shell, code, pending, done, error })
47
}
48
49
#[derive(Deserialize)]
50
pub struct CodeQuery {
51
    #[serde(default)]
52
    code: String,
53
}
54
55
pub async fn form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<CodeQuery>) -> Result<Page<DevicePage>, Error> {
56
    let shell = Shell::new(&app, "/device", &viewer);
57
    require(&viewer, &shell)?;
58
    let code = normalize_user_code(&q.code);
59
    if code.is_empty() {
60
        return Ok(page(shell, code, None, None, None));
61
    }
62
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
63
    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());
64
    Ok(page(shell, code, d, None, error))
65
}
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
78
pub async fn answer(
79
    State(app): State<Shared>,
80
    viewer: Viewer,
81
    headers: axum::http::HeaderMap,
82
    extensions: axum::http::Extensions,
83
    axum_extra::extract::Form(f): axum_extra::extract::Form<DeviceForm>,
84
) -> Result<Page<DevicePage>, Error> {
85
    let shell = Shell::new(&app, "/device", &viewer);
86
    let me = require(&viewer, &shell)?;
87
    csrf(&viewer, &f.csrf_token, &shell)?;
88
    let code = normalize_user_code(&f.code);
89
    // Guessing codes is slowed per account and address (oxygenhub#221).
90
    let addr = crate::api::client_addr(&app, &headers, &extensions);
91
    let keys = [Key::account("device", &me.account.username), Key::address("device", &addr)];
92
    let now = db::now_ms();
93
    if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now) {
94
        return Ok(page(shell, code, None, None, Some(throttle::message(wait))));
95
    }
96
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
97
    let Some(d) = d else {
98
        let started = app.throttle.lock().unwrap().failed(&keys, now);
99
        if let Some(wait) = started {
100
            accounts::audit(&app.db, Some(&me.account), "device.throttled", &addr, &format!("for {}s", wait / 1000)).await.ok();
101
        }
102
        let e = "No app is waiting for that code: it may have expired. Ask the app for a new one.";
103
        return Ok(page(shell, code, None, None, Some(e.into())));
104
    };
105
    match f.action.as_str() {
106
        "approve" => {
107
            // Only scopes the app asked for, and at least one.
108
            let asked = d.scope_list();
109
            let chosen: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| asked.contains(s)).collect();
110
            if chosen.is_empty() {
111
                return Ok(page(shell, code, Some(d), None, Some("Allow at least one of what it asks for, or deny it.".into())));
112
            }
113
            let ok = accounts::approve_device(&app.db, d.id, me.account.id, &chosen.join(","))
114
                .await
115
                .map_err(|e| Error::from_db(shell.clone(), e))?;
116
            if !ok {
117
                return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into())));
118
            }
119
            accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok();
120
            let done = Done { approved: true, client: d.client.clone(), username: me.account.username.clone() };
121
            Ok(page(shell, String::new(), None, Some(done), None))
122
        }
123
        "deny" => {
124
            accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
125
            accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok();
126
            Ok(page(
127
                shell,
128
                String::new(),
129
                None,
130
                Some(Done { approved: false, client: d.client.clone(), username: me.account.username.clone() }),
131
                None,
132
            ))
133
        }
134
        // Looking a typed code up.
135
        _ => Ok(page(shell, code, Some(d), None, None)),
136
    }
137
}