//! Sign in, sign out and registration (hub spec ยง5.1). use super::{Error, Page, Shell}; use crate::auth::{self, totp, Viewer}; use crate::db::{self, accounts}; use crate::throttle::{self, Key}; use crate::Shared; use askama::Template; use axum::extract::{Form, Query, State}; use axum::http::header; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; #[derive(Template)] #[template(path = "login.html")] pub struct LoginPage { shell: Shell, error: Option, next: String, /// Second step: the password was right, a one-time code is needed. totp_for: Option, /// That step's proof of the password (`auth::pending_login`), so it /// asks only for the code. pending: String, /// Arrived from a password reset. reset: bool, /// Providers to sign in with (#106): (id, name). providers: Vec<(&'static str, &'static str)>, } #[derive(Deserialize)] pub struct NextQuery { next: Option, reset: Option, #[serde(default)] error: Option, } fn safe_next(next: Option) -> String { match next { Some(n) if n.starts_with('/') && !n.starts_with("//") => n, _ => "/".into(), } } pub async fn login_form(State(app): State, viewer: Viewer, Query(q): Query) -> Response { if viewer.signed_in() { return Redirect::to(&safe_next(q.next)).into_response(); } let reset = q.reset.is_some(); let providers = super::signin::buttons(&app).await; Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: q.error, next: safe_next(q.next), totp_for: None, pending: String::new(), reset, providers, }) .into_response() } #[derive(Deserialize)] pub struct LoginForm { username: String, #[serde(default)] password: String, /// The code step's proof of the password (`auth::pending_login`). #[serde(default)] pending: String, #[serde(default)] code: String, #[serde(default)] next: String, } pub async fn login( State(app): State, viewer: Viewer, headers: axum::http::HeaderMap, extensions: axum::http::Extensions, Form(f): Form, ) -> Result { let shell = Shell::new(&app, "/login", &viewer); let next = safe_next(Some(f.next.clone())); let username = f.username.trim().to_string(); let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?; // The code step carries proof of the password, good for a few minutes; // a fresh one goes with each retry of the code. let now_ms = db::now_ms(); let proven = !f.pending.is_empty() && account.as_ref().is_some_and(|a| auth::pending_login_ok(&app.cookie_key, &a.username, &a.session_salt, &f.pending, now_ms)); let pending = account .as_ref() .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS)) .unwrap_or_default(); let fail = |msg: &str, totp_for: Option| { Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, pending: pending.clone(), reset: false, providers: vec![], }) .into_response() }; // Guessing is slowed per account and per address (oxygenhub#221), // before the password is even looked at. let addr = crate::api::client_addr(&app, &headers, &extensions); let keys = [Key::account("login", &username), Key::address("login", &addr)]; if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now_ms) { return Ok(fail(&throttle::message(wait), proven.then(|| username.clone()))); } let failed = |app: &Shared| { let wait = app.throttle.lock().unwrap().failed(&keys, now_ms); let (app, username, addr) = (app.clone(), username.clone(), addr.clone()); async move { if let Some(wait) = wait { let detail = format!("from {} for {}s", if addr.is_empty() { "an unknown address" } else { &addr }, wait / 1000); accounts::audit(&app.db, None, "login.throttled", &username, &detail).await.ok(); } } }; if !f.pending.is_empty() && !proven { return Ok(fail("That took too long: sign in again.", None)); } let Some(account) = account.filter(|a| proven || auth::verify_password(&a.password_hash, &f.password)) else { accounts::audit(&app.db, None, "login.failed", &username, "").await.ok(); failed(&app).await; return Ok(fail("Wrong username or password.", None)); }; if account.banned { return Ok(fail("This account is banned.", None)); } if account.disabled { accounts::audit(&app.db, None, "login.disabled", &username, "").await.ok(); return Ok(fail("Your account has been disabled. Contact the hub's administrator.", None)); } if let Some(secret) = &account.totp_secret { if f.code.trim().is_empty() { return Ok(fail("Enter the code from your authenticator app.", Some(username))); } let now = (db::now_ms() / 1000) as u64; if !totp::verify(secret, &f.code, now) { match totp::use_recovery_code(&account.recovery_codes, &f.code) { Some(rest) => { accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))? } None => { accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok(); failed(&app).await; return Ok(fail("That code is not right.", Some(username))); } } } } app.throttle.lock().unwrap().cleared(&keys[0]); accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok(); Ok(signed_in(&app, &account, &next).await) } /// How long the code step of a sign-in may take after the password. const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000; /// A redirect that sets the session cookie: a new session each time, so /// signing in (after the code, with two-factor) always rotates it /// (oxygenhub#223). pub async fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response { let value = auth::new_session_id(); if let Err(e) = db::sessions::create(&app.db, account.id, &auth::token_hash(&value), &account.session_salt).await { tracing::warn!("session for {}: {e}", account.username); return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Signing in failed for a moment: try again.").into_response(); } let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE); ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response() } #[derive(Deserialize)] pub struct CsrfForm { #[serde(rename = "_csrf")] pub csrf: String, } pub async fn logout(State(app): State, viewer: Viewer, Form(f): Form) -> Response { if !viewer.csrf_ok(&f.csrf) { return Redirect::to("/").into_response(); } if let Some(v) = &viewer.0 { db::sessions::end_hash(&app.db, &auth::token_hash(&v.session)).await.ok(); accounts::audit(&app.db, Some(&v.account), "logout", &v.account.username, "").await.ok(); } let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE); ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response() } #[derive(Template)] #[template(path = "register.html")] pub struct RegisterPage { shell: Shell, error: Option, mode: String, first: bool, invite: String, /// Providers to sign up with, when registration is open (#213) or /// the page has a usable invitation (oxygenhub#214). providers: Vec<(&'static str, &'static str)>, /// What the provider buttons carry: `?invite=CODE`, or nothing. provider_query: String, } /// The registration mode: the settings table overrides `hub.toml`. pub async fn registration_mode(app: &Shared) -> String { accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone()) } #[derive(Deserialize)] pub struct InviteQuery { invite: Option, } pub async fn register_form(State(app): State, viewer: Viewer, Query(q): Query) -> Result { if viewer.signed_in() { return Ok(Redirect::to("/").into_response()); } let shell = Shell::new(&app, "/register", &viewer); let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; let mode = registration_mode(&app).await; let invite = q.invite.unwrap_or_default().trim().to_string(); let usable_invite = mode == "invite" && super::signin::invite_code_ok(&invite) && accounts::invitation_unused(&app.db, &invite).await.map_err(|e| Error::from_db(shell.clone(), e))?; let providers = if !first && (mode == "open" || usable_invite) { super::signin::buttons(&app).await } else { vec![] }; let provider_query = if usable_invite { format!("?invite={invite}") } else { String::new() }; Ok(Page(RegisterPage { shell, error: None, mode, first, invite, providers, provider_query }).into_response()) } #[derive(Deserialize)] pub struct RegisterForm { username: String, email: String, password: String, #[serde(default)] invite: String, /// The first account's setup code (oxygenhub#245). #[serde(default)] setup: String, } pub async fn register(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let shell = Shell::new(&app, "/register", &viewer); let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; let mode = registration_mode(&app).await; let fail = |msg: &str| { Page(RegisterPage { shell: shell.clone(), error: Some(msg.into()), mode: mode.clone(), first, invite: f.invite.clone(), providers: vec![], provider_query: String::new(), }) .into_response() }; let username = f.username.trim().to_string(); if !crate::repos::names::valid_owner(&username) { return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word.")); } if let Err(e) = auth::password_ok(&f.password) { return Ok(fail(e)); } if first && !crate::setup::matches(&app, &f.setup) { return Ok(fail( "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).", )); } if !first { match mode.as_str() { "open" => {} "invite" => { let ok = !f.invite.trim().is_empty() && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?; if !ok { return Ok(fail("Registration needs a valid invitation code.")); } } _ => return Ok(fail("Registration is closed.")), } } if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() || accounts::username_retired(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))? { return Ok(fail("That name is taken.")); } let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first) .await .map_err(|e| Error::from_db(shell.clone(), e))?; if !first && mode == "invite" { accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok(); } if first { crate::setup::spend(&app); } accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok(); if let Err(e) = super::recovery::send_verification(&app, &account).await { tracing::info!("verification for {username}: {e}"); } // The site's administrators hear of each new account (oxygenhub#183). if !first { let admins: Vec<(i64, crate::db::notify::Reason)> = accounts::accounts(&app.db) .await .unwrap_or_default() .into_iter() .filter(|a| a.is_admin) .map(|a| (a.id, crate::db::notify::Reason::Participating)) .collect(); let title = format!("New account: {username} signed up on {}", app.config.site.name); crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await; } Ok(signed_in(&app, &account, "/").await) }