From 955da3a74fb643448144171822f0c6f38c93ed78 Mon Sep 17 00:00:00 2001 From: Ginger Date: Sun, 22 Mar 2026 11:46:26 -0400 Subject: [PATCH] feat: Add admin commands for managing users' email addresses --- Cargo.lock | 2 + conduwuit-example.toml | 2 + src/admin/Cargo.toml | 1 + src/admin/debug/commands.rs | 29 +++++ src/admin/debug/mod.rs | 3 + src/admin/user/commands.rs | 104 ++++++++++++++++++ src/admin/user/mod.rs | 18 +++ src/api/client/account/register.rs | 16 ++- src/core/config/mod.rs | 3 + src/database/maps.rs | 20 ++++ src/service/mailer/messages.rs | 14 +-- src/service/mailer/mod.rs | 8 +- .../templates/mail/password_reset.txt.j2 | 4 + src/service/threepid/data.rs | 17 +-- src/service/threepid/mod.rs | 86 ++++++++++----- src/service/uiaa/mod.rs | 2 +- 16 files changed, 281 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbf308352..006fd2fa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,6 +982,7 @@ dependencies = [ "conduwuit_service", "const-str", "futures", + "lettre", "log", "ruma", "serde-saphyr", @@ -1011,6 +1012,7 @@ dependencies = [ "hyper", "ipaddress", "itertools 0.14.0", + "lettre", "log", "rand 0.10.0", "reqwest", diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 2fa6b5c19..8ee7a5a1e 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2050,6 +2050,8 @@ # # For most modern mail servers, format the URI like this: # `smtps://username:password@hostname:port` +# Note that you will need to URL-encode the username and password. If your username _is_ +# your email address, you will need to replace the `@` with `%40`. # # For a guide on the accepted URI syntax, consult Lettre's documentation: # https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url diff --git a/src/admin/Cargo.toml b/src/admin/Cargo.toml index 3bf924ba6..d30e8941d 100644 --- a/src/admin/Cargo.toml +++ b/src/admin/Cargo.toml @@ -80,6 +80,7 @@ conduwuit-macros.workspace = true conduwuit-service.workspace = true const-str.workspace = true futures.workspace = true +lettre.workspace = true log.workspace = true ruma.workspace = true serde_json.workspace = true diff --git a/src/admin/debug/commands.rs b/src/admin/debug/commands.rs index ad63e365a..5bf5be90f 100644 --- a/src/admin/debug/commands.rs +++ b/src/admin/debug/commands.rs @@ -19,6 +19,7 @@ use conduwuit::{ warn, }; use futures::{FutureExt, StreamExt, TryStreamExt}; +use lettre::message::Mailbox; use ruma::{ CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomVersionId, @@ -876,3 +877,31 @@ pub(super) async fn trim_memory(&self) -> Result { writeln!(self, "done").await } + +#[admin_command] +pub(super) async fn send_test_email(&self) -> Result { + self.bail_restricted()?; + + let mailer = self.services.mailer.expect_mailer()?; + let Some(sender) = self.sender else { + return Err!("No sender user provided in context"); + }; + + let Some(email) = self + .services + .threepid + .get_email_for_localpart(sender.localpart()) + .await + else { + return Err!("{} has no associated email address", sender); + }; + + mailer + .send(Mailbox::new(None, email.clone()), service::mailer::messages::Test) + .await?; + + self.write_str(&format!("Test email successfully sent to {email}")) + .await?; + + Ok(()) +} diff --git a/src/admin/debug/mod.rs b/src/admin/debug/mod.rs index e6f778e14..f06c6d0ce 100644 --- a/src/admin/debug/mod.rs +++ b/src/admin/debug/mod.rs @@ -225,6 +225,9 @@ pub enum DebugCommand { level: Option, }, + /// Send a test email to the invoking admin's email address + SendTestEmail, + /// Developer test stubs #[command(subcommand)] #[allow(non_snake_case)] diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 9950b1ce1..7b854e587 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -11,6 +11,7 @@ use conduwuit::{ warn, }; use futures::{FutureExt, StreamExt}; +use lettre::Address; use ruma::{ OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, UserId, events::{ @@ -1094,3 +1095,106 @@ pub(super) async fn enable_login(&self, user_id: String) -> Result { self.write_str(&format!("{user_id} can now log in.")).await } + +#[admin_command] +pub(super) async fn get_email(&self, user_id: String) -> Result { + self.bail_restricted()?; + let user_id = parse_local_user_id(self.services, &user_id)?; + + match self + .services + .threepid + .get_email_for_localpart(user_id.localpart()) + .await + { + | Some(email) => + self.write_str(&format!("{user_id} has the associated email address {email}.")) + .await, + | None => + self.write_str(&format!("{user_id} has no associated email address.")) + .await, + } +} + +#[admin_command] +pub(super) async fn get_user_by_email(&self, email: String) -> Result { + self.bail_restricted()?; + + let Ok(email) = Address::try_from(email) else { + return Err!("Invalid email address"); + }; + + match self.services.threepid.get_localpart_for_email(&email).await { + | Some(localpart) => { + let user_id = OwnedUserId::parse(format!( + "@{localpart}:{}", + self.services.globals.server_name() + )) + .unwrap(); + + self.write_str(&format!("{email} belongs to {user_id}.")) + .await + }, + | None => + self.write_str(&format!("No user has {email} as their email address.")) + .await, + } +} + +#[admin_command] +pub(super) async fn change_email(&self, user_id: String, email: Option) -> Result { + self.bail_restricted()?; + + let user_id = parse_local_user_id(self.services, &user_id)?; + let Ok(new_email) = email.map(Address::try_from).transpose() else { + return Err!("Invalid email address"); + }; + + if self.services.mailer.mailer().is_none() { + warn!("SMTP has not been configured on this server, emails cannot be sent."); + } + + let current_email = self + .services + .threepid + .get_email_for_localpart(user_id.localpart()) + .await; + + match (current_email, new_email) { + | (None, None) => + self.write_str(&format!( + "{user_id} already had no associated email. No changes have been made." + )) + .await, + | (current_email, Some(new_email)) => { + self.services + .threepid + .associate_localpart_email(user_id.localpart(), &new_email) + .await?; + + if let Some(current_email) = current_email { + self.write_str(&format!( + "The associated email of {user_id} has been changed from {current_email} to \ + {new_email}." + )) + .await + } else { + self.write_str(&format!( + "{user_id} has been associated with the email {new_email}." + )) + .await + } + }, + | (Some(current_email), None) => { + self.services + .threepid + .disassociate_localpart_email(user_id.localpart()) + .await; + + self.write_str(&format!( + "The associated email of {user_id} has been removed (it was {current_email})." + )) + .await + }, + } +} diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index f4b267656..3827f51d6 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -35,6 +35,24 @@ pub enum UserCommand { username: String, }, + /// Get a user's associated email address. + GetEmail { + user_id: String, + }, + + /// Get the user with the given email address. + GetUserByEmail { + email: String, + }, + + /// Update or remove a user's email address. + /// + /// If `email` is not supplied, the user's existing address wil be removed. + ChangeEmail { + user_id: String, + email: Option, + }, + /// Deactivate a user /// /// User will be removed from all rooms by default. diff --git a/src/api/client/account/register.rs b/src/api/client/account/register.rs index 914003a67..e119b17df 100644 --- a/src/api/client/account/register.rs +++ b/src/api/client/account/register.rs @@ -168,19 +168,25 @@ pub(crate) async fn register_route( let password = if is_guest { None } else { body.password.as_deref() }; - // Create user - services.users.create(&user_id, password, None).await?; - - // If the user registered with an email, associate it with their account + // If the user registered with an email, associate it with their account. + // Do this _before_ creating the user to make sure that, if their email is + // already in use, we don't make them an account. + // + // Note that this should only rarely cause a bailout because email uniqueness is + // also checked by /requestToken. #[allow(clippy::collapsible_if)] if let Some(identity) = identity { if let Some(email) = identity.email { services .threepid - .associate_localpart_email(user_id.localpart(), email); + .associate_localpart_email(user_id.localpart(), &email) + .await?; } } + // Create user + services.users.create(&user_id, password, None).await?; + // Set an initial display name let mut displayname = user_id.localpart().to_owned(); diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index f9a3469af..a5345b662 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2461,6 +2461,9 @@ pub struct SmtpConfig { /// /// For most modern mail servers, format the URI like this: /// `smtps://username:password@hostname:port` + /// Note that you will need to URL-encode the username and password. If your + /// username _is_ your email address, you will need to replace the `@` with + /// `%40`. /// /// For a guide on the accepted URI syntax, consult Lettre's documentation: /// https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url diff --git a/src/database/maps.rs b/src/database/maps.rs index 972225237..e113c26a7 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -49,10 +49,18 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "bannedroomids", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "clientsecret_validationsessionid", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "disabledroomids", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "email_localpart", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "eventid_outlierpdu", cache_disp: CacheDisp::SharedWith("pduid_pdu"), @@ -100,6 +108,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "lazyloadedids", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "localpart_email", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "mediaid_file", ..descriptor::RANDOM_SMALL @@ -458,4 +470,12 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "userroomid_invitesender", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "validationsessionid_session", + ..descriptor::RANDOM_SMALL + }, + Descriptor { + name: "validationsessionid_token", + ..descriptor::RANDOM_SMALL + }, ]; diff --git a/src/service/mailer/messages.rs b/src/service/mailer/messages.rs index 278d3c0e0..2f5047825 100644 --- a/src/service/mailer/messages.rs +++ b/src/service/mailer/messages.rs @@ -8,8 +8,8 @@ pub trait MessageTemplate: Template { #[derive(Template)] #[template(path = "mail/change_email.txt.j2")] pub struct ChangeEmail<'a> { - user_id: &'a UserId, - verification_link: &'a str, + pub user_id: &'a UserId, + pub verification_link: String, } impl MessageTemplate for ChangeEmail<'_> { @@ -19,8 +19,8 @@ impl MessageTemplate for ChangeEmail<'_> { #[derive(Template)] #[template(path = "mail/new_account.txt.j2")] pub struct NewAccount<'a> { - server_name: &'a str, - verification_link: &'a str, + pub server_name: &'a str, + pub verification_link: String, } impl MessageTemplate for NewAccount<'_> { @@ -30,9 +30,9 @@ impl MessageTemplate for NewAccount<'_> { #[derive(Template)] #[template(path = "mail/password_reset.txt.j2")] pub struct PasswordReset<'a> { - display_name: &'a str, - user_id: &'a UserId, - verification_link: &'a str, + pub display_name: Option<&'a str>, + pub user_id: &'a UserId, + pub verification_link: String, } impl MessageTemplate for PasswordReset<'_> { diff --git a/src/service/mailer/mod.rs b/src/service/mailer/mod.rs index d43e05e28..8dedfb63f 100644 --- a/src/service/mailer/mod.rs +++ b/src/service/mailer/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use conduwuit::{Err, Result, err, info}; use lettre::{ AsyncSmtpTransport, AsyncTransport, Tokio1Executor, - message::{Mailbox, MessageBuilder}, + message::{Mailbox, MessageBuilder, header::ContentType}, }; use crate::{Args, mailer::messages::MessageTemplate}; @@ -65,6 +65,11 @@ impl Service { .as_ref() .map(|(sender, transport)| Mailer { sender, transport }) } + + pub fn expect_mailer(&self) -> Result> { + self.mailer() + .ok_or_else(|| err!("SMTP is not configured on this server")) + } } pub struct Mailer<'a> { @@ -89,6 +94,7 @@ impl Mailer<'_> { .to(recipient) .subject(subject) .date_now() + .header(ContentType::TEXT_PLAIN) .body(body) .expect("should have been able to construct message"); diff --git a/src/service/templates/mail/password_reset.txt.j2 b/src/service/templates/mail/password_reset.txt.j2 index 46ce558db..532623305 100644 --- a/src/service/templates/mail/password_reset.txt.j2 +++ b/src/service/templates/mail/password_reset.txt.j2 @@ -1,7 +1,11 @@ {% extends "_base.txt.j2" %} {% block content -%} +{%- if let Some(display_name) = display_name -%} Hello {{ display_name }} ({{ user_id }}), +{%- else -%} +Hello {{ user_id }}, +{%- endif %} Somebody, probably you, tried to reset your Matrix account's password. If you requested for your password to be reset, click this link to proceed: diff --git a/src/service/threepid/data.rs b/src/service/threepid/data.rs index c99b2798a..9bc8663b8 100644 --- a/src/service/threepid/data.rs +++ b/src/service/threepid/data.rs @@ -6,6 +6,7 @@ use std::{ use conduwuit::utils; use database::{Database, Deserialized, Map}; use lettre::Address; +use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId}; use serde::{Deserialize, Serialize}; pub(super) struct Data { @@ -20,11 +21,11 @@ pub(super) struct Data { #[derive(Debug, Serialize, Deserialize)] pub(crate) struct ValidationSession { /// The session's ID - pub session_id: String, + pub session_id: OwnedSessionId, /// The email address which is being validated pub email: Address, /// The client's supplied client secret - pub client_secret: String, + pub client_secret: OwnedClientSecret, /// Whether the email address has been validated successfully yet pub(super) has_been_validated: bool, } @@ -62,9 +63,9 @@ impl PartialEq for ValidationToken { impl Data { pub(super) fn new(db: &Arc) -> Self { Self { - clientsecret_sessionid: db["clientsecret_sessionid"].clone(), - sessionid_session: db["sessionid_session"].clone(), - sessionid_token: db["sessionid_token"].clone(), + clientsecret_sessionid: db["clientsecret_validationsessionid"].clone(), + sessionid_session: db["validationsessionid_session"].clone(), + sessionid_token: db["validationsessionid_token"].clone(), localpart_email: db["localpart_email"].clone(), email_localpart: db["email_localpart"].clone(), } @@ -74,8 +75,8 @@ impl Data { pub(super) fn create_session( &self, email: Address, - session_id: String, - client_secret: String, + session_id: OwnedSessionId, + client_secret: OwnedClientSecret, token: ValidationToken, ) { let session = ValidationSession { @@ -103,7 +104,7 @@ impl Data { /// Get a validation session by client secret. pub(super) async fn get_session_by_secret( &self, - client_secret: &str, + client_secret: &ClientSecret, ) -> Option { let session_id: String = self .clientsecret_sessionid diff --git a/src/service/threepid/mod.rs b/src/service/threepid/mod.rs index ef9976267..00654ab8f 100644 --- a/src/service/threepid/mod.rs +++ b/src/service/threepid/mod.rs @@ -10,15 +10,16 @@ use crate::{ }; mod data; -use conduwuit::{Err, Result, utils}; +use conduwuit::{Err, Result, result::FlatOk, utils}; use data::{Data, ValidationToken}; use database::Deserialized; use lettre::{Address, message::Mailbox}; +use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId}; pub struct Service { db: Data, services: Services, - send_attempts: Mutex>, + send_attempts: Mutex>, } struct Services { @@ -45,20 +46,23 @@ impl Service { const RANDOM_SID_LENGTH: usize = 16; const VALIDATION_URL_PATH: &str = "/_continuwuity/3pid/email/validate"; + #[must_use] + pub fn generate_session_id() -> OwnedSessionId { + OwnedSessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap() + } + /// Send a validation message to an email address. /// /// Returns the validation session ID on success. #[allow(clippy::impl_trait_in_params)] pub async fn send_validation_email( &self, - recipient: Address, + recipient: Mailbox, prepare_body: impl FnOnce(String) -> Template, - client_secret: &str, + client_secret: &ClientSecret, send_attempt: usize, - ) -> Result { - let Some(mailer) = self.services.mailer.mailer() else { - return Err!("SMTP is not configured"); - }; + ) -> Result { + let mailer = self.services.mailer.expect_mailer()?; let (session_id, ValidationToken { token, .. }) = match self.db.get_session_by_secret(client_secret).await { @@ -102,11 +106,11 @@ impl Service { }, // If no session exists, create a new one. | None => { - let session_id = utils::random_string(Self::RANDOM_SID_LENGTH); + let session_id = Self::generate_session_id(); let token = ValidationToken::new_random(); self.db.create_session( - recipient.clone(), + recipient.email.clone(), session_id.clone(), client_secret.to_owned(), token.clone(), @@ -125,11 +129,9 @@ impl Service { validation_url .query_pairs_mut() - .append_pair("session_id", &session_id) - .append_pair("client_secret", client_secret) + .append_pair("session_id", session_id.as_ref()) .append_pair("token", &token); - let recipient = Mailbox::new(None, recipient); let message = prepare_body(validation_url.to_string()); mailer.send(recipient, message).await?; @@ -137,10 +139,10 @@ impl Service { Ok(session_id) } + /// Attempt to mark a validation session as valid using a validation token. pub async fn try_validate_session( &self, session_id: &str, - client_secret: &str, supplied_token: &str, ) -> Result<(), Cow<'static, str>> { let Some(session) = self.db.get_session(session_id).await else { @@ -151,15 +153,12 @@ impl Service { return Ok(()); } - if session.client_secret != client_secret { - return Err("Invalid client secret for session".into()); - } - let token = self .db .get_session_validation_token(&session) .await .expect("valid session should have a token"); + if token != *supplied_token || !token.is_valid() { return Err("Validation token is invalid or expired, please request a new one".into()); } @@ -169,10 +168,12 @@ impl Service { Ok(()) } + /// Consume a validated validation session, removing it from the database + /// and returning the newly validated email address. pub async fn consume_valid_session( &self, session_id: &str, - client_secret: &str, + client_secret: &ClientSecret, ) -> Result> { let Some(session) = self.db.get_session(session_id).await else { return Err("Validation session does not exist".into()); @@ -183,14 +184,38 @@ impl Service { self.db.remove_session(session).await; Ok(email) } else { - Err("Validation failed. Did you use the link that was sent to you?".into()) + Err("This email address has not been validated. Did you use the link that was sent \ + to you?" + .into()) } } /// Associate a localpart with an email address. - pub fn associate_localpart_email(&self, localpart: &str, email: Address) { - self.db.localpart_email.raw_put(localpart, &email); - self.db.email_localpart.put_raw(email, localpart); + pub async fn associate_localpart_email( + &self, + localpart: &str, + email: &Address, + ) -> Result<()> { + match self.get_localpart_for_email(email).await { + | Some(existing_localpart) if existing_localpart != localpart => { + // Another account is already using the supplied email + + Err!(Request(ThreepidInUse("This email address is already in use."))) + }, + | Some(_) => { + // The supplied localpart is already associated with the supplied email, + // no changes are necessary + Ok(()) + }, + | None => { + // The supplied email is not already in use + + let email: &str = email.as_ref(); + self.db.localpart_email.insert(localpart, email); + self.db.email_localpart.insert(email, localpart); + Ok(()) + }, + } } /// Given a localpart, remove its corresponding email address. @@ -203,7 +228,9 @@ impl Service { .await .expect("localpart has no email associated"); self.db.localpart_email.remove(localpart); - self.db.email_localpart.del(&email); + self.db + .email_localpart + .remove(
>::as_ref(&email)); } /// Get the email associated with a localpart, if one exists. @@ -212,12 +239,19 @@ impl Service { .localpart_email .get(localpart) .await - .deserialized() + .deserialized::() .ok() + .map(TryInto::try_into) + .flat_ok() } /// Get the localpart associated with an email, if one exists. pub async fn get_localpart_for_email(&self, email: &Address) -> Option { - self.db.email_localpart.qry(email).await.deserialized().ok() + self.db + .email_localpart + .get(
>::as_ref(email)) + .await + .deserialized() + .ok() } } diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index f7f7ae73d..c81eb2fbb 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -301,7 +301,7 @@ impl Service { match self .services .threepid - .consume_valid_session(sid.as_str(), client_secret.as_str()) + .consume_valid_session(sid.as_str(), client_secret) .await { | Ok(email) => {