From 267feb3c0934f4944e05ed824eaf4d8f2eb867f0 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 3 Mar 2026 10:36:25 -0500 Subject: [PATCH] feat: Add a new service for handling password resets --- src/database/maps.rs | 4 ++ src/service/mod.rs | 1 + src/service/password_reset/data.rs | 69 ++++++++++++++++++++ src/service/password_reset/mod.rs | 101 +++++++++++++++++++++++++++++ src/service/services.rs | 6 +- 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/service/password_reset/data.rs create mode 100644 src/service/password_reset/mod.rs diff --git a/src/database/maps.rs b/src/database/maps.rs index 20286b33..1126f6c5 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -112,6 +112,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "onetimekeyid_onetimekeys", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "passwordresettoken_userid", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "pduid_pdu", cache_disp: CacheDisp::SharedWith("eventid_outlierpdu"), diff --git a/src/service/mod.rs b/src/service/mod.rs index 6df6e539..5480b838 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -23,6 +23,7 @@ pub mod globals; pub mod key_backups; pub mod media; pub mod moderation; +pub mod password_reset; pub mod presence; pub mod pusher; pub mod registration_tokens; diff --git a/src/service/password_reset/data.rs b/src/service/password_reset/data.rs new file mode 100644 index 00000000..949976d3 --- /dev/null +++ b/src/service/password_reset/data.rs @@ -0,0 +1,69 @@ +use std::{ + sync::Arc, + time::{Duration, SystemTime}, +}; + +use conduwuit::utils::{ReadyExt, stream::TryExpect}; +use database::{Database, Deserialized, Json, Map}; +use ruma::{OwnedUserId, UserId}; +use serde::{Deserialize, Serialize}; + +pub(super) struct Data { + passwordresettoken_info: Arc, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResetTokenInfo { + pub user: OwnedUserId, + pub issued_at: SystemTime, +} + +impl ResetTokenInfo { + const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60); + + // one hour + + pub fn is_valid(&self) -> bool { + let now = SystemTime::now(); + + now.duration_since(self.issued_at) + .is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE) + } +} + +impl Data { + pub(super) fn new(db: &Arc) -> Self { + Self { + passwordresettoken_info: db["passwordresettoken_info"].clone(), + } + } + + /// Associate a reset token with its info in the database. + pub(super) fn save_token(&self, token: &str, info: &ResetTokenInfo) { + self.passwordresettoken_info.raw_put(token, Json(info)); + } + + /// Lookup the info for a reset token. + pub(super) async fn lookup_token_info(&self, token: &str) -> Option { + self.passwordresettoken_info + .get(token) + .await + .deserialized() + .ok() + } + + /// Find a user's existing reset token, if any. + pub(super) async fn find_token_for_user( + &self, + user: &UserId, + ) -> Option<(String, ResetTokenInfo)> { + self.passwordresettoken_info + .stream::<'_, String, ResetTokenInfo>() + .expect_ok() + .ready_find(|(_, info)| info.user == user) + .await + } + + /// Remove a reset token. + pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.del(token); } +} diff --git a/src/service/password_reset/mod.rs b/src/service/password_reset/mod.rs new file mode 100644 index 00000000..4fa2ecaf --- /dev/null +++ b/src/service/password_reset/mod.rs @@ -0,0 +1,101 @@ +mod data; + +use std::{sync::Arc, time::SystemTime}; + +use conduwuit::{Err, Result, info, utils}; +use data::{Data, ResetTokenInfo}; +use ruma::OwnedUserId; + +use crate::{Dep, globals, users}; + +const RESET_TOKEN_LENGTH: usize = 32; + +pub struct Service { + db: Data, + services: Services, +} + +struct Services { + users: Dep, + globals: Dep, +} + +#[derive(Debug)] +pub struct ValidResetToken { + pub token: String, + pub info: ResetTokenInfo, +} + +impl crate::Service for Service { + fn build(args: crate::Args<'_>) -> Result> { + Ok(Arc::new(Self { + db: Data::new(args.db), + services: Services { + users: args.depend::("users"), + globals: args.depend::("globals"), + }, + })) + } + + fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } +} + +impl Service { + /// Generate a random string suitable to be used as a password reset token. + #[must_use] + pub fn generate_token_string() -> String { utils::random_string(RESET_TOKEN_LENGTH) } + + /// Issue a password reset token for `user`, who must be a local user with + /// the `password` origin. + pub async fn issue_token(&self, user: OwnedUserId) -> Result { + if !self.services.globals.user_is_local(&user) { + return Err!("Cannot issue a password reset token for remote user {user}"); + } + + if self.services.users.origin(&user).await? != "password" { + return Err!("Cannot issue a password reset token for non-internal user {user}"); + } + + if let Some((existing_token, _)) = self.db.find_token_for_user(&user).await { + self.db.remove_token(&existing_token); + } + + let token = Self::generate_token_string(); + let info = ResetTokenInfo { user, issued_at: SystemTime::now() }; + + self.db.save_token(&token, &info); + + info!(?info.user, "Issued a password reset token"); + Ok(ValidResetToken { token, info }) + } + + /// Check if `token` represents a valid, non-expired password reset token. + pub async fn check_token(&self, token: &str) -> Option { + self.db.lookup_token_info(token).await.and_then(|info| { + if info.is_valid() { + Some(ValidResetToken { token: token.to_owned(), info }) + } else { + self.db.remove_token(token); + None + } + }) + } + + /// Consume the supplied valid token, using it to change its user's password + /// to `new_password`. + pub async fn consume_token( + &self, + ValidResetToken { token, info }: ValidResetToken, + new_password: &str, + ) -> Result<()> { + if info.is_valid() { + self.db.remove_token(&token); + self.services + .users + .set_password(&info.user, Some(new_password)) + .await?; + } + + Ok(()) + } +} diff --git a/src/service/services.rs b/src/service/services.rs index 60a7eeab..1a1510f7 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -11,8 +11,8 @@ use crate::{ account_data, admin, announcements, antispam, appservice, client, config, emergency, federation, firstrun, globals, key_backups, manager::Manager, - media, moderation, presence, pusher, registration_tokens, resolver, rooms, sending, - server_keys, + media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms, + sending, server_keys, service::{self, Args, Map, Service}, sync, transactions, uiaa, users, }; @@ -27,6 +27,7 @@ pub struct Services { pub globals: Arc, pub key_backups: Arc, pub media: Arc, + pub password_reset: Arc, pub presence: Arc, pub pusher: Arc, pub registration_tokens: Arc, @@ -81,6 +82,7 @@ impl Services { globals: build!(globals::Service), key_backups: build!(key_backups::Service), media: build!(media::Service), + password_reset: build!(password_reset::Service), presence: build!(presence::Service), pusher: build!(pusher::Service), registration_tokens: build!(registration_tokens::Service),