diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 00:54:30 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 00:54:30 +0200 |
| commit | 5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8 (patch) | |
| tree | c64baa8d5866c8e339eaf660dd3f94f30a3f7d8a /crates/turtle/src/command/client/account/login.rs | |
| parent | chore: Somewhat simplify sync code (diff) | |
| download | atuin-5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8.zip | |
chore: Move everything into one big crate
That helps remove duplicated code and rustc/cargo will now also show
dead code correctly.
Diffstat (limited to 'crates/turtle/src/command/client/account/login.rs')
| -rw-r--r-- | crates/turtle/src/command/client/account/login.rs | 206 |
1 files changed, 206 insertions, 0 deletions
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(¤t_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") +} |
