//! 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::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, 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() }; 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(); 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(); return Ok(fail("That code is not right.", Some(username))); } } } } accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok(); Ok(signed_in(&app, &account, &next)) } /// 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. pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response { let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt); 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(a) = viewer.account() { accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok(); } let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", 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). providers: Vec<(&'static str, &'static str)>, } /// 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 providers = if mode == "open" && !first { super::signin::buttons(&app).await } else { vec![] }; Ok(Page(RegisterPage { shell, error: None, mode, first, invite: q.invite.unwrap_or_default(), providers }).into_response()) } #[derive(Deserialize)] pub struct RegisterForm { username: String, email: String, password: String, #[serde(default)] invite: 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![], }) .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 { 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(); } 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, "/")) }