diff options
| author | TymanWasTaken <ty@blahaj.land> | 2024-01-29 06:17:10 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-01-29 11:17:10 +0000 |
| commit | 0faf414cd958137ac60a1f37288994f3a1441780 (patch) | |
| tree | df7199c0366893dc393d1cc53230a8f39e88d036 | |
| parent | feat: make history list format configurable (#1638) (diff) | |
| download | atuin-0faf414cd958137ac60a1f37288994f3a1441780.zip | |
feat: Add change-password command & support on server (#1615)
* Add change-password command & support on server
* Add a test for password change
* review: run format
---------
Co-authored-by: Ellie Huxtable <ellie@elliehuxtable.com>
| -rw-r--r-- | atuin-client/src/api_client.rs | 36 | ||||
| -rw-r--r-- | atuin-common/src/api.rs | 9 | ||||
| -rw-r--r-- | atuin-server-database/src/lib.rs | 1 | ||||
| -rw-r--r-- | atuin-server-postgres/src/lib.rs | 16 | ||||
| -rw-r--r-- | atuin-server/src/handlers/user.rs | 30 | ||||
| -rw-r--r-- | atuin-server/src/router.rs | 3 | ||||
| -rw-r--r-- | atuin/src/command/client/account.rs | 4 | ||||
| -rw-r--r-- | atuin/src/command/client/account/change_password.rs | 57 | ||||
| -rw-r--r-- | atuin/tests/sync.rs | 38 |
9 files changed, 191 insertions, 3 deletions
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 @@ -34,6 +34,15 @@ pub struct RegisterResponse { 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, pub password: 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<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 c1de4d50..1f7cf47a 100644 --- a/atuin-server-postgres/src/lib.rs +++ b/atuin-server-postgres/src/lib.rs @@ -290,6 +290,22 @@ impl Database for Postgres { } #[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; let username: &str = &user.username; 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<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 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<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 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<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(()) +} 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 @@ -127,6 +127,44 @@ async fn registration() { } #[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()); let (address, shutdown, server) = start_server(&path).await; |
