anvilsign in

collin/anvil · afb6e4cc

Add `user set-password` for resetting a password from the CLI

Collin Richards · 2026-08-12 09:03 UTC · afb6e4cc6d89c4b129b17b70fc801cda0330c6fd · parent 5c0c3dfd · browse files

modifiedcrates/anvil-cli/src/main.rs+13 −0
⋯ 61 unchanged lines
6262 #[arg(long)]
6363 admin: bool,
6464 },
65+ /// Reset a user's password. Existing sessions stay signed in.
66+ SetPassword {
67+ username: String,
68+ #[arg(long)]
69+ password: String,
70+ },
6571 /// Register an SSH public key for a user (for git-over-SSH access).
6672 AddKey {
6773 username: String,
⋯ 106 unchanged lines
174180 if user.is_admin { " [admin]" } else { "" }
175181 );
176182 }
183+ UserCommand::SetPassword { username, password } => {
184+ let user = users::find_by_username(&app.db, &username)
185+ .await?
186+ .with_context(|| format!("no such user: {username}"))?;
187+ users::set_password(&app.db, user.id, &password).await?;
188+ println!("password reset for {}", user.username);
189+ }
177190 UserCommand::AddKey {
178191 username,
179192 key,
⋯ 98 unchanged lines
modifiedcrates/anvil-core/src/users.rs+58 −0
⋯ 94 unchanged lines
9595 Ok(user)
9696 }
9797
98+/// Replace a user's password hash.
99+///
100+/// Returns [`Error::NotFound`] if no user has that id. Existing sessions are
101+/// left alone — resetting a password does not sign anyone out.
102+pub async fn set_password(db: &toasty::Db, user_id: i64, password: &str) -> Result<()> {
103+ if password.is_empty() {
104+ return Err(Error::Invalid("password must not be empty".into()));
105+ }
106+ let mut conn = db.clone();
107+ let Some(mut user) = User::filter(User::fields().id().eq(user_id))
108+ .first()
109+ .exec(&mut conn)
110+ .await?
111+ else {
112+ return Err(Error::NotFound(format!("user id {user_id}")));
113+ };
114+ let hash = hash_password(password)?;
115+ let mut conn = db.clone();
116+ user.update().password_hash(hash).exec(&mut conn).await?;
117+ Ok(())
118+}
119+
98120 /// Look up a user by id.
99121 pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<User>> {
100122 let mut db = db.clone();
⋯ 13 unchanged lines
114136 .await?;
115137 Ok(user)
116138 }
139+
140+#[cfg(test)]
141+mod tests {
142+ use super::*;
143+
144+ /// A reset must persist a hash the login path accepts, and retire the old
145+ /// password.
146+ #[tokio::test]
147+ async fn set_password_replaces_the_stored_hash() {
148+ let dir = tempfile::tempdir().unwrap();
149+ let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
150+
151+ let user = create(&db, "alice", "", "old-pw", false).await.unwrap();
152+ set_password(&db, user.id, "new-pw").await.unwrap();
153+
154+ let reloaded = find_by_id(&db, user.id).await.unwrap().unwrap();
155+ assert!(verify_password(&reloaded.password_hash, "new-pw").unwrap());
156+ assert!(!verify_password(&reloaded.password_hash, "old-pw").unwrap());
157+ }
158+
159+ #[tokio::test]
160+ async fn set_password_rejects_empty_and_unknown_users() {
161+ let dir = tempfile::tempdir().unwrap();
162+ let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
163+
164+ let user = create(&db, "bob", "", "pw", false).await.unwrap();
165+ assert!(matches!(
166+ set_password(&db, user.id, "").await,
167+ Err(Error::Invalid(_))
168+ ));
169+ assert!(matches!(
170+ set_password(&db, user.id + 999, "pw").await,
171+ Err(Error::NotFound(_))
172+ ));
173+ }
174+}