feat: Add support for password resets via email

This commit is contained in:
Ginger
2026-03-22 19:34:37 -04:00
committed by Ellis Git
parent f2b7dd6519
commit 0b04757bef
6 changed files with 150 additions and 109 deletions
Generated
+1
View File
@@ -1012,6 +1012,7 @@ dependencies = [
"hyper",
"ipaddress",
"itertools 0.14.0",
"lettre",
"log",
"rand 0.10.0",
"reqwest",
+1
View File
@@ -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
+41 -2
View File
@@ -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<crate::State>,
body: Ruma<request_password_change_token_via_email::v3::Request>,
) -> Result<request_password_change_token_via_email::v3::Response> {
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.
+1
View File
@@ -36,6 +36,7 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
.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)
+1 -1
View File
@@ -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());
+105 -106
View File
@@ -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<AuthFlow>,
params: Box<RawValue>,
identity: Option<Identity>,
) -> 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<UiaaStatus> {
// 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<AuthFlow>,
params: Box<RawValue>,
identity: Option<Identity>,
) -> 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<UiaaStatus> {
// 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