A new password signs the account out everywhere (#334)

ipx user passwd, the one way to change a password and the step taken
when one may be known to someone else, only replaced the hash. Every
session stayed, and session_user moves `seen` on each use, so a
session someone kept using never reached the idle cutoff.

set_password now deletes the account's sessions too. API tokens stay:
a reset that broke every script would be a reason not to reset, and
the owner lists and removes tokens in Settings. The command says how
many still work.

Also corrects the first-start log line from #321: there is no way to
change a password in the page, so it names the command alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:45:41 +00:00
parent d1149e1236
commit 37c08195f1
4 changed files with 33 additions and 5 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -540,7 +540,13 @@ async fn user_cmd(ctx: &Arc<Ctx>, 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 <password> | ipx user passwd admin`"
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`"
);
}

View File

@@ -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
```