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>
This commit is contained in:
TymanWasTaken 2024-01-29 06:17:10 -05:00 committed by GitHub
parent e1c2b9c783
commit 0faf414cd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 191 additions and 3 deletions

View File

@ -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");
}
}
}

View File

@ -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,

View File

@ -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>;

View File

@ -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;

View File

@ -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>>,

View File

@ -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>))

View File

@ -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,
}
}
}

View File

@ -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(())
}

View File

@ -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());