diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs index dc835cfb..d53c9a36 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 b608937f..d9334ffc 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 9b154ea1..dff1204d 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; async fn add_user(&self, user: &NewUser) -> DbResult; async fn delete_user(&self, u: &User) -> DbResult<()>; + async fn update_user_password(&self, u: &User) -> DbResult<()>; async fn total_history(&self) -> DbResult; async fn count_history(&self, user: &User) -> DbResult; diff --git a/atuin-server-postgres/src/lib.rs b/atuin-server-postgres/src/lib.rs index c1de4d50..1f7cf47a 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 { let email: &str = &user.email; diff --git a/atuin-server/src/handlers/user.rs b/atuin-server/src/handlers/user.rs index fb281ab3..e5651fe2 100644 --- a/atuin-server/src/handlers/user.rs +++ b/atuin-server/src/handlers/user.rs @@ -175,6 +175,36 @@ pub async fn delete( Ok(Json(DeleteUserResponse {})) } +#[instrument(skip_all, fields(user.id = user.id, change_password))] +pub async fn change_password( + UserAuth(mut user): UserAuth, + state: State>, + Json(change_password): Json, +) -> Result, 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( state: State>, diff --git a/atuin-server/src/router.rs b/atuin-server/src/router.rs index 8509058f..74df229a 100644 --- a/atuin-server/src/router.rs +++ b/atuin-server/src/router.rs @@ -5,7 +5,7 @@ use axum::{ http::{self, request::Parts}, middleware::Next, response::{IntoResponse, Response}, - routing::{delete, get, post}, + routing::{delete, get, patch, post}, Router, }; use eyre::Result; @@ -119,6 +119,7 @@ pub fn router(database: DB, settings: 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::)) diff --git a/atuin/src/command/client/account.rs b/atuin/src/command/client/account.rs index 657552fb..75f8ed59 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 00000000..3b5ad6f5 --- /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, + + #[clap(long, short)] + pub new_password: Option, +} + +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, + new_password: &Option, +) -> 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(()) +} diff --git a/atuin/tests/sync.rs b/atuin/tests/sync.rs index 4ae56a7b..6dacbd54 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());