//! 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, /// Arrived from a password reset. reset: bool, } #[derive(Deserialize)] pub struct NextQuery { next: Option, reset: 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(); Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None, reset }) .into_response() } #[derive(Deserialize)] pub struct LoginForm { username: String, password: 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 fail = |msg: &str, totp_for: Option| { Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false }).into_response() }; 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))?; let Some(account) = account.filter(|a| 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 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)) } /// 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, } /// 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; Ok(Page(RegisterPage { shell, error: None, mode: registration_mode(&app).await, first, invite: q.invite.unwrap_or_default() }) .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() }) .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() { 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}"); } Ok(signed_in(&app, &account, "/")) }