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:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user