diff options
| author | Ellie Huxtable <ellie@elliehuxtable.com> | 2024-06-24 14:54:54 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-06-24 14:54:54 +0100 |
| commit | 67d64ec4b368c48188c746f2dba2967ec4615fe5 (patch) | |
| tree | 9da8443d4baa424e99a806cb022d53daa6a8c30e /crates/atuin-server/src | |
| parent | fix: Some --help comments didn't show properly (#2176) (diff) | |
| download | atuin-67d64ec4b368c48188c746f2dba2967ec4615fe5.zip | |
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
Diffstat (limited to 'crates/atuin-server/src')
| -rw-r--r-- | crates/atuin-server/src/handlers/user.rs | 110 | ||||
| -rw-r--r-- | crates/atuin-server/src/router.rs | 5 | ||||
| -rw-r--r-- | crates/atuin-server/src/settings.rs | 27 |
3 files changed, 139 insertions, 3 deletions
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<DB: Database>( } }; - 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<DB: Database>( Ok(Json(DeleteUserResponse {})) } +#[instrument(skip_all, fields(user.id = user.id))] +pub async fn send_verification<DB: Database>( + UserAuth(user): UserAuth, + state: State<AppState<DB>>, +) -> Result<Json<SendVerificationResponse>, 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<DB: Database>( + UserAuth(user): UserAuth, + state: State<AppState<DB>>, + Json(token_request): Json<VerificationTokenRequest>, +) -> Result<Json<VerificationTokenResponse>, 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<DB: Database>( 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<DB: Database>(database: DB, settings: Settings<DB::Settings>) -> R .route("/record", get(handlers::record::index::<DB>)) .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<String>, +} + +#[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<DbSettings> { pub register_webhook_username: String, pub metrics: Metrics, pub tls: Tls, + pub mail: Mail, #[serde(flatten)] pub db_settings: DbSettings, |
