aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/command/client/account
diff options
context:
space:
mode:
Diffstat (limited to 'crates/turtle/src/command/client/account')
-rw-r--r--crates/turtle/src/command/client/account/change_password.rs67
-rw-r--r--crates/turtle/src/command/client/account/delete.rs57
-rw-r--r--crates/turtle/src/command/client/account/login.rs206
-rw-r--r--crates/turtle/src/command/client/account/logout.rs5
-rw-r--r--crates/turtle/src/command/client/account/register.rs67
5 files changed, 402 insertions, 0 deletions
diff --git a/crates/turtle/src/command/client/account/change_password.rs b/crates/turtle/src/command/client/account/change_password.rs
new file mode 100644
index 00000000..6112b0df
--- /dev/null
+++ b/crates/turtle/src/command/client/account/change_password.rs
@@ -0,0 +1,67 @@
+use clap::Parser;
+use eyre::{Result, bail};
+
+use crate::atuin_client::{
+ auth::{self, MutateResponse},
+ 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>,
+
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+}
+
+impl Cmd {
+ pub async fn run(&self, settings: &Settings) -> Result<()> {
+ if !settings.logged_in().await? {
+ bail!("You are not logged in");
+ }
+
+ let client = auth::auth_client(settings).await;
+
+ let current_password = self.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 = self.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");
+ }
+
+ let mut totp_code = self.totp_code.clone();
+
+ loop {
+ let response = client
+ .change_password(&current_password, &new_password, totp_code.as_deref())
+ .await?;
+
+ match response {
+ MutateResponse::Success => break,
+ MutateResponse::TwoFactorRequired => {
+ totp_code = Some(super::login::or_user_input(None, "two-factor code"));
+ }
+ }
+ }
+
+ println!("Account password successfully changed!");
+
+ Ok(())
+ }
+}
diff --git a/crates/turtle/src/command/client/account/delete.rs b/crates/turtle/src/command/client/account/delete.rs
new file mode 100644
index 00000000..bcb40bc3
--- /dev/null
+++ b/crates/turtle/src/command/client/account/delete.rs
@@ -0,0 +1,57 @@
+use crate::atuin_client::{
+ auth::{self, MutateResponse},
+ settings::Settings,
+};
+use clap::Parser;
+use eyre::{Result, bail};
+
+use super::login::{or_user_input, read_user_password};
+
+#[derive(Parser, Debug)]
+pub struct Cmd {
+ #[clap(long, short)]
+ pub password: Option<String>,
+
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+}
+
+impl Cmd {
+ pub async fn run(&self, settings: &Settings) -> Result<()> {
+ if !settings.logged_in().await? {
+ bail!("You are not logged in");
+ }
+
+ let client = auth::auth_client(settings).await;
+
+ let password = self.password.clone().unwrap_or_else(read_user_password);
+
+ if password.is_empty() {
+ bail!("please provide your password");
+ }
+
+ let mut totp_code = self.totp_code.clone();
+
+ loop {
+ let response = client
+ .delete_account(&password, totp_code.as_deref())
+ .await?;
+
+ match response {
+ MutateResponse::Success => break,
+ MutateResponse::TwoFactorRequired => {
+ totp_code = Some(or_user_input(None, "two-factor code"));
+ }
+ }
+ }
+
+ // Clean up sessions from meta store
+ let meta = Settings::meta_store().await?;
+ meta.delete_session().await?;
+
+ println!("Your account is deleted");
+
+ Ok(())
+ }
+}
diff --git a/crates/turtle/src/command/client/account/login.rs b/crates/turtle/src/command/client/account/login.rs
new file mode 100644
index 00000000..0c5b66f5
--- /dev/null
+++ b/crates/turtle/src/command/client/account/login.rs
@@ -0,0 +1,206 @@
+use std::{io, path::PathBuf};
+
+use clap::Parser;
+use eyre::{Context, Result, bail};
+use tokio::{fs::File, io::AsyncWriteExt};
+
+use crate::atuin_client::{
+ auth::{self, AuthResponse},
+ encryption::{decode_key, load_key},
+ record::sqlite_store::SqliteStore,
+ record::store::Store,
+ record::sync::{self, SyncError},
+ settings::{Settings, SyncAuth},
+};
+use rpassword::prompt_password;
+
+#[derive(Parser, Debug)]
+pub struct Cmd {
+ #[clap(long, short)]
+ pub username: Option<String>,
+
+ #[clap(long, short)]
+ pub password: Option<String>,
+
+ /// The encryption key for your account
+ #[clap(long, short)]
+ pub key: Option<String>,
+
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+
+ #[clap(long, hide = true)]
+ pub from_registration: bool,
+}
+
+fn get_input() -> Result<String> {
+ let mut input = String::new();
+ io::stdin().read_line(&mut input)?;
+ Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string())
+}
+
+impl Cmd {
+ pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ match settings.resolve_sync_auth().await {
+ SyncAuth::Legacy { .. } => {
+ println!("You are logged in to your sync server.");
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+ SyncAuth::NotLoggedIn { .. } => {}
+ }
+
+ self.run_legacy_login(settings, store).await?;
+
+ verify_key_against_remote(settings).await
+ }
+
+ /// Legacy login: always prompt for username/password interactively
+ /// (or accept them via flags).
+ async fn run_legacy_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ let username = or_user_input(self.username.clone(), "username");
+ let password = self.password.clone().unwrap_or_else(read_user_password);
+
+ self.prompt_and_store_key(settings, store).await?;
+
+ let client = auth::auth_client(settings).await;
+ let response = client.login(&username, &password).await?;
+
+ match response {
+ AuthResponse::Success { session, .. } => {
+ Settings::meta_store().await?.save_session(&session).await?;
+ }
+ AuthResponse::TwoFactorRequired => {
+ // Legacy server doesn't support 2FA, so this shouldn't happen.
+ bail!("unexpected two-factor requirement from legacy server");
+ }
+ }
+
+ println!("Logged in!");
+ Ok(())
+ }
+
+ async fn prompt_and_store_key(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ let key_path = settings.key_path.as_str();
+ let key_path = PathBuf::from(key_path);
+
+ println!("IMPORTANT");
+ println!(
+ "If you are already logged in on another machine, you must ensure that the key you use here is the same as the key you used there."
+ );
+ println!("You can find your key by running 'atuin key' on the other machine.");
+ println!("Do not share this key with anyone.");
+ println!("\nRead more here: https://docs.atuin.sh/guide/sync/#login \n");
+
+ let key = or_user_input(
+ self.key.clone(),
+ "encryption key [blank to use existing key file]",
+ );
+
+ if key.is_empty() {
+ if key_path.exists() {
+ let bytes = fs_err::read_to_string(&key_path).context(format!(
+ "Existing key file at '{}' could not be read",
+ key_path.to_string_lossy()
+ ))?;
+ if decode_key(bytes).is_err() {
+ bail!(format!(
+ "The key in existing key file at '{}' is invalid",
+ key_path.to_string_lossy()
+ ));
+ }
+ } else {
+ panic!(
+ "No key provided and no existing key file found. Please use 'atuin key' on your other machine, or recover your key from a backup"
+ )
+ }
+ } else if !key_path.exists() {
+ if decode_key(key.clone()).is_err() {
+ bail!("The specified key is invalid");
+ }
+
+ let mut file = File::create(&key_path).await?;
+ file.write_all(key.as_bytes()).await?;
+ } else {
+ // we now know that the user has logged in specifying a key, AND that the key path
+ // exists
+
+ // 1. check if the saved key and the provided key match. if so, nothing to do.
+ // 2. if not, re-encrypt the local history and overwrite the key
+ let current_key: [u8; 32] = load_key(settings)?.into();
+
+ let encoded = key.clone(); // gonna want to save it in a bit
+ let new_key: [u8; 32] = decode_key(key)
+ .context("Could not decode provided key; is not valid base64-encoded key")?
+ .into();
+
+ if new_key != current_key {
+ println!("\nRe-encrypting local store with new key");
+
+ store.re_encrypt(&current_key, &new_key).await?;
+
+ println!("Writing new key");
+ let mut file = File::create(&key_path).await?;
+ file.write_all(encoded.as_bytes()).await?;
+ }
+ }
+
+ Ok(())
+ }
+}
+
+async fn verify_key_against_remote(settings: &Settings) -> Result<()> {
+ let key: [u8; 32] = load_key(settings)
+ .context("could not load encryption key for verification")?
+ .into();
+
+ let client = sync::build_client(settings).await?;
+ let remote_index = match client.record_status().await {
+ Ok(idx) => idx,
+ Err(e) => {
+ tracing::warn!("could not fetch remote status to verify key: {e}");
+ return Ok(());
+ }
+ };
+
+ match sync::check_encryption_key(&client, &remote_index, &key).await {
+ Ok(()) => Ok(()),
+ Err(SyncError::WrongKey) => {
+ // Roll back the saved session so the user is not left in a
+ // half-authenticated state with a key that can't read the data.
+ if let Ok(meta) = Settings::meta_store().await {
+ let _ = meta.delete_session().await;
+ }
+ crate::print_error::print_error(
+ "Wrong encryption key",
+ "The encryption key on this machine does not match the data on the server. \
+ You have been logged out.\n\n\
+ To fix this, find your existing key by running `atuin key` on a machine that \
+ already syncs successfully, then run `atuin login` again here with that key.",
+ );
+ std::process::exit(1);
+ }
+ Err(e) => {
+ // Non-key error (e.g. transient network issue). Don't fail the
+ // login — the user is authenticated and can sync later when the
+ // network recovers.
+ tracing::warn!("could not verify encryption key against remote: {e}");
+ Ok(())
+ }
+ }
+}
+
+pub(super) fn or_user_input(value: Option<String>, name: &'static str) -> String {
+ value.unwrap_or_else(|| read_user_input(name))
+}
+
+pub(super) fn read_user_password() -> String {
+ let password = prompt_password("Please enter password: ");
+ password.expect("Failed to read from input")
+}
+
+fn read_user_input(name: &'static str) -> String {
+ eprint!("Please enter {name}: ");
+ get_input().expect("Failed to read from input")
+}
diff --git a/crates/turtle/src/command/client/account/logout.rs b/crates/turtle/src/command/client/account/logout.rs
new file mode 100644
index 00000000..6150a52b
--- /dev/null
+++ b/crates/turtle/src/command/client/account/logout.rs
@@ -0,0 +1,5 @@
+use eyre::Result;
+
+pub async fn run() -> Result<()> {
+ crate::atuin_client::logout::logout().await
+}
diff --git a/crates/turtle/src/command/client/account/register.rs b/crates/turtle/src/command/client/account/register.rs
new file mode 100644
index 00000000..548c2739
--- /dev/null
+++ b/crates/turtle/src/command/client/account/register.rs
@@ -0,0 +1,67 @@
+use clap::Parser;
+use eyre::{Result, bail};
+
+use super::login::or_user_input;
+use crate::atuin_client::settings::{Settings, SyncAuth};
+
+#[derive(Parser, Debug)]
+pub struct Cmd {
+ #[clap(long, short)]
+ pub username: Option<String>,
+
+ #[clap(long, short)]
+ pub password: Option<String>,
+
+ #[clap(long, short)]
+ pub email: Option<String>,
+}
+
+impl Cmd {
+ pub async fn run(&self, settings: &Settings) -> Result<()> {
+ match settings.resolve_sync_auth().await {
+ SyncAuth::Legacy { .. } => {
+ println!("You are already logged in.");
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+
+ SyncAuth::NotLoggedIn { .. } => {}
+ }
+
+ // Legacy registration flow
+ println!("Registering for an Atuin Sync account");
+
+ let username = or_user_input(self.username.clone(), "username");
+ let email = or_user_input(self.email.clone(), "email");
+ let password = self
+ .password
+ .clone()
+ .unwrap_or_else(super::login::read_user_password);
+
+ if password.is_empty() {
+ bail!("please provide a password");
+ }
+
+ let session = crate::atuin_client::api_client::register(
+ settings.sync_address.as_str(),
+ &username,
+ &email,
+ &password,
+ )
+ .await?;
+
+ let meta = Settings::meta_store().await?;
+ meta.save_session(&session.session).await?;
+
+ let _key = crate::atuin_client::encryption::load_key(settings)?;
+
+ println!(
+ "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe."
+ );
+ println!(
+ "You will need it to log in on other devices, and we cannot help recover it if you lose it."
+ );
+
+ Ok(())
+ }
+}