From baf76cd4dce8316830d808874d9703edfeb55c10 Mon Sep 17 00:00:00 2001
From: Ginger
Date: Wed, 6 May 2026 14:00:41 -0400
Subject: [PATCH] feat: Add support for registering accounts with the web UI
---
Cargo.lock | 2 +
conduwuit-example.toml | 30 +-
src/admin/token/commands.rs | 27 +-
src/admin/user/commands.rs | 137 +----
src/api/client/account/register.rs | 339 ++---------
src/core/config/mod.rs | 41 +-
src/core/error/mod.rs | 1 +
src/service/firstrun/mod.rs | 15 +-
src/service/mailer/mod.rs | 6 +-
src/service/registration_tokens/mod.rs | 5 +-
src/service/users/mod.rs | 272 ++++++++-
src/web/Cargo.toml | 2 +
src/web/pages/account/login.rs | 23 +-
src/web/pages/account/mod.rs | 2 +
src/web/pages/account/register.rs | 541 ++++++++++++++++++
src/web/pages/index.rs | 28 +-
src/web/pages/mod.rs | 8 +-
src/web/pages/resources/common.css | 6 +
src/web/pages/resources/login.css | 63 +-
src/web/pages/templates/device_info.html.j2 | 3 +-
src/web/pages/templates/login.html.j2 | 12 +-
src/web/pages/templates/register.html.j2 | 159 +++++
.../templates/register_confirm_email.html.j2 | 27 +
23 files changed, 1260 insertions(+), 489 deletions(-)
create mode 100644 src/web/pages/account/register.rs
create mode 100644 src/web/pages/templates/register.html.j2
create mode 100644 src/web/pages/templates/register_confirm_email.html.j2
diff --git a/Cargo.lock b/Cargo.lock
index e0925d980..dfbe1f03f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1117,6 +1117,8 @@ dependencies = [
"lettre",
"memory-serve",
"rand 0.10.1",
+ "recaptcha-verify",
+ "reqwest 0.12.28",
"ruma",
"serde",
"serde_json",
diff --git a/conduwuit-example.toml b/conduwuit-example.toml
index 0d0455639..c657fa8eb 100644
--- a/conduwuit-example.toml
+++ b/conduwuit-example.toml
@@ -521,18 +521,6 @@
#
#recaptcha_private_site_key =
-# Policy documents, such as terms and conditions or a privacy policy,
-# which users must agree to when registering an account.
-#
-# Example:
-# ```ignore
-# [global.registration_terms.privacy_policy]
-# en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
-# es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
-# ```
-#
-#registration_terms = {}
-
# Controls whether encrypted rooms and events are allowed.
#
#allow_encryption = true
@@ -1988,6 +1976,24 @@
#
#require_email_for_token_registration = false
+#[global.registration-terms]
+
+# The language code to provide to clients along with the policy documents.
+#
+#language = "en"
+
+# Policy documents, such as terms and conditions or a privacy policy,
+# which users must agree to when registering an account.
+#
+# Example:
+# ```ignore
+# [global.registration_terms.documents.privacy_policy]
+# en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
+# es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
+# ```
+#
+#documents = {}
+
#[global.oauth]
# The compatibility mode to use for OAuth.
diff --git a/src/admin/token/commands.rs b/src/admin/token/commands.rs
index 4ff74a200..bbc58c568 100644
--- a/src/admin/token/commands.rs
+++ b/src/admin/token/commands.rs
@@ -30,14 +30,37 @@ pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
.issue_token(self.sender_or_service_user().into(), expires);
self.write_str(&format!(
- "New registration token issued: `{token}`. {}.",
+ "New registration token issued: `{token}` . {}.",
if let Some(expires) = info.expires {
format!("{expires}")
} else {
"Never expires".to_owned()
}
))
- .await
+ .await?;
+
+ if self
+ .services
+ .config
+ .oauth
+ .compatibility_mode
+ .oauth_available()
+ {
+ self.write_str(&format!(
+ "\nInvite link using this token: {}",
+ self.services
+ .config
+ .get_client_domain()
+ .join(&format!(
+ "{}/account/register/?flow=trusted&token={token}",
+ conduwuit::ROUTE_PREFIX
+ ))
+ .unwrap()
+ ))
+ .await?;
+ }
+
+ Ok(())
}
#[admin_command]
diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs
index dade0c00b..525265a22 100644
--- a/src/admin/user/commands.rs
+++ b/src/admin/user/commands.rs
@@ -1,13 +1,10 @@
-use std::{
- collections::{BTreeMap, HashSet},
- fmt::Write as _,
-};
+use std::collections::{BTreeMap, HashSet};
use api::client::{
full_user_deactivate, leave_room, recreate_push_rules_and_return, remote_leave_room,
};
use conduwuit::{
- Err, Result, debug_warn, error, info,
+ Err, Result, debug_warn, info,
matrix::{Event, pdu::PartialPdu},
utils::{self, ReadyExt},
warn,
@@ -53,130 +50,22 @@ pub(super) async fn list_users(&self) -> Result {
#[admin_command]
pub(super) async fn create_user(&self, username: String, password: Option) -> Result {
// Validate user id
- let user_id = parse_local_user_id(self.services, &username)?;
-
- if let Err(e) = user_id.validate_strict() {
- if self.services.config.emergency_password.is_none() {
- return Err!("Username {user_id} contains disallowed characters or spaces: {e}");
- }
- }
-
- if self.services.users.exists(&user_id).await {
- return Err!("User {user_id} already exists");
- }
-
- let password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
-
- // Create user
- self.services
- .users
- .create(&user_id, Some(HashedPassword::new(&password)?))
- .await?;
-
- // Default to pretty displayname
- let mut displayname = user_id.localpart().to_owned();
-
- // If `new_user_displayname_suffix` is set, registration will push whatever
- // content is set to the user's display name with a space before it
- if !self
+ let user_id = self
.services
- .server
- .config
- .new_user_displayname_suffix
- .is_empty()
- {
- write!(displayname, " {}", self.services.server.config.new_user_displayname_suffix)?;
- }
+ .users
+ .determine_registration_user_id(Some(username), None, None)
+ .await?;
+
+ let password = HashedPassword::new(
+ &password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH)),
+ )?;
self.services
.users
- .set_displayname(&user_id, Some(displayname));
+ .create_local_account(&user_id, password, None)
+ .await;
- // Initial account data
- self.services
- .account_data
- .update(
- None,
- &user_id,
- ruma::events::GlobalAccountDataEventType::PushRules
- .to_string()
- .into(),
- &serde_json::to_value(ruma::events::push_rules::PushRulesEvent::new(
- ruma::events::push_rules::PushRulesEventContent::new(
- ruma::push::Ruleset::server_default(&user_id),
- ),
- ))
- .unwrap(),
- )
- .await?;
-
- if !self.services.server.config.auto_join_rooms.is_empty() {
- for room in &self.services.server.config.auto_join_rooms {
- let Ok(room_id) = self.services.rooms.alias.resolve(room).await else {
- error!(
- %user_id,
- "Failed to resolve room alias to room ID when attempting to auto join {room}, skipping"
- );
- continue;
- };
-
- if !self
- .services
- .rooms
- .state_cache
- .server_in_room(self.services.globals.server_name(), &room_id)
- .await
- {
- warn!(
- "Skipping room {room} to automatically join as we have never joined before."
- );
- continue;
- }
-
- if let Some(room_server_name) = room.server_name() {
- match self
- .services
- .rooms
- .membership
- .join_room(
- &user_id,
- &room_id,
- Some("Automatically joining this room upon registration".to_owned()),
- &[
- self.services.globals.server_name().to_owned(),
- room_server_name.to_owned(),
- ],
- )
- .await
- {
- | Ok(_response) => {
- info!("Automatically joined room {room} for user {user_id}");
- },
- | Err(e) => {
- // don't return this error so we don't fail registrations
- error!(
- "Failed to automatically join room {room} for user {user_id}: {e}"
- );
- self.services
- .admin
- .send_text(&format!(
- "Failed to automatically join room {room} for user {user_id}: \
- {e}"
- ))
- .await;
- },
- }
- }
- }
- }
-
- // we dont add a device since we're not the user, just the creator
-
- // Make the first user to register an administrator and disable first-run mode.
- self.services.firstrun.empower_first_user(&user_id).await?;
-
- self.write_str(&format!("Created user with user_id: {user_id} and password: `{password}`"))
- .await
+ self.write_str(&format!("Created user {user_id}")).await
}
#[admin_command]
diff --git a/src/api/client/account/register.rs b/src/api/client/account/register.rs
index 601dc3615..338012b90 100644
--- a/src/api/client/account/register.rs
+++ b/src/api/client/account/register.rs
@@ -1,17 +1,15 @@
-use std::{collections::HashMap, fmt::Write};
+use std::collections::HashMap;
use axum::extract::State;
use axum_client_ip::ClientIp;
use conduwuit::{
- Err, Result, debug_info, error, info,
+ Err, Result, debug_info, info,
utils::{self},
- warn,
};
use conduwuit_service::Services;
-use futures::{FutureExt, StreamExt};
+use futures::StreamExt;
use lettre::{Address, message::Mailbox};
use ruma::{
- OwnedUserId, UserId,
api::client::{
account::{
register::{self, LoginType, RegistrationKind},
@@ -20,11 +18,6 @@ use ruma::{
uiaa::{AuthFlow, AuthType},
},
assign,
- events::{
- GlobalAccountDataEventType, push_rules::PushRulesEvent,
- room::message::RoomMessageEventContent,
- },
- push,
};
use serde_json::value::RawValue;
use service::{mailer::messages, users::HashedPassword};
@@ -32,8 +25,6 @@ use service::{mailer::messages, users::HashedPassword};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
-const RANDOM_USER_ID_LENGTH: usize = 10;
-
/// # `POST /_matrix/client/v3/register`
///
/// Register an account on this homeserver.
@@ -52,8 +43,6 @@ pub(crate) async fn register_route(
return Err!(Request(GuestAccessForbidden("Guests may not register on this server.")));
}
- let emergency_mode_enabled = services.config.emergency_password.is_some();
-
// Allow registration if it's enabled in the config file or if this is the first
// run (so the first user account can be created)
let allow_registration =
@@ -71,99 +60,51 @@ pub(crate) async fn register_route(
)));
}
- let identity = if body.identity.is_some() {
- // Appservices can skip auth
- None
+ let user_id = if body.body.login_type == Some(LoginType::ApplicationService) {
+ let Some(appservice_info) = &body.identity else {
+ return Err!(Request(Forbidden(
+ "Only appservices can use the appservice login type."
+ )));
+ };
+
+ let user_id = services
+ .users
+ .determine_registration_user_id(body.username.clone(), None, Some(appservice_info))
+ .await?;
+
+ services.users.create(&user_id, None).await?;
+
+ user_id
} else {
// Perform UIAA to determine the user's identity
let (flows, params) = create_registration_uiaa_session(&services).await?;
- Some(
- services
- .uiaa
- .authenticate(&body.auth, flows, params, None)
- .await?,
- )
- };
-
- // If the user didn't supply a username but did supply an email, use
- // the email's user as their initial localpart to avoid falling back to
- // a randomly generated localpart
- let supplied_username = body.username.clone().or_else(|| {
- if let Some(identity) = &identity
- && let Some(email) = &identity.email
- {
- Some(email.user().to_owned())
- } else {
- None
- }
- });
-
- let user_id =
- determine_registration_user_id(&services, supplied_username, emergency_mode_enabled)
+ let identity = services
+ .uiaa
+ .authenticate(&body.auth, flows, params, None)
.await?;
- if body.body.login_type == Some(LoginType::ApplicationService) {
- // For appservice logins, make sure that the user ID is in the appservice's
- // namespace
+ let password = if let Some(password) = &body.password {
+ HashedPassword::new(password)?
+ } else {
+ return Err!(Request(InvalidParam("A password must be provided.")));
+ };
- match body.identity {
- | Some(ref info) =>
- if !info.is_user_match(&user_id) && !emergency_mode_enabled {
- return Err!(Request(Exclusive(
- "Username is not in an appservice namespace."
- )));
- },
- | _ => {
- return Err!(Request(MissingToken("Missing appservice token.")));
- },
- }
- } else if services.appservice.is_exclusive_user_id(&user_id).await && !emergency_mode_enabled
- {
- // For non-appservice logins, ban user IDs which are in an appservice's
- // namespace (unless emergency mode is enabled)
- return Err!(Request(Exclusive("Username is reserved by an appservice.")));
- }
+ let user_id = services
+ .users
+ .determine_registration_user_id(body.username.clone(), identity.email.as_ref(), None)
+ .await?;
- let password = if body.identity.is_some() {
- None
- } else if let Some(password) = body.password.as_deref() {
- Some(HashedPassword::new(password)?)
- } else {
- return Err!(Request(InvalidParam("A password must be provided")));
+ services
+ .users
+ .create_local_account(&user_id, password, identity.email)
+ .await;
+
+ user_id
};
- // Create user
- services.users.create(&user_id, password).await?;
-
- // Set an initial display name
- let mut displayname = user_id.localpart().to_owned();
-
- // Apply the new user displayname suffix, if it's set
- if !services.globals.new_user_displayname_suffix().is_empty() && body.identity.is_none() {
- write!(displayname, " {}", services.server.config.new_user_displayname_suffix)?;
- }
-
- services
- .users
- .set_displayname(&user_id, Some(displayname.clone()));
-
- // Initial account data
- services
- .account_data
- .update(
- None,
- &user_id,
- GlobalAccountDataEventType::PushRules.to_string().into(),
- &serde_json::to_value(PushRulesEvent::new(
- push::Ruleset::server_default(&user_id).into(),
- ))
- .expect("should be able to serialize push rules"),
- )
- .await?;
-
- // Generate new device id if the user didn't specify one
let (token, device) = if !body.inhibit_login {
+ // Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
@@ -190,118 +131,7 @@ pub(crate) async fn register_route(
(None, None)
};
- debug_info!(%user_id, ?device, "User account was created");
-
- // If the user registered with an email, associate it with their account.
- if let Some(identity) = identity
- && let Some(email) = identity.email
- {
- // This may fail if the email is already in use, but we already check for that
- // in `/requestToken`, so ignoring the error is acceptable here in the rare case
- // that an email is sniped by another user between the `/requestToken` request
- // and the `/register` request.
- let _ = services
- .threepid
- .associate_localpart_email(user_id.localpart(), &email)
- .await;
- }
-
- let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
-
- if body.identity.is_none() {
- if !device_display_name.is_empty() {
- let notice = format!(
- "New user \"{user_id}\" registered on this server from IP {client} and device \
- display name \"{device_display_name}\""
- );
-
- info!("{notice}");
- if services.server.config.admin_room_notices {
- services.admin.notice(¬ice).await;
- }
- } else {
- let notice = format!("New user \"{user_id}\" registered on this server.");
-
- info!("{notice}");
- if services.server.config.admin_room_notices {
- services.admin.notice(¬ice).await;
- }
- }
- }
-
- // Make the first user to register an administrator and disable first-run mode.
- let was_first_user = services.firstrun.empower_first_user(&user_id).await?;
-
- // If the registering user was not the first and we're suspending users on
- // register, suspend them.
- if !was_first_user && services.config.suspend_on_register {
- // Note that we can still do auto joins for suspended users
- services
- .users
- .suspend_account(&user_id, &services.globals.server_user)
- .await;
- // And send an @room notice to the admin room, to prompt admins to review the
- // new user and ideally unsuspend them if deemed appropriate.
- if services.server.config.admin_room_notices {
- services
- .admin
- .send_loud_message(RoomMessageEventContent::text_plain(format!(
- "User {user_id} has been suspended as they are not the first user on this \
- server. Please review and unsuspend them if appropriate."
- )))
- .await
- .ok();
- }
- }
-
- if body.identity.is_none() && !services.server.config.auto_join_rooms.is_empty() {
- for room in &services.server.config.auto_join_rooms {
- let Ok(room_id) = services.rooms.alias.resolve(room).await else {
- error!(
- "Failed to resolve room alias to room ID when attempting to auto join \
- {room}, skipping"
- );
- continue;
- };
-
- if !services
- .rooms
- .state_cache
- .server_in_room(services.globals.server_name(), &room_id)
- .await
- {
- warn!(
- "Skipping room {room} to automatically join as we have never joined before."
- );
- continue;
- }
-
- if let Some(room_server_name) = room.server_name() {
- match services
- .rooms
- .membership
- .join_room(
- &user_id,
- &room_id,
- Some("Automatically joining this room upon registration".to_owned()),
- &[services.globals.server_name().to_owned(), room_server_name.to_owned()],
- )
- .boxed()
- .await
- {
- | Err(e) => {
- // don't return this error so we don't fail registrations
- error!(
- "Failed to automatically join room {room} for user {user_id}: {e}"
- );
- },
- | _ => {
- info!("Automatically joined room {room} for user {user_id}");
- },
- }
- }
- }
- }
+ debug_info!(%user_id, ?device, "New account created via legacy registration");
Ok(assign!(register::v3::Response::new(user_id), {
access_token: token,
@@ -373,21 +203,21 @@ async fn create_registration_uiaa_session(
// Require all users to agree to the terms and conditions, if configured
let terms = &services.config.registration_terms;
- if !terms.is_empty() {
- let mut terms =
- serde_json::to_value(terms.clone()).expect("failed to serialize terms");
+ if !terms.documents.is_empty() {
+ let mut terms_map = HashMap::new();
- // Insert a dummy `version` field
- for (_, documents) in terms.as_object_mut().unwrap() {
- let documents = documents.as_object_mut().unwrap();
-
- documents.insert("version".to_owned(), "latest".into());
+ for (id, document) in &terms.documents {
+ terms_map.insert(id.to_owned(), serde_json::json!({
+ terms.language.clone(): serde_json::to_value(document).expect("should be able to serialize document")
+ }));
}
+ terms_map.insert("version".to_owned(), "latest".into());
+
params.insert(
AuthType::Terms.as_str().to_owned(),
serde_json::json!({
- "policies": terms,
+ "policies": terms_map,
}),
);
@@ -420,81 +250,6 @@ async fn create_registration_uiaa_session(
Ok((flows, params))
}
-async fn determine_registration_user_id(
- services: &Services,
- supplied_username: Option,
- emergency_mode_enabled: bool,
-) -> Result {
- if let Some(supplied_username) = supplied_username {
- // The user gets to pick their username. Do some validation to make sure it's
- // acceptable.
-
- // Don't allow registration with forbidden usernames.
- if services
- .globals
- .forbidden_usernames()
- .is_match(&supplied_username)
- && !emergency_mode_enabled
- {
- return Err!(Request(Forbidden("Username is forbidden")));
- }
-
- // Create and validate the user ID
- let user_id = match UserId::parse_with_server_name(
- &supplied_username,
- services.globals.server_name(),
- ) {
- | Ok(user_id) => {
- if let Err(e) = user_id.validate_strict() {
- // Unless we are in emergency mode, we should follow synapse's behaviour on
- // not allowing things like spaces and UTF-8 characters in usernames
- if !emergency_mode_enabled {
- return Err!(Request(InvalidUsername(debug_warn!(
- "Username {supplied_username} contains disallowed characters or \
- spaces: {e}"
- ))));
- }
- }
-
- // Don't allow registration with user IDs that aren't local
- if !services.globals.user_is_local(&user_id) {
- return Err!(Request(InvalidUsername(
- "Username {supplied_username} is not local to this server"
- )));
- }
-
- user_id
- },
- | Err(e) => {
- return Err!(Request(InvalidUsername(debug_warn!(
- "Username {supplied_username} is not valid: {e}"
- ))));
- },
- };
-
- if services.users.exists(&user_id).await {
- return Err!(Request(UserInUse("User ID is not available.")));
- }
-
- Ok(user_id)
- } else {
- // The user didn't specify a username. Generate a username for
- // them.
-
- loop {
- let user_id = UserId::parse_with_server_name(
- utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
- services.globals.server_name(),
- )
- .unwrap();
-
- if !services.users.exists(&user_id).await {
- break Ok(user_id);
- }
- }
- }
-}
-
/// # `POST /_matrix/client/v3/register/email/requestToken`
///
/// Requests a validation email for the purpose of registering a new account.
diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs
index a22cc3003..080aaead4 100644
--- a/src/core/config/mod.rs
+++ b/src/core/config/mod.rs
@@ -4,7 +4,7 @@ pub mod manager;
pub mod proxy;
use std::{
- collections::{BTreeMap, BTreeSet, HashMap},
+ collections::{BTreeMap, BTreeSet},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
};
@@ -655,19 +655,9 @@ pub struct Config {
/// even if `recaptcha_site_key` is set.
pub recaptcha_private_site_key: Option,
- /// Policy documents, such as terms and conditions or a privacy policy,
- /// which users must agree to when registering an account.
- ///
- /// Example:
- /// ```ignore
- /// [global.registration_terms.privacy_policy]
- /// en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
- /// es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
- /// ```
- ///
- /// default: {}
+ /// display: nested
#[serde(default)]
- pub registration_terms: HashMap>,
+ pub registration_terms: RegistrationTerms,
/// display: nested
#[serde(default)]
@@ -2355,6 +2345,31 @@ pub struct SmtpConfig {
pub require_email_for_token_registration: bool,
}
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+#[config_example_generator(
+ filename = "conduwuit-example.toml",
+ section = "global.registration-terms",
+ optional = "true"
+)]
+pub struct RegistrationTerms {
+ /// The language code to provide to clients along with the policy documents.
+ ///
+ /// default: "en"
+ pub language: String,
+ /// Policy documents, such as terms and conditions or a privacy policy,
+ /// which users must agree to when registering an account.
+ ///
+ /// Example:
+ /// ```ignore
+ /// [global.registration_terms.documents.privacy_policy]
+ /// en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
+ /// es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
+ /// ```
+ ///
+ /// default: {}
+ pub documents: BTreeMap,
+}
+
/// A policy document for use with a m.login.terms stage.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TermsDocument {
diff --git a/src/core/error/mod.rs b/src/core/error/mod.rs
index 703726043..d61867b01 100644
--- a/src/core/error/mod.rs
+++ b/src/core/error/mod.rs
@@ -161,6 +161,7 @@ impl Error {
match self {
| Self::Federation(origin, error) => format!("Answer from {origin}: {error}"),
| Self::Ruma(error) => response::ruma_error_message(error),
+ | Self::Request(_, message, _) => message.clone().into_owned(),
| _ => format!("{self}"),
}
}
diff --git a/src/service/firstrun/mod.rs b/src/service/firstrun/mod.rs
index 75e1b69b5..85759c4b9 100644
--- a/src/service/firstrun/mod.rs
+++ b/src/service/firstrun/mod.rs
@@ -120,7 +120,7 @@ impl Service {
///
/// Returns Ok(true) if the specified user was the first user, and Ok(false)
/// if they were not.
- pub async fn empower_first_user(&self, user: &UserId) -> Result {
+ pub async fn empower_first_user(&self, user: &UserId) -> bool {
#[derive(Template)]
#[template(path = "welcome.md")]
struct WelcomeMessage<'a> {
@@ -130,10 +130,14 @@ impl Service {
// If first run mode isn't active, do nothing.
if !self.disable_first_run() {
- return Ok(false);
+ return false;
}
- self.services.admin.make_user_admin(user).boxed().await?;
+ self.services
+ .admin
+ .make_user_admin(user)
+ .await
+ .expect("should have been able to empower the first user");
// Send the welcome message
let welcome_message = WelcomeMessage {
@@ -146,11 +150,12 @@ impl Service {
self.services
.admin
.send_loud_message(RoomMessageEventContent::text_markdown(welcome_message))
- .await?;
+ .await
+ .expect("should have been able to send welcome message");
info!("{user} has been invited to the admin room as the first user.");
- Ok(true)
+ true
}
/// Get the single-use registration token which may be used to create the
diff --git a/src/service/mailer/mod.rs b/src/service/mailer/mod.rs
index c336164b5..c959c397a 100644
--- a/src/service/mailer/mod.rs
+++ b/src/service/mailer/mod.rs
@@ -92,8 +92,8 @@ impl Mailer<'_> {
let message = MessageBuilder::new()
.from(self.sender.clone())
- .to(recipient)
- .subject(subject)
+ .to(recipient.clone())
+ .subject(subject.clone())
.date_now()
.header(ContentType::TEXT_PLAIN)
.body(body)
@@ -104,6 +104,8 @@ impl Mailer<'_> {
.await
.map_err(|err: TransportError| err!("Failed to send message: {err}"))?;
+ info!(recipient = recipient.to_string(), ?subject, "Email sent");
+
Ok(())
}
}
diff --git a/src/service/registration_tokens/mod.rs b/src/service/registration_tokens/mod.rs
index 6c5bb4952..a6644cd46 100644
--- a/src/service/registration_tokens/mod.rs
+++ b/src/service/registration_tokens/mod.rs
@@ -10,6 +10,7 @@ use futures::{
stream::{iter, once},
};
use ruma::OwnedUserId;
+use serde::{Deserialize, Serialize};
use crate::{Dep, config, firstrun};
@@ -27,7 +28,7 @@ struct Services {
}
/// A validated registration token which may be used to create an account.
-#[derive(Debug)]
+#[derive(Debug, Deserialize, Serialize)]
pub struct ValidToken {
pub token: String,
pub source: ValidTokenSource,
@@ -44,7 +45,7 @@ impl PartialEq for ValidToken {
}
/// The source of a valid database token.
-#[derive(Debug)]
+#[derive(Debug, Deserialize, Serialize)]
pub enum ValidTokenSource {
/// The static token set in the homeserver's config file.
Config,
diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs
index 0e98c8644..6a224be35 100644
--- a/src/service/users/mod.rs
+++ b/src/service/users/mod.rs
@@ -9,11 +9,13 @@ use std::{
};
use conduwuit::{
- Err, Error, Result, Server, debug_error, debug_warn, err, trace,
+ Err, Error, Result, debug_error, debug_warn, err, info, trace,
utils::{self, ReadyExt, stream::TryIgnore, string::Unquoted},
+ warn,
};
use database::{Deserialized, Ignore, Interfix, Json, Map};
-use futures::{Stream, StreamExt, TryFutureExt};
+use futures::{FutureExt, Stream, StreamExt, TryFutureExt};
+use lettre::Address;
use ruma::{
DeviceId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId, OneTimeKeyName,
OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedOneTimeKeyId, OwnedUserId, RoomId, UInt, UserId,
@@ -24,15 +26,24 @@ use ruma::{
encryption::{CrossSigningKey, DeviceKeys, OneTimeKey},
events::{
AnyToDeviceEvent, GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent,
+ push_rules::PushRulesEvent, room::message::RoomMessageEventContent,
},
+ push::Ruleset,
serde::Raw,
uint,
};
use ruminuwuity::invite_permission_config::{FilterLevel, InvitePermissionConfigEvent};
use serde::{Deserialize, Serialize};
use serde_json::json;
+use tracing::error;
-use crate::{Dep, account_data, admin, appservice, globals, oauth, rooms};
+use crate::{
+ Dep, account_data, admin,
+ appservice::{self, RegistrationInfo},
+ config, firstrun, globals, oauth,
+ rooms::{self, alias, membership},
+ threepid,
+};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSuspension {
@@ -47,6 +58,7 @@ pub struct UserSuspension {
/// A password hash. This is only for use when setting a user's password,
/// if the hash needs to be kept around for a while without keeping the password
/// in memory.
+#[derive(Serialize, Deserialize)]
pub struct HashedPassword(String);
impl HashedPassword {
@@ -69,14 +81,18 @@ pub struct Service {
}
struct Services {
- server: Arc,
account_data: Dep,
admin: Dep,
+ alias: Dep,
appservice: Dep,
+ config: Dep,
+ firstrun: Dep,
globals: Dep,
+ membership: Dep,
oauth: Dep,
state_accessor: Dep,
state_cache: Dep,
+ threepid: Dep,
}
struct Data {
@@ -111,15 +127,19 @@ impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result> {
Ok(Arc::new(Self {
services: Services {
- server: args.server.clone(),
account_data: args.depend::("account_data"),
admin: args.depend::("admin"),
+ alias: args.depend::("alias"),
appservice: args.depend::("appservice"),
+ config: args.depend::("config"),
+ firstrun: args.depend::("firstrun"),
globals: args.depend::("globals"),
+ membership: args.depend::("membership"),
oauth: args.depend::("oauth"),
state_accessor: args
.depend::("rooms::state_accessor"),
state_cache: args.depend::("rooms::state_cache"),
+ threepid: args.depend::("threepid"),
},
db: Data {
keychangeid_userid: args.db["keychangeid_userid"].clone(),
@@ -208,12 +228,238 @@ impl Service {
Ok(())
}
- // /// Create a new account for a local human or bot user.
- // pub async fn create_local_account(
- // &self,
- // username: String,
- // password:
- // )
+ /// Create a new account for a local human or bot user.
+ pub async fn create_local_account(
+ &self,
+ user_id: &UserId,
+ password: HashedPassword,
+ email: Option,
+ ) {
+ self.create(user_id, Some(password))
+ .await
+ .expect("should be able to save a new local user. what happened?");
+
+ // Set an initial display name
+ {
+ let mut displayname = user_id.localpart().to_owned();
+
+ let suffix = &self.services.config.new_user_displayname_suffix;
+ if !suffix.is_empty() {
+ displayname.push_str(suffix);
+ }
+
+ self.set_displayname(user_id, Some(displayname));
+ };
+
+ // Set default push rules
+ self.services
+ .account_data
+ .update(
+ None,
+ user_id,
+ GlobalAccountDataEventType::PushRules.to_string().into(),
+ &serde_json::to_value(PushRulesEvent::new(
+ Ruleset::server_default(user_id).into(),
+ ))
+ .expect("should be able to serialize push rules"),
+ )
+ .await
+ .expect("should be able to update account data");
+
+ // If the user registered with an email, associate it with their account.
+ if let Some(email) = email {
+ // This may fail if the email is already in use, but we should have already
+ // checked that when we sent the validation email, so ignoring the error is
+ // acceptable here in the rare case that an email is sniped by another user
+ // between the validation email being sent and the account being created.
+ let _ = self
+ .services
+ .threepid
+ .associate_localpart_email(user_id.localpart(), &email)
+ .await;
+ }
+
+ // Attempt to empower the first user and disable first-run mode.
+ let was_first_user = self.services.firstrun.empower_first_user(user_id).await;
+
+ // If the registering user was not the first and we're suspending users on
+ // register, suspend them.
+ if !was_first_user && self.services.config.suspend_on_register {
+ // Note that we can still do auto joins for suspended users
+ self.suspend_account(user_id, &self.services.globals.server_user)
+ .await;
+
+ // And send an @room notice to the admin room, to prompt admins to review the
+ // new user and ideally unsuspend them if deemed appropriate.
+ if self.services.config.admin_room_notices {
+ self.services
+ .admin
+ .send_loud_message(RoomMessageEventContent::text_plain(format!(
+ "User {user_id} has been suspended as they are not the first user on \
+ this server. Please review and unsuspend them if appropriate."
+ )))
+ .await
+ .ok();
+ }
+ }
+
+ // Autojoin the user to the configured autojoin rooms
+ for room in &self.services.config.auto_join_rooms {
+ let Ok(room_id) = self.services.alias.resolve(room).await else {
+ error!(
+ "Failed to resolve room alias to room ID when attempting to auto join \
+ {room}, skipping"
+ );
+ continue;
+ };
+
+ if !self
+ .services
+ .state_cache
+ .server_in_room(self.services.globals.server_name(), &room_id)
+ .await
+ {
+ warn!(
+ "Skipping room {room} to automatically join as we have never joined before."
+ );
+ continue;
+ }
+
+ if let Some(room_server_name) = room.server_name() {
+ match self
+ .services
+ .membership
+ .join_room(
+ user_id,
+ &room_id,
+ Some("Automatically joining this room upon registration".to_owned()),
+ &[
+ self.services.globals.server_name().to_owned(),
+ room_server_name.to_owned(),
+ ],
+ )
+ .boxed()
+ .await
+ {
+ | Err(e) => {
+ // don't return this error so we don't fail registrations
+ error!(
+ "Failed to automatically join room {room} for user {user_id}: {e}"
+ );
+ },
+ | _ => {
+ info!("Automatically joined room {room} for user {user_id}");
+ },
+ }
+ }
+ }
+
+ info!("Created new user account for {user_id}");
+ }
+
+ pub async fn determine_registration_user_id(
+ &self,
+ supplied_username: Option,
+ email: Option<&Address>,
+ appservice_info: Option<&RegistrationInfo>,
+ ) -> Result {
+ const RANDOM_USER_ID_LENGTH: usize = 10;
+
+ let emergency_mode_enabled = self.services.config.emergency_password.is_some();
+
+ let supplied_username = supplied_username.or_else(|| {
+ // If the user didn't supply a username but did supply an email, use
+ // the email's user part to avoid falling back to a random username
+ email.map(|address| address.user().to_owned())
+ });
+
+ if let Some(supplied_username) = supplied_username {
+ // The user gets to pick their username. Do some validation to make sure it's
+ // acceptable.
+
+ // Don't allow registration with forbidden usernames.
+ if self
+ .services
+ .globals
+ .forbidden_usernames()
+ .is_match(&supplied_username)
+ && !emergency_mode_enabled
+ {
+ return Err!(Request(Forbidden("Username is forbidden")));
+ }
+
+ // Create and validate the user ID
+ let user_id = match UserId::parse_with_server_name(
+ &supplied_username,
+ self.services.globals.server_name(),
+ ) {
+ | Ok(user_id) => {
+ if let Err(e) = user_id.validate_strict() {
+ // Unless we are in emergency mode, we should follow synapse's behaviour
+ // on not allowing things like spaces and UTF-8 characters in
+ // usernames
+ if !emergency_mode_enabled {
+ return Err!(Request(InvalidUsername(debug_warn!(
+ "Username {supplied_username} contains disallowed characters or \
+ spaces: {e}"
+ ))));
+ }
+ }
+
+ // Don't allow registration with user IDs that aren't local
+ if !self.services.globals.user_is_local(&user_id) {
+ return Err!(Request(InvalidUsername(
+ "Username {supplied_username} is not local to this server"
+ )));
+ }
+
+ user_id
+ },
+ | Err(e) => {
+ return Err!(Request(InvalidUsername(debug_warn!(
+ "Username {supplied_username} is not valid: {e}"
+ ))));
+ },
+ };
+
+ if self.exists(&user_id).await {
+ return Err!(Request(UserInUse("User ID is not available.")));
+ }
+
+ // Check that the user ID is/is not in an appservice's namespace
+ if let Some(appservice_info) = appservice_info {
+ if !appservice_info.is_user_match(&user_id) && !emergency_mode_enabled {
+ return Err!(Request(Exclusive(
+ "Username is not in this appservice's namespace."
+ )));
+ }
+ } else if self
+ .services
+ .appservice
+ .is_exclusive_user_id(&user_id)
+ .await && !emergency_mode_enabled
+ {
+ return Err!(Request(Exclusive("Username is reserved by an appservice.")));
+ }
+
+ Ok(user_id)
+ } else {
+ // The user didn't specify a username. Generate a username for
+ // them.
+
+ loop {
+ let user_id = UserId::parse_with_server_name(
+ utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
+ self.services.globals.server_name(),
+ )
+ .unwrap();
+
+ if !self.exists(&user_id).await {
+ break Ok(user_id);
+ }
+ }
+ }
+ }
/// Deactivate account
pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> {
@@ -1308,7 +1554,7 @@ impl Service {
pub fn create_openid_token(&self, user_id: &UserId, token: &str) -> Result {
use std::num::Saturating as Sat;
- let expires_in = self.services.server.config.openid_token_ttl;
+ let expires_in = self.services.config.openid_token_ttl;
let expires_at = Sat(utils::millis_since_unix_epoch()) + Sat(expires_in) * Sat(1000);
let mut value = expires_at.0.to_be_bytes().to_vec();
@@ -1352,7 +1598,7 @@ impl Service {
pub fn create_login_token(&self, user_id: &UserId, token: &str) -> u64 {
use std::num::Saturating as Sat;
- let expires_in = self.services.server.config.login_token_ttl;
+ let expires_in = self.services.config.login_token_ttl;
let expires_at = Sat(utils::millis_since_unix_epoch()) + Sat(expires_in);
let value = (expires_at.0, user_id);
diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml
index 2137e6372..010b50bad 100644
--- a/src/web/Cargo.toml
+++ b/src/web/Cargo.toml
@@ -46,6 +46,8 @@ tower-sessions = { version = "0.15.0", default-features = false, features = ["ax
tower-sessions-core = { version = "0.15.0", features = ["deletion-task"] }
serde_urlencoded.workspace = true
url.workspace = true
+recaptcha-verify = { version = "0.2.0", default-features = false }
+reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated
[build-dependencies]
memory-serve = "2.1.0"
diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs
index d3e50bbd5..29d2afeed 100644
--- a/src/web/pages/account/login.rs
+++ b/src/web/pages/account/login.rs
@@ -17,7 +17,11 @@ use tower_sessions::Session;
use crate::{
ROUTE_PREFIX, WebError,
extract::{Expect, PostForm},
- pages::{GET_POST, Result, TemplateContext, components::UserCard},
+ pages::{
+ GET_POST, Result, TemplateContext,
+ account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status},
+ components::UserCard,
+ },
response,
session::{LoginQuery, LoginTarget, User, UserSession},
template,
@@ -41,6 +45,7 @@ template! {
enum LoginBody {
Unauthenticated {
server_name: String,
+ registration_available: bool,
},
Authenticated {
user_card: UserCard,
@@ -65,8 +70,20 @@ async fn route_login(
let user_id = user.into_session().map(|session| session.user_id);
let body = match &user_id {
- | None => LoginBody::Unauthenticated {
- server_name: services.globals.server_name().to_string(),
+ | None => {
+ let (trusted_flow_status, untrusted_flow_status) =
+ registration_flow_status(&services).await;
+
+ LoginBody::Unauthenticated {
+ server_name: services.globals.server_name().to_string(),
+ registration_available: matches!(
+ trusted_flow_status,
+ TrustedFlowStatus::Available
+ ) || matches!(
+ untrusted_flow_status,
+ UntrustedFlowStatus::Available { .. }
+ ),
+ }
},
| Some(user_id) => {
if !reauthenticate {
diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs
index 10dccb6d1..fec002337 100644
--- a/src/web/pages/account/mod.rs
+++ b/src/web/pages/account/mod.rs
@@ -31,6 +31,7 @@ pub(crate) mod device;
pub(crate) mod email;
pub(crate) mod login;
pub(crate) mod password;
+pub(crate) mod register;
pub(crate) fn build() -> Router {
#[allow(clippy::wildcard_imports)]
@@ -45,6 +46,7 @@ pub(crate) fn build() -> Router {
.nest("/cross_signing_reset", cross_signing_reset::build())
.nest("/deactivate", deactivate::build())
.nest("/device/", device::build())
+ .nest("/register/", register::build())
}
#[derive(Deserialize, Serialize)]
diff --git a/src/web/pages/account/register.rs b/src/web/pages/account/register.rs
new file mode 100644
index 000000000..c4a873b3f
--- /dev/null
+++ b/src/web/pages/account/register.rs
@@ -0,0 +1,541 @@
+use std::{collections::BTreeMap, time::SystemTime};
+
+use axum::{
+ Extension, Router,
+ extract::{Query, State},
+ response::{Redirect, Response},
+ routing::{get, on},
+};
+use conduwuit_core::{config::TermsDocument, warn};
+use conduwuit_service::{
+ mailer::messages, registration_tokens::ValidToken, users::HashedPassword,
+};
+use futures::StreamExt;
+use lettre::{Address, message::Mailbox};
+use ruma::{ClientSecret, OwnedClientSecret, OwnedServerName, OwnedSessionId, OwnedUserId};
+use serde::{Deserialize, Serialize};
+use tower_sessions::Session;
+use validator::{Validate, ValidationError, ValidationErrors};
+
+use crate::{
+ WebError,
+ extract::{Expect, PostForm},
+ pages::{GET_POST, Result, TemplateContext, account::ThreepidQuery},
+ response,
+ session::{LoginTarget, User, UserSession},
+ template,
+};
+
+const COMPLETED_REGISTRATION_KEY: &str = "completed_registration";
+
+pub(crate) fn build() -> Router {
+ Router::new()
+ .route("/", on(GET_POST, route_register))
+ .route("/validate", get(get_register_confirm_email))
+}
+
+template! {
+ struct Register use "register.html.j2" {
+ server_name: OwnedServerName,
+ body: RegisterBody
+ }
+}
+
+#[derive(Debug)]
+enum RegisterBody {
+ Unavailable,
+ UsernamePrompt {
+ allow_federation: bool,
+ trusted_flow_status: TrustedFlowStatus,
+ untrusted_flow_status: UntrustedFlowStatus,
+ username_error: Option,
+ },
+ DetailsPrompt {
+ username: Option,
+ require_email: bool,
+ flow: RegistrationFlowParameters,
+ terms: BTreeMap,
+ validation_errors: ValidationErrors,
+ },
+}
+
+#[derive(Debug)]
+pub(super) enum TrustedFlowStatus {
+ Unavailable,
+ Available,
+}
+
+#[derive(Debug)]
+pub(super) enum UntrustedFlowStatus {
+ Unavailable,
+ Available {
+ require_email: bool,
+ },
+}
+
+#[derive(Deserialize)]
+struct RegistrationQuery {
+ username: Option,
+ token: Option,
+ flow: Option,
+ #[serde(default)]
+ from_landing: bool,
+}
+
+#[derive(Clone, Copy, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum RequestedRegistrationFlow {
+ Untrusted,
+ Trusted,
+}
+
+#[derive(Debug)]
+enum RegistrationFlowParameters {
+ Untrusted {
+ recaptcha_sitekey: Option,
+ },
+ Trusted {
+ registration_token: Option,
+ },
+}
+
+#[derive(Deserialize, Validate)]
+struct RegistrationForm {
+ flow: RequestedRegistrationFlow,
+ username: String,
+ email: Option,
+ #[validate(length(min = 1, message = "Password cannot be empty"))]
+ password: String,
+ #[validate(must_match(other = "password", message = "Passwords must match"))]
+ confirm_password: String,
+ registration_token: Option,
+ #[serde(rename = "g-recaptcha-response")]
+ recaptcha_response: Option,
+}
+
+#[derive(Deserialize, Serialize)]
+struct CompletedRegistration {
+ user_id: OwnedUserId,
+ password_hash: HashedPassword,
+ registration_token: Option,
+}
+
+async fn route_register(
+ State(services): State,
+ Extension(context): Extension,
+ session_store: Session,
+ Expect(Query(query)): Expect>,
+ PostForm(form): PostForm,
+) -> Result {
+ let validation_errors = if let Some(form) = form {
+ match form.validate() {
+ | Ok(()) => {
+ match begin_registration(&services, context.clone(), session_store, form).await? {
+ | Ok(response) => return Ok(response),
+ | Err(err) => err,
+ }
+ },
+ | Err(err) => err,
+ }
+ } else {
+ ValidationErrors::new()
+ };
+
+ let (trusted_flow_status, untrusted_flow_status) = registration_flow_status(&services).await;
+
+ if matches!(trusted_flow_status, TrustedFlowStatus::Unavailable)
+ && matches!(untrusted_flow_status, UntrustedFlowStatus::Unavailable)
+ {
+ return response!(Register::new(
+ context,
+ services.globals.server_name().to_owned(),
+ RegisterBody::Unavailable
+ ));
+ }
+
+ if query.username.is_some() && query.flow.is_none() {
+ return response!(WebError::BadRequest(
+ "A flow must be provided if a username is provided".to_owned()
+ ));
+ }
+
+ if let Some(username) = &query.username
+ && query.from_landing
+ {
+ // Check if the username is valid and available before showing the details form
+ // to keep the user from wasting their time
+
+ if let Err(err) = services
+ .users
+ .determine_registration_user_id(Some(username.to_owned()), None, None)
+ .await
+ {
+ return response!(Register::new(
+ context,
+ services.globals.server_name().to_owned(),
+ RegisterBody::UsernamePrompt {
+ allow_federation: services.config.allow_federation,
+ trusted_flow_status,
+ untrusted_flow_status,
+ username_error: Some(err.message()),
+ }
+ ));
+ }
+ }
+
+ let body = {
+ let terms = services.config.registration_terms.documents.clone();
+
+ match (query.flow, query.token) {
+ | (Some(RequestedRegistrationFlow::Trusted), token) | (_, token @ Some(_)) =>
+ RegisterBody::DetailsPrompt {
+ username: query.username,
+ require_email: services
+ .config
+ .smtp
+ .as_ref()
+ .is_some_and(|smtp| smtp.require_email_for_token_registration),
+ flow: RegistrationFlowParameters::Trusted { registration_token: token },
+ terms,
+ validation_errors,
+ },
+ | (Some(RequestedRegistrationFlow::Untrusted), _) => RegisterBody::DetailsPrompt {
+ username: query.username,
+ require_email: services
+ .config
+ .smtp
+ .as_ref()
+ .is_some_and(|smtp| smtp.require_email_for_registration),
+ flow: RegistrationFlowParameters::Untrusted {
+ recaptcha_sitekey: services.config.recaptcha_site_key.clone(),
+ },
+ terms,
+ validation_errors,
+ },
+ | (None, None) => RegisterBody::UsernamePrompt {
+ allow_federation: services.config.allow_federation,
+ trusted_flow_status,
+ untrusted_flow_status,
+ username_error: None,
+ },
+ }
+ };
+
+ response!(Register::new(context, services.globals.server_name().to_owned(), body))
+}
+
+template! {
+ struct RegisterConfirmEmail use "register_confirm_email.html.j2" {
+ session_id: OwnedSessionId,
+ client_secret: OwnedClientSecret,
+ validation_error: bool
+ }
+}
+
+#[derive(Deserialize, Serialize)]
+struct RegisterConfirmEmailQuery {
+ #[serde(flatten)]
+ threepid: ThreepidQuery,
+}
+
+async fn get_register_confirm_email(
+ State(services): State,
+ Extension(context): Extension,
+ session_store: Session,
+ Expect(Query(RegisterConfirmEmailQuery {
+ threepid: ThreepidQuery { client_secret, session_id },
+ })): Expect>,
+) -> Result {
+ let Some(completed_registration) = session_store
+ .get::(COMPLETED_REGISTRATION_KEY)
+ .await
+ .expect("should be able to deserialize completed session")
+ else {
+ return response!(WebError::BadRequest(
+ "Inapplicable session. What are you doing here?".to_owned()
+ ));
+ };
+
+ let Ok(session) = services
+ .threepid
+ .get_valid_session(&session_id, &client_secret)
+ .await
+ else {
+ return response!(RegisterConfirmEmail::new(context, session_id, client_secret, true,));
+ };
+
+ let email = session.consume();
+
+ complete_registration(&services, session_store, completed_registration, Some(email)).await;
+
+ response!(Redirect::to(&LoginTarget::Account.target_path()))
+}
+
+async fn begin_registration(
+ services: &crate::State,
+ context: TemplateContext,
+ session_store: Session,
+ form: RegistrationForm,
+) -> Result> {
+ let open_registration = services
+ .config
+ .yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse;
+ let mut errors = ValidationErrors::new();
+
+ let user_id = match services
+ .users
+ .determine_registration_user_id(Some(form.username), form.email.as_ref(), None)
+ .await
+ {
+ | Ok(user_id) => user_id,
+ | Err(err) => {
+ errors.add(
+ "username",
+ ValidationError::new("invalid").with_message(err.message().into()),
+ );
+ return Ok(Err(errors));
+ },
+ };
+
+ let password_hash = match HashedPassword::new(&form.password) {
+ | Ok(password) => password,
+ | Err(err) => {
+ errors.add(
+ "password",
+ ValidationError::new("invalid").with_message(err.message().into()),
+ );
+ return Ok(Err(errors));
+ },
+ };
+
+ let mut registration_token = None;
+
+ // Check flow-specific form fields
+ match form.flow {
+ | RequestedRegistrationFlow::Trusted => {
+ // If the form claims to be using the trusted flow, it has to have a
+ // registration token
+
+ let Some(valid_token) = async {
+ services
+ .registration_tokens
+ .validate_token(form.registration_token?)
+ .await
+ }
+ .await
+ else {
+ errors.add(
+ "registration_token",
+ ValidationError::new("invalid")
+ .with_message("Invalid registration token".into()),
+ );
+ return Ok(Err(errors));
+ };
+
+ registration_token = Some(valid_token);
+ },
+ | RequestedRegistrationFlow::Untrusted => {
+ // Don't check auth for the untrusted flow at all if open reg is enabled
+ if !open_registration {
+ // If the form claims to be using the untrusted flow, it _may_ need to have a
+ // reCAPTCHA response if reCAPTCHA is configured
+
+ if let Some(recaptcha_private_site_key) =
+ &services.config.recaptcha_private_site_key
+ {
+ let Some(recaptcha_response) = form.recaptcha_response else {
+ return Err(WebError::BadRequest(
+ "reCAPTCHA response expected".to_owned(),
+ ));
+ };
+
+ if recaptcha_verify::verify_v3(
+ recaptcha_private_site_key,
+ &recaptcha_response,
+ None,
+ )
+ .await
+ .is_err()
+ {
+ errors.add(
+ "recaptcha",
+ ValidationError::new("missing")
+ .with_message("Please complete the CAPTCHA".into()),
+ );
+ return Ok(Err(errors));
+ }
+ }
+ }
+ },
+ }
+
+ let completed_registration = CompletedRegistration {
+ user_id,
+ password_hash,
+ registration_token,
+ };
+
+ // Check if we need to send an email
+ let require_email = services
+ .config
+ .smtp
+ .as_ref()
+ .is_some_and(|smtp| match form.flow {
+ | RequestedRegistrationFlow::Trusted => smtp.require_email_for_token_registration,
+ | RequestedRegistrationFlow::Untrusted =>
+ !open_registration && smtp.require_email_for_registration,
+ });
+
+ if require_email {
+ // If an email is required we have to validate it before we can complete
+ // registration
+ let Some(address) = form.email else {
+ errors.add(
+ "email",
+ ValidationError::new("missing")
+ .with_message("Please provide an email address".into()),
+ );
+ return Ok(Err(errors));
+ };
+
+ if services
+ .threepid
+ .get_localpart_for_email(&address)
+ .await
+ .is_some()
+ {
+ errors.add(
+ "email",
+ ValidationError::new("in_use")
+ .with_message("This email address is already in use.".into()),
+ );
+ return Ok(Err(errors));
+ }
+
+ let client_secret = ClientSecret::new();
+
+ let session_id = {
+ match services
+ .threepid
+ .send_validation_email(
+ Mailbox::new(None, address.clone()),
+ |verification_link| messages::NewAccount {
+ server_name: services.globals.server_name().as_str(),
+ verification_link,
+ },
+ &client_secret,
+ 0,
+ )
+ .await
+ {
+ | Ok(session_id) => session_id,
+ | Err(err) => {
+ warn!(
+ "Failed to send new account message for {} to {}: {err}",
+ &completed_registration.user_id, address,
+ );
+
+ errors.add(
+ "email",
+ ValidationError::new("invalid").with_message(
+ "Failed to send validation email. Is this address correct?".into(),
+ ),
+ );
+ return Ok(Err(errors));
+ },
+ }
+ };
+
+ session_store
+ .insert(COMPLETED_REGISTRATION_KEY, completed_registration)
+ .await
+ .expect("should have been able to serialize completed registration");
+
+ Ok(response!(RegisterConfirmEmail::new(context, session_id, client_secret, false,)))
+ } else {
+ // If email isn't required we can immediately complete registration
+ complete_registration(services, session_store, completed_registration, None).await;
+
+ Ok(response!(Redirect::to(&LoginTarget::Account.target_path())))
+ }
+}
+
+async fn complete_registration(
+ services: &crate::State,
+ session_store: Session,
+ CompletedRegistration {
+ user_id,
+ password_hash,
+ registration_token,
+ }: CompletedRegistration,
+ email: Option,
+) {
+ services
+ .users
+ .create_local_account(&user_id, password_hash, email)
+ .await;
+
+ if let Some(registration_token) = registration_token {
+ services
+ .registration_tokens
+ .mark_token_as_used(registration_token);
+ }
+
+ let user_session = UserSession { user_id, last_login: SystemTime::now() };
+
+ session_store
+ .insert(User::KEY, user_session)
+ .await
+ .expect("should be able to serialize user session");
+}
+
+pub(super) async fn registration_flow_status(
+ services: &crate::State,
+) -> (TrustedFlowStatus, UntrustedFlowStatus) {
+ // Allow registration if it's enabled in the config file or if this is the first
+ // run (so the first user account can be created)
+ let allow_registration =
+ services.config.allow_registration || services.firstrun.is_first_run();
+
+ // Trusted flow is only available if any registration tokens exist
+ let trusted_flow_status = {
+ if !allow_registration {
+ TrustedFlowStatus::Unavailable
+ } else if services
+ .registration_tokens
+ .iterate_tokens()
+ .next()
+ .await
+ .is_some()
+ {
+ TrustedFlowStatus::Available
+ } else {
+ TrustedFlowStatus::Unavailable
+ }
+ };
+
+ // Untrusted flow is available if email is required for registration,
+ // or reCAPTCHA is configured, or open registration is enabled
+ let untrusted_flow_status = {
+ let require_email = services
+ .config
+ .smtp
+ .as_ref()
+ .is_some_and(|smtp| smtp.require_email_for_registration);
+
+ if !allow_registration {
+ UntrustedFlowStatus::Unavailable
+ } else if services.config.recaptcha_private_site_key.is_some() || require_email {
+ UntrustedFlowStatus::Available { require_email }
+ } else if services
+ .config
+ .yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
+ {
+ UntrustedFlowStatus::Available { require_email: false }
+ } else {
+ UntrustedFlowStatus::Unavailable
+ }
+ };
+
+ (trusted_flow_status, untrusted_flow_status)
+}
diff --git a/src/web/pages/index.rs b/src/web/pages/index.rs
index 7a7808cf2..6e7548b5d 100644
--- a/src/web/pages/index.rs
+++ b/src/web/pages/index.rs
@@ -1,6 +1,9 @@
-use axum::{Extension, Router, extract::State, response::IntoResponse, routing::get};
+use axum::{Extension, Router, extract::State, routing::get};
-use crate::{WebError, pages::TemplateContext, template};
+use crate::{
+ pages::{Result, TemplateContext},
+ response, template,
+};
pub(crate) fn build() -> Router {
Router::new()
@@ -8,21 +11,20 @@ pub(crate) fn build() -> Router {
.route(&format!("{}/", crate::ROUTE_PREFIX), get(index))
}
+template! {
+ struct Index<'a> use "index.html.j2" {
+ server_name: &'a str,
+ first_run: bool
+ }
+}
+
async fn index(
State(services): State,
Extension(context): Extension,
-) -> Result {
- template! {
- struct Index<'a> use "index.html.j2" {
- server_name: &'a str,
- first_run: bool
- }
- }
-
- Ok(Index::new(
+) -> Result {
+ response!(Index::new(
context,
services.globals.server_name().as_str(),
services.firstrun.is_first_run(),
- )
- .into_response())
+ ))
}
diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs
index 1e7a9f6f0..7a75e5bb1 100644
--- a/src/web/pages/mod.rs
+++ b/src/web/pages/mod.rs
@@ -49,11 +49,17 @@ pub(super) async fn template_context_middleware(
let mut response = next.run(request).await;
+ let child_src = if config.recaptcha_site_key.is_some() {
+ "www.google.com"
+ } else {
+ "'none'"
+ };
+
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_str(&format!(
"default-src 'none'; style-src 'self'; img-src 'self' 'https' data:; script-src \
- 'nonce-{csp_nonce}';"
+ 'nonce-{csp_nonce}'; child-src {child_src};"
))
.expect("should be able to build CSP header"),
);
diff --git a/src/web/pages/resources/common.css b/src/web/pages/resources/common.css
index 1d532c27a..3b4d15737 100644
--- a/src/web/pages/resources/common.css
+++ b/src/web/pages/resources/common.css
@@ -108,6 +108,11 @@ em {
}
}
+hr {
+ border-width: 1px;
+ border-color: var(--secondary);
+}
+
small {
color: var(--secondary);
}
@@ -223,6 +228,7 @@ input {
input[type="checkbox"] {
display: inline;
margin: 0;
+ width: auto !important;
}
button, a.button {
diff --git a/src/web/pages/resources/login.css b/src/web/pages/resources/login.css
index 526767d42..ab7a8b417 100644
--- a/src/web/pages/resources/login.css
+++ b/src/web/pages/resources/login.css
@@ -1,5 +1,62 @@
-.reset-password {
+.centered-links {
display: flex;
- width: 100%;
- justify-content: right;
+ justify-content: space-between;
+
+ :last-child {
+ margin-left: auto;
+ }
+}
+
+.text-rule {
+ display: flex;
+ align-items: center;
+ text-align: center;
+ color: var(--secondary);
+ margin-bottom: 0.5em;
+}
+
+.text-rule::before, .text-rule::after {
+ content: '';
+ flex: 1;
+ border-bottom: 1px solid var(--secondary);
+}
+
+.text-rule:not(:empty)::before {
+ margin-right: 1rem;
+}
+
+.text-rule:not(:empty)::after {
+ margin-left: 1rem;
+}
+
+.username-input {
+ display: flex;
+ padding: 0.5em;
+ margin-bottom: 0.5em;
+ line-height: 1;
+
+ border-radius: var(--border-radius-sm);
+ border: 2px solid var(--secondary);
+
+ &:has(input:focus-visible) {
+ outline: 2px solid var(--c1);
+ border-color: transparent;
+ }
+
+ input {
+ flex: 1;
+ padding: 0;
+ margin: 0;
+ border: none;
+ outline: none;
+ }
+
+ span {
+ flex: 0;
+ color: var(--secondary);
+
+ &:first-of-type {
+ margin-inline-end: 0.5em;
+ }
+ }
}
diff --git a/src/web/pages/templates/device_info.html.j2 b/src/web/pages/templates/device_info.html.j2
index afcf762e0..267ade353 100644
--- a/src/web/pages/templates/device_info.html.j2
+++ b/src/web/pages/templates/device_info.html.j2
@@ -28,8 +28,7 @@ Device information
{% else %}
This device can access and control all features of your Matrix account.
- ❖ This is a legacy device. Legacy devices
- always have full access to your account.
+ ❖ This is a legacy device. Legacy devices always have full access to your account.
{% endif %}
diff --git a/src/web/pages/templates/login.html.j2 b/src/web/pages/templates/login.html.j2
index 4242dcbdb..f590907ba 100644
--- a/src/web/pages/templates/login.html.j2
+++ b/src/web/pages/templates/login.html.j2
@@ -11,7 +11,7 @@ Log in
{%- block content -%}
{% match body %}
- {% when LoginBody::Unauthenticated { server_name } %}
+ {% when LoginBody::Unauthenticated { server_name, registration_available } %}
{% if has_next %}
Log in to continue
@@ -37,6 +37,12 @@ Log in
Log in
+
{% when LoginBody::Authenticated { user_card } %}
Confirm your identity
{{ user_card }}
@@ -48,10 +54,12 @@ Log in
Continue
+
{% endmatch %}
{% if let Some(error) = login_error %}
{{ error }}
{% endif %}
- Forgot your password?
{%- endblock -%}
diff --git a/src/web/pages/templates/register.html.j2 b/src/web/pages/templates/register.html.j2
new file mode 100644
index 000000000..8b095be6c
--- /dev/null
+++ b/src/web/pages/templates/register.html.j2
@@ -0,0 +1,159 @@
+{% extends "_layout.html.j2" %}
+{% import "_components/form.html.j2" as form %}
+
+{%- block head -%}
+
+{%- endblock -%}
+
+{%- block title -%}
+Sign up
+{%- endblock -%}
+
+{%- block content -%}
+
+
+ {% if false %}
+ Sign up to continue
+ {% else %}
+ Sign up
+ {% endif %}
+
+
+
+
+ {% match body %}
+ {% when RegisterBody::Unavailable %}
+
+ This server is not currently accepting new accounts.
+
+ {% when RegisterBody::UsernamePrompt { allow_federation, untrusted_flow_status, trusted_flow_status, username_error } %}
+
+ You're about to register a new Matrix account on {{ server_name }} .
+
+ {% if allow_federation %}
+
+ Like email, Matrix is a network of servers. Your account will be able to talk to
+ users on hundreds of different Matrix servers across the world.
+
+ {% endif %}
+
+
+ Choose a username to continue.
+
+
+ {% when RegisterBody::DetailsPrompt { username, require_email, flow, terms, validation_errors } %}
+ {% let validation_errors = validation_errors.clone() %}
+ {% let field_errors = validation_errors.field_errors() %}
+
+ {% endmatch %}
+
+{%- endblock -%}
diff --git a/src/web/pages/templates/register_confirm_email.html.j2 b/src/web/pages/templates/register_confirm_email.html.j2
new file mode 100644
index 000000000..ef716bc02
--- /dev/null
+++ b/src/web/pages/templates/register_confirm_email.html.j2
@@ -0,0 +1,27 @@
+{% extends "_layout.html.j2" %}
+
+{%- block title -%}
+Confirm your email
+{%- endblock -%}
+
+{%- block content -%}
+
+
Confirm your email
+
+ A message has been sent to your new email address with a validation link.
+ To finish creating your account, click the link and then return to this page.
+ If you do not receive the email:
+
+ Check your spam filter.
+
+
+ {% if validation_error %}
+
Validation failed. Have you clicked the link in the email that was sent to you?
+ {% endif %}
+
+
+{%- endblock -%}