OXYGENHUB

Coverage / crates/hub/src/web/sudo.rs 89.2%

165 of 185 lines run, 17 of 47 functions · run #540 on main · run missed

1
//! The sudo step (oxygenhub#356): before a sensitive change (a password,
2
//! two-factor settings, keys and tokens, secrets, a repository's deletion
3
//! or transfer, the site's administration), a session that has not proved
4
//! a second factor for a while proves one again: a phone's approval, a code
5
//! from the authenticator app, or the password when the account has no
6
//! second factor. Signing in counts as proof.
7
8
use super::{Error, Page, Shell};
9
use crate::auth::{self, phone, totp, Viewer};
10
use crate::db::{self, accounts};
11
use crate::throttle::{self, Key};
12
use crate::Shared;
13
use askama::Template;
14
use axum::extract::{Form, Path, Query, State};
15
use axum::http::{header, Method, StatusCode};
16
use axum::response::{IntoResponse, Redirect, Response};
17
use serde::Deserialize;
18
19
/// Whether a request changes something the sudo step guards. Only
20
/// changes are guarded: reading a page never asks.
213954×
pub fn sensitive(method: &Method, path: &str) -> bool {
223954×
    if method == Method::GET || method == Method::HEAD {
232049×
        return false;
241905×
    }
251905×
    let p = path.trim_end_matches('/');
261905×
    if p.starts_with("/admin") {
27103×
        return true;
281802×
    }
2939×
    if matches!(
301802×
        p,
311802×
        "/settings/password"
321801×
            | "/settings/security/totp/disable"
331799×
            | "/settings/keys/upload"
341795×
            | "/settings/keys/unlink"
351795×
            | "/settings/tokens"
361769×
            | "/settings/backups"
371763×
    ) || p.starts_with("/settings/security/phones/")
381762×
        || p.starts_with("/settings/security/disconnect/")
391761×
        || (p.starts_with("/settings/tokens/") && p.ends_with("/agent"))
40
    {
4142×
        return true;
421760×
    }
431760×
    let parts: Vec<&str> = p.trim_start_matches('/').split('/').collect();
441760×
    match parts.as_slice() {
45443×
        ["orgs", _, "settings", what] => matches!(*what, "secrets" | "storage" | "hooks" | "backups"),
46430×
        [owner, _, "settings", what] if !matches!(*owner, "settings" | "admin" | "orgs") => {
4770×
            matches!(*what, "delete" | "transfer" | "secrets" | "hooks" | "push-protection" | "secret-bypass" | "backup" | "collaborators")
48
        }
491677×
        _ => false,
50
    }
513954×
}
52
53
/// The middleware: a guarded change from a session whose proof is older
54
/// than [`phone::FRESH_MS`] goes to the sudo page instead, which comes back
55
/// to the page it was sent from.
563932×
pub async fn gate(State(app): State<Shared>, req: axum::extract::Request, next: axum::middleware::Next) -> Response {
573932×
    if !sensitive(req.method(), req.uri().path()) {
583761×
        return next.run(req).await;
59171×
    }
60171×
    let Some(value) = auth::viewer::cookie(req.headers(), auth::SESSION_COOKIE) else { return next.run(req).await };
61170×
    let Some((_, session)) = auth::viewer::resolve(&app, &value).await else { return next.run(req).await };
62170×
    if db::now_ms() - session.second_factor_ms < phone::FRESH_MS {
63169×
        return next.run(req).await;
641×
    }
651×
    let base = app.config.base_url().trim_end_matches('/').to_string();
661×
    let back = req
671×
        .headers()
681×
        .get(header::REFERER)
691×
        .and_then(|v| v.to_str().ok())
701×
        .and_then(|r| r.strip_prefix(&base))
711×
        .filter(|p| p.starts_with('/') && !p.starts_with("//"))
721×
        .unwrap_or("/settings/security")
731×
        .to_string();
741×
    Redirect::to(&format!("/sudo?next={}", super::settings::urlencode(&back))).into_response()
753932×
}
76
77
#[derive(Template)]
78
#[template(path = "sudo.html")]
79
pub struct SudoPage {
80
    shell: Shell,
81
    next: String,
82
    error: Option<String>,
83
    /// The account has a phone that approves.
84
    phones: bool,
85
    /// The account has an authenticator app.
86
    totp: bool,
87
    /// Neither: the password stands in.
88
    password: bool,
89
    /// Waiting for the phone: the approval and its number.
90
    approval: Option<(String, i64)>,
91
}
92
93
#[derive(Deserialize)]
94
pub struct NextQuery {
95
    #[serde(default)]
96
    next: String,
97
}
98
995×
fn safe_next(next: &str) -> String {
1005×
    if next.starts_with('/') && !next.starts_with("//") {
1015×
        next.to_string()
102
    } else {
1030×
        "/".into()
104
    }
1055×
}
106
1074×
async fn page(
1084×
    app: &Shared,
1094×
    viewer: &Viewer,
1104×
    next: &str,
1114×
    error: Option<String>,
1124×
    approval: Option<(String, i64)>,
1134×
) -> Result<Response, Error> {
1144×
    let shell = Shell::new(app, "/sudo", viewer);
1154×
    let Some(me) = viewer.account() else {
1160×
        return Ok(Redirect::to(&format!("/login?next={}", super::settings::urlencode(next))).into_response());
117
    };
1184×
    let totp = me.totp_secret.is_some();
1194×
    let phones = totp && !db::approvals::authenticators(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty();
1204×
    let password = !totp && !me.password_hash.is_empty();
1214×
    Ok(Page(SudoPage { shell, next: safe_next(next), error, phones, totp, password, approval }).into_response())
1224×
}
123
124
/// `GET /sudo?next=`: prove it is you, then carry on.
1252×
pub async fn show(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Result<Response, Error> {
1262×
    page(&app, &viewer, &q.next, None, None).await
1272×
}
128
129
#[derive(Deserialize)]
130
pub struct SudoForm {
131
    #[serde(rename = "_csrf")]
132
    csrf: String,
133
    #[serde(default)]
134
    next: String,
135
    #[serde(default)]
136
    code: String,
137
    #[serde(default)]
138
    password: String,
139
}
140
141
/// `POST /sudo`: a code from the authenticator app (or a recovery code),
142
/// or the password for an account without one.
1432×
pub async fn confirm(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SudoForm>) -> Result<Response, Error> {
1442×
    let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) };
1452×
    let shell = Shell::new(&app, "/sudo", &viewer);
1462×
    if !viewer.csrf_ok(&f.csrf) {
1470×
        return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await;
1482×
    }
1492×
    let now = db::now_ms();
1502×
    let keys = [Key::account("sudo", &me.account.username)];
1512×
    let wait = app.throttle.lock().unwrap().wait(&keys, now);
1522×
    if let Some(wait) = wait {
1530×
        return page(&app, &viewer, &f.next, Some(throttle::message(wait)), None).await;
1542×
    }
1552×
    let ok = match &me.account.totp_secret {
1562×
        Some(secret) => {
1572×
            totp::verify(secret, f.code.trim(), (now / 1000) as u64)
1581×
                || match totp::use_recovery_code(&me.account.recovery_codes, f.code.trim()) {
1590×
                    Some(rest) => {
1600×
                        accounts::set_totp(&app.db, me.account.id, Some(secret), &rest)
1610×
                            .await
1620×
                            .map_err(|e| Error::from_db(shell.clone(), e))?;
1630×
                        true
164
                    }
1651×
                    None => false,
166
                }
167
        }
1680×
        None => !me.account.password_hash.is_empty() && auth::verify_password(&me.account.password_hash, &f.password),
169
    };
1702×
    if !ok {
1711×
        app.throttle.lock().unwrap().failed(&keys, now);
1721×
        accounts::audit(&app.db, Some(&me.account), "sudo.failed", &me.account.username, "").await.ok();
1731×
        let what = if me.account.totp_secret.is_some() { "That code is not right." } else { "That password is not right." };
1741×
        return page(&app, &viewer, &f.next, Some(what.into()), None).await;
1751×
    }
1761×
    app.throttle.lock().unwrap().cleared(&keys[0]);
1771×
    db::sessions::mark_second_factor(&app.db, me.session_id, now).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1781×
    accounts::audit(
1791×
        &app.db,
1801×
        Some(&me.account),
1811×
        "sudo",
1821×
        &me.account.username,
1831×
        if me.account.totp_secret.is_some() { "code" } else { "password" },
184
    )
1851×
    .await
1861×
    .ok();
1871×
    Ok(Redirect::to(&safe_next(&f.next)).into_response())
1882×
}
189
190
#[derive(Deserialize)]
191
pub struct PhoneForm {
192
    #[serde(rename = "_csrf")]
193
    csrf: String,
194
    #[serde(default)]
195
    next: String,
196
}
197
198
/// `POST /sudo/phone`: ask the account's phones; the page waits for one.
1991×
pub async fn ask_phone(
2001×
    State(app): State<Shared>,
2011×
    viewer: Viewer,
2021×
    headers: axum::http::HeaderMap,
2031×
    Form(f): Form<PhoneForm>,
2041×
) -> Result<Response, Error> {
2051×
    let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) };
2061×
    let shell = Shell::new(&app, "/sudo", &viewer);
2071×
    if !viewer.csrf_ok(&f.csrf) {
2080×
        return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await;
2091×
    }
2101×
    let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
2111×
    let what = format!("Confirm a change from {}", super::settings::browser_of(agent));
2121×
    let a = phone::ask(&app, &me.account, "stepup", &what, Some(me.session_id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2131×
    page(&app, &viewer, &f.next, None, Some((a.id, a.number))).await
2141×
}
215
216
/// `GET /sudo/approval/{id}`: what became of this session's approval.
2173×
pub async fn approval_state(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Response {
2183×
    let found = match viewer.0.as_ref() {
2193×
        Some(me) => {
2203×
            db::approvals::get(&app.db, &id).await.ok().flatten().filter(|a| a.session_id == Some(me.session_id) && a.kind == "stepup")
221
        }
2220×
        None => None,
223
    };
2243×
    match found {
2252×
        Some(a) => {
2262×
            let state = if a.state == "pending" && a.expires_ms <= db::now_ms() { "expired".to_string() } else { a.state };
2272×
            axum::Json(serde_json::json!({ "state": state })).into_response()
228
        }
2291×
        None => (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(),
230
    }
2313×
}
232
233
#[cfg(test)]
234
mod tests {
235
    use super::*;
236
237
    #[test]
2381×
    fn only_changes_that_matter_are_guarded() {
2391×
        let post = Method::POST;
24013×
        for p in [
2411×
            "/settings/password",
2421×
            "/settings/security/totp/disable",
2431×
            "/settings/security/phones/3/remove",
2441×
            "/settings/tokens",
2451×
            "/settings/tokens/4/agent",
2461×
            "/settings/keys/upload",
2471×
            "/admin/mail",
2481×
            "/admin/runners/7/state",
2491×
            "/orgs/acme/settings/secrets",
2501×
            "/andy/demo/settings/delete",
2511×
            "/andy/demo/settings/transfer",
2521×
            "/andy/demo/settings/secrets",
2531×
            "/andy/demo/settings/collaborators",
2541×
        ] {
25513×
            assert!(sensitive(&post, p), "{p}");
256
        }
2578×
        for p in [
2581×
            "/settings",
2591×
            "/settings/appearance",
2601×
            "/settings/security/totp/begin",
2611×
            "/settings/tokens/4/delete",
2621×
            "/andy/demo/issues/new",
2631×
            "/andy/demo/settings/topics",
2641×
            "/login",
2651×
            "/sudo",
2661×
        ] {
2678×
            assert!(!sensitive(&post, p), "{p}");
268
        }
2691×
        assert!(!sensitive(&Method::GET, "/admin/mail"), "reading never asks");
2701×
    }
271
}