mirror of
https://github.com/atuinsh/atuin.git
synced 2025-06-20 09:58:00 +02:00
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
This commit is contained in:
parent
8956142cc5
commit
67d64ec4b3
20
Cargo.lock
generated
20
Cargo.lock
generated
@ -303,8 +303,10 @@ dependencies = [
|
|||||||
name = "atuin-common"
|
name = "atuin-common"
|
||||||
version = "18.3.0"
|
version = "18.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"directories",
|
"directories",
|
||||||
"eyre",
|
"eyre",
|
||||||
|
"getrandom",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rand",
|
"rand",
|
||||||
@ -404,6 +406,7 @@ dependencies = [
|
|||||||
"fs-err",
|
"fs-err",
|
||||||
"metrics",
|
"metrics",
|
||||||
"metrics-exporter-prometheus",
|
"metrics-exporter-prometheus",
|
||||||
|
"postmark",
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
@ -2615,6 +2618,23 @@ version = "1.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0"
|
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]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
@ -11,8 +11,9 @@ use reqwest::{
|
|||||||
use atuin_common::{
|
use atuin_common::{
|
||||||
api::{
|
api::{
|
||||||
AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
|
AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
|
||||||
ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse,
|
ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse,
|
||||||
SyncHistoryResponse,
|
SendVerificationResponse, StatusResponse, SyncHistoryResponse, VerificationTokenRequest,
|
||||||
|
VerificationTokenResponse,
|
||||||
},
|
},
|
||||||
record::RecordStatus,
|
record::RecordStatus,
|
||||||
};
|
};
|
||||||
@ -403,4 +404,35 @@ impl<'a> Client<'a> {
|
|||||||
bail!("Unknown error");
|
bail!("Unknown error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Either request a verification email if token is null, or validate a token
|
||||||
|
pub async fn verify(&self, token: Option<String>) -> 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::<VerificationTokenResponse>().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::<SendVerificationResponse>().await?;
|
||||||
|
|
||||||
|
(resp.email_sent, resp.verified)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((email_sent, verified))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,8 @@ semver = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
directories = { workspace = true }
|
directories = { workspace = true }
|
||||||
sysinfo = "0.30.7"
|
sysinfo = "0.30.7"
|
||||||
|
base64 = { workspace = true }
|
||||||
|
getrandom = "0.2"
|
||||||
|
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
|
|
||||||
|
@ -33,6 +33,22 @@ pub struct RegisterResponse {
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct DeleteUserResponse {}
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct ChangePasswordRequest {
|
pub struct ChangePasswordRequest {
|
||||||
pub current_password: String,
|
pub current_password: String,
|
||||||
|
@ -4,17 +4,30 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use eyre::{eyre, Result};
|
use eyre::{eyre, Result};
|
||||||
|
|
||||||
use rand::RngCore;
|
use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
|
||||||
|
use getrandom::getrandom;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn random_bytes<const N: usize>() -> [u8; N] {
|
/// Generate N random bytes, using a cryptographically secure source
|
||||||
|
pub fn crypto_random_bytes<const N: usize>() -> [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];
|
let mut ret = [0u8; N];
|
||||||
|
|
||||||
rand::thread_rng().fill_bytes(&mut ret);
|
getrandom(&mut ret).expect("Failed to generate random bytes!");
|
||||||
|
|
||||||
ret
|
ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate N random bytes using a cryptographically secure source, return encoded as a string
|
||||||
|
pub fn crypto_random_string<const N: usize>() -> String {
|
||||||
|
let bytes = crypto_random_bytes::<N>();
|
||||||
|
|
||||||
|
// 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 {
|
pub fn uuid_v7() -> Uuid {
|
||||||
Uuid::now_v7()
|
Uuid::now_v7()
|
||||||
}
|
}
|
||||||
@ -178,6 +191,7 @@ impl<T: AsRef<str>> Escapable for T {}
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use pretty_assertions::assert_ne;
|
||||||
use time::Month;
|
use time::Month;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -292,4 +306,17 @@ mod tests {
|
|||||||
Cow::Owned(_)
|
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>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(&self, username: &str) -> DbResult<User>;
|
||||||
async fn get_user_session(&self, u: &User) -> DbResult<Session>;
|
async fn get_user_session(&self, u: &User) -> DbResult<Session>;
|
||||||
async fn add_user(&self, user: &NewUser) -> DbResult<i64>;
|
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 update_user_password(&self, u: &User) -> DbResult<()>;
|
||||||
|
|
||||||
async fn total_history(&self) -> DbResult<i64>;
|
async fn total_history(&self) -> DbResult<i64>;
|
||||||
|
@ -32,6 +32,7 @@ pub struct User {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
pub verified: Option<OffsetDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
|
@ -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
|
||||||
|
);
|
@ -3,6 +3,7 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
|
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::models::{History, NewHistory, NewSession, NewUser, Session, User};
|
||||||
use atuin_server_database::{Database, DbError, DbResult};
|
use atuin_server_database::{Database, DbError, DbResult};
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
@ -11,7 +12,7 @@ use sqlx::postgres::PgPoolOptions;
|
|||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
|
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
|
||||||
use tracing::instrument;
|
use tracing::{instrument, trace};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
|
use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
|
||||||
|
|
||||||
@ -100,7 +101,9 @@ impl Database for Postgres {
|
|||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
async fn get_user(&self, username: &str) -> DbResult<User> {
|
async fn get_user(&self, username: &str) -> DbResult<User> {
|
||||||
sqlx::query_as("select id, username, email, password from users where username = $1")
|
sqlx::query_as(
|
||||||
|
"select id, username, email, password, verified_at from users where username = $1",
|
||||||
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
@ -108,10 +111,90 @@ impl Database for Postgres {
|
|||||||
.map(|DbUser(user)| user)
|
.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)]
|
#[instrument(skip_all)]
|
||||||
async fn get_session_user(&self, token: &str) -> DbResult<User> {
|
async fn get_session_user(&self, token: &str) -> DbResult<User> {
|
||||||
sqlx::query_as(
|
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
|
inner join sessions
|
||||||
on users.id = sessions.user_id
|
on users.id = sessions.user_id
|
||||||
and sessions.token = $1",
|
and sessions.token = $1",
|
||||||
|
@ -16,6 +16,7 @@ impl<'a> FromRow<'a, PgRow> for DbUser {
|
|||||||
username: row.try_get("username")?,
|
username: row.try_get("username")?,
|
||||||
email: row.try_get("email")?,
|
email: row.try_get("email")?,
|
||||||
password: row.try_get("password")?,
|
password: row.try_get("password")?,
|
||||||
|
verified: row.try_get("verified_at")?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,3 +37,4 @@ argon2 = "0.5"
|
|||||||
semver = { workspace = true }
|
semver = { workspace = true }
|
||||||
metrics-exporter-prometheus = "0.12.1"
|
metrics-exporter-prometheus = "0.12.1"
|
||||||
metrics = "0.21.1"
|
metrics = "0.21.1"
|
||||||
|
postmark = {version= "0.10.0", features=["reqwest"]}
|
||||||
|
@ -12,9 +12,11 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use metrics::counter;
|
use metrics::counter;
|
||||||
|
|
||||||
|
use postmark::{reqwest::PostmarkClient, Query};
|
||||||
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use tracing::{debug, error, info, instrument};
|
use tracing::{debug, error, info, instrument};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::{ErrorResponse, ErrorResponseStatus, RespExt};
|
use super::{ErrorResponse, ErrorResponseStatus, RespExt};
|
||||||
use crate::router::{AppState, UserAuth};
|
use crate::router::{AppState, UserAuth};
|
||||||
@ -25,7 +27,7 @@ use atuin_server_database::{
|
|||||||
|
|
||||||
use reqwest::header::CONTENT_TYPE;
|
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 {
|
pub fn verify_str(hash: &str, password: &str) -> bool {
|
||||||
let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
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 {
|
let new_session = NewSession {
|
||||||
user_id,
|
user_id,
|
||||||
@ -175,6 +178,107 @@ pub async fn delete<DB: Database>(
|
|||||||
Ok(Json(DeleteUserResponse {}))
|
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))]
|
#[instrument(skip_all, fields(user.id = user.id, change_password))]
|
||||||
pub async fn change_password<DB: Database>(
|
pub async fn change_password<DB: Database>(
|
||||||
UserAuth(mut user): UserAuth,
|
UserAuth(mut user): UserAuth,
|
||||||
|
@ -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", get(handlers::record::index::<DB>))
|
||||||
.route("/record/next", get(handlers::record::next))
|
.route("/record/next", get(handlers::record::next))
|
||||||
.route("/api/v0/me", get(handlers::v0::me::get))
|
.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", post(handlers::v0::record::post))
|
||||||
.route("/api/v0/record", get(handlers::v0::record::index))
|
.route("/api/v0/record", get(handlers::v0::record::index))
|
||||||
.route("/api/v0/record/next", get(handlers::v0::record::next))
|
.route("/api/v0/record/next", get(handlers::v0::record::next))
|
||||||
|
@ -7,8 +7,34 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
|||||||
|
|
||||||
static EXAMPLE_CONFIG: &str = include_str!("../server.toml");
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct Metrics {
|
pub struct Metrics {
|
||||||
|
#[serde(alias = "enabled")]
|
||||||
pub enable: bool,
|
pub enable: bool,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@ -37,6 +63,7 @@ pub struct Settings<DbSettings> {
|
|||||||
pub register_webhook_username: String,
|
pub register_webhook_username: String,
|
||||||
pub metrics: Metrics,
|
pub metrics: Metrics,
|
||||||
pub tls: Tls,
|
pub tls: Tls,
|
||||||
|
pub mail: Mail,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub db_settings: DbSettings,
|
pub db_settings: DbSettings,
|
||||||
|
@ -9,6 +9,7 @@ pub mod delete;
|
|||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
|
pub mod verify;
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct Cmd {
|
pub struct Cmd {
|
||||||
@ -32,6 +33,9 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// Change your password
|
/// Change your password
|
||||||
ChangePassword(change_password::Cmd),
|
ChangePassword(change_password::Cmd),
|
||||||
|
|
||||||
|
/// Verify your account
|
||||||
|
Verify(verify::Cmd),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cmd {
|
impl Cmd {
|
||||||
@ -42,6 +46,7 @@ impl Cmd {
|
|||||||
Commands::Logout => logout::run(&settings),
|
Commands::Logout => logout::run(&settings),
|
||||||
Commands::Delete => delete::run(&settings).await,
|
Commands::Delete => delete::run(&settings).await,
|
||||||
Commands::ChangePassword(c) => c.run(&settings).await,
|
Commands::ChangePassword(c) => c.run(&settings).await,
|
||||||
|
Commands::Verify(c) => c.run(&settings).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
47
crates/atuin/src/command/client/account/verify.rs
Normal file
47
crates/atuin/src/command/client/account/verify.rs
Normal file
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd {
|
||||||
|
pub async fn run(self, settings: &Settings) -> Result<()> {
|
||||||
|
run(settings, self.token).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(settings: &Settings, token: Option<String>) -> 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(())
|
||||||
|
}
|
@ -38,6 +38,7 @@ pub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandl
|
|||||||
db_settings: PostgresSettings { db_uri },
|
db_settings: PostgresSettings { db_uri },
|
||||||
metrics: atuin_server::settings::Metrics::default(),
|
metrics: atuin_server::settings::Metrics::default(),
|
||||||
tls: atuin_server::settings::Tls::default(),
|
tls: atuin_server::settings::Tls::default(),
|
||||||
|
mail: atuin_server::settings::Mail::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user