aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-server
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@elliehuxtable.com>2024-06-24 14:54:54 +0100
committerGitHub <noreply@github.com>2024-06-24 14:54:54 +0100
commit67d64ec4b368c48188c746f2dba2967ec4615fe5 (patch)
tree9da8443d4baa424e99a806cb022d53daa6a8c30e /crates/atuin-server
parentfix: Some --help comments didn't show properly (#2176) (diff)
downloadatuin-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')
-rw-r--r--crates/atuin-server/Cargo.toml1
-rw-r--r--crates/atuin-server/src/handlers/user.rs110
-rw-r--r--crates/atuin-server/src/router.rs5
-rw-r--r--crates/atuin-server/src/settings.rs27
4 files changed, 140 insertions, 3 deletions
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<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,