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);
}
}

View File

@@ -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<String>,
/// 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,
}
}
}

146
src/db.rs
View File

@@ -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<String>,
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<i64> {
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<Option<User>> {
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<Option<User>> {
self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE id = ?1", id)
}
fn one_user<P: rusqlite::ToSql>(&self, sql: &str, key: P) -> Result<Option<User>> {
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<Vec<User>> {
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::<rusqlite::Result<Vec<_>>>()?;
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<Option<User>> {
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<usize> {

View File

@@ -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<Ctx>, cmd: UserCmd) -> Result<()> {
let read_password = || -> Result<String> {
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 <name>");
}
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<Ctx>, 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 <password> | 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(_) => {}

View File

@@ -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::<std::net::SocketAddr>(),
)
.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 `<audio src>` request is issued by the
/// browser, and there is no way to attach a header to it.
async fn auth(State(state): State<WebState>, req: Request, next: Next) -> Response {
let expected = state.ctx.cfg().web.token.clone();
if expected.is_empty() {
// Refuse to serve rather than serve unauthenticated.
return (StatusCode::INTERNAL_SERVER_ERROR, "no web token configured").into_response();
/// Any of them has to survive being put in a cookie: an `<audio src>` request is issued by
/// the browser, and there is no way to attach a header to it.
async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Response {
let cfg = state.ctx.cfg();
let token = cfg.web.token.clone();
let peer = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default();
// 1. A header, but only from a hop we were told to believe. Anyone able to reach the
// port could otherwise send it and be whoever they liked.
let vouched = (!cfg.web.trusted_header.is_empty()
&& cfg.web.trusted_proxies.iter().any(|p| p == &peer))
.then(|| {
req.headers()
.get(&cfg.web.trusted_header)
.and_then(|v| v.to_str().ok())
.and_then(crate::auth::name_from_header)
})
.flatten();
let mut set_cookie: Option<String> = None;
let mut user = None;
if let Some(name) = vouched {
user = match state.ctx.db.user_by_name(&name) {
Ok(Some(u)) => Some(u),
Ok(None) if cfg.web.auto_create_users => {
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
state
.ctx
.db
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
.ok()
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
}
Ok(None) => {
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
None
}
Err(e) => {
tracing::error!(error = %e, "looking up the vouched user");
None
}
};
}
// 2. A session cookie from signing in here.
if user.is_none() {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
user = state
.ctx
.db
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
.unwrap_or(None);
}
}
// 3. The shared token, which is the admin: the healthcheck and any scripts predate
// accounts and must keep working.
let from_query = req.uri().query().and_then(|q| {
q.split('&')
.find_map(|kv| kv.strip_prefix("token=").map(str::to_owned))
});
let from_cookie = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| {
c.split(';')
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(str::to_owned))
});
let supplied = from_query.clone().or(from_cookie);
if !supplied.is_some_and(|t| constant_time_eq(&t, &expected)) {
return (StatusCode::UNAUTHORIZED, "bad or missing token").into_response();
if user.is_none() && !token.is_empty() {
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
user = admin_user(&state);
if from_query.is_some() {
set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
));
}
}
}
let Some(user) = user else {
// A browser asking for a page gets the sign-in form; anything else gets a 401 it
// can act on.
let wants_html = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("text/html"));
return if wants_html {
Redirect::to("/login").into_response()
} else {
(StatusCode::UNAUTHORIZED, "sign in").into_response()
};
};
req.extensions_mut().insert(user);
let mut resp = next.run(req).await;
if from_query.is_some() {
// Remember it so the rest of the page (and the audio element) authenticates.
if let Ok(v) = header::HeaderValue::from_str(&format!(
"{COOKIE}={expected}; Path=/; SameSite=Lax; Max-Age=31536000"
)) {
if let Some(c) = set_cookie {
if let Ok(v) = header::HeaderValue::from_str(&c) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
}
resp
}
/// Compares without leaking length or position through timing.
const SESSION_COOKIE: &str = "ipx_session";
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
/// reaches a handler without passing through it.
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
parts
.extensions
.get::<crate::db::User>()
.cloned()
.ok_or((StatusCode::UNAUTHORIZED, "sign in"))
}
}
fn cookie(req: &Request, name: &str) -> Option<String> {
req.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| {
c.split(';')
.find_map(|kv| kv.trim().strip_prefix(&format!("{name}=")).map(str::to_owned))
})
}
/// The account the shared token stands for: the first admin, or the first user at all.
fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().ok()?;
users
.iter()
.find(|u| u.is_admin)
.or_else(|| users.first())
.cloned()
}
#[derive(Deserialize)]
struct Credentials {
name: String,
password: String,
}
async fn login(
State(state): State<WebState>,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name)?;
// The same answer either way: whether a name exists is not something to leak.
let ok = user
.as_ref()
.and_then(|u| u.pass_hash.as_deref())
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
if !ok {
tracing::warn!(user = %name, "failed sign-in");
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
}
let user = user.expect("verified above");
let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token)?;
tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1);
let mut resp = Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
.into_response();
if let Ok(v) = header::HeaderValue::from_str(&format!(
"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
days * 86_400
)) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
Ok(resp)
}
async fn logout(State(state): State<WebState>, req: Request) -> Response {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
let _ = state.ctx.db.delete_session(&sid);
}
let mut resp = StatusCode::NO_CONTENT.into_response();
for c in [
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
format!("{COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
] {
if let Ok(v) = header::HeaderValue::from_str(&c) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
}
resp
}
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
}
async fn login_page() -> Html<&'static str> {
Html(include_str!("../web/login.html"))
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {