diff --git a/Cargo.lock b/Cargo.lock index da92361..0c6abe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -326,6 +338,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -350,6 +368,24 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bs58" version = "0.5.1" @@ -508,6 +544,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -606,6 +648,15 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.37.0" @@ -617,6 +668,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -778,6 +838,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", + "ctutils", +] + [[package]] name = "diligent-date-parser" version = "0.1.5" @@ -1309,6 +1380,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.1" @@ -1558,6 +1638,7 @@ version = "0.1.0" dependencies = [ "ammonia", "anyhow", + "argon2", "atom_syndication", "axum", "chrono", @@ -2401,12 +2482,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index c98620e..209c449 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] ammonia = "4.1.4" anyhow = "1.0.104" +argon2 = "0.6.0" atom_syndication = "0.12.10" axum = "0.8.9" chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } diff --git a/PROGRESS.md b/PROGRESS.md index a79f7fd..b1248bf 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,19 +56,55 @@ and until now nothing set them. --- +## 2026-09-10 — Marking an item read + +Two bugs in one place. `epAction`'s `redraw` closure called *itself* when it had a row to +update -- `if(el) redraw()` where it meant to swap the row -- so the Mark read button in the +text pane recursed until the stack blew. It now swaps that one row in place and refreshes the +text below only when it is the item being read. + +And opening an item now marks it read, which is what clicking a thing to read it means. The row +is redrawn where it stands rather than the list reloaded, so an item does not vanish from under +the pointer on the Unread tab. + +--- + +## 2026-09-10 — Step A: accounts and sign-in + +`users` and `sessions` tables, Argon2id hashing, a session cookie, and `ipx user add|list|passwd|rm` +(passwords come in on stdin, so they miss the shell history and any `ps` listing). + +Three ways in, in order of how specific the claim is: + +1. **A proxy header** naming the user -- `Cf-Access-Authenticated-User-Email` for the Cloudflare + Zero Trust in front of `ipodderx.sdf1.net`. Honoured **only** from an address in + `trusted_proxies` (loopback by default): a header is worth exactly as much as the hop that set + it, and the LAN port would otherwise let anyone claim to be anyone. Verified both ways -- a + spoof from an untrusted address is refused. +2. **A session cookie** from signing in at `/login`. +3. **The shared token**, which is the admin, so the healthcheck and existing links keep working. + +A database with no accounts creates **admin / ipodderx** and says so loudly in the log. The UI shows +who is signed in above the sidebar footer, with a sign-out, and a 401 sends the page to `/login`. + +Nothing is per-user *yet*: everyone still sees the same feeds and read state. That is step B. + +--- + ## Multi-user — the plan Decided with Ray: **stay on SQLite** (Postgres was considered and dropped -- it is a deployment choice, not a capability one, and nothing here contends for writes). Sign-in is either a local -username and password or the Authentik that already fronts `ipodderx.sdf1.net` through a Cloudflare -tunnel. Feeds, items and files are **shared**; read state and subscriptions are **per user**. +username and password or the Cloudflare Zero Trust that already fronts `ipodderx.sdf1.net`, which +puts the authenticated identity in `Cf-Access-Authenticated-User-Email`. Feeds, items and files are **shared**; read state and subscriptions are **per user**. The point of sharing: two people subscribed to the same show cost one fetch, one parse, and one file on disk. `enclosures.url` is already globally UNIQUE, so the file half is nearly free. -- [ ] **A. Users, sessions, sign-in.** `users` + `sessions` tables, Argon2 hashing, session cookie, - `ipx user add|list|passwd|rm`. Authentik/proxy header (`trusted_header` in `[web]`) signs in and - optionally creates a user. The existing shared token keeps working and resolves to the admin, so +- [x] **A. Users, sessions, sign-in.** `users` + `sessions` tables, Argon2 hashing, session cookie, + `ipx user add|list|passwd|rm`. A proxy header (`trusted_header` in `[web]`) signs in and + optionally creates a user -- honoured only from a `trusted_proxies` address, so a LAN client + cannot simply assert it. The existing shared token keeps working and resolves to the admin, so the healthcheck and any scripts survive. Login page for direct access. - [ ] **B. Per-user read state.** `entry_state(user_id, feed_id, guid, read, flagged, position)`; the current columns on `entries` migrate into the first user's rows. Unread counts, filters and diff --git a/docker-compose.yml b/docker-compose.yml index c3a2fcb..bfd1f45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,22 @@ services: - iPodderX: - build: . - # image: ipx:latest # swap build: for image: once you have published one + ipodderx: + image: 192.168.1.130:5000/ipodderx:latest container_name: iPodderX restart: unless-stopped environment: - # Unraid shares expect these; downloads land owned by nobody:users. PUID: "99" PGID: "100" TZ: "America/Toronto" - # ipx=debug for verbose, or add librqbit=info to watch torrents. IPX_LOG: "ipx=info" ports: - "8099:8099" # web UI - "6881:6881/tcp" # BitTorrent peers - "6881:6881/udp" # DHT volumes: - - ./config:/config # config.toml, and the web token - - ./data:/data # state.db - - /mnt/user/audio/ipx:/downloads + - /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token + - /mnt/user/ipodderx/:/data # state.db + - /mnt/user/ipodderx/downloads:/downloads healthcheck: - # `ipx status` proxies through the control socket to the command worker, so this - # catches a daemon that is alive but wedged -- not just one that has died. (A - # blocked worker is a real failure mode: a torrent used to be able to cause it.) test: ["CMD", "ipx", "status"] interval: 30s timeout: 5s diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..5b2d72a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,92 @@ +//! Who is asking. Sign-in is either a local password or a header set by whatever fronts +//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated +//! address in `Cf-Access-Authenticated-User-Email`. + +use anyhow::{Result, bail}; +use argon2::Argon2; +use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}; + +/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The +/// salt is generated per password by the hasher itself. +pub fn hash_password(password: &str) -> Result { + if password.len() < 8 { + bail!("password must be at least 8 characters"); + } + Argon2::default() + .hash_password(password.as_bytes()) + .map(|h| h.to_string()) + .map_err(|e| anyhow::anyhow!("could not hash the password: {e}")) +} + +/// False for a wrong password *and* for a stored hash this build cannot parse; either way +/// the answer is no. +pub fn verify_password(password: &str, stored: &str) -> bool { + let Ok(parsed) = PasswordHash::new(stored) else { + tracing::warn!("stored password hash is unreadable; refusing the sign-in"); + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy. +pub fn new_session_token() -> String { + let mut bytes = [0u8; 32]; + if getrandom(&mut bytes).is_err() { + // Falling back to the clock would be a predictable session id. Better to fail. + panic!("no source of randomness for a session token"); + } + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn getrandom(buf: &mut [u8]) -> std::io::Result<()> { + use std::io::Read; + std::fs::File::open("/dev/urandom")?.read_exact(buf) +} + +/// A username taken from a proxy header. Cloudflare sends an email address; the local part +/// is what a person recognises, and the whole thing stays unique enough for one household. +pub fn name_from_header(raw: &str) -> Option { + let name = raw.trim(); + if name.is_empty() || name.len() > 190 { + return None; + } + // Anything that could confuse a lookup or a log line is not a name. + if name.chars().any(|c| c.is_control() || c == ',' || c == ';') { + return None; + } + Some(name.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_password_verifies_only_against_itself() { + let h = hash_password("correct horse battery").unwrap(); + assert!(verify_password("correct horse battery", &h)); + assert!(!verify_password("Correct horse battery", &h)); + assert!(!verify_password("", &h)); + // A hash from a different scheme, or a truncated one, must not authenticate. + assert!(!verify_password("correct horse battery", "not-a-hash")); + assert!(hash_password("short").is_err()); + } + + #[test] + fn session_tokens_are_long_and_distinct() { + let a = new_session_token(); + let b = new_session_token(); + assert_eq!(a.len(), 64); + assert_ne!(a, b); + } + + #[test] + fn a_header_name_is_cleaned_or_refused() { + assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into())); + assert_eq!(name_from_header(""), None); + assert_eq!(name_from_header("ray\nadmin"), None); + assert_eq!(name_from_header("ray;admin"), None); + } +} diff --git a/src/config.rs b/src/config.rs index 3e300cf..7d6605b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,8 +71,20 @@ pub struct Web { pub enabled: bool, /// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token. pub bind: String, - /// Shared secret. Generated and written back on first run when left empty. + /// Shared secret. Generated and written back on first run when left empty. It signs + /// in as the admin, which is what keeps the healthcheck and any scripts working. pub token: String, + /// A header naming the signed-in user, set by whatever fronts this -- Cloudflare Zero + /// Trust sends `Cf-Access-Authenticated-User-Email`. Empty disables the whole path. + pub trusted_header: String, + /// Addresses allowed to assert that header. A header is only as trustworthy as the + /// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could + /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon. + pub trusted_proxies: Vec, + /// Create an account the first time the proxy vouches for a name it has not seen. + pub auto_create_users: bool, + /// Sign a session out after this long without a request. + pub session_days: i64, } impl Default for Web { @@ -81,6 +93,10 @@ impl Default for Web { enabled: false, bind: "127.0.0.1:8080".into(), token: String::new(), + trusted_header: String::new(), + trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], + auto_create_users: true, + session_days: 30, } } } diff --git a/src/db.rs b/src/db.rs index 6a88f5a..7428fd3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,7 +1,7 @@ //! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat. use anyhow::{Context, Result}; -use rusqlite::{Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, params}; use std::path::Path; use std::sync::Mutex; @@ -69,8 +69,35 @@ CREATE TABLE IF NOT EXISTS enclosures ( ); CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid); + +-- pass_hash is NULL for someone who only ever arrives through the proxy: there is no +-- password to check, and leaving it empty is not the same as leaving it unset. +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + pass_hash TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + created INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created INTEGER NOT NULL, + seen INTEGER NOT NULL +); "; +/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives +/// through the proxy. +#[derive(Debug, Clone)] +pub struct User { + pub id: i64, + pub name: String, + pub pass_hash: Option, + pub is_admin: bool, +} + /// A feed derived from an OPML subscription rather than written into the config. #[derive(Debug, Clone)] pub struct Managed { @@ -681,6 +708,123 @@ impl Db { } /// Marks every entry in a feed read, for the "mark all read" button. + // ---- users and sessions ---- + + pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO users (name, pass_hash, is_admin, created) VALUES (?1, ?2, ?3, ?4)", + params![name, pass_hash, admin as i64, now()], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn user_by_name(&self, name: &str) -> Result> { + self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE name = ?1", name) + } + + pub fn user_by_id(&self, id: i64) -> Result> { + self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE id = ?1", id) + } + + fn one_user(&self, sql: &str, key: P) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(sql)?; + let mut rows = stmt.query(params![key])?; + Ok(match rows.next()? { + Some(r) => Some(User { + id: r.get(0)?, + name: r.get(1)?, + pass_hash: r.get(2)?, + is_admin: r.get::<_, i64>(3)? != 0, + }), + None => None, + }) + } + + pub fn users(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT id, name, pass_hash, is_admin FROM users ORDER BY name")?; + let out = stmt + .query_map([], |r| { + Ok(User { + id: r.get(0)?, + name: r.get(1)?, + pass_hash: r.get(2)?, + is_admin: r.get::<_, i64>(3)? != 0, + }) + })? + .collect::>>()?; + Ok(out) + } + + pub fn set_password(&self, id: i64, hash: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("UPDATE users SET pass_hash = ?2 WHERE id = ?1", params![id, hash])?; + Ok(()) + } + + pub fn set_admin(&self, id: i64, admin: bool) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("UPDATE users SET is_admin = ?2 WHERE id = ?1", params![id, admin as i64])?; + Ok(()) + } + + /// Sessions go with the user: a deleted account must not leave a usable cookie behind. + pub fn delete_user(&self, id: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM sessions WHERE user_id = ?1", [id])?; + conn.execute("DELETE FROM users WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO sessions (token, user_id, created, seen) VALUES (?1, ?2, ?3, ?3)", + params![token, user_id, now()], + )?; + Ok(()) + } + + /// The user behind a session cookie, if it is still live. Idle sessions expire after + /// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive. + pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let cutoff = now() - max_idle_secs; + let mut stmt = conn.prepare( + "SELECT u.id, u.name, u.pass_hash, u.is_admin + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = ?1 AND s.seen >= ?2", + )?; + let mut rows = stmt.query(params![token, cutoff])?; + let found = match rows.next()? { + Some(r) => Some(User { + id: r.get(0)?, + name: r.get(1)?, + pass_hash: r.get(2)?, + is_admin: r.get::<_, i64>(3)? != 0, + }), + None => None, + }; + drop(rows); + drop(stmt); + if found.is_some() { + conn.execute("UPDATE sessions SET seen = ?2 WHERE token = ?1", params![token, now()])?; + } else { + // Either unknown or timed out; either way it is dead weight. + conn.execute("DELETE FROM sessions WHERE token = ?1 OR seen < ?2", params![token, cutoff])?; + } + Ok(found) + } + + pub fn delete_session(&self, token: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM sessions WHERE token = ?1", [token])?; + Ok(()) + } + /// Marks every entry of the given feeds read. Takes a list because an OPML subscription /// holds no entries itself -- marking it read means the feeds inside it. pub fn mark_all_read(&self, feed_ids: &[String]) -> Result { diff --git a/src/main.rs b/src/main.rs index b977e66..7cd5cc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod auth; mod config; mod db; mod download; @@ -67,6 +68,11 @@ enum Command { Import { file: PathBuf }, /// Write subscriptions out as OPML Export { file: PathBuf }, + /// Add, list or remove the accounts that can sign in to the web UI + User { + #[command(subcommand)] + cmd: UserCmd, + }, /// Run the scheduler and serve the control socket Daemon { /// Serve the web UI on this address, overriding [web] in the config @@ -75,6 +81,30 @@ enum Command { }, } +#[derive(Subcommand)] +enum UserCmd { + /// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray` + Add { + name: String, + /// May manage other accounts, and is who the shared web token signs in as + #[arg(long)] + admin: bool, + /// Sign-in comes from the proxy instead, so there is no password to set + #[arg(long)] + no_password: bool, + }, + /// Show the accounts and how each one signs in + List, + /// Replace a password, read from stdin + Passwd { name: String }, + /// Delete an account and everything it knows: its subscriptions and read state + Rm { name: String }, +} + +/// What a brand new database starts with, so there is always a way in. Announced loudly +/// in the log, and the first thing the settings page nags about. +const DEFAULT_PASSWORD: &str = "ipodderx"; + /// Everything a command needs. One per process. pub struct Ctx { /// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks @@ -157,6 +187,7 @@ async fn main() -> Result<()> { Command::Status => Some(Cmd::Status), Command::List | Command::Daemon { .. } + | Command::User { .. } | Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } @@ -191,12 +222,89 @@ async fn main() -> Result<()> { add(&ctx, &config_path, &url, folder, keywords).await } Command::Rm { feed } => rm(&ctx, &config_path, &feed), + Command::User { cmd } => user_cmd(&ctx, cmd), Command::Import { file } => import(&ctx, &config_path, &file).await, Command::Export { file } => export(&ctx, &file), _ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, } } +/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps` +/// listing. +fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { + let read_password = || -> Result { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + let pw = buf.trim_end_matches(['\n', '\r']).to_string(); + if pw.is_empty() { + anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`"); + } + Ok(pw) + }; + + match cmd { + UserCmd::Add { name, admin, no_password } => { + let name = name.trim().to_ascii_lowercase(); + if name.is_empty() { + anyhow::bail!("a name is required"); + } + if ctx.db.user_by_name(&name)?.is_some() { + anyhow::bail!("{name} already exists"); + } + let hash = if no_password { + None + } else { + Some(crate::auth::hash_password(&read_password()?)?) + }; + // The first account runs the place; there is nobody else to grant it. + let first = ctx.db.users()?.is_empty(); + ctx.db.create_user(&name, hash.as_deref(), admin || first)?; + println!( + "added {name}{}{}", + if admin || first { " (admin)" } else { "" }, + if no_password { ", signs in through the proxy" } else { "" } + ); + Ok(()) + } + UserCmd::List => { + let users = ctx.db.users()?; + if users.is_empty() { + println!("no accounts yet: ipx user add "); + } + for u in users { + println!( + "{:<20} {:<8} {}", + u.name, + if u.is_admin { "admin" } else { "" }, + if u.pass_hash.is_some() { "password" } else { "proxy only" } + ); + } + Ok(()) + } + UserCmd::Passwd { name } => { + let name = name.trim().to_ascii_lowercase(); + let user = ctx + .db + .user_by_name(&name)? + .ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; + ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?)?; + println!("password changed for {name}"); + Ok(()) + } + UserCmd::Rm { name } => { + let name = name.trim().to_ascii_lowercase(); + let user = ctx + .db + .user_by_name(&name)? + .ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; + ctx.db.delete_user(user.id)?; + println!("removed {name}"); + Ok(()) + } + } +} + async fn run(ctx: &Arc, cmd: Cmd) -> Result<()> { match cmd { Cmd::Fetch { feed, force } => { @@ -226,6 +334,16 @@ async fn daemon( anyhow::bail!("a daemon is already listening on {}", socket.display()); } + // A database with nobody in it cannot be signed into, and an install that predates + // accounts still has to serve its owner. Both get the same starting point. + if ctx.db.users()?.is_empty() { + ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true)?; + tracing::warn!( + "no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \ + Change it with `echo -n | ipx user passwd admin`" + ); + } + match migrate_opml_children(&ctx) { Ok(n) if n > 0 => tracing::info!(count = n, "moved OPML feeds out of config.toml into the database"), Ok(_) => {} diff --git a/src/web.rs b/src/web.rs index 2c40318..98e0048 100644 --- a/src/web.rs +++ b/src/web.rs @@ -7,7 +7,7 @@ use axum::{ http::{StatusCode, header}, middleware::{self, Next}, response::{ - Html, IntoResponse, Response, + Html, IntoResponse, Redirect, Response, sse::{Event as SseEvent, Sse}, }, routing::{delete, get, patch, post}, @@ -60,6 +60,8 @@ pub fn generate_token() -> String { pub fn router(state: WebState) -> Router { Router::new() .route("/", get(index)) + .route("/api/me", get(me)) + .route("/api/logout", post(logout)) .route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}/entries", get(entries)) @@ -76,6 +78,9 @@ pub fn router(state: WebState) -> Router { .route("/api/events", get(events)) .route("/media/{id}", get(media)) .layer(middleware::from_fn_with_state(state.clone(), auth)) + // Signing in cannot require being signed in, so these sit outside the auth layer. + .route("/login", get(login_page)) + .route("/api/login", post(login)) .layer(middleware::from_fn(access_log)) .with_state(state) } @@ -85,53 +90,223 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> { .await .with_context(|| format!("binding {bind}"))?; tracing::info!(bind, "web ui listening"); - axum::serve(listener, router(state)) - .await - .context("serving the web ui") + axum::serve( + listener, + router(state).into_make_service_with_connect_info::(), + ) + .await + .context("serving the web ui") } -/// Token in `?token=` (which then sets a cookie) or in the cookie itself. +/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, +/// a session cookie, then the shared token (which is the admin). /// -/// It has to be a cookie rather than a header: an `