diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index ec30543..3bbef01 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339). - An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337). - A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336). +- `ipx user passwd` signs the account out of every browser, and says how many of its API tokens still work. A session signed in with the old password went on working (#334). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index d95caac..b441a73 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -2059,8 +2059,14 @@ impl Db { Ok(()) } + /// Signs the account out everywhere, too: a password is changed because someone else may + /// know it, and a session they signed in with stayed good, every use putting off its expiry + /// (#334). API tokens stay; they are listed and removed in Settings. pub async fn set_password(&self, id: i64, hash: &str) -> Result<()> { - self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() }).await + self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() }) + .await?; + sessions::Entity::delete_many().filter(sessions::Column::UserId.eq(id)).exec(&self.orm).await?; + Ok(()) } pub async fn set_admin(&self, id: i64, admin: bool) -> Result<()> { @@ -3003,6 +3009,22 @@ pub fn now() -> i64 { mod tests { use super::*; + #[tokio::test] + async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() { + let db = Db::memory().await.unwrap(); + let (me, you) = + (db.create_user("me", None, false).await.unwrap(), db.create_user("you", None, false).await.unwrap()); + db.create_session(me, "mine").await.unwrap(); + db.create_session(you, "yours").await.unwrap(); + db.create_api_token(me, "script", "hash").await.unwrap(); + + db.set_password(me, "new-hash").await.unwrap(); + + assert!(db.session_user("mine", 3600).await.unwrap().is_none(), "a stolen cookie stops working (#334)"); + assert!(db.session_user("yours", 3600).await.unwrap().is_some(), "nobody else is signed out"); + assert!(db.api_token_user("hash").await.unwrap().is_some()); + } + #[tokio::test] async fn deadair_looks_at_a_file_once_per_download_and_counts_an_episode_once() { let db = Db::memory().await.unwrap(); diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index f15f962..e0ce05d 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -540,7 +540,13 @@ async fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { let name = name.trim().to_ascii_lowercase(); let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?; - println!("password changed for {name}"); + println!("password changed for {name}, and every browser signed in as {name} signed out"); + let tokens = ctx.db.api_tokens(user.id).await?.len(); + if tokens > 0 { + println!( + "{tokens} API token(s) of {name}'s still work: remove any that may be someone else's in Settings" + ); + } Ok(()) } UserCmd::Rename { name, new_name } => { @@ -641,8 +647,7 @@ async fn daemon( let (password, told) = first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?; ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), true).await?; tracing::warn!( - "no accounts yet: created 'admin' {told}. Sign in and change it, or with \ - `echo -n | ipx user passwd admin`" + "no accounts yet: created 'admin' {told}. Change it with `echo -n | ipx user passwd admin`" ); } diff --git a/docs/cli.md b/docs/cli.md index 05339d4..e95abe0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -31,7 +31,7 @@ Passwords are read from **stdin**, so they miss the shell history and any `ps` l ```sh echo -n 'a good password' | ipx user add ray # local account ipx user add ray@example.com --no-password # signs in through the proxy only -echo -n 'a good password' | ipx user passwd admin # change a password +echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere ipx user list # who exists, and how each signs in ipx user rm sam # account, subscriptions and read state ```