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,