From 862e2d6709be8d652de282260cf40a0baa738ddc Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 13:59:06 +0000 Subject: [PATCH 1/4] feat: add mail client Untested --- Cargo.toml | 1 + src/api/v1/me/mod.rs | 6 +---- src/config.rs | 24 ++++++++++++++++++ src/error.rs | 5 ++++ src/main.rs | 7 ++++++ src/structs.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7a062d8..af5b2ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-con diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" actix-multipart = "0.7.2" +lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } [dependencies.tokio] version = "1.44" diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index 14067c3..58eaa02 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -33,7 +33,7 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result, display_name: Option, - password: Option, + //password: Option, will probably be handled through a reset password link email: Option, } @@ -83,10 +83,6 @@ pub async fn update( todo!(); } - if let Some(password) = &new_info.password { - todo!(); - } - if let Some(email) = &new_info.email { todo!(); } diff --git a/src/config.rs b/src/config.rs index 079ce35..4d2e96b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use crate::error::Error; use bunny_api_tokio::edge_storage::Endpoint; +use lettre::transport::smtp::authentication::Credentials; use log::debug; use serde::Deserialize; use tokio::fs::read_to_string; @@ -12,6 +13,7 @@ pub struct ConfigBuilder { web: Option, instance: Option, bunny: BunnyBuilder, + mail: Mail, } #[derive(Debug, Deserialize, Clone)] @@ -52,6 +54,20 @@ struct BunnyBuilder { cdn_url: Url, } +#[derive(Debug, Deserialize, Clone)] +pub struct Mail { + pub smtp: Smtp, + pub from: String, + pub tls: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Smtp { + pub server: String, + username: String, + password: String, +} + impl ConfigBuilder { pub async fn load(path: String) -> Result { debug!("loading config from: {}", path); @@ -101,6 +117,7 @@ impl ConfigBuilder { web, instance: self.instance.unwrap_or(Instance { registration: true }), bunny, + mail: self.mail, } } } @@ -112,6 +129,7 @@ pub struct Config { pub web: Web, pub instance: Instance, pub bunny: Bunny, + pub mail: Mail, } #[derive(Debug, Clone)] @@ -179,3 +197,9 @@ impl CacheDatabase { url } } + +impl Smtp { + pub fn credentials(&self) -> Credentials { + Credentials::new(self.username.clone(), self.password.clone()) + } +} diff --git a/src/error.rs b/src/error.rs index fb990c7..ca05c98 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,7 @@ use serde_json::Error as JsonError; use thiserror::Error; use tokio::task::JoinError; use toml::de::Error as TomlError; +use lettre::{address::AddressError, transport::smtp::Error as SmtpError}; #[derive(Debug, Error)] pub enum Error { @@ -54,6 +55,10 @@ pub enum Error { PayloadError(#[from] PayloadError), #[error(transparent)] WsClosed(#[from] actix_ws::Closed), + #[error(transparent)] + SmtpError(#[from] SmtpError), + #[error(transparent)] + SmtpAddressError(#[from] AddressError), #[error("{0}")] PasswordHashError(String), #[error("{0}")] diff --git a/src/main.rs b/src/main.rs index 5ad1dc8..8bc1c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; use error::Error; use simple_logger::SimpleLogger; +use structs::MailClient; use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; @@ -40,6 +41,7 @@ pub struct Data { pub argon2: Argon2<'static>, pub start_time: SystemTime, pub bunny_cdn: bunny_api_tokio::Client, + pub mail_client: MailClient, } #[tokio::main] @@ -72,6 +74,10 @@ async fn main() -> Result<(), Error> { .init(bunny.api_key, bunny.endpoint, bunny.storage_zone) .await?; + let mail = config.mail.clone(); + + let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.from, mail.tls)?; + let database_url = config.database.url(); tokio::task::spawn_blocking(move || { @@ -112,6 +118,7 @@ async fn main() -> Result<(), Error> { argon2: Argon2::default(), start_time: SystemTime::now(), bunny_cdn, + mail_client, }; HttpServer::new(move || { diff --git a/src/structs.rs b/src/structs.rs index be4bd43..541d6ec 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -5,6 +5,8 @@ use diesel::{ update, }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; +use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; @@ -35,6 +37,62 @@ fn load_or_empty( } } +#[derive(PartialEq, Eq, Clone)] +pub enum MailTls { + StartTls, + Tls, +} + +impl From for MailTls { + fn from(value: String) -> Self { + match &*value.to_lowercase() { + "starttls" => Self::StartTls, + _ => Self::Tls, + } + } +} + +#[derive(Clone)] +pub struct MailClient { + creds: Credentials, + smtp_server: String, + mbox: Mailbox, + tls: MailTls, +} + +impl MailClient { + pub fn new>(creds: Credentials, smtp_server: String, mbox: String, tls: T) -> Result { + Ok(Self { + creds, + smtp_server, + mbox: mbox.parse()?, + tls: tls.into(), + }) + } + + pub async fn message_builder(&self) -> EmailBuilder { + Email::builder() + .from(self.mbox.clone()) + } + + pub async fn send_mail(&self, email: Email) -> Result<(), Error> { + let mailer: AsyncSmtpTransport = match self.tls { + MailTls::StartTls => AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build(), + MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build(), + }; + + let response = mailer.send(email).await?; + + debug!("mail sending response: {:?}", response); + + Ok(()) + } +} + #[derive(Queryable, Selectable, Insertable, Clone, Debug)] #[diesel(table_name = channels)] #[diesel(check_for_backend(diesel::pg::Pg))] From 83f031779f5b307ccd8e653ac79ff05655f94a9a Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 21:57:08 +0200 Subject: [PATCH 2/4] feat: add email verification system Co-Authored-By: JustTemmie --- Cargo.toml | 7 +- Dockerfile | 10 +- compose.dev.yml | 6 + compose.yml | 6 + entrypoint.sh | 12 ++ .../down.sql | 2 + .../up.sql | 7 ++ src/api/v1/auth/mod.rs | 3 + src/api/v1/auth/verify_email.rs | 103 ++++++++++++++++++ src/config.rs | 24 ++-- src/error.rs | 8 +- src/main.rs | 4 +- src/schema.rs | 11 ++ src/structs.rs | 95 ++++++++++++++-- 14 files changed, 265 insertions(+), 33 deletions(-) create mode 100644 migrations/2025-05-27-162114_create_email_tokens/down.sql create mode 100644 migrations/2025-05-27-162114_create_email_tokens/up.sql create mode 100644 src/api/v1/auth/verify_email.rs diff --git a/Cargo.toml b/Cargo.toml index af5b2ff..492a284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,20 +25,21 @@ redis = { version = "0.31.0", features= ["tokio-comp"] } tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] } toml = "0.8" url = { version = "2.5", features = ["serde"] } -uuid = { version = "1.16", features = ["serde", "v7"] } +uuid = { version = "1.17", features = ["serde", "v7"] } random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" bunny-api-tokio = "0.3.0" bindet = "0.3.2" deadpool = "0.12" -diesel = { version = "2.2", features = ["uuid"] } +diesel = { version = "2.2", features = ["uuid", "chrono"] } diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-connection-wrapper"] } diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" actix-multipart = "0.7.2" lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } +chrono = { version = "0.4.41", features = ["serde"] } [dependencies.tokio] -version = "1.44" +version = "1.45" features = ["full"] diff --git a/Dockerfile b/Dockerfile index 0f07fcb..8ea076f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ RUN useradd --create-home --home-dir /gorb gorb USER gorb -ENV DATABASE_USERNAME=gorb \ +ENV WEB_URL=https://gorb.app/web/ \ +DATABASE_USERNAME=gorb \ DATABASE_PASSWORD=gorb \ DATABASE=gorb \ DATABASE_HOST=database \ @@ -28,6 +29,11 @@ CACHE_DB_PORT=6379 \ BUNNY_API_KEY=your_storage_zone_password_here \ BUNNY_ENDPOINT=Frankfurt \ BUNNY_ZONE=gorb \ -BUNNY_CDN_URL=https://cdn.gorb.app +BUNNY_CDN_URL=https://cdn.gorb.app \ +MAIL_ADDRESS=Gorb \ +MAIL_TLS=tls \ +SMTP_SERVER=mail.gorb.app \ +SMTP_USERNAME=your_smtp_username \ +SMTP_PASSWORD=your_smtp_password \ ENTRYPOINT ["/usr/bin/entrypoint.sh"] diff --git a/compose.dev.yml b/compose.dev.yml index 3da7c89..e80f2a7 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -18,6 +18,7 @@ services: - gorb-backend:/gorb environment: #- RUST_LOG=debug + - WEB_URL=https://gorb.app/web/ - DATABASE_USERNAME=gorb - DATABASE_PASSWORD=gorb - DATABASE=gorb @@ -27,6 +28,11 @@ services: - BUNNY_ENDPOINT=Frankfurt - BUNNY_ZONE=gorb - BUNNY_CDN_URL=https://cdn.gorb.app + - MAIL_ADDRESS=Gorb + - MAIL_TLS=tls + - SMTP_SERVER=mail.gorb.app + - SMTP_USERNAME=your_smtp_username + - SMTP_PASSWORD=your_smtp_password database: image: postgres:16 restart: always diff --git a/compose.yml b/compose.yml index f87411a..2bc7339 100644 --- a/compose.yml +++ b/compose.yml @@ -16,6 +16,7 @@ services: - gorb-backend:/gorb environment: #- RUST_LOG=debug + - WEB_URL=https://gorb.app/web/ - DATABASE_USERNAME=gorb - DATABASE_PASSWORD=gorb - DATABASE=gorb @@ -25,6 +26,11 @@ services: - BUNNY_ENDPOINT=Frankfurt - BUNNY_ZONE=gorb - BUNNY_CDN_URL=https://cdn.gorb.app + - MAIL_ADDRESS=Gorb + - MAIL_TLS=tls + - SMTP_SERVER=mail.gorb.app + - SMTP_USERNAME=your_smtp_username + - SMTP_PASSWORD=your_smtp_password database: image: postgres:16 restart: always diff --git a/entrypoint.sh b/entrypoint.sh index a29e6bb..9c7a401 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,6 +10,9 @@ fi if [ ! -f "/gorb/config/config.toml" ]; then cat > /gorb/config/config.toml < Scope { .service(login::response) .service(refresh::res) .service(revoke::res) + .service(verify_email::get) + .service(verify_email::post) } pub async fn check_access_token(access_token: &str, conn: &mut Conn) -> Result { diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs new file mode 100644 index 0000000..d8df8c3 --- /dev/null +++ b/src/api/v1/auth/verify_email.rs @@ -0,0 +1,103 @@ +//! `/api/v1/auth/verify-email` Endpoints for verifying user emails + +use actix_web::{HttpRequest, HttpResponse, get, post, web}; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::{ + api::v1::auth::check_access_token, error::Error, structs::{EmailToken, Me}, utils::get_auth_header, Data +}; + +#[derive(Deserialize)] +struct Query { + token: String, +} + +/// `GET /api/v1/auth/verify-email` Verifies user email address +/// +/// requires auth? yes +/// +/// ### Query Parameters +/// token +/// +/// ### Responses +/// 200 Success +/// 410 Token Expired +/// 404 Not Found +/// 401 Unauthorized +/// +#[get("/verify-email")] +pub async fn get( + req: HttpRequest, + query: web::Query, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + let me = Me::get(&mut conn, uuid).await?; + + let email_token = EmailToken::get(&mut conn, me.uuid).await?; + + if query.token != email_token.token { + return Ok(HttpResponse::Unauthorized().finish()); + } + + if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(24) { + email_token.delete(&mut conn).await?; + return Ok(HttpResponse::Gone().finish()); + } + + me.verify_email(&mut conn).await?; + + email_token.delete(&mut conn).await?; + + Ok(HttpResponse::Ok().finish()) +} + +/// `POST /api/v1/auth/verify-email` Sends user verification email +/// +/// requires auth? yes +/// +/// ### Responses +/// 200 Email sent +/// 204 Already verified +/// 429 Too Many Requests +/// 401 Unauthorized +/// +#[post("/verify-email")] +pub async fn post( + req: HttpRequest, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + let me = Me::get(&mut conn, uuid).await?; + + if me.email_verified { + return Ok(HttpResponse::NoContent().finish()) + } + + if let Ok(email_token) = EmailToken::get(&mut conn, me.uuid).await { + if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(1) { + email_token.delete(&mut conn).await?; + } else { + return Err(Error::TooManyRequests("Please allow 1 hour before sending a new email".to_string())) + } + } + + EmailToken::new(&data, me).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/config.rs b/src/config.rs index 4d2e96b..9ffd9c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ use url::Url; pub struct ConfigBuilder { database: Database, cache_database: CacheDatabase, - web: Option, + web: WebBuilder, instance: Option, bunny: BunnyBuilder, mail: Mail, @@ -36,8 +36,9 @@ pub struct CacheDatabase { #[derive(Debug, Deserialize)] struct WebBuilder { - url: Option, + ip: Option, port: Option, + url: Url, _ssl: Option, } @@ -57,7 +58,7 @@ struct BunnyBuilder { #[derive(Debug, Deserialize, Clone)] pub struct Mail { pub smtp: Smtp, - pub from: String, + pub address: String, pub tls: String, } @@ -79,16 +80,10 @@ impl ConfigBuilder { } pub fn build(self) -> Config { - let web = if let Some(web) = self.web { - Web { - url: web.url.unwrap_or(String::from("0.0.0.0")), - port: web.port.unwrap_or(8080), - } - } else { - Web { - url: String::from("0.0.0.0"), - port: 8080, - } + let web = Web { + ip: self.web.ip.unwrap_or(String::from("0.0.0.0")), + port: self.web.port.unwrap_or(8080), + url: self.web.url, }; let endpoint = match &*self.bunny.endpoint { @@ -134,8 +129,9 @@ pub struct Config { #[derive(Debug, Clone)] pub struct Web { - pub url: String, + pub ip: String, pub port: u16, + pub url: Url, } #[derive(Debug, Clone)] diff --git a/src/error.rs b/src/error.rs index ca05c98..984f57e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,7 +19,7 @@ use serde_json::Error as JsonError; use thiserror::Error; use tokio::task::JoinError; use toml::de::Error as TomlError; -use lettre::{address::AddressError, transport::smtp::Error as SmtpError}; +use lettre::{error::Error as EmailError, address::AddressError, transport::smtp::Error as SmtpError}; #[derive(Debug, Error)] pub enum Error { @@ -56,6 +56,8 @@ pub enum Error { #[error(transparent)] WsClosed(#[from] actix_ws::Closed), #[error(transparent)] + EmailError(#[from] EmailError), + #[error(transparent)] SmtpError(#[from] SmtpError), #[error(transparent)] SmtpAddressError(#[from] AddressError), @@ -68,6 +70,8 @@ pub enum Error { #[error("{0}")] Forbidden(String), #[error("{0}")] + TooManyRequests(String), + #[error("{0}")] InternalServerError(String), } @@ -87,6 +91,8 @@ impl ResponseError for Error { Error::BunnyError(BunnyError::NotFound(_)) => StatusCode::NOT_FOUND, Error::BadRequest(_) => StatusCode::BAD_REQUEST, Error::Unauthorized(_) => StatusCode::UNAUTHORIZED, + Error::Forbidden(_) => StatusCode::FORBIDDEN, + Error::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS, _ => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/src/main.rs b/src/main.rs index 8bc1c68..0f94be8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,7 +76,7 @@ async fn main() -> Result<(), Error> { let mail = config.mail.clone(); - let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.from, mail.tls)?; + let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.address, mail.tls)?; let database_url = config.database.url(); @@ -152,7 +152,7 @@ async fn main() -> Result<(), Error> { .wrap(cors) .service(api::web()) }) - .bind((web.url, web.port))? + .bind((web.ip, web.port))? .run() .await?; diff --git a/src/schema.rs b/src/schema.rs index cc5e97c..744ce10 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,6 +31,15 @@ diesel::table! { } } +diesel::table! { + email_tokens (token, user_uuid) { + #[max_length = 64] + token -> Varchar, + user_uuid -> Uuid, + created_at -> Timestamptz, + } +} + diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -133,6 +142,7 @@ diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); +diesel::joinable!(email_tokens -> users (user_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); diesel::joinable!(guilds -> users (owner_uuid)); @@ -149,6 +159,7 @@ diesel::allow_tables_to_appear_in_same_query!( access_tokens, channel_permissions, channels, + email_tokens, guild_members, guilds, instance_permissions, diff --git a/src/structs.rs b/src/structs.rs index 541d6ec..50b5ac5 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,11 +1,10 @@ use actix_web::web::BytesMut; +use chrono::Utc; use diesel::{ - ExpressionMethods, QueryDsl, Selectable, SelectableHelper, delete, insert_into, - prelude::{Insertable, Queryable}, - update, + delete, dsl::now, insert_into, prelude::{Insertable, Queryable}, update, ExpressionMethods, QueryDsl, Selectable, SelectableHelper }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; -use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; +use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder, MultiPart}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; @@ -13,10 +12,7 @@ use url::Url; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - schema::*, - utils::{image_check, order_by_is_above}, + error::Error, schema::*, utils::{generate_refresh_token, image_check, order_by_is_above}, Conn, Data }; pub trait HasUuid { @@ -70,7 +66,7 @@ impl MailClient { }) } - pub async fn message_builder(&self) -> EmailBuilder { + pub fn message_builder(&self) -> EmailBuilder { Email::builder() .from(self.mbox.clone()) } @@ -780,12 +776,12 @@ impl User { #[diesel(table_name = users)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Me { - uuid: Uuid, + pub uuid: Uuid, username: String, display_name: Option, avatar: Option, email: String, - email_verified: bool, + pub email_verified: bool, } impl Me { @@ -849,6 +845,17 @@ impl Me { Ok(()) } + + pub async fn verify_email(&self, conn: &mut Conn) -> Result<(), Error> { + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::email_verified.eq(true)) + .execute(conn) + .await?; + + Ok(()) + } } #[derive(Deserialize)] @@ -856,3 +863,69 @@ pub struct StartAmountQuery { pub start: Option, pub amount: Option, } + +#[derive(Selectable, Queryable)] +#[diesel(table_name = email_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct EmailToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl EmailToken { + pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { + use email_tokens::dsl; + let email_token = dsl::email_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(EmailToken::as_select()) + .get_result(conn) + .await?; + + Ok(email_token) + } + + pub async fn new(data: &Data, me: Me) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + use email_tokens::dsl; + insert_into(email_tokens::table) + .values((dsl::user_uuid.eq(me.uuid), dsl::token.eq(&token), dsl::created_at.eq(now))) + .execute(&mut conn) + .await?; + + let mut verify_endpoint = data.config.web.url.join("verify-email")?; + + verify_endpoint.set_query(Some(&format!("token={}", token))); + + let email = data + .mail_client + .message_builder() + .to(me.email.parse()?) + .subject("Gorb E-mail Verification") + .multipart(MultiPart::alternative_plain_html( + format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", me.username, verify_endpoint), + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), me.username, verify_endpoint) + ))?; + + data + .mail_client + .send_mail(email) + .await?; + + Ok(()) + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use email_tokens::dsl; + delete(email_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} From 501141b584e1ed9627fa6ff9fc3158c0f61de309 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 28 May 2025 23:13:41 +0200 Subject: [PATCH 3/4] feat: add password reset --- .../down.sql | 2 + .../up.sql | 7 + src/api/v1/auth/login.rs | 59 ++----- src/api/v1/auth/mod.rs | 9 +- src/api/v1/auth/reset_password.rs | 90 +++++++++++ src/schema.rs | 11 ++ src/structs.rs | 153 +++++++++++++++++- src/utils.rs | 31 +++- 8 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 migrations/2025-05-28-175918_create_password_reset_tokens/down.sql create mode 100644 migrations/2025-05-28-175918_create_password_reset_tokens/up.sql create mode 100644 src/api/v1/auth/reset_password.rs diff --git a/migrations/2025-05-28-175918_create_password_reset_tokens/down.sql b/migrations/2025-05-28-175918_create_password_reset_tokens/down.sql new file mode 100644 index 0000000..dcccc77 --- /dev/null +++ b/migrations/2025-05-28-175918_create_password_reset_tokens/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE password_reset_tokens; diff --git a/migrations/2025-05-28-175918_create_password_reset_tokens/up.sql b/migrations/2025-05-28-175918_create_password_reset_tokens/up.sql new file mode 100644 index 0000000..f788b77 --- /dev/null +++ b/migrations/2025-05-28-175918_create_password_reset_tokens/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +CREATE TABLE password_reset_tokens ( + token VARCHAR(64) NOT NULL, + user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid), + created_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (token, user_uuid) +); diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 81ef117..254b913 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -5,15 +5,14 @@ use argon2::{PasswordHash, PasswordVerifier}; use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use serde::Deserialize; -use uuid::Uuid; use crate::{ Data, error::Error, schema::*, utils::{ - EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_access_token, generate_refresh_token, - refresh_token_cookie, + PASSWORD_REGEX, generate_access_token, generate_refresh_token, + refresh_token_cookie, user_uuid_from_identifier }, }; @@ -39,58 +38,20 @@ pub async fn response( let mut conn = data.pool.get().await?; - if EMAIL_REGEX.is_match(&login_information.username) { - // FIXME: error handling, right now i just want this to work - let (uuid, password): (Uuid, String) = dsl::users - .filter(dsl::email.eq(&login_information.username)) - .select((dsl::uuid, dsl::password)) - .get_result(&mut conn) - .await?; + let uuid = user_uuid_from_identifier(&mut conn, &login_information.username).await?; - return login( - data.clone(), - uuid, - login_information.password.clone(), - password, - login_information.device_name.clone(), - ) - .await; - } else if USERNAME_REGEX.is_match(&login_information.username) { - // FIXME: error handling, right now i just want this to work - let (uuid, password): (Uuid, String) = dsl::users - .filter(dsl::username.eq(&login_information.username)) - .select((dsl::uuid, dsl::password)) - .get_result(&mut conn) - .await?; - - return login( - data.clone(), - uuid, - login_information.password.clone(), - password, - login_information.device_name.clone(), - ) - .await; - } - - Ok(HttpResponse::Unauthorized().finish()) -} - -async fn login( - data: actix_web::web::Data, - uuid: Uuid, - request_password: String, - database_password: String, - device_name: String, -) -> Result { - let mut conn = data.pool.get().await?; + let database_password: String = dsl::users + .filter(dsl::uuid.eq(uuid)) + .select(dsl::password) + .get_result(&mut conn) + .await?; let parsed_hash = PasswordHash::new(&database_password) .map_err(|e| Error::PasswordHashError(e.to_string()))?; if data .argon2 - .verify_password(request_password.as_bytes(), &parsed_hash) + .verify_password(login_information.password.as_bytes(), &parsed_hash) .is_err() { return Err(Error::Unauthorized( @@ -110,7 +71,7 @@ async fn login( rdsl::token.eq(&refresh_token), rdsl::uuid.eq(uuid), rdsl::created_at.eq(current_time), - rdsl::device_name.eq(device_name), + rdsl::device_name.eq(&login_information.device_name), )) .execute(&mut conn) .await?; diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index 1689d2b..cabe114 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -1,12 +1,8 @@ -use std::{ - sync::LazyLock, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::time::{SystemTime, UNIX_EPOCH}; use actix_web::{Scope, web}; use diesel::{ExpressionMethods, QueryDsl}; use diesel_async::RunQueryDsl; -use regex::Regex; use serde::Serialize; use uuid::Uuid; @@ -17,6 +13,7 @@ mod refresh; mod register; mod revoke; mod verify_email; +mod reset_password; #[derive(Serialize)] struct Response { @@ -31,6 +28,8 @@ pub fn web() -> Scope { .service(revoke::res) .service(verify_email::get) .service(verify_email::post) + .service(reset_password::get) + .service(reset_password::post) } pub async fn check_access_token(access_token: &str, conn: &mut Conn) -> Result { diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs new file mode 100644 index 0000000..6c6dee7 --- /dev/null +++ b/src/api/v1/auth/reset_password.rs @@ -0,0 +1,90 @@ +//! `/api/v1/auth/reset-password` Endpoints for resetting user password + +use actix_web::{HttpResponse, get, post, web}; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::{ + error::Error, structs::PasswordResetToken, Data +}; + +#[derive(Deserialize)] +struct Query { + identifier: String, +} + +/// `GET /api/v1/auth/reset-password` Sends password reset email to user +/// +/// requires auth? no +/// +/// ### Query Parameters +/// identifier: Email or username +/// +/// ### Responses +/// 200 Email sent +/// 429 Too Many Requests +/// 404 Not found +/// 400 Bad request +/// +#[get("/reset-password")] +pub async fn get( + query: web::Query, + data: web::Data, +) -> Result { + let mut conn = data.pool.get().await?; + + if let Ok(password_reset_token) = PasswordResetToken::get_with_identifier(&mut conn, query.identifier.clone()).await { + if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(1) { + password_reset_token.delete(&mut conn).await?; + } else { + return Err(Error::TooManyRequests("Please allow 1 hour before sending a new email".to_string())) + } + } + + PasswordResetToken::new(&data, query.identifier.clone()).await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Deserialize)] +struct ResetPassword { + password: String, + token: String, +} + +/// `POST /api/v1/auth/reset-password` Resets user password +/// +/// requires auth? no +/// +/// ### Request Example: +/// ``` +/// json!({ +/// "password": "1608c17a27f6ae3891c23d680c73ae91528f20a54dcf4973e2c3126b9734f48b7253047f2395b51bb8a44a6daa188003", +/// "token": "a3f7e29c1b8d0456e2c9f83b7a1d6e4f5028c3b9a7e1f2d5c6b8a0d3e7f4a2b" +/// }); +/// ``` +/// +/// ### Responses +/// 200 Success +/// 410 Token Expired +/// 404 Not Found +/// 400 Bad Request +/// +#[post("/reset-password")] +pub async fn post( + reset_password: web::Json, + data: web::Data, +) -> Result { + let mut conn = data.pool.get().await?; + + let password_reset_token = PasswordResetToken::get(&mut conn, reset_password.token.clone()).await?; + + if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(24) { + password_reset_token.delete(&mut conn).await?; + return Ok(HttpResponse::Gone().finish()); + } + + password_reset_token.set_password(&data, reset_password.password.clone()).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/schema.rs b/src/schema.rs index 744ce10..1b34400 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -89,6 +89,15 @@ diesel::table! { } } +diesel::table! { + password_reset_tokens (token, user_uuid) { + #[max_length = 64] + token -> Varchar, + user_uuid -> Uuid, + created_at -> Timestamptz, + } +} + diesel::table! { refresh_tokens (token) { #[max_length = 64] @@ -151,6 +160,7 @@ diesel::joinable!(invites -> guilds (guild_uuid)); diesel::joinable!(invites -> users (user_uuid)); diesel::joinable!(messages -> channels (channel_uuid)); diesel::joinable!(messages -> users (user_uuid)); +diesel::joinable!(password_reset_tokens -> users (user_uuid)); diesel::joinable!(refresh_tokens -> users (uuid)); diesel::joinable!(role_members -> guild_members (member_uuid)); diesel::joinable!(roles -> guilds (guild_uuid)); @@ -165,6 +175,7 @@ diesel::allow_tables_to_appear_in_same_query!( instance_permissions, invites, messages, + password_reset_tokens, refresh_tokens, role_members, roles, diff --git a/src/structs.rs b/src/structs.rs index e4d2610..e8002bf 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -10,12 +10,13 @@ use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; use uuid::Uuid; +use argon2::{ + PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, +}; use crate::{ - Conn, Data, - error::Error, - schema::*, - utils::{EMAIL_REGEX, USERNAME_REGEX, generate_refresh_token, image_check, order_by_is_above}, + error::Error, schema::*, utils::{generate_refresh_token, image_check, order_by_is_above, user_uuid_from_identifier, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, Conn, Data }; pub trait HasUuid { @@ -986,3 +987,147 @@ impl EmailToken { Ok(()) } } + +#[derive(Selectable, Queryable)] +#[diesel(table_name = password_reset_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PasswordResetToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl PasswordResetToken { + pub async fn get(conn: &mut Conn, token: String) -> Result { + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::token.eq(token)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + pub async fn get_with_identifier(conn: &mut Conn, identifier: String) -> Result { + let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; + + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + + use users::dsl as udsl; + let (username, email_address): (String, String) = udsl::users + .filter(udsl::uuid.eq(user_uuid)) + .select((udsl::username, udsl::email)) + .get_result(&mut conn) + .await?; + + use password_reset_tokens::dsl; + insert_into(password_reset_tokens::table) + .values((dsl::user_uuid.eq(user_uuid), dsl::token.eq(&token), dsl::created_at.eq(now))) + .execute(&mut conn) + .await?; + + let mut reset_endpoint = data.config.web.url.join("reset-password")?; + + reset_endpoint.set_query(Some(&format!("token={}", token))); + + //TODO: Correct this email + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + // twig: change this + .subject("Gorb E-mail Verification") + .multipart(MultiPart::alternative_plain_html( + // twig: change this line + format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, reset_endpoint), + // twig: and this one + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) + ))?; + + data + .mail_client + .send_mail(email) + .await?; + + Ok(()) + } + + pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { + if !PASSWORD_REGEX.is_match(&password) { + return Err(Error::BadRequest("Please provide a valid password".to_string())) + } + + let salt = SaltString::generate(&mut OsRng); + + let hashed_password = data + .argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| Error::PasswordHashError(e.to_string()))?; + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.user_uuid)) + .set(dsl::password.eq(hashed_password.to_string())) + .execute(&mut conn) + .await?; + + let (username, email_address): (String, String) = dsl::users + .filter(dsl::uuid.eq(self.user_uuid)) + .select((dsl::username, dsl::email)) + .get_result(&mut conn) + .await?; + + let login_page = data.config.web.url.join("login")?; + + //TODO: Correct this email + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + // twig: change this (post password change email) + .subject("Gorb E-mail Verification") + .multipart(MultiPart::alternative_plain_html( + // twig: change this line + format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, login_page), + // twig: and this one + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, login_page) + ))?; + + data + .mail_client + .send_mail(email) + .await?; + + self.delete(&mut conn).await + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use password_reset_tokens::dsl; + delete(password_reset_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} + diff --git a/src/utils.rs b/src/utils.rs index 143b544..1d39fdb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,16 +6,17 @@ use actix_web::{ web::BytesMut, }; use bindet::FileType; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; use getrandom::fill; use hex::encode; use redis::RedisError; use regex::Regex; use serde::Serialize; +use uuid::Uuid; use crate::{ - Data, - error::Error, - structs::{HasIsAbove, HasUuid}, + error::Error, schema::users, structs::{HasIsAbove, HasUuid}, Conn, Data }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { @@ -136,6 +137,30 @@ pub fn image_check(icon: BytesMut) -> Result { )) } +pub async fn user_uuid_from_identifier(conn: &mut Conn, identifier: &String) -> Result { + if EMAIL_REGEX.is_match(identifier) { + use users::dsl; + let user_uuid = dsl::users + .filter(dsl::email.eq(identifier)) + .select(dsl::uuid) + .get_result(conn) + .await?; + + Ok(user_uuid) + } else if USERNAME_REGEX.is_match(identifier) { + use users::dsl; + let user_uuid = dsl::users + .filter(dsl::username.eq(identifier)) + .select(dsl::uuid) + .get_result(conn) + .await?; + + Ok(user_uuid) + } else { + Err(Error::BadRequest("Please provide a valid username or email".to_string())) + } +} + pub async fn order_by_is_above(mut items: Vec) -> Result, Error> where T: HasUuid + HasIsAbove, From cf2398ed66bf2f148154d9b653d856ad98a63acf Mon Sep 17 00:00:00 2001 From: JustTemmie <47639983+JustTemmie@users.noreply.github.com> Date: Wed, 28 May 2025 23:36:18 +0200 Subject: [PATCH 4/4] fix: fix incorrect email templates --- src/structs.rs | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/structs.rs b/src/structs.rs index e8002bf..019d553 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -962,9 +962,9 @@ impl EmailToken { .mail_client .message_builder() .to(me.email.parse()?) - .subject("Gorb E-mail Verification") + .subject(format!("{} E-mail Verification", data.config.web.url.domain().unwrap())) .multipart(MultiPart::alternative_plain_html( - format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", me.username, verify_endpoint), + format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), me.username, verify_endpoint), format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), me.username, verify_endpoint) ))?; @@ -1046,18 +1046,14 @@ impl PasswordResetToken { reset_endpoint.set_query(Some(&format!("token={}", token))); - //TODO: Correct this email let email = data .mail_client .message_builder() .to(email_address.parse()?) - // twig: change this - .subject("Gorb E-mail Verification") + .subject(format!("{} Password Reset", data.config.web.url.domain().unwrap())) .multipart(MultiPart::alternative_plain_html( - // twig: change this line - format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, reset_endpoint), - // twig: and this one - format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) + format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), username, reset_endpoint), + format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) ))?; data @@ -1097,18 +1093,14 @@ impl PasswordResetToken { let login_page = data.config.web.url.join("login")?; - //TODO: Correct this email let email = data .mail_client .message_builder() .to(email_address.parse()?) - // twig: change this (post password change email) - .subject("Gorb E-mail Verification") + .subject(format!("Your {} Password has been Reset", data.config.web.url.domain().unwrap())) .multipart(MultiPart::alternative_plain_html( - // twig: change this line - format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, login_page), - // twig: and this one - format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, login_page) + format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), username, login_page), + format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.web.url.domain().unwrap(), username, login_page) ))?; data