From a6482ddb0ef47f7bb8a4f310bad1c3e00738a3f1 Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Mon, 3 Aug 2026 11:33:24 -0400 Subject: [PATCH] reset/update password --- e2e/tests/auth.spec.ts | 30 ++++++++++++++++++++++++++++++ src/http.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/ports.rs | 6 ++++++ src/services.rs | 16 ++++++++++++++++ src/sqlite.rs | 15 +++++++++++++++ src/views.rs | 28 +++++++++++++++++++++++++++- static/style.css | 1 + 7 files changed, 133 insertions(+), 1 deletion(-) diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index d7d56d3..f7a69eb 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -13,3 +13,33 @@ test("a user can log out", async ({ page }) => { await expect(page).toHaveURL(/\/login/); await expect(page.locator("h1")).toContainText("Welcome back"); }); + +test("a user can change their password", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await page.goto("/account"); + await page.fill("#new-password", "a-new-strong-password"); + await page.fill("#confirm-password", "a-new-strong-password"); + await page.click('button:has-text("Update password")'); + await expect(page.locator(".alert-success")).toContainText("updated"); + + // The old password no longer works; the new one does. + await page.click('button:has-text("Sign out")'); + await page.fill("#email", "alice@example.com"); + await page.fill("#password", "a-strong-password"); + await page.click('button[type="submit"]'); + await expect(page.locator(".alert-error")).toContainText("incorrect"); + + await page.fill("#email", "alice@example.com"); + await page.fill("#password", "a-new-strong-password"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/lists/); +}); + +test("changing password rejects a mismatched confirmation", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await page.goto("/account"); + await page.fill("#new-password", "a-new-strong-password"); + await page.fill("#confirm-password", "a-different-password"); + await page.click('button:has-text("Update password")'); + await expect(page.locator(".alert-error")).toContainText("do not match"); +}); diff --git a/src/http.rs b/src/http.rs index e818b12..76a4ac5 100644 --- a/src/http.rs +++ b/src/http.rs @@ -92,6 +92,7 @@ pub fn build_router(state: AppState) -> Router { "/account/passkeys/{passkey_id}/delete", post(delete_passkey), ) + .route("/account/password", post(change_password)) .route("/lists", get(lists_page).post(create_list)) .route("/lists/{list_id}", get(list_page)) .route("/lists/{list_id}/items", post(add_item)) @@ -274,6 +275,13 @@ struct DeletePasskeyForm { csrf: String, } +#[derive(Debug, Deserialize)] +struct ChangePasswordForm { + csrf: String, + new_password: String, + confirm_password: String, +} + #[derive(Debug, Deserialize)] struct MealForm { name: String, @@ -448,9 +456,39 @@ async fn account_page( &user.session.user, &passkeys, &user.session.csrf_token, + None, + false, ))) } +async fn change_password( + State(state): State, + user: CurrentUser, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + let passkeys = state.webauthn.list_passkeys(user.session.user.id).await?; + let render = |error: Option<&str>, success: bool| { + html_response(views::account_page( + &user.session.user, + &passkeys, + &user.session.csrf_token, + error, + success, + )) + }; + + if form.new_password != form.confirm_password { + return Ok(render(Some("New password and confirmation do not match."), false)); + } + + state + .auth + .change_password(user.session.user.id, form.new_password) + .await?; + Ok(render(None, true)) +} + async fn passkey_register_start( State(state): State, user: CurrentUser, diff --git a/src/ports.rs b/src/ports.rs index 1801a35..31db3da 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -29,6 +29,12 @@ pub trait UserRepository: Send + Sync { txn: &mut SqliteConnection, user_handle: Vec, ) -> DomainResult>; + async fn update_password_hash( + &self, + txn: &mut SqliteConnection, + user_id: i64, + password_hash: String, + ) -> DomainResult<()>; async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult; } diff --git a/src/services.rs b/src/services.rs index b233711..2f2c080 100644 --- a/src/services.rs +++ b/src/services.rs @@ -147,6 +147,22 @@ impl AuthService { }) .await } + + /// Replaces the user's password hash with a freshly hashed new password. + /// No current-password check is performed because the account page is + /// already authenticated and this app has no email capabilities. + pub async fn change_password(&self, user_id: i64, new_password: String) -> DomainResult<()> { + let users = Arc::clone(&self.users); + let hasher = Arc::clone(&self.hasher); + self.db + .run(move |txn| { + Box::pin(async move { + let new_hash = hasher.hash(&new_password)?; + users.update_password_hash(txn, user_id, new_hash).await + }) + }) + .await + } } pub struct ListService { diff --git a/src/sqlite.rs b/src/sqlite.rs index 5610a9e..06ed8c6 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -209,6 +209,21 @@ impl UserRepository for SqliteUserRepository { })) } + async fn update_password_hash( + &self, + txn: &mut SqliteConnection, + user_id: i64, + password_hash: String, + ) -> DomainResult<()> { + sqlx::query("UPDATE users SET password_hash = ?1 WHERE id = ?2") + .bind(&password_hash) + .bind(user_id) + .execute(&mut *txn) + .await + .map_err(db_error)?; + Ok(()) + } + async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult { let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users)") .fetch_one(&mut *txn) diff --git a/src/views.rs b/src/views.rs index 50d38c3..21b1c06 100644 --- a/src/views.rs +++ b/src/views.rs @@ -82,7 +82,13 @@ pub fn registration_closed_page() -> Markup { ) } -pub fn account_page(user: &User, passkeys: &[Passkey], csrf_token: &str) -> Markup { +pub fn account_page( + user: &User, + passkeys: &[Passkey], + csrf_token: &str, + password_error: Option<&str>, + password_success: bool, +) -> Markup { page( "Account", Some(user), @@ -121,6 +127,26 @@ pub fn account_page(user: &User, passkeys: &[Passkey], csrf_token: &str) -> Mark } button id="add-passkey" class="button button-primary" type="button" data-csrf=(csrf_token) { "Add a passkey" } } + section class="panel" { + div class="panel-heading" { + h2 { "Password" } + } + p { "Set a new password for your account." } + @if let Some(error) = password_error { + div class="alert alert-error" role="alert" { (error) } + } + @if password_success { + div class="alert alert-success" role="alert" { "Your password has been updated." } + } + form method="post" action="/account/password" class="stack" { + input type="hidden" name="csrf" value=(csrf_token); + label for="new-password" { "New password" } + input id="new-password" name="new_password" type="password" autocomplete="new-password" required; + label for="confirm-password" { "Confirm new password" } + input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required; + button class="button button-primary" type="submit" { "Update password" } + } + } } script src="/static/passkey-register.js" {} }, diff --git a/static/style.css b/static/style.css index 8c06cbf..b8ae00e 100644 --- a/static/style.css +++ b/static/style.css @@ -107,6 +107,7 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .auth-switch a { color: var(--deep-sage); font-weight: 800; } .alert { margin-bottom: 18px; padding: 12px 14px; border-radius: 12px; font-size: .9rem; } .alert-error { color: #874d40; background: #fbe7e0; } +.alert-success { color: #3d6b4f; background: #e4f2e6; } .list-topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 27px; } .back-link { color: var(--muted); font-size: .85rem; font-weight: 700; text-decoration: none; }