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
93 lines
3.4 KiB
Rust
93 lines
3.4 KiB
Rust
//! 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);
|
|
}
|
|
}
|