diff --git a/Cargo.lock b/Cargo.lock index 431f3bea..c88c85c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,8 +303,10 @@ dependencies = [ name = "atuin-common" version = "18.3.0" dependencies = [ + "base64 0.22.1", "directories", "eyre", + "getrandom", "lazy_static", "pretty_assertions", "rand", @@ -404,6 +406,7 @@ dependencies = [ "fs-err", "metrics", "metrics-exporter-prometheus", + "postmark", "rand", "reqwest", "rustls", @@ -2615,6 +2618,23 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" +[[package]] +name = "postmark" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3bc71e3fdb4e15d2636d67f5784f17488a612324235086dde765ab6016a43fa" +dependencies = [ + "async-trait", + "bytes", + "http 0.2.12", + "reqwest", + "serde", + "serde_json", + "thiserror", + "typed-builder", + "url", +] + [[package]] name = "powerfmt" version = "0.2.0" diff --git a/crates/atuin-client/src/api_client.rs b/crates/atuin-client/src/api_client.rs index 60f2d300..53f24424 100644 --- a/crates/atuin-client/src/api_client.rs +++ b/crates/atuin-client/src/api_client.rs @@ -11,8 +11,9 @@ use reqwest::{ use atuin_common::{ api::{ AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest, - ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse, - SyncHistoryResponse, + ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, + SendVerificationResponse, StatusResponse, SyncHistoryResponse, VerificationTokenRequest, + VerificationTokenResponse, }, record::RecordStatus, }; @@ -403,4 +404,35 @@ impl<'a> Client<'a> { bail!("Unknown error"); } } + + // Either request a verification email if token is null, or validate a token + pub async fn verify(&self, token: Option) -> Result<(bool, bool)> { + // could dedupe this a bit, but it's simple at the moment + let (email_sent, verified) = if let Some(token) = token { + let url = format!("{}/api/v0/account/verify", self.sync_addr); + let url = Url::parse(url.as_str())?; + + let resp = self + .client + .post(url) + .json(&VerificationTokenRequest { token }) + .send() + .await?; + let resp = handle_resp_error(resp).await?; + let resp = resp.json::().await?; + + (false, resp.verified) + } else { + let url = format!("{}/api/v0/account/send-verification", self.sync_addr); + let url = Url::parse(url.as_str())?; + + let resp = self.client.post(url).send().await?; + let resp = handle_resp_error(resp).await?; + let resp = resp.json::().await?; + + (resp.email_sent, resp.verified) + }; + + Ok((email_sent, verified)) + } } diff --git a/crates/atuin-common/Cargo.toml b/crates/atuin-common/Cargo.toml index 5fdcbfa7..f89c1d06 100644 --- a/crates/atuin-common/Cargo.toml +++ b/crates/atuin-common/Cargo.toml @@ -24,6 +24,8 @@ semver = { workspace = true } thiserror = { workspace = true } directories = { workspace = true } sysinfo = "0.30.7" +base64 = { workspace = true } +getrandom = "0.2" lazy_static = "1.4.0" diff --git a/crates/atuin-common/src/api.rs b/crates/atuin-common/src/api.rs index 99b57cec..4e897811 100644 --- a/crates/atuin-common/src/api.rs +++ b/crates/atuin-common/src/api.rs @@ -33,6 +33,22 @@ pub struct RegisterResponse { #[derive(Debug, Serialize, Deserialize)] pub struct DeleteUserResponse {} +#[derive(Debug, Serialize, Deserialize)] +pub struct SendVerificationResponse { + pub email_sent: bool, + pub verified: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VerificationTokenRequest { + pub token: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VerificationTokenResponse { + pub verified: bool, +} + #[derive(Debug, Serialize, Deserialize)] pub struct ChangePasswordRequest { pub current_password: String, diff --git a/crates/atuin-common/src/utils.rs b/crates/atuin-common/src/utils.rs index 65f5efc4..869866b0 100644 --- a/crates/atuin-common/src/utils.rs +++ b/crates/atuin-common/src/utils.rs @@ -4,17 +4,30 @@ use std::path::PathBuf; use eyre::{eyre, Result}; -use rand::RngCore; +use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD}; +use getrandom::getrandom; use uuid::Uuid; -pub fn random_bytes() -> [u8; N] { +/// Generate N random bytes, using a cryptographically secure source +pub fn crypto_random_bytes() -> [u8; N] { + // rand say they are in principle safe for crypto purposes, but that it is perhaps a better + // idea to use getrandom for things such as passwords. let mut ret = [0u8; N]; - rand::thread_rng().fill_bytes(&mut ret); + getrandom(&mut ret).expect("Failed to generate random bytes!"); ret } +/// Generate N random bytes using a cryptographically secure source, return encoded as a string +pub fn crypto_random_string() -> String { + let bytes = crypto_random_bytes::(); + + // We only use this to create a random string, and won't be reversing it to find the original + // data - no padding is OK there. It may be in URLs. + BASE64_URL_SAFE_NO_PAD.encode(bytes) +} + pub fn uuid_v7() -> Uuid { Uuid::now_v7() } @@ -178,6 +191,7 @@ impl> Escapable for T {} #[cfg(test)] mod tests { + use pretty_assertions::assert_ne; use time::Month; use super::*; @@ -292,4 +306,17 @@ mod tests { Cow::Owned(_) )); } + + #[test] + fn dumb_random_test() { + // Obviously not a test of randomness, but make sure we haven't made some + // catastrophic error + + assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>()); + assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>()); + assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>()); + assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>()); + assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>()); + assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>()); + } } diff --git a/crates/atuin-server-database/src/lib.rs b/crates/atuin-server-database/src/lib.rs index d2c16b3d..f6933b94 100644 --- a/crates/atuin-server-database/src/lib.rs +++ b/crates/atuin-server-database/src/lib.rs @@ -53,6 +53,11 @@ pub trait Database: Sized + Clone + Send + Sync + 'static { async fn get_user(&self, username: &str) -> DbResult; async fn get_user_session(&self, u: &User) -> DbResult; async fn add_user(&self, user: &NewUser) -> DbResult; + + async fn user_verified(&self, id: i64) -> DbResult; + async fn verify_user(&self, id: i64) -> DbResult<()>; + async fn user_verification_token(&self, id: i64) -> DbResult; + async fn update_user_password(&self, u: &User) -> DbResult<()>; async fn total_history(&self) -> DbResult; diff --git a/crates/atuin-server-database/src/models.rs b/crates/atuin-server-database/src/models.rs index b71a9bc9..894ac7f6 100644 --- a/crates/atuin-server-database/src/models.rs +++ b/crates/atuin-server-database/src/models.rs @@ -32,6 +32,7 @@ pub struct User { pub username: String, pub email: String, pub password: String, + pub verified: Option, } pub struct Session { diff --git a/crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql b/crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql new file mode 100644 index 00000000..6eba02ec --- /dev/null +++ b/crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql @@ -0,0 +1,8 @@ +alter table users add verified_at timestamp with time zone default null; + +create table user_verification_token( + id bigserial primary key, + user_id bigint unique references users(id), + token text, + valid_until timestamp with time zone +); diff --git a/crates/atuin-server-postgres/src/lib.rs b/crates/atuin-server-postgres/src/lib.rs index 8a010195..7aa87424 100644 --- a/crates/atuin-server-postgres/src/lib.rs +++ b/crates/atuin-server-postgres/src/lib.rs @@ -3,6 +3,7 @@ use std::ops::Range; use async_trait::async_trait; use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus}; +use atuin_common::utils::crypto_random_string; use atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User}; use atuin_server_database::{Database, DbError, DbResult}; use futures_util::TryStreamExt; @@ -11,7 +12,7 @@ use sqlx::postgres::PgPoolOptions; use sqlx::Row; use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset}; -use tracing::instrument; +use tracing::{instrument, trace}; use uuid::Uuid; use wrappers::{DbHistory, DbRecord, DbSession, DbUser}; @@ -100,18 +101,100 @@ impl Database for Postgres { #[instrument(skip_all)] async fn get_user(&self, username: &str) -> DbResult { - sqlx::query_as("select id, username, email, password from users where username = $1") - .bind(username) - .fetch_one(&self.pool) - .await - .map_err(fix_error) - .map(|DbUser(user)| user) + sqlx::query_as( + "select id, username, email, password, verified_at from users where username = $1", + ) + .bind(username) + .fetch_one(&self.pool) + .await + .map_err(fix_error) + .map(|DbUser(user)| user) + } + + #[instrument(skip_all)] + async fn user_verified(&self, id: i64) -> DbResult { + let res: (bool,) = + sqlx::query_as("select verified_at is not null from users where id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + .map_err(fix_error)?; + + Ok(res.0) + } + + #[instrument(skip_all)] + async fn verify_user(&self, id: i64) -> DbResult<()> { + sqlx::query( + "update users set verified_at = (current_timestamp at time zone 'utc') where id=$1", + ) + .bind(id) + .execute(&self.pool) + .await + .map_err(fix_error)?; + + Ok(()) + } + + /// Return a valid verification token for the user + /// If the user does not have any token, create one, insert it, and return + /// If the user has a token, but it's invalid, delete it, create a new one, return + /// If the user already has a valid token, return it + #[instrument(skip_all)] + async fn user_verification_token(&self, id: i64) -> DbResult { + const TOKEN_VALID_MINUTES: i64 = 15; + + // First we check if there is a verification token + let token: Option<(String, sqlx::types::time::OffsetDateTime)> = sqlx::query_as( + "select token, valid_until from user_verification_token where user_id = $1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(fix_error)?; + + let token = if let Some((token, valid_until)) = token { + trace!("Token for user {id} valid until {valid_until}"); + + // We have a token, AND it's still valid + if valid_until > time::OffsetDateTime::now_utc() { + token + } else { + // token has expired. generate a new one, return it + let token = crypto_random_string::<24>(); + + sqlx::query("update user_verification_token set token = $2, valid_until = $3 where user_id=$1") + .bind(id) + .bind(&token) + .bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES)) + .execute(&self.pool) + .await + .map_err(fix_error)?; + + token + } + } else { + // No token in the database! Generate one, insert it + let token = crypto_random_string::<24>(); + + sqlx::query("insert into user_verification_token (user_id, token, valid_until) values ($1, $2, $3)") + .bind(id) + .bind(&token) + .bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES)) + .execute(&self.pool) + .await + .map_err(fix_error)?; + + token + }; + + Ok(token) } #[instrument(skip_all)] async fn get_session_user(&self, token: &str) -> DbResult { sqlx::query_as( - "select users.id, users.username, users.email, users.password from users + "select users.id, users.username, users.email, users.password, users.verified_at from users inner join sessions on users.id = sessions.user_id and sessions.token = $1", diff --git a/crates/atuin-server-postgres/src/wrappers.rs b/crates/atuin-server-postgres/src/wrappers.rs index 3ccf9c19..80f95f21 100644 --- a/crates/atuin-server-postgres/src/wrappers.rs +++ b/crates/atuin-server-postgres/src/wrappers.rs @@ -16,6 +16,7 @@ impl<'a> FromRow<'a, PgRow> for DbUser { username: row.try_get("username")?, email: row.try_get("email")?, password: row.try_get("password")?, + verified: row.try_get("verified_at")?, })) } } diff --git a/crates/atuin-server/Cargo.toml b/crates/atuin-server/Cargo.toml index b076a466..089fe715 100644 --- a/crates/atuin-server/Cargo.toml +++ b/crates/atuin-server/Cargo.toml @@ -37,3 +37,4 @@ argon2 = "0.5" semver = { workspace = true } metrics-exporter-prometheus = "0.12.1" metrics = "0.21.1" +postmark = {version= "0.10.0", features=["reqwest"]} diff --git a/crates/atuin-server/src/handlers/user.rs b/crates/atuin-server/src/handlers/user.rs index 8e53dd49..7fd1a4a2 100644 --- a/crates/atuin-server/src/handlers/user.rs +++ b/crates/atuin-server/src/handlers/user.rs @@ -12,9 +12,11 @@ use axum::{ Json, }; use metrics::counter; + +use postmark::{reqwest::PostmarkClient, Query}; + use rand::rngs::OsRng; use tracing::{debug, error, info, instrument}; -use uuid::Uuid; use super::{ErrorResponse, ErrorResponseStatus, RespExt}; use crate::router::{AppState, UserAuth}; @@ -25,7 +27,7 @@ use atuin_server_database::{ use reqwest::header::CONTENT_TYPE; -use atuin_common::api::*; +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()); @@ -126,7 +128,8 @@ pub async fn register( } }; - let token = Uuid::new_v4().as_simple().to_string(); + // 24 bytes encoded as base64 + let token = crypto_random_string::<24>(); let new_session = NewSession { user_id, @@ -175,6 +178,107 @@ pub async fn delete( 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; + + 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 = if let Some(token) = settings.mail.postmark.token { + token + } else { + 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, diff --git a/crates/atuin-server/src/router.rs b/crates/atuin-server/src/router.rs index 96dff2bd..df3a2937 100644 --- a/crates/atuin-server/src/router.rs +++ b/crates/atuin-server/src/router.rs @@ -126,6 +126,11 @@ pub fn router(database: DB, settings: Settings) -> R .route("/record", get(handlers::record::index::)) .route("/record/next", get(handlers::record::next)) .route("/api/v0/me", get(handlers::v0::me::get)) + .route("/api/v0/account/verify", post(handlers::user::verify_user)) + .route( + "/api/v0/account/send-verification", + post(handlers::user::send_verification), + ) .route("/api/v0/record", post(handlers::v0::record::post)) .route("/api/v0/record", get(handlers::v0::record::index)) .route("/api/v0/record/next", get(handlers::v0::record::next)) diff --git a/crates/atuin-server/src/settings.rs b/crates/atuin-server/src/settings.rs index 286b5688..1246982a 100644 --- a/crates/atuin-server/src/settings.rs +++ b/crates/atuin-server/src/settings.rs @@ -7,8 +7,34 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; static EXAMPLE_CONFIG: &str = include_str!("../server.toml"); +#[derive(Default, Clone, Debug, Deserialize, Serialize)] +pub struct Mail { + #[serde(alias = "enable")] + pub enabled: bool, + + /// Configuration for the postmark api client + /// This is what we use for Atuin Cloud, the forum, etc. + pub postmark: Postmark, + + pub verification: MailVerification, +} + +#[derive(Default, Clone, Debug, Deserialize, Serialize)] +pub struct Postmark { + #[serde(alias = "enable")] + pub token: Option, +} + +#[derive(Default, Clone, Debug, Deserialize, Serialize)] +pub struct MailVerification { + #[serde(alias = "enable")] + pub from: String, + pub subject: String, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Metrics { + #[serde(alias = "enabled")] pub enable: bool, pub host: String, pub port: u16, @@ -37,6 +63,7 @@ pub struct Settings { pub register_webhook_username: String, pub metrics: Metrics, pub tls: Tls, + pub mail: Mail, #[serde(flatten)] pub db_settings: DbSettings, diff --git a/crates/atuin/src/command/client/account.rs b/crates/atuin/src/command/client/account.rs index e99c9593..011d7c69 100644 --- a/crates/atuin/src/command/client/account.rs +++ b/crates/atuin/src/command/client/account.rs @@ -9,6 +9,7 @@ pub mod delete; pub mod login; pub mod logout; pub mod register; +pub mod verify; #[derive(Args, Debug)] pub struct Cmd { @@ -32,6 +33,9 @@ pub enum Commands { /// Change your password ChangePassword(change_password::Cmd), + + /// Verify your account + Verify(verify::Cmd), } impl Cmd { @@ -42,6 +46,7 @@ impl Cmd { Commands::Logout => logout::run(&settings), Commands::Delete => delete::run(&settings).await, Commands::ChangePassword(c) => c.run(&settings).await, + Commands::Verify(c) => c.run(&settings).await, } } } diff --git a/crates/atuin/src/command/client/account/verify.rs b/crates/atuin/src/command/client/account/verify.rs new file mode 100644 index 00000000..f803bd3d --- /dev/null +++ b/crates/atuin/src/command/client/account/verify.rs @@ -0,0 +1,47 @@ +use clap::Parser; +use eyre::Result; + +use atuin_client::{api_client, settings::Settings}; + +#[derive(Parser, Debug)] +pub struct Cmd { + #[clap(long, short)] + pub token: Option, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, self.token).await + } +} + +pub async fn run(settings: &Settings, token: Option) -> Result<()> { + let client = api_client::Client::new( + &settings.sync_address, + settings.session_token()?.as_str(), + settings.network_connect_timeout, + settings.network_timeout, + )?; + + let (email_sent, verified) = client.verify(token).await?; + + match (email_sent, verified) { + (true, false) => { + println!("Verification sent! Please check your inbox"); + } + + (false, true) => { + println!("Your account is verified"); + } + + (false, false) => { + println!("Your Atuin server does not have mail setup. This is not required, though your account cannot be verified. Speak to your admin."); + } + + _ => { + println!("Invalid email and verification status. This is a bug. Please open an issue: https://github.com/atuinsh/atuin"); + } + } + + Ok(()) +} diff --git a/crates/atuin/tests/common/mod.rs b/crates/atuin/tests/common/mod.rs index 65679244..84e3cea6 100644 --- a/crates/atuin/tests/common/mod.rs +++ b/crates/atuin/tests/common/mod.rs @@ -38,6 +38,7 @@ pub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandl db_settings: PostgresSettings { db_uri }, metrics: atuin_server::settings::Metrics::default(), tls: atuin_server::settings::Tls::default(), + mail: atuin_server::settings::Mail::default(), }; let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();