OXYGENHUB
◆ v0.9.22 oxygenhub / crates / hub / src / web / sudo.rs
10.3 KiB · Rust · last changed A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) 3 days ago source blame history raw
a22e4aec Andy McCall ↶ 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.
21
pub fn sensitive(method: &Method, path: &str) -> bool {
22
    if method == Method::GET || method == Method::HEAD {
23
        return false;
24
    }
25
    let p = path.trim_end_matches('/');
26
    if p.starts_with("/admin") {
27
        return true;
28
    }
29
    if matches!(
30
        p,
31
        "/settings/password"
32
            | "/settings/security/totp/disable"
33
            | "/settings/keys/upload"
34
            | "/settings/keys/unlink"
35
            | "/settings/tokens"
36
            | "/settings/backups"
37
    ) || p.starts_with("/settings/security/phones/")
38
        || p.starts_with("/settings/security/disconnect/")
39
        || (p.starts_with("/settings/tokens/") && p.ends_with("/agent"))
40
    {
41
        return true;
42
    }
43
    let parts: Vec<&str> = p.trim_start_matches('/').split('/').collect();
44
    match parts.as_slice() {
45
        ["orgs", _, "settings", what] => matches!(*what, "secrets" | "storage" | "hooks" | "backups"),
46
        [owner, _, "settings", what] if !matches!(*owner, "settings" | "admin" | "orgs") => {
47
            matches!(*what, "delete" | "transfer" | "secrets" | "hooks" | "push-protection" | "secret-bypass" | "backup" | "collaborators")
48
        }
49
        _ => false,
50
    }
51
}
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.
56
pub async fn gate(State(app): State<Shared>, req: axum::extract::Request, next: axum::middleware::Next) -> Response {
57
    if !sensitive(req.method(), req.uri().path()) {
58
        return next.run(req).await;
59
    }
60
    let Some(value) = auth::viewer::cookie(req.headers(), auth::SESSION_COOKIE) else { return next.run(req).await };
61
    let Some((_, session)) = auth::viewer::resolve(&app, &value).await else { return next.run(req).await };
62
    if db::now_ms() - session.second_factor_ms < phone::FRESH_MS {
63
        return next.run(req).await;
64
    }
65
    let base = app.config.base_url().trim_end_matches('/').to_string();
66
    let back = req
67
        .headers()
68
        .get(header::REFERER)
69
        .and_then(|v| v.to_str().ok())
70
        .and_then(|r| r.strip_prefix(&base))
71
        .filter(|p| p.starts_with('/') && !p.starts_with("//"))
72
        .unwrap_or("/settings/security")
73
        .to_string();
74
    Redirect::to(&format!("/sudo?next={}", super::settings::urlencode(&back))).into_response()
75
}
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
99
fn safe_next(next: &str) -> String {
100
    if next.starts_with('/') && !next.starts_with("//") {
101
        next.to_string()
102
    } else {
103
        "/".into()
104
    }
105
}
106
107
async fn page(
108
    app: &Shared,
109
    viewer: &Viewer,
110
    next: &str,
111
    error: Option<String>,
112
    approval: Option<(String, i64)>,
113
) -> Result<Response, Error> {
114
    let shell = Shell::new(app, "/sudo", viewer);
115
    let Some(me) = viewer.account() else {
116
        return Ok(Redirect::to(&format!("/login?next={}", super::settings::urlencode(next))).into_response());
117
    };
118
    let totp = me.totp_secret.is_some();
119
    let phones = totp && !db::approvals::authenticators(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty();
120
    let password = !totp && !me.password_hash.is_empty();
121
    Ok(Page(SudoPage { shell, next: safe_next(next), error, phones, totp, password, approval }).into_response())
122
}
123
124
/// `GET /sudo?next=`: prove it is you, then carry on.
125
pub async fn show(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Result<Response, Error> {
126
    page(&app, &viewer, &q.next, None, None).await
127
}
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.
143
pub async fn confirm(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SudoForm>) -> Result<Response, Error> {
144
    let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) };
145
    let shell = Shell::new(&app, "/sudo", &viewer);
146
    if !viewer.csrf_ok(&f.csrf) {
147
        return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await;
148
    }
149
    let now = db::now_ms();
150
    let keys = [Key::account("sudo", &me.account.username)];
151
    let wait = app.throttle.lock().unwrap().wait(&keys, now);
152
    if let Some(wait) = wait {
153
        return page(&app, &viewer, &f.next, Some(throttle::message(wait)), None).await;
154
    }
155
    let ok = match &me.account.totp_secret {
156
        Some(secret) => {
157
            totp::verify(secret, f.code.trim(), (now / 1000) as u64)
158
                || match totp::use_recovery_code(&me.account.recovery_codes, f.code.trim()) {
159
                    Some(rest) => {
160
                        accounts::set_totp(&app.db, me.account.id, Some(secret), &rest)
161
                            .await
162
                            .map_err(|e| Error::from_db(shell.clone(), e))?;
163
                        true
164
                    }
165
                    None => false,
166
                }
167
        }
168
        None => !me.account.password_hash.is_empty() && auth::verify_password(&me.account.password_hash, &f.password),
169
    };
170
    if !ok {
171
        app.throttle.lock().unwrap().failed(&keys, now);
172
        accounts::audit(&app.db, Some(&me.account), "sudo.failed", &me.account.username, "").await.ok();
173
        let what = if me.account.totp_secret.is_some() { "That code is not right." } else { "That password is not right." };
174
        return page(&app, &viewer, &f.next, Some(what.into()), None).await;
175
    }
176
    app.throttle.lock().unwrap().cleared(&keys[0]);
177
    db::sessions::mark_second_factor(&app.db, me.session_id, now).await.map_err(|e| Error::from_db(shell.clone(), e))?;
178
    accounts::audit(
179
        &app.db,
180
        Some(&me.account),
181
        "sudo",
182
        &me.account.username,
183
        if me.account.totp_secret.is_some() { "code" } else { "password" },
184
    )
185
    .await
186
    .ok();
187
    Ok(Redirect::to(&safe_next(&f.next)).into_response())
188
}
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.
199
pub async fn ask_phone(
200
    State(app): State<Shared>,
201
    viewer: Viewer,
202
    headers: axum::http::HeaderMap,
203
    Form(f): Form<PhoneForm>,
204
) -> Result<Response, Error> {
205
    let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) };
206
    let shell = Shell::new(&app, "/sudo", &viewer);
207
    if !viewer.csrf_ok(&f.csrf) {
208
        return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await;
209
    }
210
    let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
211
    let what = format!("Confirm a change from {}", super::settings::browser_of(agent));
212
    let a = phone::ask(&app, &me.account, "stepup", &what, Some(me.session_id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
213
    page(&app, &viewer, &f.next, None, Some((a.id, a.number))).await
214
}
215
216
/// `GET /sudo/approval/{id}`: what became of this session's approval.
217
pub async fn approval_state(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Response {
218
    let found = match viewer.0.as_ref() {
219
        Some(me) => {
220
            db::approvals::get(&app.db, &id).await.ok().flatten().filter(|a| a.session_id == Some(me.session_id) && a.kind == "stepup")
221
        }
222
        None => None,
223
    };
224
    match found {
225
        Some(a) => {
226
            let state = if a.state == "pending" && a.expires_ms <= db::now_ms() { "expired".to_string() } else { a.state };
227
            axum::Json(serde_json::json!({ "state": state })).into_response()
228
        }
229
        None => (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(),
230
    }
231
}
232
233
#[cfg(test)]
234
mod tests {
235
    use super::*;
236
237
    #[test]
238
    fn only_changes_that_matter_are_guarded() {
239
        let post = Method::POST;
240
        for p in [
241
            "/settings/password",
242
            "/settings/security/totp/disable",
243
            "/settings/security/phones/3/remove",
244
            "/settings/tokens",
245
            "/settings/tokens/4/agent",
246
            "/settings/keys/upload",
247
            "/admin/mail",
248
            "/admin/runners/7/state",
249
            "/orgs/acme/settings/secrets",
250
            "/andy/demo/settings/delete",
251
            "/andy/demo/settings/transfer",
252
            "/andy/demo/settings/secrets",
253
            "/andy/demo/settings/collaborators",
254
        ] {
255
            assert!(sensitive(&post, p), "{p}");
256
        }
257
        for p in [
258
            "/settings",
259
            "/settings/appearance",
260
            "/settings/security/totp/begin",
261
            "/settings/tokens/4/delete",
262
            "/andy/demo/issues/new",
263
            "/andy/demo/settings/topics",
264
            "/login",
265
            "/sudo",
266
        ] {
267
            assert!(!sensitive(&post, p), "{p}");
268
        }
269
        assert!(!sensitive(&Method::GET, "/admin/mail"), "reading never asks");
270
    }
271
}