//! The sudo step (oxygenhub#356): before a sensitive change (a password, //! two-factor settings, keys and tokens, secrets, a repository's deletion //! or transfer, the site's administration), a session that has not proved //! a second factor for a while proves one again: a phone's approval, a code //! from the authenticator app, or the password when the account has no //! second factor. Signing in counts as proof. use super::{Error, Page, Shell}; use crate::auth::{self, phone, totp, Viewer}; use crate::db::{self, accounts}; use crate::throttle::{self, Key}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::http::{header, Method, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; /// Whether a request changes something the sudo step guards. Only /// changes are guarded: reading a page never asks. pub fn sensitive(method: &Method, path: &str) -> bool { if method == Method::GET || method == Method::HEAD { return false; } let p = path.trim_end_matches('/'); if p.starts_with("/admin") { return true; } if matches!( p, "/settings/password" | "/settings/security/totp/disable" | "/settings/keys/upload" | "/settings/keys/unlink" | "/settings/tokens" | "/settings/backups" ) || p.starts_with("/settings/security/phones/") || p.starts_with("/settings/security/disconnect/") || (p.starts_with("/settings/tokens/") && p.ends_with("/agent")) { return true; } let parts: Vec<&str> = p.trim_start_matches('/').split('/').collect(); match parts.as_slice() { ["orgs", _, "settings", what] => matches!(*what, "secrets" | "storage" | "hooks" | "backups"), [owner, _, "settings", what] if !matches!(*owner, "settings" | "admin" | "orgs") => { matches!(*what, "delete" | "transfer" | "secrets" | "hooks" | "push-protection" | "secret-bypass" | "backup" | "collaborators") } _ => false, } } /// The middleware: a guarded change from a session whose proof is older /// than [`phone::FRESH_MS`] goes to the sudo page instead, which comes back /// to the page it was sent from. pub async fn gate(State(app): State, req: axum::extract::Request, next: axum::middleware::Next) -> Response { if !sensitive(req.method(), req.uri().path()) { return next.run(req).await; } let Some(value) = auth::viewer::cookie(req.headers(), auth::SESSION_COOKIE) else { return next.run(req).await }; let Some((_, session)) = auth::viewer::resolve(&app, &value).await else { return next.run(req).await }; if db::now_ms() - session.second_factor_ms < phone::FRESH_MS { return next.run(req).await; } let base = app.config.base_url().trim_end_matches('/').to_string(); let back = req .headers() .get(header::REFERER) .and_then(|v| v.to_str().ok()) .and_then(|r| r.strip_prefix(&base)) .filter(|p| p.starts_with('/') && !p.starts_with("//")) .unwrap_or("/settings/security") .to_string(); Redirect::to(&format!("/sudo?next={}", super::settings::urlencode(&back))).into_response() } #[derive(Template)] #[template(path = "sudo.html")] pub struct SudoPage { shell: Shell, next: String, error: Option, /// The account has a phone that approves. phones: bool, /// The account has an authenticator app. totp: bool, /// Neither: the password stands in. password: bool, /// Waiting for the phone: the approval and its number. approval: Option<(String, i64)>, } #[derive(Deserialize)] pub struct NextQuery { #[serde(default)] next: String, } fn safe_next(next: &str) -> String { if next.starts_with('/') && !next.starts_with("//") { next.to_string() } else { "/".into() } } async fn page( app: &Shared, viewer: &Viewer, next: &str, error: Option, approval: Option<(String, i64)>, ) -> Result { let shell = Shell::new(app, "/sudo", viewer); let Some(me) = viewer.account() else { return Ok(Redirect::to(&format!("/login?next={}", super::settings::urlencode(next))).into_response()); }; let totp = me.totp_secret.is_some(); let phones = totp && !db::approvals::authenticators(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty(); let password = !totp && !me.password_hash.is_empty(); Ok(Page(SudoPage { shell, next: safe_next(next), error, phones, totp, password, approval }).into_response()) } /// `GET /sudo?next=`: prove it is you, then carry on. pub async fn show(State(app): State, viewer: Viewer, Query(q): Query) -> Result { page(&app, &viewer, &q.next, None, None).await } #[derive(Deserialize)] pub struct SudoForm { #[serde(rename = "_csrf")] csrf: String, #[serde(default)] next: String, #[serde(default)] code: String, #[serde(default)] password: String, } /// `POST /sudo`: a code from the authenticator app (or a recovery code), /// or the password for an account without one. pub async fn confirm(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) }; let shell = Shell::new(&app, "/sudo", &viewer); if !viewer.csrf_ok(&f.csrf) { return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await; } let now = db::now_ms(); let keys = [Key::account("sudo", &me.account.username)]; let wait = app.throttle.lock().unwrap().wait(&keys, now); if let Some(wait) = wait { return page(&app, &viewer, &f.next, Some(throttle::message(wait)), None).await; } let ok = match &me.account.totp_secret { Some(secret) => { totp::verify(secret, f.code.trim(), (now / 1000) as u64) || match totp::use_recovery_code(&me.account.recovery_codes, f.code.trim()) { Some(rest) => { accounts::set_totp(&app.db, me.account.id, Some(secret), &rest) .await .map_err(|e| Error::from_db(shell.clone(), e))?; true } None => false, } } None => !me.account.password_hash.is_empty() && auth::verify_password(&me.account.password_hash, &f.password), }; if !ok { app.throttle.lock().unwrap().failed(&keys, now); accounts::audit(&app.db, Some(&me.account), "sudo.failed", &me.account.username, "").await.ok(); let what = if me.account.totp_secret.is_some() { "That code is not right." } else { "That password is not right." }; return page(&app, &viewer, &f.next, Some(what.into()), None).await; } app.throttle.lock().unwrap().cleared(&keys[0]); db::sessions::mark_second_factor(&app.db, me.session_id, now).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit( &app.db, Some(&me.account), "sudo", &me.account.username, if me.account.totp_secret.is_some() { "code" } else { "password" }, ) .await .ok(); Ok(Redirect::to(&safe_next(&f.next)).into_response()) } #[derive(Deserialize)] pub struct PhoneForm { #[serde(rename = "_csrf")] csrf: String, #[serde(default)] next: String, } /// `POST /sudo/phone`: ask the account's phones; the page waits for one. pub async fn ask_phone( State(app): State, viewer: Viewer, headers: axum::http::HeaderMap, Form(f): Form, ) -> Result { let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) }; let shell = Shell::new(&app, "/sudo", &viewer); if !viewer.csrf_ok(&f.csrf) { return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await; } let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or(""); let what = format!("Confirm a change from {}", super::settings::browser_of(agent)); let a = phone::ask(&app, &me.account, "stepup", &what, Some(me.session_id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; page(&app, &viewer, &f.next, None, Some((a.id, a.number))).await } /// `GET /sudo/approval/{id}`: what became of this session's approval. pub async fn approval_state(State(app): State, viewer: Viewer, Path(id): Path) -> Response { let found = match viewer.0.as_ref() { Some(me) => { db::approvals::get(&app.db, &id).await.ok().flatten().filter(|a| a.session_id == Some(me.session_id) && a.kind == "stepup") } None => None, }; match found { Some(a) => { let state = if a.state == "pending" && a.expires_ms <= db::now_ms() { "expired".to_string() } else { a.state }; axum::Json(serde_json::json!({ "state": state })).into_response() } None => (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(), } } #[cfg(test)] mod tests { use super::*; #[test] fn only_changes_that_matter_are_guarded() { let post = Method::POST; for p in [ "/settings/password", "/settings/security/totp/disable", "/settings/security/phones/3/remove", "/settings/tokens", "/settings/tokens/4/agent", "/settings/keys/upload", "/admin/mail", "/admin/runners/7/state", "/orgs/acme/settings/secrets", "/andy/demo/settings/delete", "/andy/demo/settings/transfer", "/andy/demo/settings/secrets", "/andy/demo/settings/collaborators", ] { assert!(sensitive(&post, p), "{p}"); } for p in [ "/settings", "/settings/appearance", "/settings/security/totp/begin", "/settings/tokens/4/delete", "/andy/demo/issues/new", "/andy/demo/settings/topics", "/login", "/sudo", ] { assert!(!sensitive(&post, p), "{p}"); } assert!(!sensitive(&Method::GET, "/admin/mail"), "reading never asks"); } }