From 67d64ec4b368c48188c746f2dba2967ec4615fe5 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 24 Jun 2024 14:54:54 +0100 Subject: feat: add user account verification (#2190) * add verified column to users table * add database functions to check if verified, or to verify * getting there * verification check * use base64 urlsafe no pad * add verification client * clippy * correct docs * fix integration tests --- crates/atuin-server/src/handlers/user.rs | 110 ++++++++++++++++++++++++++++++- crates/atuin-server/src/router.rs | 5 ++ crates/atuin-server/src/settings.rs | 27 ++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) (limited to 'crates/atuin-server/src') 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, -- cgit v1.3.1