use std::borrow::Borrow; use std::collections::HashMap; use std::time::Duration; use argon2::{ Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, password_hash::SaltString, }; use axum::{ Json, extract::{Path, State}, http::StatusCode, }; use metrics::counter; use postmark::{Query, reqwest::PostmarkClient}; use rand::rngs::OsRng; use tracing::{debug, error, info, instrument}; use super::{ErrorResponse, ErrorResponseStatus, RespExt}; use crate::router::{AppState, UserAuth}; use atuin_server_database::{ Database, DbError, models::{NewSession, NewUser}, }; use reqwest::header::CONTENT_TYPE; use atuin_common::{api::*, utils::crypto_random_string}; pub fn verify_str(hash: &str, password: &str) -> bool { let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); let Ok(hash) = PasswordHash::new(hash) else { return false; }; arg2.verify_password(password.as_bytes(), &hash).is_ok() } // Try to send a Discord webhook once - if it fails, we don't retry. "At most once", and best effort. // Don't return the status because if this fails, we don't really care. async fn send_register_hook(url: &str, username: String, registered: String) { let hook = HashMap::from([ ("username", username), ("content", format!("{registered} has just signed up!")), ]); let client = reqwest::Client::new(); let resp = client .post(url) .timeout(Duration::new(5, 0)) .header(CONTENT_TYPE, "application/json") .json(&hook) .send() .await; match resp { Ok(_) => info!("register webhook sent ok!"), Err(e) => error!("failed to send register webhook: {}", e), } } #[instrument(skip_all, fields(user.username = username.as_str()))] pub async fn get( Path(username): Path, state: State>, ) -> Result, ErrorResponseStatus<'static>> { let db = &state.0.database; let user = match db.get_user(username.as_ref()).await { Ok(user) => user, Err(DbError::NotFound) => { debug!("user not found: {}", username); return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); } Err(DbError::Other(err)) => { error!("database error: {}", err); return Err(ErrorResponse::reply("database error") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); } }; Ok(Json(UserResponse { username: user.username, })) } #[instrument(skip_all)] pub async fn register( state: State>, Json(register): Json, ) -> Result, ErrorResponseStatus<'static>> { if !state.settings.open_registration { return Err( ErrorResponse::reply("this server is not open for registrations") .with_status(StatusCode::BAD_REQUEST), ); } for c in register.username.chars() { match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => {} _ => { return Err(ErrorResponse::reply( "Only alphanumeric and hyphens (-) are allowed in usernames", ) .with_status(StatusCode::BAD_REQUEST)); } } } let hashed = hash_secret(®ister.password); let new_user = NewUser { email: register.email.clone(), username: register.username.clone(), password: hashed, }; let db = &state.0.database; let user_id = match db.add_user(&new_user).await { Ok(id) => id, Err(e) => { error!("failed to add user: {}", e); return Err( ErrorResponse::reply("failed to add user").with_status(StatusCode::BAD_REQUEST) ); } }; // 24 bytes encoded as base64 let token = crypto_random_string::<24>(); let new_session = NewSession { user_id, token: (&token).into(), }; if let Some(url) = &state.settings.register_webhook_url { // Could probs be run on another thread, but it's ok atm send_register_hook( url, state.settings.register_webhook_username.clone(), register.username, ) .await; } counter!("atuin_users_registered", 1); match db.add_session(&new_session).await { Ok(_) => Ok(Json(RegisterResponse { session: token })), Err(e) => { error!("failed to add session: {}", e); Err(ErrorResponse::reply("failed to register user") .with_status(StatusCode::BAD_REQUEST)) } } } #[instrument(skip_all, fields(user.id = user.id))] pub async fn delete( UserAuth(user): UserAuth, state: State>, ) -> Result, ErrorResponseStatus<'static>> { debug!("request to delete user {}", user.id); let db = &state.0.database; if let Err(e) = db.delete_user(&user).await { error!("failed to delete user: {}", e); return Err(ErrorResponse::reply("failed to delete user") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); }; counter!("atuin_users_deleted", 1); Ok(Json(DeleteUserResponse {})) } #[instrument(skip_all, fields(user.id = user.id))] pub async fn send_verification( UserAuth(user): UserAuth, state: State>, ) -> Result, ErrorResponseStatus<'static>> { let settings = state.0.settings; debug!("request to verify user {}", user.username); if !settings.mail.enabled { return Ok(Json(SendVerificationResponse { email_sent: false, verified: false, })); } if user.verified.is_some() { return Ok(Json(SendVerificationResponse { email_sent: false, verified: true, })); } // TODO: if we ever add another mail provider, can match on them all here. let postmark_token = match settings.mail.postmark.token { Some(token) => token, _ => { error!("Failed to verify email: got None for postmark token"); return Err(ErrorResponse::reply("mail not configured") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); } }; let db = &state.0.database; let verification_token = db .user_verification_token(user.id) .await .expect("Failed to verify"); debug!("Generated verification token, emailing user"); let client = PostmarkClient::builder() .base_url("https://api.postmarkapp.com/") .token(postmark_token) .build(); let req = postmark::api::email::SendEmailRequest::builder() .from(settings.mail.verification.from) .subject(settings.mail.verification.subject) .to(user.email) .body(postmark::api::Body::text(format!( "Please run the following command to finalize your Atuin account verification. It is valid for 15 minutes:\n\natuin account verify --token '{}'", verification_token ))) .build(); req.execute(&client) .await .expect("postmark email request failed"); debug!("Email sent"); Ok(Json(SendVerificationResponse { email_sent: true, verified: false, })) } #[instrument(skip_all, fields(user.id = user.id))] pub async fn verify_user( UserAuth(user): UserAuth, state: State>, Json(token_request): Json, ) -> Result, ErrorResponseStatus<'static>> { let db = state.0.database; if user.verified.is_some() { return Ok(Json(VerificationTokenResponse { verified: true })); } let token = db.user_verification_token(user.id).await.map_err(|e| { error!("Failed to read user token: {e}"); ErrorResponse::reply("Failed to verify").with_status(StatusCode::INTERNAL_SERVER_ERROR) })?; if token_request.token == token { db.verify_user(user.id).await.map_err(|e| { error!("Failed to verify user: {e}"); ErrorResponse::reply("Failed to verify").with_status(StatusCode::INTERNAL_SERVER_ERROR) })?; } else { info!( "Incorrect verification token {} vs {}", token_request.token, token ); return Ok(Json(VerificationTokenResponse { verified: false })); } Ok(Json(VerificationTokenResponse { verified: true })) } #[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>, login: Json, ) -> Result, ErrorResponseStatus<'static>> { let db = &state.0.database; let user = match db.get_user(login.username.borrow()).await { Ok(u) => u, Err(DbError::NotFound) => { return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); } Err(DbError::Other(e)) => { error!("failed to get user {}: {}", login.username.clone(), e); return Err(ErrorResponse::reply("database error") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); } }; let session = match db.get_user_session(&user).await { Ok(u) => u, Err(DbError::NotFound) => { debug!("user session not found for user id={}", user.id); return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); } Err(DbError::Other(err)) => { error!("database error for user {}: {}", login.username, err); return Err(ErrorResponse::reply("database error") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); } }; let verified = verify_str(user.password.as_str(), login.password.borrow()); if !verified { debug!(user = user.username, "login failed"); return Err( ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED) ); } debug!(user = user.username, "login success"); Ok(Json(LoginResponse { session: session.token, })) } fn hash_secret(password: &str) -> String { let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); let salt = SaltString::generate(&mut OsRng); let hash = arg2.hash_password(password.as_bytes(), &salt).unwrap(); hash.to_string() }