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

+ {% when LoginBody::Authenticated { user_card } %}

Confirm your identity

{{ user_card }} @@ -48,10 +54,12 @@ Log in

+ {% 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 %} + + Matrix logo + +

+ {% 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. +

+
+ +

+ + + @ + + :{{ server_name }} + + {% if let Some(username_error) = username_error %} + + {{ username_error }} + + {% endif %} +

+ {% if let UntrustedFlowStatus::Available { require_email } = untrusted_flow_status %} + {% if require_email %} + + {% else %} + + {% endif %} + {% endif %} + {% if let UntrustedFlowStatus::Available { .. } = untrusted_flow_status && let TrustedFlowStatus::Available = trusted_flow_status %} +
or
+ {% endif %} + {% if let TrustedFlowStatus::Available = trusted_flow_status %} + + {% endif %} +
+ {% when RegisterBody::DetailsPrompt { username, require_email, flow, terms, validation_errors } %} + {% let validation_errors = validation_errors.clone() %} + {% let field_errors = validation_errors.field_errors() %} +
+

+ + + @ + {% if let Some(username) = username %} + + {% else %} + + {% endif %} + :{{ server_name }} + + {{ form::errors(field_errors, std::borrow::Cow::Borrowed("username")) }} +

+

+ Just a few more details to finish creating your account. +

+

+ + + {{ form::errors(field_errors, std::borrow::Cow::Borrowed("password")) }} +

+

+ + + {{ form::errors(field_errors, std::borrow::Cow::Borrowed("confirm_password")) }} +

+ {% if require_email %} +

+ + + {{ form::errors(field_errors, std::borrow::Cow::Borrowed("email")) }} +

+ {% endif %} + {% match flow %} + {% when RegistrationFlowParameters::Untrusted { recaptcha_sitekey } %} + + {% if let Some(recaptcha_sitekey) = recaptcha_sitekey %} + + +

+ + {{ form::errors(field_errors, std::borrow::Cow::Borrowed("recaptcha")) }} +

+ {% endif %} + {% when RegistrationFlowParameters::Trusted { registration_token } %} + + {% if let Some(registration_token) = registration_token %} + +
+ {{ form::errors(field_errors, std::borrow::Cow::Borrowed("registration_token")) }} +
+ {% else %} +

+ + + {{ form::errors(field_errors, std::borrow::Cow::Borrowed("registration_token")) }} +

+ {% endif %} + {% endmatch %} + + {% if !terms.is_empty() %} +

+ {% for (id, document) in terms %} + + {% endfor %} + All policy links will open in a new tab. +

+ {% endif %} + +
+ {% 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 -%}