Accounts and sign-in, and fix the read toggle

epAction's redraw closure called itself when handed a row, so Mark read
recursed until the stack blew; it now swaps that row in place. Opening an
item also marks it read, redrawn where it stands so nothing vanishes from
under the pointer on the Unread tab.

Step A of multi-user: users and sessions tables, Argon2id, a session
cookie, ipx user subcommands, and a trusted proxy header for Cloudflare
Zero Trust -- honoured only from a trusted_proxies address. The shared
token still works and is the admin. A new database starts with
admin/ipodderx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 23:11:20 +00:00
parent ed26fa2061
commit 06f182b555
12 changed files with 876 additions and 53 deletions

92
src/auth.rs Normal file
View File

@@ -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<String> {
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<String> {
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);
}
}