From b49295099522ce716a0ca305762b78349e302f1e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:25:21 -0300 Subject: [PATCH 01/14] feat(cloud): the server's accounts, signed in by an e-mail link (C2) A new crate, botloft-cloud, with its own SQLite: POST /v1/login, the page the link opens (a button confirms, so mail scanners sign nobody in), the app's poll, /v1/me, logout and removing a device. Tokens are kept as SHA-256, sign-in is rate limited by e-mail and by address, and the e-mail goes through a Mailer (SMTP, or an outbox in tests). The spec (27.2-27.4) now says the button confirms, that the 60 s gap and bad addresses answer 429 and 400, and adds behind_proxy. --- CLAUDE.md | 1 + Cargo.lock | 96 ++++++- Cargo.toml | 4 +- crates/botloft-cloud/Cargo.toml | 33 +++ .../migrations/0001_accounts.sql | 39 +++ crates/botloft-cloud/src/accounts.rs | 247 ++++++++++++++++ crates/botloft-cloud/src/app.rs | 34 +++ crates/botloft-cloud/src/clock.rs | 23 ++ crates/botloft-cloud/src/config.rs | 135 +++++++++ crates/botloft-cloud/src/db.rs | 82 ++++++ crates/botloft-cloud/src/error.rs | 100 +++++++ crates/botloft-cloud/src/lib.rs | 23 ++ crates/botloft-cloud/src/limits.rs | 114 ++++++++ crates/botloft-cloud/src/login.rs | 270 ++++++++++++++++++ crates/botloft-cloud/src/mail_text.rs | 100 +++++++ crates/botloft-cloud/src/mailer.rs | 110 +++++++ crates/botloft-cloud/src/main.rs | 55 ++++ crates/botloft-cloud/src/pages.rs | 137 +++++++++ crates/botloft-cloud/src/session.rs | 93 ++++++ crates/botloft-cloud/src/tokens.rs | 37 +++ crates/botloft-cloud/tests/common/mod.rs | 133 +++++++++ crates/botloft-cloud/tests/login.rs | 246 ++++++++++++++++ docs/spec.md | 7 +- 23 files changed, 2112 insertions(+), 7 deletions(-) create mode 100644 crates/botloft-cloud/Cargo.toml create mode 100644 crates/botloft-cloud/migrations/0001_accounts.sql create mode 100644 crates/botloft-cloud/src/accounts.rs create mode 100644 crates/botloft-cloud/src/app.rs create mode 100644 crates/botloft-cloud/src/clock.rs create mode 100644 crates/botloft-cloud/src/config.rs create mode 100644 crates/botloft-cloud/src/db.rs create mode 100644 crates/botloft-cloud/src/error.rs create mode 100644 crates/botloft-cloud/src/lib.rs create mode 100644 crates/botloft-cloud/src/limits.rs create mode 100644 crates/botloft-cloud/src/login.rs create mode 100644 crates/botloft-cloud/src/mail_text.rs create mode 100644 crates/botloft-cloud/src/mailer.rs create mode 100644 crates/botloft-cloud/src/main.rs create mode 100644 crates/botloft-cloud/src/pages.rs create mode 100644 crates/botloft-cloud/src/session.rs create mode 100644 crates/botloft-cloud/src/tokens.rs create mode 100644 crates/botloft-cloud/tests/common/mod.rs create mode 100644 crates/botloft-cloud/tests/login.rs diff --git a/CLAUDE.md b/CLAUDE.md index 027c6e90..f10860e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Este projeto é implementação própria. Não copie nem adapte código, testes, crates/botloft-core tipos de domínio, IDs, envelope, tipos do protocolo (exportados via ts-rs) crates/botloft-store SQLite, migrations em migrations/NNNN_nome.sql crates/botloftd daemon: rpc, supervisor, runtime, chat, courier, tools, platform +crates/botloft-cloud servidor da conta e das cópias na nuvem (spec 27), com SQLite próprio app/ Tauri v2 + React 19 + TS strict + Tailwind v4 + Zustand docs/ spec e ADRs (docs/adr/0001: bots headless com chat) ``` diff --git a/Cargo.lock b/Cargo.lock index a4ecbf55..980cd706 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -469,6 +469,30 @@ dependencies = [ "winreg", ] +[[package]] +name = "botloft-cloud" +version = "0.11.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.23.1", + "clap", + "getrandom 0.4.3", + "hex", + "lettre", + "rusqlite", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.21", + "tokio", + "toml 1.1.6+spec-1.1.0", + "tower", + "tracing", + "tracing-subscriber", +] + [[package]] name = "botloft-core" version = "0.11.0" @@ -1279,6 +1303,22 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "embed-resource" version = "3.0.11" @@ -2462,6 +2502,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2713,6 +2780,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify-rust" version = "4.18.1" @@ -3372,6 +3448,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3649,6 +3731,7 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4907,9 +4990,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -5627,6 +5710,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" diff --git a/Cargo.toml b/Cargo.toml index 12891a42..6a93e376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd", "app/src-tauri", "app/src-setup"] -default-members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd"] +members = ["crates/botloft-core", "crates/botloft-store", "crates/botloft-cloud", "crates/botloftd", "app/src-tauri", "app/src-setup"] +default-members = ["crates/botloft-core", "crates/botloft-store", "crates/botloft-cloud", "crates/botloftd"] [workspace.package] version = "0.11.0" diff --git a/crates/botloft-cloud/Cargo.toml b/crates/botloft-cloud/Cargo.toml new file mode 100644 index 00000000..ddb71fd9 --- /dev/null +++ b/crates/botloft-cloud/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "botloft-cloud" +description = "Botloft cloud server: accounts and sealed backup copies" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.104" +axum = "0.8.9" +base64 = "0.23.1" +clap = { version = "4.6.7", features = ["derive"] } +getrandom = "0.4.3" +hex = "0.4.3" +lettre = { version = "0.11.23", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls", "ring", "webpki-roots"] } +rusqlite = { version = "0.40.2", features = ["bundled"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +thiserror = "2.0.21" +tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync"] } +toml = "1.1.6" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3.27.0" +tower = { version = "0.5.3", features = ["util"] } diff --git a/crates/botloft-cloud/migrations/0001_accounts.sql b/crates/botloft-cloud/migrations/0001_accounts.sql new file mode 100644 index 00000000..017fbdb0 --- /dev/null +++ b/crates/botloft-cloud/migrations/0001_accounts.sql @@ -0,0 +1,39 @@ +-- One row per e-mail address; the account is born when the first sign-in link +-- is confirmed (spec 27.3). +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +-- A computer or phone that signed in. Only the SHA-256 of its token is kept. +CREATE TABLE devices ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL +); +CREATE INDEX devices_by_account ON devices(account_id); + +-- A sign-in waiting for the link. `account_id` is set when the link is +-- confirmed; the app's next poll turns it into a device and the row goes away. +CREATE TABLE logins ( + request_hash TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + device_name TEXT NOT NULL, + locale TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE +); + +-- What the rate limits count (27.3): `kind` and a hashed `key`. +CREATE TABLE attempts ( + kind TEXT NOT NULL, + key TEXT NOT NULL, + at INTEGER NOT NULL +); +CREATE INDEX attempts_by_key ON attempts(kind, key, at); diff --git a/crates/botloft-cloud/src/accounts.rs b/crates/botloft-cloud/src/accounts.rs new file mode 100644 index 00000000..73b84d9d --- /dev/null +++ b/crates/botloft-cloud/src/accounts.rs @@ -0,0 +1,247 @@ +//! Queries for logins, accounts and devices (spec 27.3). + +use rusqlite::{Connection, OptionalExtension, params}; +use serde::Serialize; + +/// How long a sign-in link and its request live. +pub const LOGIN_TTL_MS: i64 = 600_000; + +pub struct NewLogin<'a> { + pub request_hash: &'a str, + pub code_hash: &'a str, + pub email: &'a str, + pub device_name: &'a str, + pub locale: &'a str, +} + +pub fn insert_login(conn: &Connection, login: &NewLogin, now: i64) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO logins (request_hash, code_hash, email, device_name, locale, created_at, \ + expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + login.request_hash, + login.code_hash, + login.email, + login.device_name, + login.locale, + now, + now + LOGIN_TTL_MS + ], + )?; + Ok(()) +} + +pub fn drop_login(conn: &Connection, request_hash: &str) -> rusqlite::Result<()> { + conn.execute("DELETE FROM logins WHERE request_hash = ?1", [request_hash])?; + Ok(()) +} + +/// What the page behind a link shows before the owner confirms. +pub struct Waiting { + pub device_name: String, + pub locale: String, +} + +/// A link that is still good: not used, not expired. +pub fn peek_login( + conn: &Connection, + code_hash: &str, + now: i64, +) -> rusqlite::Result> { + conn.query_row( + "SELECT device_name, locale FROM logins \ + WHERE code_hash = ?1 AND expires_at > ?2 AND account_id IS NULL", + params![code_hash, now], + |row| { + Ok(Waiting { + device_name: row.get(0)?, + locale: row.get(1)?, + }) + }, + ) + .optional() +} + +/// The link was confirmed: the account exists now (born here, if new) and the +/// request waits for the app to collect its device. False if the link is bad. +pub fn confirm_login(conn: &mut Connection, code_hash: &str, now: i64) -> rusqlite::Result { + let tx = conn.transaction()?; + let email: Option = tx + .query_row( + "SELECT email FROM logins \ + WHERE code_hash = ?1 AND expires_at > ?2 AND account_id IS NULL", + params![code_hash, now], + |row| row.get(0), + ) + .optional()?; + let Some(email) = email else { + return Ok(false); + }; + tx.execute( + "INSERT INTO accounts (email, created_at) VALUES (?1, ?2) ON CONFLICT(email) DO NOTHING", + params![email, now], + )?; + let account: i64 = tx.query_row( + "SELECT id FROM accounts WHERE email = ?1", + [&email], + |row| row.get(0), + )?; + tx.execute( + "UPDATE logins SET account_id = ?1 WHERE code_hash = ?2", + params![account, code_hash], + )?; + tx.commit()?; + Ok(true) +} + +/// What a poll of a request finds. +pub enum Request { + /// No such request, or it ran out. + Gone, + Pending, + Approved(Approved), +} + +pub struct Approved { + pub account_id: i64, + pub email: String, + pub device_name: String, +} + +pub fn read_request(conn: &Connection, request_hash: &str, now: i64) -> rusqlite::Result { + let row = conn + .query_row( + "SELECT logins.expires_at, logins.account_id, logins.email, logins.device_name \ + FROM logins WHERE request_hash = ?1", + [request_hash], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional()?; + Ok(match row { + Some((expires, _, _, _)) if expires <= now => Request::Gone, + Some((_, None, _, _)) => Request::Pending, + Some((_, Some(account_id), email, device_name)) => Request::Approved(Approved { + account_id, + email, + device_name, + }), + None => Request::Gone, + }) +} + +/// The app collected its sign-in: a device with the hash of its new token, and +/// the request is gone, so the token is handed out once. +pub fn finish_login( + conn: &mut Connection, + request_hash: &str, + approved: &Approved, + device_id: &str, + token_hash: &str, + now: i64, +) -> rusqlite::Result { + let tx = conn.transaction()?; + let removed = tx.execute( + "DELETE FROM logins WHERE request_hash = ?1 AND account_id IS NOT NULL", + [request_hash], + )?; + if removed == 0 { + return Ok(false); + } + tx.execute( + "INSERT INTO devices (id, account_id, name, token_hash, created_at, last_used_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params![ + device_id, + approved.account_id, + approved.device_name, + token_hash, + now + ], + )?; + tx.commit()?; + Ok(true) +} + +/// Who a token belongs to. +pub struct Who { + pub account_id: i64, + pub device_id: String, + pub email: String, +} + +/// Looks the token up and notes that the device was used, at most once a minute. +pub fn who_is(conn: &Connection, token_hash: &str, now: i64) -> rusqlite::Result> { + let who = conn + .query_row( + "SELECT devices.account_id, devices.id, accounts.email FROM devices \ + JOIN accounts ON accounts.id = devices.account_id WHERE devices.token_hash = ?1", + [token_hash], + |row| { + Ok(Who { + account_id: row.get(0)?, + device_id: row.get(1)?, + email: row.get(2)?, + }) + }, + ) + .optional()?; + if let Some(who) = &who { + conn.execute( + "UPDATE devices SET last_used_at = ?1 WHERE id = ?2 AND last_used_at < ?1 - 60000", + params![now, who.device_id], + )?; + } + Ok(who) +} + +#[derive(Serialize)] +pub struct Device { + pub id: String, + pub name: String, + pub created_at: i64, + pub last_used_at: i64, +} + +pub fn devices(conn: &Connection, account_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, name, created_at, last_used_at FROM devices \ + WHERE account_id = ?1 ORDER BY created_at", + )?; + stmt.query_map([account_id], |row| { + Ok(Device { + id: row.get(0)?, + name: row.get(1)?, + created_at: row.get(2)?, + last_used_at: row.get(3)?, + }) + })? + .collect() +} + +/// False when the account has no such device. +pub fn delete_device(conn: &Connection, account_id: i64, id: &str) -> rusqlite::Result { + let removed = conn.execute( + "DELETE FROM devices WHERE id = ?1 AND account_id = ?2", + params![id, account_id], + )?; + Ok(removed > 0) +} + +/// Bytes the account's copies take. The copies arrive in C3 (spec 27.8). +pub fn used_bytes(_conn: &Connection, _account_id: i64) -> rusqlite::Result { + Ok(0) +} + +/// Drops what ran out: links and the old counts of the rate limits. +pub fn purge(conn: &Connection, now: i64) -> rusqlite::Result<()> { + conn.execute("DELETE FROM logins WHERE expires_at <= ?1", [now])?; + conn.execute("DELETE FROM attempts WHERE at <= ?1", [now - 3_600_000])?; + Ok(()) +} diff --git a/crates/botloft-cloud/src/app.rs b/crates/botloft-cloud/src/app.rs new file mode 100644 index 00000000..059e87ac --- /dev/null +++ b/crates/botloft-cloud/src/app.rs @@ -0,0 +1,34 @@ +//! The routes (spec 27.3, 27.4) and what every handler shares. + +use std::sync::Arc; + +use axum::Router; +use axum::routing::{delete, get, post}; + +use crate::clock::Clock; +use crate::config::Config; +use crate::db::Db; +use crate::mailer::Mailer; +use crate::{login, session}; + +#[derive(Clone)] +pub struct AppState { + pub db: Db, + pub mailer: Arc, + pub clock: Clock, + pub config: Arc, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/v1/login", post(login::ask)) + .route( + "/v1/login/confirm", + get(login::confirm_page).post(login::confirm), + ) + .route("/v1/login/{request}", get(login::poll)) + .route("/v1/me", get(session::me)) + .route("/v1/logout", post(session::logout)) + .route("/v1/devices/{id}", delete(session::remove_device)) + .with_state(state) +} diff --git a/crates/botloft-cloud/src/clock.rs b/crates/botloft-cloud/src/clock.rs new file mode 100644 index 00000000..1260ae8c --- /dev/null +++ b/crates/botloft-cloud/src/clock.rs @@ -0,0 +1,23 @@ +//! The time the server goes by, in ms Unix. Tests move it forward instead of +//! waiting for a link to expire. + +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Default)] +pub struct Clock(Arc); + +impl Clock { + pub fn now(&self) -> i64 { + let real = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_millis() as i64); + real + self.0.load(Ordering::Relaxed) + } + + /// Jumps `ms` ahead, for tests. + pub fn advance(&self, ms: i64) { + self.0.fetch_add(ms, Ordering::Relaxed); + } +} diff --git a/crates/botloft-cloud/src/config.rs b/crates/botloft-cloud/src/config.rs new file mode 100644 index 00000000..33be99dc --- /dev/null +++ b/crates/botloft-cloud/src/config.rs @@ -0,0 +1,135 @@ +//! `cloud.toml` (spec 27.2). + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default = "default_listen")] + pub listen: SocketAddr, + /// The public address, which goes in the links of the e-mails. + pub public_url: String, + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, + #[serde(default = "default_quota")] + pub quota_bytes: u64, + #[serde(default = "default_max_copy")] + pub max_copy_bytes: u64, + #[serde(default = "default_keep")] + pub keep: u32, + /// A proxy in front sets `X-Forwarded-For`; its last value is the client. + #[serde(default = "default_true")] + pub behind_proxy: bool, + pub smtp: Smtp, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Smtp { + pub host: String, + #[serde(default = "default_port")] + pub port: u16, + pub user: String, + /// A file with the password alone, so it never sits in this one. + pub password_file: PathBuf, + pub from: String, +} + +fn default_listen() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 8787)) +} +fn default_data_dir() -> PathBuf { + PathBuf::from("./data") +} +fn default_quota() -> u64 { + 2 << 30 +} +fn default_max_copy() -> u64 { + 1 << 30 +} +fn default_keep() -> u32 { + 3 +} +fn default_true() -> bool { + true +} +fn default_port() -> u16 { + 587 +} + +impl Config { + pub fn load(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path) + .map_err(|err| anyhow::anyhow!("cannot read {}: {err}", path.display()))?; + let config: Self = toml::from_str(&text) + .map_err(|err| anyhow::anyhow!("{} is not valid: {err}", path.display()))?; + if !config.public_url.starts_with("https://") + && !config.public_url.starts_with("http://127.0.0.1") + { + anyhow::bail!("public_url must be https (or http://127.0.0.1 for tests)"); + } + Ok(config) + } + + /// The link base, without a closing slash. + pub fn base_url(&self) -> &str { + self.public_url.trim_end_matches('/') + } + + /// A config for tests: nothing on disk and a mailer that never connects. + pub fn for_tests() -> Self { + Self { + listen: default_listen(), + public_url: "http://127.0.0.1:8787".to_owned(), + data_dir: PathBuf::from("."), + quota_bytes: default_quota(), + max_copy_bytes: default_max_copy(), + keep: default_keep(), + behind_proxy: true, + smtp: Smtp { + host: "localhost".to_owned(), + port: 587, + user: String::new(), + password_file: PathBuf::new(), + from: "Botloft ".to_owned(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(text: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("dir"); + let path = dir.path().join("cloud.toml"); + std::fs::write(&path, text).expect("write"); + (dir, path) + } + + #[test] + fn a_small_file_gets_the_defaults() { + let (_dir, path) = write( + "public_url = \"https://cloud.example.org/\"\n[smtp]\nhost = \"mail\"\nuser = \"u\"\n\ + password_file = \"p\"\nfrom = \"Botloft \"\n", + ); + let config = Config::load(&path).expect("load"); + assert_eq!(config.keep, 3); + assert_eq!(config.quota_bytes, 2 << 30); + assert_eq!(config.smtp.port, 587); + assert_eq!(config.base_url(), "https://cloud.example.org"); + } + + #[test] + fn an_http_address_is_refused() { + let (_dir, path) = write( + "public_url = \"http://cloud.example.org\"\n[smtp]\nhost = \"m\"\nuser = \"u\"\n\ + password_file = \"p\"\nfrom = \"a@b.c\"\n", + ); + assert!(Config::load(&path).is_err()); + } +} diff --git a/crates/botloft-cloud/src/db.rs b/crates/botloft-cloud/src/db.rs new file mode 100644 index 00000000..a94272e3 --- /dev/null +++ b/crates/botloft-cloud/src/db.rs @@ -0,0 +1,82 @@ +//! The server's own SQLite database: one connection behind a mutex, queries +//! short, migrations numbered in `migrations/`. + +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rusqlite::Connection; + +use crate::error::ApiError; + +/// `(version, sql)`; versions are consecutive and match the file prefix. +const MIGRATIONS: &[(u32, &str)] = &[(1, include_str!("../migrations/0001_accounts.sql"))]; + +#[derive(Clone)] +pub struct Db { + conn: Arc>, +} + +impl Db { + pub fn open(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.busy_timeout(Duration::from_secs(5))?; + Self::ready(conn) + } + + pub fn memory() -> rusqlite::Result { + Self::ready(Connection::open_in_memory()?) + } + + fn ready(mut conn: Connection) -> rusqlite::Result { + conn.pragma_update(None, "foreign_keys", "ON")?; + migrate(&mut conn)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Runs `work` with the connection. Keep it short: the lock is shared. + pub(crate) fn run( + &self, + work: impl FnOnce(&mut Connection) -> rusqlite::Result, + ) -> Result { + let mut conn = self + .conn + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + work(&mut conn).map_err(ApiError::from) + } +} + +fn migrate(conn: &mut Connection) -> rusqlite::Result<()> { + let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + for (version, sql) in MIGRATIONS.iter().filter(|(version, _)| *version > current) { + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", version)?; + tx.commit()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_new_database_has_every_table() { + let db = Db::memory().expect("db"); + let tables: Vec = db + .run(|conn| { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")?; + stmt.query_map([], |row| row.get(0))?.collect() + }) + .expect("tables"); + for name in ["accounts", "attempts", "devices", "logins"] { + assert!(tables.iter().any(|table| table == name), "{name}"); + } + } +} diff --git a/crates/botloft-cloud/src/error.rs b/crates/botloft-cloud/src/error.rs new file mode 100644 index 00000000..5c965256 --- /dev/null +++ b/crates/botloft-cloud/src/error.rs @@ -0,0 +1,100 @@ +//! What every endpoint answers when it fails: `{reason, message}` (spec 27.4). + +use axum::Json; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +#[derive(Debug)] +pub struct ApiError { + status: StatusCode, + reason: &'static str, + message: &'static str, + retry_after: Option, +} + +impl ApiError { + const fn new(status: StatusCode, reason: &'static str, message: &'static str) -> Self { + Self { + status, + reason, + message, + retry_after: None, + } + } + + pub fn unauthorized() -> Self { + Self::new( + StatusCode::UNAUTHORIZED, + "unauthorized", + "Sign in again to use this account.", + ) + } + + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", "There is nothing here.") + } + + pub fn bad_email() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "bad_email", + "That does not look like an e-mail address.", + ) + } + + pub fn rate_limited(retry_after: u64) -> Self { + Self { + retry_after: Some(retry_after), + ..Self::new( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + "Too many tries. Wait a little and try again.", + ) + } + } + + pub fn mail_failed() -> Self { + Self::new( + StatusCode::BAD_GATEWAY, + "mail_failed", + "The e-mail could not be sent. Try again in a moment.", + ) + } + + pub fn internal() -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + "Something went wrong on our side.", + ) + } + + pub fn reason(&self) -> &'static str { + self.reason + } +} + +impl From for ApiError { + fn from(err: rusqlite::Error) -> Self { + // Never the query or the values: they can hold an e-mail address. + tracing::error!("database: {err}"); + Self::internal() + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let mut body = json!({ "reason": self.reason, "message": self.message }); + if let Some(seconds) = self.retry_after { + body["retry_after"] = seconds.into(); + } + let mut response = (self.status, Json(body)).into_response(); + if let Some(seconds) = self.retry_after + && let Ok(value) = HeaderValue::from_str(&seconds.to_string()) + { + response.headers_mut().insert(header::RETRY_AFTER, value); + } + response + } +} diff --git a/crates/botloft-cloud/src/lib.rs b/crates/botloft-cloud/src/lib.rs new file mode 100644 index 00000000..84c6825e --- /dev/null +++ b/crates/botloft-cloud/src/lib.rs @@ -0,0 +1,23 @@ +//! The Botloft cloud server (spec 27): accounts by e-mail link, and the sealed +//! backup copies each account keeps. It never opens a copy. + +mod accounts; +mod app; +mod clock; +mod config; +mod db; +mod error; +mod limits; +mod login; +mod mail_text; +mod mailer; +mod pages; +mod session; +mod tokens; + +pub use app::{AppState, router}; +pub use clock::Clock; +pub use config::{Config, Smtp}; +pub use db::Db; +pub use error::ApiError; +pub use mailer::{Mail, Mailer, Outbox, SmtpMailer}; diff --git a/crates/botloft-cloud/src/limits.rs b/crates/botloft-cloud/src/limits.rs new file mode 100644 index 00000000..543d7642 --- /dev/null +++ b/crates/botloft-cloud/src/limits.rs @@ -0,0 +1,114 @@ +//! Rate limits of sign-in (spec 27.3): so many tries per e-mail and per +//! address in an hour, and a gap between two e-mails to the same person. + +use rusqlite::{Connection, params}; + +pub struct Rule { + pub kind: &'static str, + pub limit: u32, + pub window_ms: i64, +} + +pub const PER_EMAIL: Rule = Rule { + kind: "email", + limit: 5, + window_ms: 3_600_000, +}; +pub const PER_ADDRESS: Rule = Rule { + kind: "address", + limit: 20, + window_ms: 3_600_000, +}; +pub const GAP: Rule = Rule { + kind: "gap", + limit: 1, + window_ms: 60_000, +}; + +/// Counts the tries of every `(rule, key)` and, if none is over, records one +/// more for each. When one is over, nothing is recorded and the answer is how +/// many seconds until it frees up. `key` is hashed by the caller. +pub fn take( + conn: &mut Connection, + now: i64, + checks: &[(&Rule, &str)], +) -> rusqlite::Result> { + let tx = conn.transaction()?; + let mut wait: Option = None; + for (rule, key) in checks { + let since = now - rule.window_ms; + let (count, oldest): (u32, Option) = tx.query_row( + "SELECT COUNT(*), MIN(at) FROM attempts WHERE kind = ?1 AND key = ?2 AND at > ?3", + params![rule.kind, key, since], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if count >= rule.limit { + let free = oldest.map_or(rule.window_ms, |at| at + rule.window_ms - now); + wait = Some(wait.map_or(free, |other| other.max(free))); + } + } + if let Some(ms) = wait { + return Ok(Err(((ms + 999) / 1000).max(1) as u64)); + } + for (rule, key) in checks { + tx.execute( + "INSERT INTO attempts (kind, key, at) VALUES (?1, ?2, ?3)", + params![rule.kind, key, now], + )?; + } + tx.commit()?; + Ok(Ok(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Db; + + #[test] + fn the_sixth_try_in_an_hour_waits_for_the_first_to_age() { + let db = Db::memory().expect("db"); + for second in 0..5 { + let now = second * 1000; + let taken = db + .run(|conn| take(conn, now, &[(&PER_EMAIL, "k")])) + .expect("take"); + assert_eq!(taken, Ok(())); + } + let taken = db + .run(|conn| take(conn, 10_000, &[(&PER_EMAIL, "k")])) + .expect("take"); + assert_eq!(taken, Err(3590)); + // Another key is not held back, and an hour later the first is free. + let other = db + .run(|conn| take(conn, 10_000, &[(&PER_EMAIL, "z")])) + .expect("take"); + assert_eq!(other, Ok(())); + let later = db + .run(|conn| take(conn, 3_601_000, &[(&PER_EMAIL, "k")])) + .expect("take"); + assert_eq!(later, Ok(())); + } + + #[test] + fn a_refused_try_records_nothing() { + let db = Db::memory().expect("db"); + let first = db.run(|conn| take(conn, 0, &[(&GAP, "k")])).expect("take"); + assert_eq!(first, Ok(())); + // The gap holds, so the address count must not move either. + let refused = db + .run(|conn| take(conn, 1000, &[(&PER_ADDRESS, "a"), (&GAP, "k")])) + .expect("take"); + assert_eq!(refused, Err(59)); + let count: u32 = db + .run(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM attempts WHERE kind = 'address'", + [], + |row| row.get(0), + ) + }) + .expect("count"); + assert_eq!(count, 0); + } +} diff --git a/crates/botloft-cloud/src/login.rs b/crates/botloft-cloud/src/login.rs new file mode 100644 index 00000000..26157410 --- /dev/null +++ b/crates/botloft-cloud/src/login.rs @@ -0,0 +1,270 @@ +//! Signing in by e-mail link (spec 27.3): ask, confirm in the e-mail, collect. + +use axum::extract::{Extension, Form, Path, Query, State}; +use axum::http::{HeaderMap, header}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, extract::ConnectInfo}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::net::SocketAddr; + +use crate::accounts::{self, LOGIN_TTL_MS, NewLogin, Request}; +use crate::app::AppState; +use crate::error::ApiError; +use crate::limits::{self, GAP, PER_ADDRESS, PER_EMAIL}; +use crate::mail_text::{Locale, login_mail}; +use crate::mailer::Mail; +use crate::{pages, tokens}; + +#[derive(Deserialize)] +pub struct Ask { + email: String, + #[serde(default)] + device_name: String, + #[serde(default)] + locale: String, +} + +/// `POST /v1/login`: always the same answer, whoever has an account. +pub async fn ask( + State(state): State, + headers: HeaderMap, + connection: Option>>, + Json(ask): Json, +) -> Result<(axum::http::StatusCode, Json), ApiError> { + let email = clean_email(&ask.email).ok_or_else(ApiError::bad_email)?; + let device = clean_device(&ask.device_name); + let locale = Locale::parse(&ask.locale); + let address = client_address(&state, &headers, connection.map(|Extension(info)| info)); + let (email_key, address_key) = (tokens::hash(&email), tokens::hash(&address)); + let now = state.clock.now(); + + let taken = state.db.run(|conn| { + limits::take( + conn, + now, + &[ + (&PER_EMAIL, &email_key), + (&PER_ADDRESS, &address_key), + (&GAP, &email_key), + ], + ) + })?; + taken.map_err(ApiError::rate_limited)?; + + let (request, code) = (tokens::random()?, tokens::random()?); + let request_hash = tokens::hash(&request); + state.db.run(|conn| { + accounts::purge(conn, now)?; + accounts::insert_login( + conn, + &NewLogin { + request_hash: &request_hash, + code_hash: &tokens::hash(&code), + email: &email, + device_name: &device, + locale: locale.tag(), + }, + now, + ) + })?; + + let link = format!("{}/v1/login/confirm?code={code}", state.config.base_url()); + let (subject, body) = login_mail(locale, &link, &device); + let sent = state + .mailer + .send(Mail { + to: email, + subject, + body, + }) + .await; + if sent.is_err() { + state + .db + .run(|conn| accounts::drop_login(conn, &request_hash))?; + return Err(ApiError::mail_failed()); + } + Ok(( + axum::http::StatusCode::ACCEPTED, + Json(json!({ "request": request, "wait": LOGIN_TTL_MS / 1000 })), + )) +} + +/// `GET /v1/login/`: the app asks whether the link was opened. +pub async fn poll( + State(state): State, + Path(request): Path, +) -> Result, ApiError> { + let now = state.clock.now(); + let request_hash = tokens::hash(&request); + let found = state + .db + .run(|conn| accounts::read_request(conn, &request_hash, now))?; + match found { + Request::Gone => Ok(Json(json!({ "status": "expired" }))), + Request::Pending => Ok(Json(json!({ "status": "pending" }))), + Request::Approved(approved) => { + let (token, device) = ( + tokens::random()?, + format!("dev_{}", &tokens::random()?[..16]), + ); + let token_hash = tokens::hash(&token); + let finished = state.db.run(|conn| { + accounts::finish_login(conn, &request_hash, &approved, &device, &token_hash, now) + })?; + if !finished { + // Two polls at once: the other one took it. + return Ok(Json(json!({ "status": "expired" }))); + } + Ok(Json(json!({ + "status": "approved", + "token": token, + "device": device, + "email": approved.email, + }))) + } + } +} + +#[derive(Deserialize)] +pub struct Code { + code: String, +} + +/// `GET /v1/login/confirm?code=`: the page the e-mail link opens. It only +/// asks; nothing is confirmed until the button posts. +pub async fn confirm_page( + State(state): State, + headers: HeaderMap, + Query(Code { code }): Query, +) -> Result { + let now = state.clock.now(); + let waiting = state + .db + .run(|conn| accounts::peek_login(conn, &tokens::hash(&code), now))?; + Ok(match waiting { + Some(waiting) => { + pages::confirm(Locale::parse(&waiting.locale), &waiting.device_name, &code) + } + None => pages::gone(browser_locale(&headers)), + }) +} + +/// `POST /v1/login/confirm`: the button. +pub async fn confirm( + State(state): State, + headers: HeaderMap, + Form(Code { code }): Form, +) -> Result { + let now = state.clock.now(); + let code_hash = tokens::hash(&code); + let locale = state.db.run(|conn| { + let waiting = accounts::peek_login(conn, &code_hash, now)?; + let Some(waiting) = waiting else { + return Ok(None); + }; + Ok(accounts::confirm_login(conn, &code_hash, now)?.then(|| Locale::parse(&waiting.locale))) + })?; + Ok(match locale { + Some(locale) => pages::done(locale).into_response(), + None => pages::gone(browser_locale(&headers)), + }) +} + +fn browser_locale(headers: &HeaderMap) -> Locale { + headers + .get(header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()) + .map_or(Locale::En, Locale::from_accept_language) +} + +/// Where the request really comes from: behind the proxy, its last +/// `X-Forwarded-For`; otherwise the connection. +fn client_address( + state: &AppState, + headers: &HeaderMap, + connection: Option>, +) -> String { + if state.config.behind_proxy + && let Some(forwarded) = headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|list| list.rsplit(',').next()) + { + return forwarded.trim().to_owned(); + } + connection.map_or_else( + || "unknown".to_owned(), + |ConnectInfo(addr)| addr.ip().to_string(), + ) +} + +/// The address as it is stored: trimmed, lower case, one `@`, a domain with a +/// dot, and none of the characters that could break a header or a link. +fn clean_email(raw: &str) -> Option { + let email = raw.trim().to_lowercase(); + let (local, domain) = email.split_once('@')?; + let bad = |c: char| c.is_whitespace() || c.is_control() || "<>()[]\\,;:\"@".contains(c); + let ok = email.len() <= 254 + && !local.is_empty() + && local.len() <= 64 + && !local.contains(bad) + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + && !domain.contains("..") + && !domain.contains(bad); + ok.then_some(email) +} + +/// The name of the computer: printable, at most 60 characters. +fn clean_device(raw: &str) -> String { + let name: String = raw + .chars() + .filter(|c| !c.is_control()) + .take(60) + .collect::() + .trim() + .to_owned(); + if name.is_empty() { + "Botloft".to_owned() + } else { + name + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses_are_cleaned_or_refused() { + assert_eq!( + clean_email(" Ana@Exemplo.COM "), + Some("ana@exemplo.com".to_owned()) + ); + for bad in [ + "", + "ana", + "ana@", + "@x.com", + "a b@x.com", + "a@b", + "a@@b.com", + "a@b..com", + "a\"@b.com", + "a@b.com\r\nBcc: x@y.z", + "@b.com", + ] { + assert_eq!(clean_email(bad), None, "{bad:?}"); + } + } + + #[test] + fn a_device_name_is_printable_and_short() { + assert_eq!(clean_device(""), "Botloft"); + assert_eq!(clean_device(" PC\u{7} da Ana "), "PC da Ana"); + assert_eq!(clean_device(&"x".repeat(100)).len(), 60); + } +} diff --git a/crates/botloft-cloud/src/mail_text.rs b/crates/botloft-cloud/src/mail_text.rs new file mode 100644 index 00000000..c76a0896 --- /dev/null +++ b/crates/botloft-cloud/src/mail_text.rs @@ -0,0 +1,100 @@ +//! The words of the sign-in e-mail, in the three languages of the app +//! (spec 27.2, 15.6). + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locale { + En, + PtBr, + Es, +} + +impl Locale { + /// The tag the app sends (`en`, `pt-BR`, `es`); anything else is English. + pub fn parse(tag: &str) -> Self { + Self::from_tag(tag).unwrap_or(Self::En) + } + + fn from_tag(tag: &str) -> Option { + let tag = tag.trim().to_ascii_lowercase(); + match tag.split(['-', '_']).next()? { + "pt" => Some(Self::PtBr), + "es" => Some(Self::Es), + "en" => Some(Self::En), + _ => None, + } + } + + /// The first language a browser lists that we speak. + pub fn from_accept_language(header: &str) -> Self { + header + .split(',') + .filter_map(|part| Self::from_tag(part.split(';').next().unwrap_or(""))) + .next() + .unwrap_or(Self::En) + } + + /// The tag to store and send back. + pub fn tag(self) -> &'static str { + match self { + Self::En => "en", + Self::PtBr => "pt-BR", + Self::Es => "es", + } + } +} + +/// The subject and the text of the e-mail with the sign-in link. +pub fn login_mail(locale: Locale, link: &str, device: &str) -> (String, String) { + match locale { + Locale::En => ( + "Your Botloft sign-in link".to_owned(), + format!( + "Open this link to sign in to Botloft on \"{device}\":\n\n{link}\n\n\ + It works once and expires in 10 minutes. If you did not ask for it, \ + ignore this e-mail: nothing happens.\n" + ), + ), + Locale::PtBr => ( + "Seu link de entrada no Botloft".to_owned(), + format!( + "Abra este link para entrar no Botloft em \"{device}\":\n\n{link}\n\n\ + Ele vale uma vez e expira em 10 minutos. Se não foi você que pediu, \ + ignore este e-mail: nada acontece.\n" + ), + ), + Locale::Es => ( + "Tu enlace para entrar en Botloft".to_owned(), + format!( + "Abre este enlace para entrar en Botloft en \"{device}\":\n\n{link}\n\n\ + Sirve una vez y caduca en 10 minutos. Si no lo pediste tú, \ + ignora este correo: no pasa nada.\n" + ), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tags_and_browser_headers_pick_a_language() { + assert_eq!(Locale::parse("pt-BR"), Locale::PtBr); + assert_eq!(Locale::parse("es_MX"), Locale::Es); + assert_eq!(Locale::parse("fr"), Locale::En); + assert_eq!( + Locale::from_accept_language("fr, es;q=0.8, en;q=0.5"), + Locale::Es + ); + assert_eq!(Locale::from_accept_language(""), Locale::En); + } + + #[test] + fn every_language_carries_the_link() { + for locale in [Locale::En, Locale::PtBr, Locale::Es] { + let (subject, body) = login_mail(locale, "https://x/y", "Casa"); + assert!(!subject.is_empty()); + assert!(body.contains("https://x/y") && body.contains("Casa")); + } + } +} diff --git a/crates/botloft-cloud/src/mailer.rs b/crates/botloft-cloud/src/mailer.rs new file mode 100644 index 00000000..cf7b0ed5 --- /dev/null +++ b/crates/botloft-cloud/src/mailer.rs @@ -0,0 +1,110 @@ +//! Sending e-mail goes through here, so tests read an outbox instead of +//! talking to a mail server (spec 27.2). + +use std::sync::{Arc, Mutex}; + +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +use crate::config::Smtp; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mail { + pub to: String, + pub subject: String, + pub body: String, +} + +/// Mail kept in memory, for tests. +#[derive(Clone, Default)] +pub struct Outbox { + mails: Arc>>, + broken: bool, +} + +impl Outbox { + /// An outbox whose mail never goes, to see what the server does then. + pub fn broken() -> Self { + Self { + broken: true, + ..Self::default() + } + } + + pub fn sent(&self) -> Vec { + self.mails + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +pub struct SmtpMailer { + transport: AsyncSmtpTransport, + from: String, +} + +impl SmtpMailer { + pub fn new(smtp: &Smtp) -> anyhow::Result { + let password = std::fs::read_to_string(&smtp.password_file) + .map_err(|err| anyhow::anyhow!("cannot read the smtp password file: {err}"))?; + let builder = if smtp.port == 465 { + AsyncSmtpTransport::::relay(&smtp.host)? + } else { + AsyncSmtpTransport::::starttls_relay(&smtp.host)? + }; + let transport = builder + .port(smtp.port) + .credentials(Credentials::new( + smtp.user.clone(), + password.trim().to_owned(), + )) + .build(); + Ok(Self { + transport, + from: smtp.from.clone(), + }) + } + + async fn send(&self, mail: &Mail) -> anyhow::Result<()> { + let message = Message::builder() + .from(self.from.parse()?) + .to(mail.to.parse()?) + .subject(mail.subject.as_str()) + .header(ContentType::TEXT_PLAIN) + .body(mail.body.clone())?; + self.transport.send(message).await?; + Ok(()) + } +} + +/// The mail did not go. Nothing more: a mail server may repeat the address. +#[derive(Debug)] +pub struct SendFailed; + +pub enum Mailer { + Smtp(Box), + Outbox(Outbox), +} + +impl Mailer { + pub async fn send(&self, mail: Mail) -> Result<(), SendFailed> { + match self { + Self::Outbox(outbox) if outbox.broken => Err(SendFailed), + Self::Outbox(outbox) => { + outbox + .mails + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(mail); + Ok(()) + } + Self::Smtp(smtp) => smtp.send(&mail).await.map_err(|_| { + // Nothing of the error: a mail server may repeat the address. + tracing::error!("sending an e-mail failed"); + SendFailed + }), + } + } +} diff --git a/crates/botloft-cloud/src/main.rs b/crates/botloft-cloud/src/main.rs new file mode 100644 index 00000000..6a9a39b9 --- /dev/null +++ b/crates/botloft-cloud/src/main.rs @@ -0,0 +1,55 @@ +//! `botloft-cloud serve --config cloud.toml` (spec 27.2). + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use botloft_cloud::{AppState, Clock, Config, Db, Mailer, SmtpMailer, router}; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "botloft-cloud", version, about)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Runs the server. + Serve { + #[arg(long, default_value = "cloud.toml")] + config: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "botloft_cloud=info,warn".into()), + ) + .init(); + let Command::Serve { config } = Cli::parse().command; + let config = Config::load(&config)?; + std::fs::create_dir_all(&config.data_dir).context("cannot create data_dir")?; + let state = AppState { + db: Db::open(&config.data_dir.join("cloud.db")).context("cannot open the database")?, + mailer: Arc::new(Mailer::Smtp(Box::new(SmtpMailer::new(&config.smtp)?))), + clock: Clock::default(), + config: Arc::new(config.clone()), + }; + let listener = tokio::net::TcpListener::bind(config.listen).await?; + tracing::info!("listening on {}", config.listen); + axum::serve( + listener, + router(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await?; + Ok(()) +} diff --git a/crates/botloft-cloud/src/pages.rs b/crates/botloft-cloud/src/pages.rs new file mode 100644 index 00000000..c438d45d --- /dev/null +++ b/crates/botloft-cloud/src/pages.rs @@ -0,0 +1,137 @@ +//! The three small pages behind the e-mail link (spec 27.3). The link only +//! shows a button; the sign-in is confirmed by the button, because mail +//! scanners open links on their own and must not sign anyone in. + +use axum::http::{HeaderValue, header}; +use axum::response::{Html, IntoResponse, Response}; + +use crate::mail_text::Locale; + +struct Words { + title: &'static str, + ask: &'static str, + button: &'static str, + done_title: &'static str, + done: &'static str, + gone_title: &'static str, + gone: &'static str, +} + +fn words(locale: Locale) -> Words { + match locale { + Locale::En => Words { + title: "Sign in to Botloft", + ask: "Sign in to Botloft on", + button: "Sign in", + done_title: "Done", + done: "You can go back to Botloft now.", + gone_title: "This link no longer works", + gone: "It was already used or it expired. Ask for a new one in Botloft.", + }, + Locale::PtBr => Words { + title: "Entrar no Botloft", + ask: "Entrar no Botloft em", + button: "Entrar", + done_title: "Pronto", + done: "Pode voltar ao Botloft.", + gone_title: "Este link não vale mais", + gone: "Ele já foi usado ou expirou. Peça outro no Botloft.", + }, + Locale::Es => Words { + title: "Entrar en Botloft", + ask: "Entrar en Botloft en", + button: "Entrar", + done_title: "Listo", + done: "Ya puedes volver a Botloft.", + gone_title: "Este enlace ya no sirve", + gone: "Ya se usó o caducó. Pide otro en Botloft.", + }, + } +} + +/// The button page. The device name is what the app sent: it is escaped. +pub fn confirm(locale: Locale, device: &str, code: &str) -> Response { + let w = words(locale); + let body = format!( + "

{}

{} {}.

\ +
\ +
", + w.title, + w.ask, + escape(device), + escape(code), + w.button + ); + page(locale, w.title, &body) +} + +pub fn done(locale: Locale) -> Response { + let w = words(locale); + page( + locale, + w.done_title, + &format!("

{}

{}

", w.done_title, w.done), + ) +} + +pub fn gone(locale: Locale) -> Response { + let w = words(locale); + let mut response = page( + locale, + w.gone_title, + &format!("

{}

{}

", w.gone_title, w.gone), + ); + *response.status_mut() = axum::http::StatusCode::GONE; + response +} + +fn page(locale: Locale, title: &str, body: &str) -> Response { + let html = format!( + "\ + \ + {title}{body}", + locale.tag() + ); + let mut response = Html(html).into_response(); + let headers = response.headers_mut(); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'", + ), + ); + response +} + +fn escape(text: &str) -> String { + text.chars() + .map(|c| match c { + '&' => "&".to_owned(), + '<' => "<".to_owned(), + '>' => ">".to_owned(), + '"' => """.to_owned(), + '\'' => "'".to_owned(), + c => c.to_string(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_the_app_sent_cannot_become_markup() { + assert_eq!( + escape("&'"), + "<b onclick="x">&'" + ); + } +} diff --git a/crates/botloft-cloud/src/session.rs b/crates/botloft-cloud/src/session.rs new file mode 100644 index 00000000..80ab65e8 --- /dev/null +++ b/crates/botloft-cloud/src/session.rs @@ -0,0 +1,93 @@ +//! Who is asking, once signed in (spec 27.3): the bearer token of a device. + +use axum::Json; +use axum::extract::{FromRequestParts, Path, State}; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use axum::http::request::Parts; +use serde_json::{Value, json}; + +use crate::accounts::{self, Who}; +use crate::app::AppState; +use crate::error::ApiError; +use crate::tokens; + +/// The device behind a request. +pub struct Authed(pub Who); + +impl FromRequestParts for Authed { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let token = parts + .headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(ApiError::unauthorized)?; + let (hash, now) = (tokens::hash(token.trim()), state.clock.now()); + let who = state.db.run(|conn| accounts::who_is(conn, &hash, now))?; + who.map(Self).ok_or_else(ApiError::unauthorized) + } +} + +/// `GET /v1/me` +pub async fn me( + State(state): State, + Authed(who): Authed, +) -> Result, ApiError> { + let (devices, used) = state.db.run(|conn| { + Ok(( + accounts::devices(conn, who.account_id)?, + accounts::used_bytes(conn, who.account_id)?, + )) + })?; + let devices: Vec = devices + .into_iter() + .map(|device| { + json!({ + "id": device.id, + "name": device.name, + "created_at": device.created_at, + "last_used_at": device.last_used_at, + "current": device.id == who.device_id, + }) + }) + .collect(); + Ok(Json(json!({ + "email": who.email, + "used": used, + "quota": state.config.quota_bytes, + "devices": devices, + }))) +} + +/// `POST /v1/logout`: this device's token stops working. +pub async fn logout( + State(state): State, + Authed(who): Authed, +) -> Result { + state + .db + .run(|conn| accounts::delete_device(conn, who.account_id, &who.device_id))?; + Ok(StatusCode::NO_CONTENT) +} + +/// `DELETE /v1/devices/`: another device of the same account. +pub async fn remove_device( + State(state): State, + Authed(who): Authed, + Path(id): Path, +) -> Result { + let removed = state + .db + .run(|conn| accounts::delete_device(conn, who.account_id, &id))?; + if removed { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found()) + } +} diff --git a/crates/botloft-cloud/src/tokens.rs b/crates/botloft-cloud/src/tokens.rs new file mode 100644 index 00000000..8a2f7545 --- /dev/null +++ b/crates/botloft-cloud/src/tokens.rs @@ -0,0 +1,37 @@ +//! Secrets the server hands out: 32 random bytes, kept only as a SHA-256. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use sha2::{Digest, Sha256}; + +use crate::error::ApiError; + +/// A new secret, as it goes to whoever must hold it. +pub fn random() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|err| { + tracing::error!("random bytes: {err}"); + ApiError::internal() + })?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +/// What the database keeps of a secret. +pub fn hash(secret: &str) -> String { + hex::encode(Sha256::digest(secret.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secrets_differ_and_hash_the_same_way() { + let (a, b) = (random().expect("a"), random().expect("b")); + assert_ne!(a, b); + assert_eq!(a.len(), 43); + assert_eq!(hash(&a), hash(&a)); + assert_ne!(hash(&a), hash(&b)); + assert_eq!(hash(&a).len(), 64); + } +} diff --git a/crates/botloft-cloud/tests/common/mod.rs b/crates/botloft-cloud/tests/common/mod.rs new file mode 100644 index 00000000..5cb875aa --- /dev/null +++ b/crates/botloft-cloud/tests/common/mod.rs @@ -0,0 +1,133 @@ +//! A server in memory: the router is called directly, mail goes to an outbox +//! and the clock moves when a test says so. + +#![allow(dead_code)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use botloft_cloud::{AppState, Clock, Config, Db, Mailer, Outbox, router}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +pub struct Server { + app: Router, + pub outbox: Outbox, + pub clock: Clock, +} + +pub struct Reply { + pub status: StatusCode, + pub body: String, +} + +impl Reply { + pub fn json(&self) -> Value { + serde_json::from_str(&self.body).unwrap_or_else(|_| panic!("not json: {}", self.body)) + } +} + +pub fn server() -> Server { + start(Outbox::default()) +} + +pub fn start(outbox: Outbox) -> Server { + let clock = Clock::default(); + let state = AppState { + db: Db::memory().expect("db"), + mailer: Arc::new(Mailer::Outbox(outbox.clone())), + clock: clock.clone(), + config: Arc::new(Config::for_tests()), + }; + Server { + app: router(state), + outbox, + clock, + } +} + +impl Server { + pub async fn send(&self, request: Request) -> Reply { + let response = self.app.clone().oneshot(request).await.expect("response"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1 << 20).await.expect("body"); + Reply { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + } + } + + /// `POST /v1/login` from `address`. + pub async fn ask(&self, email: &str, address: &str) -> Reply { + self.ask_with( + json!({ "email": email, "device_name": "PC da Ana", "locale": "pt-BR" }), + address, + ) + .await + } + + pub async fn ask_with(&self, body: Value, address: &str) -> Reply { + let request = Request::post("/v1/login") + .header(header::CONTENT_TYPE, "application/json") + .header("x-forwarded-for", address) + .body(Body::from(body.to_string())) + .expect("request"); + self.send(request).await + } + + pub async fn get(&self, path: &str) -> Reply { + self.send(Request::get(path).body(Body::empty()).expect("request")) + .await + } + + pub async fn authed(&self, method: &str, path: &str, token: &str) -> Reply { + let request = Request::builder() + .method(method) + .uri(path) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .expect("request"); + self.send(request).await + } + + /// The button on the page the e-mail link opens. + pub async fn press(&self, code: &str) -> Reply { + let request = Request::post("/v1/login/confirm") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("code={code}"))) + .expect("request"); + self.send(request).await + } + + /// The link of the newest e-mail, as a path and the code in it. + pub fn last_link(&self) -> (String, String) { + let mail = self.outbox.sent().pop().expect("an e-mail"); + let link = mail + .body + .split_whitespace() + .find(|word| word.starts_with("http")) + .expect("a link"); + let path = link + .strip_prefix("http://127.0.0.1:8787") + .expect("our address"); + let code = path.split("code=").nth(1).expect("a code"); + (path.to_owned(), code.to_owned()) + } + + /// Signs a device in and returns its token. + pub async fn sign_in(&self, email: &str, address: &str) -> String { + let asked = self.ask(email, address).await; + assert_eq!(asked.status, StatusCode::ACCEPTED, "{}", asked.body); + let request = asked.json()["request"] + .as_str() + .expect("request") + .to_owned(); + let (_, code) = self.last_link(); + assert_eq!(self.press(&code).await.status, StatusCode::OK); + let polled = self.get(&format!("/v1/login/{request}")).await.json(); + assert_eq!(polled["status"], "approved", "{polled}"); + polled["token"].as_str().expect("token").to_owned() + } +} diff --git a/crates/botloft-cloud/tests/login.rs b/crates/botloft-cloud/tests/login.rs new file mode 100644 index 00000000..176a1211 --- /dev/null +++ b/crates/botloft-cloud/tests/login.rs @@ -0,0 +1,246 @@ +//! Signing in by e-mail link (spec 27.3). + +mod common; + +use axum::http::StatusCode; +use common::{server, start}; +use serde_json::json; + +#[tokio::test] +async fn the_link_signs_a_device_in_and_the_token_works_once_collected() { + let s = server(); + let asked = s.ask("Ana@Exemplo.com", "1.1.1.1").await; + assert_eq!(asked.status, StatusCode::ACCEPTED); + assert_eq!(asked.json()["wait"], 600); + let request = asked.json()["request"] + .as_str() + .expect("request") + .to_owned(); + + // The e-mail went to the cleaned address, in the language the app asked. + let mails = s.outbox.sent(); + assert_eq!(mails.len(), 1); + assert_eq!(mails[0].to, "ana@exemplo.com"); + assert!(mails[0].subject.contains("Botloft") && mails[0].body.contains("PC da Ana")); + assert!(mails[0].body.contains("expira em 10 minutos")); + + // Nothing is approved by the e-mail alone, and opening the link only + // shows the button: a mail scanner that opens it signs nobody in. + let poll = format!("/v1/login/{request}"); + assert_eq!(s.get(&poll).await.json()["status"], "pending"); + let (path, code) = s.last_link(); + let page = s.get(&path).await; + assert_eq!(page.status, StatusCode::OK); + assert!(page.body.contains(" = reply + .json() + .as_object() + .expect("object") + .keys() + .cloned() + .collect(); + keys.sort(); + keys + }; + assert_eq!(keys(&known), keys(&new)); + assert_eq!(s.outbox.sent().len(), 3); +} + +#[tokio::test] +async fn a_link_works_once_and_not_after_ten_minutes() { + let s = server(); + s.ask("ana@exemplo.com", "1.1.1.1").await; + let (path, code) = s.last_link(); + assert_eq!(s.press(&code).await.status, StatusCode::OK); + // Used already: the page and the button both say it is gone. + assert_eq!(s.get(&path).await.status, StatusCode::GONE); + assert_eq!(s.press(&code).await.status, StatusCode::GONE); + + s.clock.advance(61_000); + let asked = s.ask("bia@exemplo.com", "1.1.1.1").await; + let request = asked.json()["request"] + .as_str() + .expect("request") + .to_owned(); + let (path, code) = s.last_link(); + s.clock.advance(601_000); + let page = s.get(&path).await; + assert_eq!(page.status, StatusCode::GONE); + assert!(page.body.contains("Ask for a new one")); + assert_eq!(s.press(&code).await.status, StatusCode::GONE); + let polled = s.get(&format!("/v1/login/{request}")).await.json(); + assert_eq!(polled["status"], "expired"); +} + +#[tokio::test] +async fn a_request_that_never_existed_is_just_expired() { + let s = server(); + let polled = s.get("/v1/login/not-a-request").await; + assert_eq!(polled.status, StatusCode::OK); + assert_eq!(polled.json()["status"], "expired"); +} + +#[tokio::test] +async fn another_e_mail_is_held_for_a_minute_and_an_hour_allows_five() { + let s = server(); + assert_eq!( + s.ask("ana@exemplo.com", "1.1.1.1").await.status, + StatusCode::ACCEPTED + ); + // A second e-mail to the same person within a minute is refused, not sent. + let held = s.ask("ana@exemplo.com", "1.1.1.1").await; + assert_eq!(held.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(held.json()["reason"], "rate_limited"); + assert!(held.json()["retry_after"].as_u64().expect("seconds") <= 60); + assert_eq!(s.outbox.sent().len(), 1); + + for _ in 0..4 { + s.clock.advance(61_000); + assert_eq!( + s.ask("ana@exemplo.com", "1.1.1.1").await.status, + StatusCode::ACCEPTED + ); + } + s.clock.advance(61_000); + let sixth = s.ask("ana@exemplo.com", "1.1.1.1").await; + assert_eq!(sixth.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(s.outbox.sent().len(), 5); +} + +#[tokio::test] +async fn one_address_cannot_flood_the_server_with_different_e_mails() { + let s = server(); + for n in 0..20 { + let sent = s.ask(&format!("p{n}@exemplo.com"), "9.9.9.9").await; + assert_eq!(sent.status, StatusCode::ACCEPTED, "{n}"); + } + assert_eq!( + s.ask("p20@exemplo.com", "9.9.9.9").await.status, + StatusCode::TOO_MANY_REQUESTS + ); + // Someone else is not held back by it. + assert_eq!( + s.ask("p20@exemplo.com", "8.8.8.8").await.status, + StatusCode::ACCEPTED + ); +} + +#[tokio::test] +async fn bad_addresses_send_nothing() { + let s = server(); + for bad in [ + "", + "ana", + "a b@x.com", + "ana@x.com\r\nBcc: z@z.com", + "
@x.com", + ] { + let reply = s.ask(bad, "1.1.1.1").await; + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{bad:?}"); + assert_eq!(reply.json()["reason"], "bad_email"); + } + assert!(s.outbox.sent().is_empty()); +} + +#[tokio::test] +async fn a_mail_that_does_not_go_leaves_no_request_behind() { + let s = start(botloft_cloud::Outbox::broken()); + let reply = s.ask("ana@exemplo.com", "1.1.1.1").await; + assert_eq!(reply.status, StatusCode::BAD_GATEWAY); + assert_eq!(reply.json()["reason"], "mail_failed"); +} + +#[tokio::test] +async fn the_device_name_cannot_become_markup_on_the_page() { + let s = server(); + let body = json!({ "email": "ana@exemplo.com", "device_name": "", "locale": "en" }); + s.ask_with(body, "1.1.1.1").await; + let (path, _) = s.last_link(); + let page = s.get(&path).await; + assert!(page.body.contains("<script>x()</script>")); + assert!(!page.body.contains("", "\">"); + assert!(html.contains("<script>x()</script>")); + assert!(!html.contains("