OXYGENHUB

Coverage / crates/hub/src/web/session.rs 88.9%

264 of 297 lines run, 28 of 88 functions · run #545 on main · run missed

1
//! Sign in, sign out and registration (hub spec §5.1).
2
3
use super::{Error, Page, Shell};
4
use crate::auth::{self, totp, Viewer};
5
use crate::db::{self, accounts};
6
use crate::throttle::{self, Key};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Query, State};
10
use axum::http::header;
11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
14
#[derive(Template)]
15
#[template(path = "login.html")]
16
pub struct LoginPage {
17
    shell: Shell,
18
    error: Option<String>,
19
    next: String,
20
    /// Second step: the password was right, a one-time code is needed.
21
    totp_for: Option<String>,
22
    /// That step's proof of the password (`auth::pending_login`), so it
23
    /// asks only for the code.
24
    pending: String,
25
    /// Arrived from a password reset.
26
    reset: bool,
27
    /// Providers to sign in with (#106): (id, name).
28
    providers: Vec<(&'static str, &'static str)>,
29
    /// Waiting for the phone (oxygenhub#356): the approval and the number
30
    /// to pick on it.
31
    approval: Option<(String, i64)>,
32
    /// The account has a phone that approves, so the code step offers it.
33
    phones: bool,
34
}
35
36
#[derive(Deserialize)]
37
pub struct NextQuery {
38
    next: Option<String>,
39
    reset: Option<String>,
40
    #[serde(default)]
41
    error: Option<String>,
42
}
43
4457×
fn safe_next(next: Option<String>) -> String {
4547×
    match next {
4647×
        Some(n) if n.starts_with('/') && !n.starts_with("//") => n,
4754×
        _ => "/".into(),
48
    }
4957×
}
50
5110×
pub async fn login_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Response {
5210×
    if viewer.signed_in() {
530×
        return Redirect::to(&safe_next(q.next)).into_response();
5410×
    }
5510×
    let reset = q.reset.is_some();
5610×
    let providers = super::signin::buttons(&app).await;
5710×
    Page(LoginPage {
5810×
        shell: Shell::new(&app, "/login", &viewer),
5910×
        error: q.error,
6010×
        next: safe_next(q.next),
6110×
        totp_for: None,
6210×
        pending: String::new(),
6310×
        reset,
6410×
        providers,
6510×
        approval: None,
6610×
        phones: false,
6710×
    })
6810×
    .into_response()
6910×
}
70
71
#[derive(Deserialize)]
72
pub struct LoginForm {
73
    username: String,
74
    #[serde(default)]
75
    password: String,
76
    /// The code step's proof of the password (`auth::pending_login`).
77
    #[serde(default)]
78
    pending: String,
79
    #[serde(default)]
80
    code: String,
81
    #[serde(default)]
82
    next: String,
83
    /// The approval the phone gave this sign-in (oxygenhub#356).
84
    #[serde(default)]
85
    approval: String,
86
    /// Asked for the code rather than the phone.
87
    #[serde(default)]
88
    use_code: String,
89
}
90
9147×
pub async fn login(
9247×
    State(app): State<Shared>,
9347×
    viewer: Viewer,
9447×
    headers: axum::http::HeaderMap,
9547×
    extensions: axum::http::Extensions,
9647×
    Form(f): Form<LoginForm>,
9747×
) -> Result<Response, Error> {
9847×
    let shell = Shell::new(&app, "/login", &viewer);
9947×
    let next = safe_next(Some(f.next.clone()));
10047×
    let username = f.username.trim().to_string();
10147×
    let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?;
102
    // The code step carries proof of the password, good for a few minutes;
103
    // a fresh one goes with each retry of the code.
10447×
    let now_ms = db::now_ms();
10547×
    let proven = !f.pending.is_empty()
1065×
        && account.as_ref().is_some_and(|a| auth::pending_login_ok(&app.cookie_key, &a.username, &a.session_salt, &f.pending, now_ms));
10747×
    let pending = account
10847×
        .as_ref()
10947×
        .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS))
11047×
        .unwrap_or_default();
11147×
    let phones = match &account {
11226×
        Some(a) if a.totp_secret.is_some() => {
1139×
            !db::approvals::authenticators(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty()
114
        }
11538×
        _ => false,
116
    };
11747×
    let fail = |msg: &str, totp_for: Option<String>| {
118
        Page(LoginPage {
11937×
            shell: shell.clone(),
12037×
            error: (!msg.is_empty()).then(|| msg.into()),
12137×
            next: next.clone(),
12237×
            totp_for,
12337×
            pending: pending.clone(),
124
            reset: false,
12537×
            providers: vec![],
12637×
            approval: None,
12737×
            phones,
128
        })
12937×
        .into_response()
13037×
    };
131
    // Guessing is slowed per account and per address (oxygenhub#221),
132
    // before the password is even looked at.
13347×
    let addr = crate::api::client_addr(&app, &headers, &extensions);
13447×
    let keys = [Key::account("login", &username), Key::address("login", &addr)];
13547×
    if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now_ms) {
1362×
        return Ok(fail(&throttle::message(wait), proven.then(|| username.clone())));
13745×
    }
13845×
    let failed = |app: &Shared| {
13929×
        let wait = app.throttle.lock().unwrap().failed(&keys, now_ms);
14029×
        let (app, username, addr) = (app.clone(), username.clone(), addr.clone());
14129×
        async move {
14229×
            if let Some(wait) = wait {
1432×
                let detail = format!("from {} for {}s", if addr.is_empty() { "an unknown address" } else { &addr }, wait / 1000);
1442×
                accounts::audit(&app.db, None, "login.throttled", &username, &detail).await.ok();
14527×
            }
14629×
        }
14729×
    };
14845×
    if !f.pending.is_empty() && !proven {
1491×
        return Ok(fail("That took too long: sign in again.", None));
15044×
    }
15144×
    let Some(account) = account.filter(|a| proven || auth::verify_password(&a.password_hash, &f.password)) else {
15229×
        accounts::audit(&app.db, None, "login.failed", &username, "").await.ok();
15329×
        failed(&app).await;
15429×
        return Ok(fail("Wrong username or password.", None));
155
    };
15615×
    if account.banned {
1570×
        return Ok(fail("This account is banned.", None));
15815×
    }
15915×
    if account.disabled {
1601×
        accounts::audit(&app.db, None, "login.disabled", &username, "").await.ok();
1611×
        return Ok(fail("Your account has been disabled. Contact the hub's administrator.", None));
16214×
    }
16314×
    if let Some(secret) = &account.totp_secret {
1648×
        if f.code.trim().is_empty() {
165
            // The phone, when the account has one (oxygenhub#356): an
166
            // approval it gave this sign-in, or a new one to ask for.
1677×
            if !f.use_code.is_empty() {
1681×
                return Ok(fail("", Some(username)));
1696×
            }
1706×
            if !f.approval.is_empty() {
1713×
                if !db::approvals::take(&app.db, &f.approval, account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1722×
                    return Ok(fail("Your phone has not approved this sign-in. Try again, or use a code.", Some(username)));
1731×
                }
1741×
                app.throttle.lock().unwrap().cleared(&keys[0]);
1751×
                accounts::audit(&app.db, Some(&account), "login", &username, "approved on a phone").await.ok();
1761×
                return Ok(signed_in(&app, &account, &next).await);
1773×
            }
1783×
            if phones {
1792×
                let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
1802×
                let from = if addr.is_empty() { String::new() } else { format!(", {addr}") };
1812×
                let what = format!("Sign in from {}{from}", super::settings::browser_of(agent));
1822×
                let a =
1832×
                    crate::auth::phone::ask(&app, &account, "signin", &what, None).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1842×
                return Ok(Page(LoginPage {
1852×
                    shell: shell.clone(),
1862×
                    error: None,
1872×
                    next: next.clone(),
1882×
                    totp_for: Some(username),
1892×
                    pending: pending.clone(),
1902×
                    reset: false,
1912×
                    providers: vec![],
1922×
                    approval: Some((a.id, a.number)),
1932×
                    phones,
1942×
                })
1952×
                .into_response());
1961×
            }
1971×
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
1981×
        }
1991×
        let now = (db::now_ms() / 1000) as u64;
2001×
        if !totp::verify(secret, &f.code, now) {
2010×
            match totp::use_recovery_code(&account.recovery_codes, &f.code) {
2020×
                Some(rest) => {
2030×
                    accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))?
204
                }
205
                None => {
2060×
                    accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok();
2070×
                    failed(&app).await;
2080×
                    return Ok(fail("That code is not right.", Some(username)));
209
                }
210
            }
2111×
        }
2126×
    }
2137×
    app.throttle.lock().unwrap().cleared(&keys[0]);
2147×
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
2157×
    Ok(signed_in(&app, &account, &next).await)
21647×
}
217
218
#[derive(Deserialize)]
219
pub struct ApprovalPoll {
220
    username: String,
221
    pending: String,
222
    approval: String,
223
}
224
225
/// `POST /login/approval` (oxygenhub#356): what became of a sign-in's
226
/// approval, for the page waiting on the phone. Only the sign-in that
227
/// proved the password may ask.
2284×
pub async fn approval_state(State(app): State<Shared>, Form(f): Form<ApprovalPoll>) -> Response {
2294×
    let now = db::now_ms();
2304×
    let state = async {
2314×
        let account = accounts::account_by_name(&app.db, f.username.trim()).await.ok()??;
2324×
        if !auth::pending_login_ok(&app.cookie_key, &account.username, &account.session_salt, &f.pending, now) {
2331×
            return None;
2343×
        }
2353×
        let a = db::approvals::get(&app.db, &f.approval).await.ok()??;
2363×
        if a.account_id != account.id || a.kind != "signin" {
2370×
            return None;
2383×
        }
2393×
        Some(if a.state == "pending" && a.expires_ms <= now { "expired".to_string() } else { a.state })
2404×
    }
2414×
    .await;
2424×
    match state {
2433×
        Some(s) => axum::Json(serde_json::json!({ "state": s })).into_response(),
2441×
        None => (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(),
245
    }
2464×
}
247
248
/// How long the code step of a sign-in may take after the password.
249
const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000;
250
251
/// A redirect that sets the session cookie: a new session each time, so
252
/// signing in (after the code, with two-factor) always rotates it
253
/// (oxygenhub#223).
254148×
pub async fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
255148×
    let value = auth::new_session_id();
256148×
    if let Err(e) = db::sessions::create(&app.db, account.id, &auth::token_hash(&value), &account.session_salt).await {
2570×
        tracing::warn!("session for {}: {e}", account.username);
2580×
        return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Signing in failed for a moment: try again.").into_response();
259148×
    }
260148×
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
261148×
    let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE);
262148×
    ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response()
263148×
}
264
265
#[derive(Deserialize)]
266
pub struct CsrfForm {
267
    #[serde(rename = "_csrf")]
268
    pub csrf: String,
269
}
270
2711×
pub async fn logout(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfForm>) -> Response {
2721×
    if !viewer.csrf_ok(&f.csrf) {
2730×
        return Redirect::to("/").into_response();
2741×
    }
2751×
    if let Some(v) = &viewer.0 {
2761×
        db::sessions::end_hash(&app.db, &auth::token_hash(&v.session)).await.ok();
2771×
        accounts::audit(&app.db, Some(&v.account), "logout", &v.account.username, "").await.ok();
2780×
    }
2791×
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
2801×
    let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE);
2811×
    ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
2821×
}
283
284
#[derive(Template)]
285
#[template(path = "register.html")]
286
pub struct RegisterPage {
287
    shell: Shell,
288
    error: Option<String>,
289
    mode: String,
290
    first: bool,
291
    invite: String,
292
    /// Providers to sign up with, when registration is open (#213) or
293
    /// the page has a usable invitation (oxygenhub#214).
294
    providers: Vec<(&'static str, &'static str)>,
295
    /// What the provider buttons carry: `?invite=CODE`, or nothing.
296
    provider_query: String,
297
}
298
299
/// The registration mode: the settings table overrides `hub.toml`.
300246×
pub async fn registration_mode(app: &Shared) -> String {
301246×
    accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone())
302246×
}
303
304
#[derive(Deserialize)]
305
pub struct InviteQuery {
306
    invite: Option<String>,
307
}
308
3094×
pub async fn register_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InviteQuery>) -> Result<Response, Error> {
3104×
    if viewer.signed_in() {
3110×
        return Ok(Redirect::to("/").into_response());
3124×
    }
3134×
    let shell = Shell::new(&app, "/register", &viewer);
3144×
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
3154×
    let mode = registration_mode(&app).await;
3164×
    let invite = q.invite.unwrap_or_default().trim().to_string();
3174×
    let usable_invite = mode == "invite"
3184×
        && super::signin::invite_code_ok(&invite)
3192×
        && accounts::invitation_unused(&app.db, &invite).await.map_err(|e| Error::from_db(shell.clone(), e))?;
3204×
    let providers = if !first && (mode == "open" || usable_invite) { super::signin::buttons(&app).await } else { vec![] };
3214×
    let provider_query = if usable_invite { format!("?invite={invite}") } else { String::new() };
3224×
    Ok(Page(RegisterPage { shell, error: None, mode, first, invite, providers, provider_query }).into_response())
3234×
}
324
325
#[derive(Deserialize)]
326
pub struct RegisterForm {
327
    username: String,
328
    email: String,
329
    password: String,
330
    #[serde(default)]
331
    invite: String,
332
    /// The first account's setup code (oxygenhub#245).
333
    #[serde(default)]
334
    setup: String,
335
}
336
337139×
pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> {
338139×
    let shell = Shell::new(&app, "/register", &viewer);
339139×
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
340139×
    let mode = registration_mode(&app).await;
341139×
    let fail = |msg: &str| {
3425×
        Page(RegisterPage {
3435×
            shell: shell.clone(),
3445×
            error: Some(msg.into()),
3455×
            mode: mode.clone(),
3465×
            first,
3475×
            invite: f.invite.clone(),
3485×
            providers: vec![],
3495×
            provider_query: String::new(),
3505×
        })
3515×
        .into_response()
3525×
    };
353139×
    let username = f.username.trim().to_string();
354139×
    if !crate::repos::names::valid_owner(&username) {
3550×
        return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word."));
356139×
    }
357139×
    if let Err(e) = auth::password_ok(&f.password) {
3580×
        return Ok(fail(e));
359139×
    }
360139×
    if first && !crate::setup::matches(&app, &f.setup) {
3612×
        return Ok(fail(
3622×
            "This hub has no administrator yet. The first account needs the setup code the server logged when it started (also in setup-code in its data directory).",
3632×
        ));
364137×
    }
365137×
    if !first {
36642×
        match mode.as_str() {
36742×
            "open" => {}
36811×
            "invite" => {
36911×
                let ok = !f.invite.trim().is_empty()
3709×
                    && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
37111×
                if !ok {
3722×
                    return Ok(fail("Registration needs a valid invitation code."));
3739×
                }
374
            }
3750×
            _ => return Ok(fail("Registration is closed.")),
376
        }
37795×
    }
378135×
    if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
379135×
        || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
380135×
        || accounts::username_retired(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?
381
    {
3821×
        return Ok(fail("That name is taken."));
383134×
    }
384134×
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
385134×
    let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first)
386134×
        .await
387134×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
388134×
    if !first && mode == "invite" {
3898×
        accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok();
390126×
    }
391134×
    if first {
39295×
        crate::setup::spend(&app);
39395×
    }
394134×
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
395134×
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
396132×
        tracing::info!("verification for {username}: {e}");
3972×
    }
398
    // The site's administrators hear of each new account (oxygenhub#183).
399134×
    if !first {
40039×
        let admins: Vec<(i64, crate::db::notify::Reason)> = accounts::accounts(&app.db)
40139×
            .await
40239×
            .unwrap_or_default()
40339×
            .into_iter()
40439×
            .filter(|a| a.is_admin)
40539×
            .map(|a| (a.id, crate::db::notify::Reason::Participating))
40639×
            .collect();
40739×
        let title = format!("New account: {username} signed up on {}", app.config.site.name);
40839×
        crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await;
40995×
    }
410134×
    Ok(signed_in(&app, &account, "/").await)
411139×
}