OXYGENHUB

Coverage / crates/hub/src/web/settings.rs 74.3%

479 of 645 lines run, 72 of 237 functions · run #538 on main · run missed

1
//! Account settings (hub spec §6.3 `/settings/*`): profile, security,
2
//! linked identities, tokens.
3
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::{self, totp, Viewer, ViewerAccount};
6
use crate::db::{self, accounts};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Multipart, Path, Query, State};
10
use axum::http::{header, HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use oxy_core::identity::{Identity, Signature};
13
use serde::Deserialize;
14
15
/// The signed-in account, or a redirect to sign in.
16449×
pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> {
17449×
    viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path))))
18449×
}
19
20379×
pub fn urlencode(s: &str) -> String {
21379×
    let mut out = String::new();
228411×
    for b in s.bytes() {
238411×
        match b {
247536×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),
25875×
            _ => out.push_str(&format!("%{b:02X}")),
26
        }
27
    }
28379×
    out
29379×
}
30
31498×
pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> {
32498×
    if viewer.csrf_ok(token) {
33495×
        Ok(())
34
    } else {
353×
        Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again."))
36
    }
37498×
}
38
39
#[derive(Template)]
40
#[template(path = "settings.html")]
41
pub struct SettingsPage {
42
    shell: Shell,
43
    me: accounts::Account,
44
    notice: Option<String>,
45
    error: Option<String>,
46
    /// The theme cookie's value, for the Appearance form.
47
    theme: &'static str,
48
    themes: &'static [(&'static str, &'static str)],
49
    /// The backdrop cookie's value and the choices (docs/backgrounds.md §7).
50
    backdrop: &'static str,
51
    backdrops: &'static [(&'static str, &'static str)],
52
    /// Whether the hub sends mail, for the "send a new link" button.
53
    mail: bool,
54
}
55
56
/// The themes (hub spec §6.1): the cookie value and its label.
57
const THEMES: &[(&str, &str)] = &[("light", "Light"), ("dark", "Dark"), ("auto", "Match the system")];
58
59
/// Backdrops (docs/backgrounds.md §7): the cookie value and its label.
60
const BACKDROPS: &[(&str, &str)] = &[("moving", "Moving"), ("still", "Still"), ("off", "Off")];
61
62
/// The backdrop the request's cookie asks for; moving when unset.
6325×
fn backdrop_of(headers: &HeaderMap) -> &'static str {
6425×
    let v = auth::viewer::cookie(headers, "backdrop").unwrap_or_default();
6575×
    BACKDROPS.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("moving")
6625×
}
67
68
/// The theme the request's cookie asks for; light when unset or unknown.
6925×
fn theme_of(headers: &HeaderMap) -> &'static str {
7025×
    let v = auth::viewer::cookie(headers, "theme").unwrap_or_default();
7174×
    THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("light")
7225×
}
73
74
impl SettingsPage {
7525×
    fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage {
7625×
        SettingsPage {
7725×
            shell,
7825×
            me,
7925×
            notice: None,
8025×
            error: None,
8125×
            theme: theme_of(headers),
8225×
            themes: THEMES,
8325×
            backdrop: backdrop_of(headers),
8425×
            backdrops: BACKDROPS,
8525×
            mail: false,
8625×
        }
8725×
    }
88
8925×
    fn with_mail(mut self, app: &Shared) -> Self {
9025×
        self.mail = crate::mail::enabled(app);
9125×
        self
9225×
    }
93
948×
    fn notice(mut self, n: impl Into<String>) -> Self {
958×
        self.notice = Some(n.into());
968×
        self
978×
    }
98
994×
    fn error(mut self, e: impl Into<String>) -> Self {
1004×
        self.error = Some(e.into());
1014×
        self
1024×
    }
103
}
104
10518×
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
10618×
    let shell = Shell::new(&app, "/settings", &viewer);
10718×
    let me = require(&viewer, &shell)?;
10813×
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app)))
10918×
}
110
111
/// `POST /settings/avatar`: upload or remove the profile picture.
1124×
pub async fn avatar(
1134×
    State(app): State<Shared>,
1144×
    viewer: Viewer,
1154×
    headers: HeaderMap,
1164×
    multipart: Multipart,
1174×
) -> Result<Page<SettingsPage>, Error> {
1184×
    let shell = Shell::new(&app, "/settings", &viewer);
1194×
    let me = require(&viewer, &shell)?;
1204×
    let upload = super::avatar::read_upload(multipart).await;
1214×
    csrf(&viewer, &upload.csrf, &shell)?;
1223×
    let result = super::avatar::apply(&app, "account", me.account.id, upload).await;
1233×
    if result.is_ok() {
1242×
        accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok();
1251×
    }
1263×
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
1273×
    let page = SettingsPage::new(shell, fresh, &headers).with_mail(&app);
1283×
    Ok(Page(match result {
1292×
        Ok(m) => page.notice(m),
1301×
        Err(e) => page.error(e),
131
    }))
1324×
}
133
134
/// `POST /settings/email/verify`: send a new verification link.
1351×
pub async fn resend_verification(
1361×
    State(app): State<Shared>,
1371×
    viewer: Viewer,
1381×
    headers: HeaderMap,
1391×
    Form(f): Form<super::session::CsrfForm>,
1401×
) -> Result<Page<SettingsPage>, Error> {
1411×
    let shell = Shell::new(&app, "/settings", &viewer);
1421×
    let me = require(&viewer, &shell)?;
1431×
    csrf(&viewer, &f.csrf, &shell)?;
1441×
    let page = SettingsPage::new(shell, me.account.clone(), &headers).with_mail(&app);
1451×
    Ok(Page(match super::recovery::send_verification(&app, &me.account).await {
1460×
        Ok(()) if me.account.email_verified => page.notice("Your email is already verified."),
1470×
        Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)),
1481×
        Err(e) => page.error(e),
149
    }))
1501×
}
151
152
#[derive(Deserialize)]
153
pub struct AppearanceForm {
154
    #[serde(rename = "_csrf")]
155
    csrf_token: String,
156
    theme: String,
157
    #[serde(default)]
158
    backdrop: String,
159
}
160
161
/// Remember the theme and the backdrop in cookies for a year; the page
162
/// scripts apply them.
1635×
pub async fn appearance(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AppearanceForm>) -> Result<Response, Error> {
1645×
    let shell = Shell::new(&app, "/settings", &viewer);
1655×
    require(&viewer, &shell)?;
1665×
    csrf(&viewer, &f.csrf_token, &shell)?;
1677×
    let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else {
1681×
        return Err(Error::bad(shell, "Pick dark, light or match the system."));
169
    };
1703×
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
1713×
    let backdrop = if f.backdrop.is_empty() { "moving" } else { f.backdrop.as_str() };
1726×
    let Some((backdrop, _)) = BACKDROPS.iter().find(|(b, _)| *b == backdrop) else {
1731×
        return Err(Error::bad(shell, "Pick a moving, still or no background."));
174
    };
1752×
    Ok((
1762×
        StatusCode::SEE_OTHER,
1772×
        axum::response::AppendHeaders([
1782×
            (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")),
1792×
            (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")),
1802×
            (header::LOCATION, "/settings".to_string()),
1812×
        ]),
1822×
    )
1832×
        .into_response())
1845×
}
185
186
#[derive(Deserialize)]
187
pub struct ProfileForm {
188
    #[serde(rename = "_csrf")]
189
    csrf_token: String,
190
    display_name: String,
191
    email: String,
192
    #[serde(default)]
193
    bio: String,
194
    #[serde(default)]
195
    website: String,
196
    #[serde(default)]
197
    location: String,
198
    #[serde(default)]
199
    bluesky: String,
200
    #[serde(default)]
201
    mastodon: String,
202
    #[serde(default)]
203
    github: String,
204
}
205
2069×
pub async fn update_profile(
2079×
    State(app): State<Shared>,
2089×
    viewer: Viewer,
2099×
    headers: HeaderMap,
2109×
    Form(f): Form<ProfileForm>,
2119×
) -> Result<Page<SettingsPage>, Error> {
2129×
    let shell = Shell::new(&app, "/settings", &viewer);
2139×
    let me = require(&viewer, &shell)?;
2149×
    csrf(&viewer, &f.csrf_token, &shell)?;
2158×
    let website = f.website.trim();
2168×
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
2171×
        return Ok(Page(
2181×
            SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."),
2191×
        ));
2207×
    }
221
    // Accounts elsewhere: checked and normalised, or refused by name.
22219×
    let link = |given: &str, what: &str, parse: fn(&str) -> Option<String>| -> Result<String, String> {
22319×
        if given.trim().is_empty() {
22415×
            Ok(String::new())
225
        } else {
2264×
            parse(given).ok_or_else(|| format!("That is not a {what}."))
227
        }
22819×
    };
2297×
    let links = (|| {
230
        Ok::<_, String>((
2317×
            link(&f.bluesky, "Bluesky handle (such as andy.bsky.social)", crate::social::bluesky)?,
2326×
            link(&f.mastodon, "Mastodon address (such as @[email protected])", crate::social::mastodon)?,
2336×
            link(&f.github, "GitHub username", crate::social::github)?,
234
        ))
235
    })();
2367×
    let (bluesky, mastodon, github) = match links {
2376×
        Ok(l) => l,
2381×
        Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(&msg))),
239
    };
2406×
    let bio: String = f.bio.trim().chars().take(280).collect();
2416×
    let location: String = f.location.trim().chars().take(80).collect();
2426×
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
2436×
        .await
2446×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
2456×
    accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2466×
    accounts::set_links(&app.db, me.account.id, &bluesky, &mastodon, &github).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2476×
    let changed = me.account.email != f.email.trim();
2486×
    let updated =
2496×
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
2506×
    let mut notice = "Profile saved.".to_string();
2516×
    if changed && !updated.email.is_empty() {
2524×
        accounts::audit(&app.db, Some(&updated), "email.changed", &updated.username, "").await.ok();
2534×
        match super::recovery::send_verification(&app, &updated).await {
2543×
            Ok(()) => notice = format!("Profile saved. A verification link is on its way to {}.", updated.email),
2551×
            Err(e) => notice = format!("Profile saved. {e}"),
256
        }
2572×
    }
2586×
    Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice)))
2599×
}
260
261
#[derive(Deserialize)]
262
pub struct PasswordForm {
263
    #[serde(rename = "_csrf")]
264
    csrf_token: String,
265
    current: String,
266
    password: String,
267
}
268
2690×
pub async fn change_password(
2700×
    State(app): State<Shared>,
2710×
    viewer: Viewer,
2720×
    headers: HeaderMap,
2730×
    Form(f): Form<PasswordForm>,
2740×
) -> Result<Response, Error> {
2750×
    let shell = Shell::new(&app, "/settings", &viewer);
2760×
    let me = require(&viewer, &shell)?;
2770×
    csrf(&viewer, &f.csrf_token, &shell)?;
2780×
    if !auth::verify_password(&me.account.password_hash, &f.current) {
2790×
        return Ok(
2800×
            Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response()
2810×
        );
2820×
    }
2830×
    if let Err(e) = auth::password_ok(&f.password) {
2840×
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response());
2850×
    }
2860×
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
287
    // A new session salt signs out every other session; this one is renewed.
2880×
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2890×
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
2900×
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
2910×
    Ok(super::session::signed_in(&app, &fresh, "/settings/security").await)
2920×
}
293
294
#[derive(Template)]
295
#[template(path = "settings_security.html")]
296
pub struct SecurityPage {
297
    shell: Shell,
298
    me: accounts::Account,
299
    notice: Option<String>,
300
    error: Option<String>,
301
    /// Enrolment in progress: secret, URI and QR.
302
    enrol: Option<(String, String, String)>,
303
    recovery: Vec<String>,
304
    /// Providers on this hub and the login connected, if any (#106).
305
    connected: Vec<(&'static str, &'static str, Option<String>)>,
306
    /// Where the account is signed in (oxygenhub#223).
307
    sessions: Vec<SessionView>,
308
    /// Phones that approve as a second factor (oxygenhub#356).
309
    phones: Vec<crate::db::approvals::Authenticator>,
310
}
311
312
/// One signed-in browser, as Settings › Security lists it.
313
pub struct SessionView {
314
    pub id: i64,
315
    pub current: bool,
316
    pub browser: String,
317
    pub address: String,
318
    pub seen_ms: i64,
319
    pub created_ms: i64,
320
}
321
322
/// "Firefox on Linux" from a User-Agent; "Unknown browser" when it says nothing useful.
32322×
pub fn browser_of(agent: &str) -> String {
324303×
    let has = |s: &str| agent.contains(s);
32522×
    let browser = if has("Edg/") {
3261×
        "Edge"
32721×
    } else if has("OPR/") {
3280×
        "Opera"
32921×
    } else if has("Firefox/") {
3301×
        "Firefox"
33120×
    } else if has("Chrome/") || has("Chromium/") {
3321×
        "Chrome"
33319×
    } else if has("Safari/") {
3341×
        "Safari"
33518×
    } else if has("curl/") {
3360×
        "curl"
337
    } else {
33818×
        ""
339
    };
34022×
    let os = if has("Android") {
3410×
        "Android"
34222×
    } else if has("iPhone") || has("iPad") {
3431×
        "iOS"
34421×
    } else if has("Windows") {
3451×
        "Windows"
34620×
    } else if has("Mac OS X") || has("Macintosh") {
3471×
        "macOS"
34819×
    } else if has("CrOS") {
3490×
        "ChromeOS"
35019×
    } else if has("Linux") {
3511×
        "Linux"
352
    } else {
35318×
        ""
354
    };
35522×
    match (browser, os) {
35622×
        ("", "") => "Unknown browser".into(),
3574×
        (b, "") => b.into(),
3584×
        ("", o) => format!("A browser on {o}"),
3594×
        (b, o) => format!("{b} on {o}"),
360
    }
36122×
}
362
36310×
async fn sessions_of(app: &Shared, me: &crate::auth::ViewerAccount) -> sqlx::Result<Vec<SessionView>> {
36410×
    Ok(crate::db::sessions::of_account(&app.db, me.account.id, &me.account.session_salt)
36510×
        .await?
36610×
        .into_iter()
36710×
        .map(|s| SessionView {
36814×
            id: s.id,
36914×
            current: s.id == me.session_id,
37014×
            browser: browser_of(&s.agent),
37114×
            address: s.address,
37214×
            seen_ms: s.seen_ms,
37314×
            created_ms: s.created_ms,
37414×
        })
37510×
        .collect())
37610×
}
377
378
#[derive(Deserialize)]
379
pub struct SecurityQuery {
380
    notice: Option<String>,
381
    error: Option<String>,
382
}
383
38414×
pub async fn security(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SecurityQuery>) -> Result<Page<SecurityPage>, Error> {
38514×
    let shell = Shell::new(&app, "/settings/security", &viewer);
38614×
    let me = require(&viewer, &shell)?;
38710×
    let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
38810×
    let sessions = sessions_of(&app, &me).await.map_err(|e| Error::from_db(shell.clone(), e))?;
38910×
    let phones = crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
39010×
    Ok(Page(SecurityPage {
39110×
        shell,
39210×
        me: me.account,
39310×
        notice: q.notice,
39410×
        error: q.error,
39510×
        enrol: None,
39610×
        recovery: vec![],
39710×
        connected,
39810×
        sessions,
39910×
        phones,
40010×
    }))
40114×
}
402
403
/// Each sign-in provider this hub offers, with the login connected to the account.
40410×
pub async fn connected_of(app: &Shared, account_id: i64) -> sqlx::Result<Vec<(&'static str, &'static str, Option<String>)>> {
40510×
    let links = crate::signin::links_of(&app.db, account_id).await?;
40610×
    let mut out = vec![];
40710×
    for p in crate::signin::enabled(app).await {
4086×
        out.push((p.id, p.name, links.iter().find(|l| l.provider == p.id).map(|l| l.login.clone())));
409
    }
41010×
    Ok(out)
41110×
}
412
413
#[derive(Deserialize)]
414
pub struct TotpForm {
415
    #[serde(rename = "_csrf")]
416
    csrf_token: String,
417
    #[serde(default)]
418
    secret: String,
419
    #[serde(default)]
420
    code: String,
421
    #[serde(default)]
422
    password: String,
423
}
424
425
/// Step one: show a fresh secret to scan.
4262×
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
4272×
    let shell = Shell::new(&app, "/settings/security", &viewer);
4282×
    let me = require(&viewer, &shell)?;
4292×
    csrf(&viewer, &f.csrf_token, &shell)?;
4302×
    let secret = totp::new_secret();
4312×
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
4322×
    let qr = totp::qr_svg(&uri).unwrap_or_default();
4332×
    Ok(Page(SecurityPage {
4342×
        shell,
4352×
        me: me.account,
4362×
        notice: None,
4372×
        error: None,
4382×
        enrol: Some((secret, uri, qr)),
4392×
        recovery: vec![],
4402×
        connected: vec![],
4412×
        sessions: vec![],
4422×
        phones: vec![],
4432×
    }))
4442×
}
445
446
/// Step two: the first code proves the app holds the secret.
4472×
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
4482×
    let shell = Shell::new(&app, "/settings/security", &viewer);
4492×
    let me = require(&viewer, &shell)?;
4502×
    csrf(&viewer, &f.csrf_token, &shell)?;
4512×
    let now = (db::now_ms() / 1000) as u64;
4522×
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
4530×
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
4540×
        let qr = totp::qr_svg(&uri).unwrap_or_default();
4550×
        return Ok(Page(SecurityPage {
4560×
            shell,
4570×
            me: me.account,
4580×
            notice: None,
4590×
            error: Some("That code did not match; scan again and enter the current code.".into()),
4600×
            enrol: Some((f.secret, uri, qr)),
4610×
            recovery: vec![],
4620×
            connected: vec![],
4630×
            sessions: vec![],
4640×
            phones: vec![],
4650×
        }));
4662×
    }
4672×
    let (codes, stored) = totp::new_recovery_codes();
4682×
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4692×
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
4702×
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
4712×
    Ok(Page(SecurityPage {
4722×
        shell,
4732×
        me: fresh,
4742×
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
4752×
        error: None,
4762×
        enrol: None,
4772×
        recovery: codes,
4782×
        connected: vec![],
4792×
        sessions: vec![],
4802×
        phones: vec![],
4812×
    }))
4822×
}
483
4841×
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
4851×
    let shell = Shell::new(&app, "/settings/security", &viewer);
4861×
    let me = require(&viewer, &shell)?;
4871×
    csrf(&viewer, &f.csrf_token, &shell)?;
4881×
    if !auth::verify_password(&me.account.password_hash, &f.password) {
4890×
        return Ok(Page(SecurityPage {
4900×
            shell,
4910×
            me: me.account,
4920×
            notice: None,
4930×
            error: Some("Your password is wrong.".into()),
4940×
            enrol: None,
4950×
            recovery: vec![],
4960×
            connected: vec![],
4970×
            sessions: vec![],
4980×
            phones: vec![],
4990×
        }));
5001×
    }
5011×
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
5021×
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
503
    // A phone stands beside the app's codes, never alone (oxygenhub#356).
5041×
    for p in crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
5051×
        crate::db::approvals::remove_authenticator(&app.db, me.account.id, p.token_id)
5061×
            .await
5071×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
508
    }
5091×
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
5101×
    Ok(Page(SecurityPage {
5111×
        shell,
5121×
        me: fresh,
5131×
        notice: Some("Two-factor authentication is off, and no phone approves for you now.".into()),
5141×
        error: None,
5151×
        enrol: None,
5161×
        recovery: vec![],
5171×
        connected: vec![],
5181×
        sessions: vec![],
5191×
        phones: vec![],
5201×
    }))
5211×
}
522
523
/// Sign out everywhere: a new session salt invalidates every cookie, then
524
/// this browser gets a fresh one.
5251×
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
5261×
    let shell = Shell::new(&app, "/settings/security", &viewer);
5271×
    let me = require(&viewer, &shell)?;
5281×
    csrf(&viewer, &f.csrf, &shell)?;
5291×
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5301×
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
5311×
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
5321×
    Ok(super::session::signed_in(&app, &fresh, "/settings/security").await)
5331×
}
534
535
/// `POST /settings/security/sessions/{id}/end`: sign one browser out.
5361×
pub async fn session_end(
5371×
    State(app): State<Shared>,
5381×
    viewer: Viewer,
5391×
    Path(id): Path<i64>,
5401×
    Form(f): Form<super::session::CsrfForm>,
5411×
) -> Result<Response, Error> {
5421×
    let shell = Shell::new(&app, "/settings/security", &viewer);
5431×
    let me = require(&viewer, &shell)?;
5441×
    csrf(&viewer, &f.csrf, &shell)?;
5451×
    let ended = crate::db::sessions::end(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5461×
    if ended {
5471×
        accounts::audit(&app.db, Some(&me.account), "session.ended", &me.account.username, &id.to_string()).await.ok();
5480×
    }
5491×
    let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." };
5501×
    Ok(Redirect::to(to).into_response())
5511×
}
552
553
/// `POST /settings/security/phones/{id}/remove` (oxygenhub#356): that
554
/// phone no longer approves for the account.
5550×
pub async fn phone_remove(
5560×
    State(app): State<Shared>,
5570×
    viewer: Viewer,
5580×
    Path(id): Path<i64>,
5590×
    Form(f): Form<super::session::CsrfForm>,
5600×
) -> Result<Response, Error> {
5610×
    let shell = Shell::new(&app, "/settings/security", &viewer);
5620×
    let me = require(&viewer, &shell)?;
5630×
    csrf(&viewer, &f.csrf, &shell)?;
5640×
    let removed =
5650×
        crate::db::approvals::remove_authenticator(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5660×
    if removed {
5670×
        accounts::audit(&app.db, Some(&me.account), "2fa.phone_removed", &me.account.username, &id.to_string()).await.ok();
5680×
    }
5690×
    Ok(Redirect::to("/settings/security?notice=That+phone+no+longer+approves+for+you.#phones").into_response())
5700×
}
571
572
// ------------------------------------------------------------------- keys
573
574
#[derive(Template)]
575
#[template(path = "settings_keys.html")]
576
pub struct KeysPage {
577
    shell: Shell,
578
    me: accounts::Account,
579
    keys: Vec<accounts::LinkedIdentity>,
580
    notice: Option<String>,
581
    error: Option<String>,
582
    /// A pending challenge: id, hex bytes, identity name.
583
    pending: Option<(i64, String, String)>,
584
}
585
5869×
async fn keys_page(
5879×
    app: &Shared,
5889×
    shell: Shell,
5899×
    me: accounts::Account,
5909×
    notice: Option<String>,
5919×
    error: Option<String>,
5929×
    pending: Option<(i64, String, String)>,
5939×
) -> Result<Page<KeysPage>, Error> {
5949×
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5959×
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
5969×
}
597
5983×
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
5993×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
6003×
    let me = require(&viewer, &shell)?;
6013×
    keys_page(&app, shell, me.account, None, None, None).await
6023×
}
603
604
/// Upload an identity object (`oxy identity export`): the hub issues a
605
/// challenge to sign with that key.
6063×
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
6073×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
6083×
    let me = require(&viewer, &shell)?;
6093×
    let mut token = String::new();
6103×
    let mut object: Vec<u8> = Vec::new();
6119×
    while let Ok(Some(field)) = multipart.next_field().await {
6126×
        match field.name() {
6136×
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
6143×
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
6150×
            _ => {}
616
        }
617
    }
6183×
    csrf(&viewer, &token, &shell)?;
6193×
    let identity = match Identity::decode(&object) {
6203×
        Ok(i) => i,
6210×
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
622
    };
6233×
    if identity.hash() == app.hub_key.key {
6241×
        let msg = "That is this hub's own key, which only the hub holds. Upload your identity: after `oxy identity delegate add` with the hub's key, run `oxy identity export` in that checkout and upload the file it writes.";
6251×
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
6262×
    }
6272×
    let key = identity.hash().to_hex();
6282×
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
6290×
        let msg = if existing.account_id == me.account.id {
6300×
            "That key is already linked to you. If you have delegated since (`oxy identity delegate add`), export again: a delegation makes a new version of your identity."
631
        } else {
6320×
            "That key is linked to another account."
633
        };
6340×
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
6352×
    }
6362×
    let bytes = auth::random_hex(32);
6372×
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6382×
    sqlx::query("UPDATE challenges SET object = $1 WHERE id = $2")
6392×
        .bind(&object)
6402×
        .bind(id)
6412×
        .execute(&app.db)
6422×
        .await
6432×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
6442×
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
6453×
}
646
647
#[derive(Deserialize)]
648
pub struct KeyVerifyForm {
649
    #[serde(rename = "_csrf")]
650
    csrf_token: String,
651
    challenge: i64,
652
    signature: String,
653
}
654
6553×
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
6563×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
6573×
    let me = require(&viewer, &shell)?;
6583×
    csrf(&viewer, &f.csrf_token, &shell)?;
6593×
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = $1 AND account_id = $2")
6603×
        .bind(f.challenge)
6613×
        .bind(me.account.id)
6623×
        .fetch_optional(&app.db)
6633×
        .await
6643×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
6653×
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6663×
    let (Some(object), Some(bytes)) = (object, bytes) else {
6671×
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
668
    };
6692×
    let Ok(identity) = Identity::decode(&object) else {
6700×
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
671
    };
6722×
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
6732×
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
6742×
    let challenge = hex::decode(&bytes).unwrap_or_default();
6752×
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
6762×
    if !ok {
677
        // Signed by another key than the one uploaded: say which, as it is
678
        // usually the wrong checkout or an `author.signer` set there.
6791×
        let msg = match sigs.first() {
6801×
            Some(sig) if sig.key != identity.hash() => format!(
681
                "That signature is by key {}, but the identity uploaded is {} ({}). Sign in a checkout whose `oxy identity export` gives that identity, without an `author.signer` set, or upload the identity that checkout exports.",
6821×
                sig.key.short(12),
6831×
                identity.hash().short(12),
684
                identity.name
685
            ),
6860×
            _ => "The signature does not verify against that identity and challenge.".into(),
687
        };
6881×
        return keys_page(&app, shell, me.account, None, Some(msg), None).await;
6891×
    }
6901×
    let key = identity.hash().to_hex();
6911×
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
6921×
        .await
6931×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
6941×
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
6951×
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
6963×
}
697
698
#[derive(Deserialize)]
699
pub struct KeyForm {
700
    #[serde(rename = "_csrf")]
701
    csrf_token: String,
702
    key: String,
703
}
704
7050×
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
7060×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
7070×
    let me = require(&viewer, &shell)?;
7080×
    csrf(&viewer, &f.csrf_token, &shell)?;
7090×
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7100×
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
7110×
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
7120×
}
713
714
// ----------------------------------------------------------------- tokens
715
716
#[derive(Template)]
717
#[template(path = "settings_tokens.html")]
718
pub struct TokensPage {
719
    shell: Shell,
720
    scopes: &'static [(&'static str, &'static str)],
721
    me: accounts::Account,
722
    tokens: Vec<accounts::Token>,
723
    /// A freshly created token, shown once.
724
    fresh: Option<String>,
725
    notice: Option<String>,
726
    error: Option<String>,
727
}
728
72948×
async fn tokens_page(
73048×
    app: &Shared,
73148×
    shell: Shell,
73248×
    me: accounts::Account,
73348×
    fresh: Option<String>,
73448×
    notice: Option<String>,
73548×
    error: Option<String>,
73648×
) -> Result<Page<TokensPage>, Error> {
73748×
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
73848×
    Ok(Page(TokensPage { shell, scopes: SCOPES, me, tokens, fresh, notice, error }))
73948×
}
740
74124×
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
74224×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
74324×
    let me = require(&viewer, &shell)?;
74424×
    tokens_page(&app, shell, me.account, None, None, None).await
74524×
}
746
747
#[derive(Deserialize)]
748
pub struct TokenForm {
749
    #[serde(rename = "_csrf")]
750
    csrf_token: String,
751
    name: String,
752
    #[serde(default)]
753
    scopes: Vec<String>,
754
    #[serde(default)]
755
    repos: String,
756
    #[serde(default)]
757
    expires_days: String,
758
    /// An agent uses it: tell me what it does (oxygenhub#183).
759
    #[serde(default)]
760
    agent: String,
761
}
762
763
/// Every scope the hub checks, with what it grants. The tokens page lists
764
/// exactly these, so a scope cannot exist without a checkbox.
765
pub const SCOPES: &[(&str, &str)] = &[
766
    ("repo:read", "read repositories you can see: clone, fetch, pull, and the read API"),
767
    ("repo:write", "push to repositories you can write to; also covers proposals, issues and checks"),
768
    ("repo:admin", "create repositories and change settings through the API"),
769
    ("proposals:write", "open, comment on and review proposals, without push access"),
770
    ("issues:write", "open, comment on and close issues, without push access"),
771
    ("checks:write", "report checks on states, for CI systems, without push access"),
772
    ("hooks:admin", "manage webhooks through the API"),
773
    ("user:read", "read your own profile through the API"),
774
];
775
77624×
pub async fn tokens_create(
77724×
    State(app): State<Shared>,
77824×
    viewer: Viewer,
77924×
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
78024×
) -> Result<Page<TokensPage>, Error> {
78124×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
78224×
    let me = require(&viewer, &shell)?;
78324×
    csrf(&viewer, &f.csrf_token, &shell)?;
78424×
    let name = f.name.trim();
78524×
    if name.is_empty() {
7860×
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
78724×
    }
78889×
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
78924×
    if scopes.is_empty() {
7900×
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
79124×
    }
79224×
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
79324×
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
79424×
    let (secret, hash) = auth::new_token();
79524×
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
79624×
        .await
79724×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
79824×
    if !f.agent.is_empty() {
7990×
        if let Ok(Some(t)) = accounts::token_by_hash(&app.db, &hash).await {
8000×
            accounts::set_token_agent(&app.db, me.account.id, t.id, true).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8010×
        }
80224×
    }
80324×
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
80424×
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
80524×
}
806
807
/// Mark a token as an agent's, or not (oxygenhub#183): what is done with
808
/// an agent's token is told to its owner, as anyone else's doing is.
8090×
pub async fn tokens_agent(
8100×
    State(app): State<Shared>,
8110×
    viewer: Viewer,
8120×
    Path(id): Path<i64>,
8130×
    Form(f): Form<super::session::CsrfForm>,
8140×
) -> Result<Page<TokensPage>, Error> {
8150×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
8160×
    let me = require(&viewer, &shell)?;
8170×
    csrf(&viewer, &f.csrf, &shell)?;
8180×
    let tokens = accounts::tokens_of(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8190×
    let Some(t) = tokens.iter().find(|t| t.id == id) else {
8200×
        return tokens_page(&app, shell, me.account, None, None, Some("No such token.".into())).await;
821
    };
8220×
    accounts::set_token_agent(&app.db, me.account.id, id, !t.agent).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8230×
    accounts::audit(&app.db, Some(&me.account), "token.agent", &t.name, if t.agent { "off" } else { "on" }).await.ok();
8240×
    let notice = if t.agent {
8250×
        format!("{} is no longer marked as an agent's.", t.name)
826
    } else {
8270×
        format!("{} is an agent's: you will hear what it does.", t.name)
828
    };
8290×
    tokens_page(&app, shell, me.account, None, Some(notice), None).await
8300×
}
831
8320×
pub async fn tokens_delete(
8330×
    State(app): State<Shared>,
8340×
    viewer: Viewer,
8350×
    Path(id): Path<i64>,
8360×
    Form(f): Form<super::session::CsrfForm>,
8370×
) -> Result<Page<TokensPage>, Error> {
8380×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
8390×
    let me = require(&viewer, &shell)?;
8400×
    csrf(&viewer, &f.csrf, &shell)?;
8410×
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8420×
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
8430×
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
8440×
}
845
846
#[cfg(test)]
847
mod tests {
848
    use super::browser_of;
849
850
    #[test]
8511×
    fn browsers_are_named_from_their_agent() {
8521×
        let ff = "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0";
8531×
        let chrome_mac =
8541×
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36";
8551×
        let edge = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 Edg/129.0";
8561×
        let iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1";
8571×
        assert_eq!(browser_of(ff), "Firefox on Linux");
8581×
        assert_eq!(browser_of(chrome_mac), "Chrome on macOS");
8591×
        assert_eq!(browser_of(edge), "Edge on Windows");
8601×
        assert_eq!(browser_of(iphone), "Safari on iOS");
8611×
        assert_eq!(browser_of(""), "Unknown browser");
8621×
    }
863
}