Repository: atuinsh/atuin (language: rust)

Issue:
Change password
**Describe the bug**
There is no clear way to change password

**To Reproduce**
Steps to reproduce the behavior:
1. Register with shitty password
1. You're screwed

**Expected behavior**
A clear and concise description of what you expected to happen.
Account command let user change or rotate password. This flow is documented

**Error logs**
Please try and provide error logs where possible

**Device (please complete the following information):**
 - OS: [e.g. macOS]
 - Shell [bash/zsh/fish/nu]
 - Version [e.g. 16.0.0]

**Self hosted**
If you are self hosting Atuin server, please detail your setup here. What version are you using, and how is it deployed?

**Additional context**
Add any other context about the problem here.



Discussion / hints:
I believe that there are two potential walkarounds, until a cli supports this:

1. A magic Postgres update query. @ellie  - can you help with that?
2. A way to delete or rename a user on the server side. 
I won't be running any Postgres queries for this, but you can run

`atuin account delete`

And then re register

I'll try and get this one fixed for the next release, unless anyone else fancies having a go! It should be quite straightforward 

Fix patch:
diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs
index dc835cfbae0..d53c9a36490 100644
--- a/atuin-client/src/api_client.rs
+++ b/atuin-client/src/api_client.rs
@@ -10,8 +10,9 @@ use reqwest::{
 
 use atuin_common::{
     api::{
-        AddHistoryRequest, CountResponse, DeleteHistoryRequest, ErrorResponse, LoginRequest,
-        LoginResponse, RegisterResponse, StatusResponse, SyncHistoryResponse,
+        AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
+        ErrorResponse, LoginRequest, LoginResponse, RegisterResponse, StatusResponse,
+        SyncHistoryResponse,
     },
     record::RecordStatus,
 };
@@ -359,4 +360,35 @@ impl<'a> Client<'a> {
             bail!("Unknown error");
         }
     }
+
+    pub async fn change_password(
+        &self,
+        current_password: String,
+        new_password: String,
+    ) -> Result<()> {
+        let url = format!("{}/account/password", self.sync_addr);
+        let url = Url::parse(url.as_str())?;
+
+        let resp = self
+            .client
+            .patch(url)
+            .json(&ChangePasswordRequest {
+                current_password,
+                new_password,
+            })
+            .send()
+            .await?;
+
+        dbg!(&resp);
+
+        if resp.status() == 401 {
+            bail!("current password is incorrect")
+        } else if resp.status() == 403 {
+            bail!("invalid login details");
+        } else if resp.status() == 200 {
+            Ok(())
+        } else {
+            bail!("Unknown error");
+        }
+    }
 }
diff --git a/atuin-common/src/api.rs b/atuin-common/src/api.rs
index b608937f62f..d9334ffc819 100644
--- a/atuin-common/src/api.rs
+++ b/atuin-common/src/api.rs
@@ -33,6 +33,15 @@ pub struct RegisterResponse {
 #[derive(Debug, Serialize, Deserialize)]
 pub struct DeleteUserResponse {}
 
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ChangePasswordRequest {
+    pub current_password: String,
+    pub new_password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ChangePasswordResponse {}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub struct LoginRequest {
     pub username: String,
diff --git a/atuin-server-database/src/lib.rs b/atuin-server-database/src/lib.rs
index 9b154ea1071..dff1204d0d4 100644
--- a/atuin-server-database/src/lib.rs
+++ b/atuin-server-database/src/lib.rs
@@ -54,6 +54,7 @@ pub trait Database: Sized + Clone + Send + Sync + 'static {
     async fn get_user_session(&self, u: &User) -> DbResult<Session>;
     async fn add_user(&self, user: &NewUser) -> DbResult<i64>;
     async fn delete_user(&self, u: &User) -> DbResult<()>;
+    async fn update_user_password(&self, u: &User) -> DbResult<()>;
 
     async fn total_history(&self) -> DbResult<i64>;
     async fn count_history(&self, user: &User) -> DbResult<i64>;
diff --git a/atuin-server-postgres/src/lib.rs b/atuin-server-postgres/src/lib.rs
index c1de4d5097e..1f7cf47af2c 100644
--- a/atuin-server-postgres/src/lib.rs
+++ b/atuin-server-postgres/src/lib.rs
@@ -289,6 +289,22 @@ impl Database for Postgres {
         Ok(())
     }
 
+    #[instrument(skip_all)]
+    async fn update_user_password(&self, user: &User) -> DbResult<()> {
+        sqlx::query(
+            "update users
+            set password = $1
+            where id = $2",
+        )
+        .bind(&user.password)
+        .bind(user.id)
+        .execute(&self.pool)
+        .await
+        .map_err(fix_error)?;
+
+        Ok(())
+    }
+
     #[instrument(skip_all)]
     async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
         let email: &str = &user.email;
diff --git a/atuin-server/src/handlers/user.rs b/atuin-server/src/handlers/user.rs
index 535c740f14f..70bea3fe9a5 100644
--- a/atuin-server/src/handlers/user.rs
+++ b/atuin-server/src/handlers/user.rs
@@ -175,6 +175,36 @@ pub async fn delete<DB: Database>(
     Ok(Json(DeleteUserResponse {}))
 }
 
+#[instrument(skip_all, fields(user.id = user.id, change_password))]
+pub async fn change_password<DB: Database>(
+    UserAuth(mut user): UserAuth,
+    state: State<AppState<DB>>,
+    Json(change_password): Json<ChangePasswordRequest>,
+) -> Result<Json<ChangePasswordResponse>, ErrorResponseStatus<'static>> {
+    let db = &state.0.database;
+
+    let verified = verify_str(
+        user.password.as_str(),
+        change_password.current_password.borrow(),
+    );
+    if !verified {
+        return Err(
+            ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED)
+        );
+    }
+
+    let hashed = hash_secret(&change_password.new_password);
+    user.password = hashed;
+
+    if let Err(e) = db.update_user_password(&user).await {
+        error!("failed to change user password: {}", e);
+
+        return Err(ErrorResponse::reply("failed to change user password")
+            .with_status(StatusCode::INTERNAL_SERVER_ERROR));
+    };
+    Ok(Json(ChangePasswordResponse {}))
+}
+
 #[instrument(skip_all, fields(user.username = login.username.as_str()))]
 pub async fn login<DB: Database>(
     state: State<AppState<DB>>,
diff --git a/atuin-server/src/router.rs b/atuin-server/src/router.rs
index 500e1a29a0a..dbb55b30c5c 100644
--- a/atuin-server/src/router.rs
+++ b/atuin-server/src/router.rs
@@ -5,7 +5,7 @@ use axum::{
     http::Request,
     middleware::Next,
     response::{IntoResponse, Response},
-    routing::{delete, get, post},
+    routing::{delete, get, patch, post},
     Router,
 };
 use eyre::Result;
@@ -120,6 +120,7 @@ pub fn router<DB: Database>(database: DB, settings: Settings<DB::Settings>) -> R
         .route("/history", delete(handlers::history::delete))
         .route("/user/:username", get(handlers::user::get))
         .route("/account", delete(handlers::user::delete))
+        .route("/account/password", patch(handlers::user::change_password))
         .route("/register", post(handlers::user::register))
         .route("/login", post(handlers::user::login))
         .route("/record", post(handlers::record::post::<DB>))
diff --git a/atuin/src/command/client/account.rs b/atuin/src/command/client/account.rs
index 657552fb6b8..75f8ed594bb 100644
--- a/atuin/src/command/client/account.rs
+++ b/atuin/src/command/client/account.rs
@@ -3,6 +3,7 @@ use eyre::Result;
 
 use atuin_client::settings::Settings;
 
+pub mod change_password;
 pub mod delete;
 pub mod login;
 pub mod logout;
@@ -27,6 +28,8 @@ pub enum Commands {
 
     // Delete your account, and all synced data
     Delete,
+
+    ChangePassword(change_password::Cmd),
 }
 
 impl Cmd {
@@ -36,6 +39,7 @@ impl Cmd {
             Commands::Register(r) => r.run(&settings).await,
             Commands::Logout => logout::run(&settings),
             Commands::Delete => delete::run(&settings).await,
+            Commands::ChangePassword(c) => c.run(&settings).await,
         }
     }
 }
diff --git a/atuin/src/command/client/account/change_password.rs b/atuin/src/command/client/account/change_password.rs
new file mode 100644
index 00000000000..3b5ad6f5879
--- /dev/null
+++ b/atuin/src/command/client/account/change_password.rs
@@ -0,0 +1,57 @@
+use clap::Parser;
+use eyre::{bail, Result};
+
+use atuin_client::{api_client, settings::Settings};
+use rpassword::prompt_password;
+
+#[derive(Parser, Debug)]
+pub struct Cmd {
+    #[clap(long, short)]
+    pub current_password: Option<String>,
+
+    #[clap(long, short)]
+    pub new_password: Option<String>,
+}
+
+impl Cmd {
+    pub async fn run(self, settings: &Settings) -> Result<()> {
+        run(settings, &self.current_password, &self.new_password).await
+    }
+}
+
+pub async fn run(
+    settings: &Settings,
+    current_password: &Option<String>,
+    new_password: &Option<String>,
+) -> Result<()> {
+    let client = api_client::Client::new(
+        &settings.sync_address,
+        &settings.session_token,
+        settings.network_connect_timeout,
+        settings.network_timeout,
+    )?;
+
+    let current_password = current_password.clone().unwrap_or_else(|| {
+        prompt_password("Please enter the current password: ").expect("Failed to read from input")
+    });
+
+    if current_password.is_empty() {
+        bail!("please provide the current password");
+    }
+
+    let new_password = new_password.clone().unwrap_or_else(|| {
+        prompt_password("Please enter the new password: ").expect("Failed to read from input")
+    });
+
+    if new_password.is_empty() {
+        bail!("please provide a new password");
+    }
+
+    client
+        .change_password(current_password, new_password)
+        .await?;
+
+    println!("Account password successfully changed!");
+
+    Ok(())
+}


Test patch:
diff --git a/atuin/tests/sync.rs b/atuin/tests/sync.rs
index 8c42b171815..6d7413c5d61 100644
--- a/atuin/tests/sync.rs
+++ b/atuin/tests/sync.rs
@@ -126,6 +126,44 @@ async fn registration() {
     server.await.unwrap();
 }
 
+#[tokio::test]
+async fn change_password() {
+    let path = format!("/{}", uuid_v7().as_simple());
+    let (address, shutdown, server) = start_server(&path).await;
+
+    // -- REGISTRATION --
+
+    let username = uuid_v7().as_simple().to_string();
+    let password = uuid_v7().as_simple().to_string();
+    let client = register_inner(&address, &username, &password).await;
+
+    // the session token works
+    let status = client.status().await.unwrap();
+    assert_eq!(status.username, username);
+
+    // -- PASSWORD CHANGE --
+
+    let current_password = password;
+    let new_password = uuid_v7().as_simple().to_string();
+    let result = client
+        .change_password(current_password, new_password.clone())
+        .await;
+
+    // the password change request succeeded
+    assert!(result.is_ok());
+
+    // -- LOGIN --
+
+    let client = login(&address, username.clone(), new_password).await;
+
+    // login with new password yields a working token
+    let status = client.status().await.unwrap();
+    assert_eq!(status.username, username);
+
+    shutdown.send(()).unwrap();
+    server.await.unwrap();
+}
+
 #[tokio::test]
 async fn sync() {
     let path = format!("/{}", uuid_v7().as_simple());