diff --git a/Cargo.lock b/Cargo.lock index cb68967fe..006fd2fa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1012,6 +1012,7 @@ dependencies = [ "hyper", "ipaddress", "itertools 0.14.0", + "lettre", "log", "rand 0.10.0", "reqwest", diff --git a/src/api/Cargo.toml b/src/api/Cargo.toml index 6f1779bc0..564520a6b 100644 --- a/src/api/Cargo.toml +++ b/src/api/Cargo.toml @@ -85,6 +85,7 @@ http-body-util.workspace = true hyper.workspace = true ipaddress.workspace = true itertools.workspace = true +lettre.workspace = true log.workspace = true rand.workspace = true reqwest.workspace = true diff --git a/src/api/client/account/mod.rs b/src/api/client/account/mod.rs index 76beffcd8..8fe5d2eeb 100644 --- a/src/api/client/account/mod.rs +++ b/src/api/client/account/mod.rs @@ -7,6 +7,7 @@ use conduwuit::{ }; use conduwuit_service::Services; use futures::{FutureExt, StreamExt}; +use lettre::{Address, message::Mailbox}; use ruma::{ OwnedRoomId, OwnedUserId, UserId, api::client::{ @@ -14,7 +15,7 @@ use ruma::{ ThirdPartyIdRemovalStatus, change_password, check_registration_token_validity, deactivate, get_3pids, get_username_availability, request_3pid_management_token_via_email, request_3pid_management_token_via_msisdn, - whoami, + request_password_change_token_via_email, whoami, }, uiaa::{AuthFlow, AuthType}, }, @@ -26,7 +27,7 @@ use ruma::{ }, }, }; -use service::uiaa::Identity; +use service::{mailer::messages, uiaa::Identity}; use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH, join_room_by_id_helper}; use crate::Ruma; @@ -222,6 +223,44 @@ pub(crate) async fn change_password_route( Ok(change_password::v3::Response {}) } +/// # `POST /_matrix/client/v3/account/password/email/requestToken` +/// +/// Requests a validation email for the purpose of resetting a user's password. +pub(crate) async fn password_request_token_route( + State(services): State, + body: Ruma, +) -> Result { + let Ok(email) = Address::try_from(body.email.clone()) else { + return Err!(Request(InvalidParam("Invalid email address"))); + }; + + let Some(localpart) = services.threepid.get_localpart_for_email(&email).await else { + return Err!(Request(ThreepidNotFound( + "No account is associated with this email address" + ))); + }; + + let user_id = + OwnedUserId::parse(format!("@{localpart}:{}", services.globals.server_name())).unwrap(); + let display_name = services.users.displayname(&user_id).await.ok(); + + let session = services + .threepid + .send_validation_email( + Mailbox::new(display_name.clone(), email), + |verification_link| messages::PasswordReset { + display_name: display_name.as_deref(), + user_id: &user_id, + verification_link, + }, + &body.client_secret, + body.send_attempt.try_into().unwrap(), + ) + .await?; + + Ok(request_password_change_token_via_email::v3::Response::new(session)) +} + /// # `GET /_matrix/client/v3/account/whoami` /// /// Get `user_id` of the sender user. diff --git a/src/api/router.rs b/src/api/router.rs index 6f20eb490..54e902ea3 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -36,6 +36,7 @@ pub fn build(router: Router, server: &Server) -> Router { .ruma_route(&client::logout_route) .ruma_route(&client::logout_all_route) .ruma_route(&client::change_password_route) + .ruma_route(&client::password_request_token_route) .ruma_route(&client::deactivate_route) .ruma_route(&client::third_party_route) .ruma_route(&client::request_3pid_management_token_via_email_route) diff --git a/src/service/threepid/mod.rs b/src/service/threepid/mod.rs index fcd2f1480..8db752ad7 100644 --- a/src/service/threepid/mod.rs +++ b/src/service/threepid/mod.rs @@ -125,7 +125,7 @@ impl Service { validation_url .query_pairs_mut() - .append_pair("session_id", session.session_id.as_ref()) + .append_pair("session", session.session_id.as_ref()) .append_pair("token", &token.token); let message = prepare_body(validation_url.to_string()); diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index b74197a44..6ee37e38b 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -127,112 +127,6 @@ impl Identity { impl Service { const SESSION_ID_LENGTH: usize = 32; - /// Create a new UIAA session with a random session ID. - /// - /// If information about the user's identity is already known, it may be - /// supplied with the `identity` parameter. Authentication will fail if - /// flows provide different values for known identity information. - /// - /// Returns the info of the newly created session. - async fn create_session( - &self, - flows: Vec, - params: Box, - identity: Option, - ) -> UiaaInfo { - let mut uiaa_sessions = self.uiaa_sessions.lock().await; - - let session_id = utils::random_string(Self::SESSION_ID_LENGTH); - let mut info = UiaaInfo::new(flows, params); - info.session = Some(session_id.clone()); - - uiaa_sessions.insert(session_id, UiaaSession { - info: info.clone(), - identity: identity.unwrap_or_default(), - }); - - info - } - - /// Proceed with UIAA authentication given a client's authorization data. - async fn continue_session(&self, auth: &AuthData, session: &str) -> Result { - // Hold this lock for the entire function to make sure that, if try_auth() - // is called concurrently with the same session, only one call will succeed - let mut uiaa_sessions = self.uiaa_sessions.lock().await; - - let Entry::Occupied(mut session) = uiaa_sessions.entry(session.to_owned()) else { - return Err!(Request(InvalidParam("Invalid session"))); - }; - - if let &AuthData::FallbackAcknowledgement(_) = auth { - // The client is checking if authentication has succeeded out-of-band. This is - // possible if the client is using "fallback auth" (see spec section - // 4.9.1.4), which we don't support (and probably never will, because it's a - // disgusting hack). - - // Return early to tell the client that no, authentication did not succeed while - // it wasn't looking. - return Ok(UiaaStatus::Retry(session.get().info.clone())); - } - - let completed = 'completed: { - let UiaaSession { info, identity } = session.get_mut(); - - let completed_stages: HashSet<_> = info - .completed - .iter() - .map(AuthType::as_str) - .map(ToOwned::to_owned) - .collect(); - - // If the provided stage has already been completed, return early - if completed_stages - .contains(auth.auth_type().expect("auth type should be set").as_str()) - { - return Ok(UiaaStatus::Retry(session.get().info.clone())); - } - - match self.check_stage(auth, identity.clone()).await { - | Ok((completed_stage, updated_identity)) => { - info.completed.push(completed_stage); - *identity = updated_identity; - }, - | Err(error) => { - info.auth_error = Some(error); - }, - } - - // Check all flows to see if any of them succeeded - - for flow in &info.flows { - let flow_stages = flow - .stages - .iter() - .map(AuthType::as_str) - .map(ToOwned::to_owned) - .collect(); - - if completed_stages.is_superset(&flow_stages) { - // All stages in this flow are completed - break 'completed true; - } - } - - // No flows had all their stages completed - break 'completed false; - }; - - if completed { - // This session is complete, remove it and return success - let (_, UiaaSession { identity, .. }) = session.remove_entry(); - - Ok(UiaaStatus::Success(identity)) - } else { - // The client needs to try again, return the updated session - Ok(UiaaStatus::Retry(session.get().info.clone())) - } - } - /// Perform the full UIAA authentication sequence for a route given its /// authentication data. pub async fn authenticate( @@ -288,6 +182,111 @@ impl Service { .await } + /// Create a new UIAA session with a random session ID. + /// + /// If information about the user's identity is already known, it may be + /// supplied with the `identity` parameter. Authentication will fail if + /// flows provide different values for known identity information. + /// + /// Returns the info of the newly created session. + async fn create_session( + &self, + flows: Vec, + params: Box, + identity: Option, + ) -> UiaaInfo { + let mut uiaa_sessions = self.uiaa_sessions.lock().await; + + let session_id = utils::random_string(Self::SESSION_ID_LENGTH); + let mut info = UiaaInfo::new(flows, params); + info.session = Some(session_id.clone()); + + uiaa_sessions.insert(session_id, UiaaSession { + info: info.clone(), + identity: identity.unwrap_or_default(), + }); + + info + } + + /// Proceed with UIAA authentication given a client's authorization data. + async fn continue_session(&self, auth: &AuthData, session: &str) -> Result { + // Hold this lock for the entire function to make sure that, if try_auth() + // is called concurrently with the same session, only one call will succeed + let mut uiaa_sessions = self.uiaa_sessions.lock().await; + + let Entry::Occupied(mut session) = uiaa_sessions.entry(session.to_owned()) else { + return Err!(Request(InvalidParam("Invalid session"))); + }; + + if let &AuthData::FallbackAcknowledgement(_) = auth { + // The client is checking if authentication has succeeded out-of-band. This is + // possible if the client is using "fallback auth" (see spec section + // 4.9.1.4), which we don't support (and probably never will, because it's a + // disgusting hack). + + // Return early to tell the client that no, authentication did not succeed while + // it wasn't looking. + return Ok(UiaaStatus::Retry(session.get().info.clone())); + } + + let completed = 'completed: { + let UiaaSession { info, identity } = session.get_mut(); + + let mut completed_stages: HashSet<_> = info + .completed + .iter() + .map(AuthType::as_str) + .map(ToOwned::to_owned) + .collect(); + + // If the provided stage hasn't already been completed, check it for completion + if !completed_stages + .contains(auth.auth_type().expect("auth type should be set").as_str()) + { + match self.check_stage(auth, identity.clone()).await { + | Ok((completed_stage, updated_identity)) => { + info.auth_error = None; + completed_stages.insert(completed_stage.to_string()); + info.completed.push(completed_stage); + *identity = updated_identity; + }, + | Err(error) => { + info.auth_error = Some(error); + }, + } + } + + // Check all flows to see if any of them succeeded + for flow in &info.flows { + let flow_stages = flow + .stages + .iter() + .map(AuthType::as_str) + .map(ToOwned::to_owned) + .collect(); + + if completed_stages.is_superset(&flow_stages) { + // All stages in this flow are completed + break 'completed true; + } + } + + // No flows had all their stages completed + break 'completed false; + }; + + if completed { + // This session is complete, remove it and return success + let (_, UiaaSession { identity, .. }) = session.remove_entry(); + + Ok(UiaaStatus::Success(identity)) + } else { + // The client needs to try again, return the updated session + Ok(UiaaStatus::Retry(session.get().info.clone())) + } + } + /// Check if the provided authentication data is valid. /// /// Returns the completed stage's type on success and error information on