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 | |
| 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 '')
| -rw-r--r-- | crates/atuin-server-database/src/lib.rs | 5 | ||||
| -rw-r--r-- | crates/atuin-server-database/src/models.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql | 8 | ||||
| -rw-r--r-- | crates/atuin-server-postgres/src/lib.rs | 99 | ||||
| -rw-r--r-- | crates/atuin-server-postgres/src/wrappers.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-server/Cargo.toml | 1 | ||||
| -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 |
9 files changed, 246 insertions, 11 deletions
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<User>; async fn get_user_session(&self, u: &User) -> DbResult<Session>; async fn add_user(&self, user: &NewUser) -> DbResult<i64>; + + async fn user_verified(&self, id: i64) -> DbResult<bool>; + async fn verify_user(&self, id: i64) -> DbResult<()>; + async fn user_verification_token(&self, id: i64) -> DbResult<String>; + async fn update_user_password(&self, u: &User) -> DbResult<()>; async fn total_history(&self) -> DbResult<i64>; 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<OffsetDateTime>, } 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<User> { - 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<bool> { + 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<String> { + 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<User> { 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<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, |
