From 83f031779f5b307ccd8e653ac79ff05655f94a9a Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 21:57:08 +0200 Subject: [PATCH] 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(()) + } +}