Compare commits

...

59 Commits

Author SHA1 Message Date
Ginger 61a2e236b6 fix: Fix backwards logic in auth check 2026-06-09 14:45:20 -04:00
Ginger d4fdf87daa fix: Update error message wording 2026-06-09 14:06:24 -04:00
Ginger 513259a837 refactor: Remove redundant destination check in server auth logic
Ruma already does this check for us
2026-06-09 13:58:03 -04:00
Ginger 0f14a91bf3 refactor: Update Ruma and adjust auth logic 2026-06-09 13:56:21 -04:00
Ginger d557ed9a2c refactor: Use determine_registration_user_id in admin user create route 2026-06-08 10:53:54 -04:00
Ginger cad2bb659b feat: Set MSC4484 unstable feature flag 2026-06-08 10:53:54 -04:00
Ginger 4ee69f9061 fix: Adjust admin API routes to work with new auth logic 2026-06-08 10:53:31 -04:00
timedout 9812067c39 feat: Add user creation endpoint 2026-06-08 10:41:13 -04:00
timedout 10136d4f78 feat: Include predecessor and successor information in room list 2026-06-08 10:41:12 -04:00
timedout d6d0694387 feat: Add pagination to rooms list & include more information 2026-06-08 10:41:12 -04:00
timedout 0db74089c1 feat: Enable pagination for the users list route 2026-06-08 10:41:12 -04:00
timedout efe37dab12 feat: Define routes for listing and creating users 2026-06-08 10:41:12 -04:00
timedout 1f16468dac feat: Add version part to admin API URLs
This is a surprise tool that will help us later
2026-06-08 10:41:12 -04:00
timedout 00bdffb783 chore: Add some documentation to API stuff 2026-06-08 10:41:12 -04:00
timedout ed83d8fbb4 feat: Drop ruminuwuity msc4323 definitions 2026-06-08 10:41:12 -04:00
timedout 50f22cbf10 feat: Use upstream ruma defs for msc4323, add locking endpoints 2026-06-08 10:41:12 -04:00
Ginger 476f5249ce fix: Check for existing device when creating oauth session 2026-06-08 10:38:00 -04:00
Ginger d4eff0256c fix: Use RFC-compliant error responses for OAuth endpoints 2026-06-08 10:37:44 -04:00
Ginger 23aa0f5005 fix: Force trusted flow UI for first-run registration 2026-06-08 09:33:06 -04:00
Ginger 0594892921 fix: Panic when trying to check an unknown UIAA stage type 2026-06-08 09:33:06 -04:00
Ginger 5c16cb60fd fix: Use the right error code for CAPTCHA errors 2026-06-08 09:33:06 -04:00
Ginger 809a9429dc fix: Formatting 2026-06-08 09:33:06 -04:00
Ginger 707587aa15 refactor: Update logic for checking if a username is available 2026-06-08 09:33:06 -04:00
Ginger a10c709f1a fix: CSS adjustments 2026-06-08 09:33:05 -04:00
Ginger 7407435334 fix: Adjust error codes to comply with MSC4190 2026-06-08 09:33:05 -04:00
Ginger cb7c678a34 feat: Mark spec version 1.15 as supported 2026-06-08 09:33:05 -04:00
Ginger 0db01a6763 feat: Add a page with some information about the server 2026-06-08 09:33:05 -04:00
Ginger d7b066c03a fix: Correct config file example section name 2026-06-08 09:33:05 -04:00
Ginger 5c2afd9f0d chore: My Giant Future 2026-06-08 09:33:05 -04:00
Ginger 886aaf0e17 feat: Improve account panel UI for locked and suspended accounts 2026-06-08 09:33:05 -04:00
Ginger 86a33b5eb3 fix: Include query parameters in link back to login on register page 2026-06-08 09:33:05 -04:00
Ginger 01dcb9cf9d fix: CSS tweaks 2026-06-08 09:33:05 -04:00
Ginger 5effc3411e feat: Improve registration UI in first-run mode 2026-06-08 09:33:05 -04:00
Ginger d67000f0bc fix: Minor wording improvements 2026-06-08 09:33:05 -04:00
Ginger f294361eb2 fix: Set default for allow_deactivation 2026-06-08 09:33:05 -04:00
Ginger d1eeefed77 fix: Fix registration terms example in config 2026-06-08 09:33:05 -04:00
Ginger 1c347841ce feat: Implement support for prompt=create in the authorization code flow 2026-06-08 09:33:05 -04:00
Ginger a73973197d fix: Don't let logged-in users access the registration page 2026-06-08 09:33:05 -04:00
Ginger 74e0d87c0d feat: Allow self-service deactivation to be disabled 2026-06-08 09:33:05 -04:00
Ginger 3691cccf11 refactor: Use more consistent terminology for email validation pages 2026-06-08 09:33:05 -04:00
Ginger 86368b46b0 feat: Add support for registering accounts with the web UI 2026-06-08 09:33:05 -04:00
Ginger 71a44bf7ea refactor: Change template context to allow using a CSP nonce 2026-06-08 09:33:05 -04:00
Ginger 81b865f258 fix: Minor CSS improvements 2026-06-08 09:33:05 -04:00
Ginger b0d90e614c fix: Remove errant whitespace in device details 2026-06-08 09:33:05 -04:00
Ginger e6f8e6c868 chore: News fragment 2026-06-08 09:33:05 -04:00
Ginger db460e0a59 feat: Allow configuring the OAuth compatibility mode 2026-06-08 09:33:05 -04:00
Ginger 810b40536b fix: Use button styling for account management link on index page 2026-06-08 09:33:05 -04:00
Ginger 577b33599f fix: Use the right text color on input elements 2026-06-08 09:33:05 -04:00
Ginger 573f2e4892 feat: Add support for account management deeplinks 2026-06-08 09:33:05 -04:00
Ginger 8aa93c7839 fix: Return the correct error code for expired access tokens 2026-06-08 09:33:05 -04:00
Ginger 9aed687ee1 feat: Add a page for viewing a device's details 2026-06-08 09:33:05 -04:00
Ginger c3df2e4570 fix: Use SameSite=Lax for session cookie 2026-06-08 09:33:05 -04:00
Ginger 8c178c32f9 feat: Allow devices to be removed from the account panel 2026-06-08 09:33:05 -04:00
Ginger e8d409c6ed feat: Implement oauth token revocation 2026-06-08 09:33:05 -04:00
Ginger b50c7027f5 chore: Clippy fixes 2026-06-08 09:33:05 -04:00
Ginger 0ed101e973 feat: Implement oauth auth code and refresh token flows 2026-06-08 09:33:05 -04:00
Ginger 2d7a35310f chore: Clippy fixes 2026-06-08 09:33:05 -04:00
Ginger cf1b3c6d4b feat: Implement a web-based account management dashboard 2026-06-08 09:33:05 -04:00
Ginger 07f7d6f071 feat: Implement oauth service and client registration 2026-06-08 09:33:05 -04:00
144 changed files with 7431 additions and 1679 deletions
Generated
+88 -21
View File
@@ -1088,6 +1088,7 @@ dependencies = [
"serde",
"serde-saphyr",
"serde_json",
"serde_urlencoded",
"sha2 0.11.0",
"termimad",
"tokio",
@@ -1107,18 +1108,29 @@ dependencies = [
"axum",
"axum-extra",
"base64 0.22.1",
"conduwuit_api",
"conduwuit_build_metadata",
"conduwuit_core",
"conduwuit_database",
"conduwuit_service",
"form_urlencoded",
"futures",
"lettre",
"memory-serve",
"rand 0.10.1",
"recaptcha-verify",
"reqwest 0.12.28",
"ruma",
"serde",
"serde_json",
"serde_urlencoded",
"thiserror",
"tower-http",
"tower-sec-fetch",
"tower-sessions",
"tower-sessions-core",
"tracing",
"url",
"validator",
]
@@ -1536,6 +1548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
"serde_core",
]
[[package]]
@@ -4250,8 +4263,8 @@ dependencies = [
[[package]]
name = "ruma"
version = "0.15.1"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.16.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"assign",
"js_int",
@@ -4269,8 +4282,8 @@ dependencies = [
[[package]]
name = "ruma-appservice-api"
version = "0.15.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.16.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"js_int",
"ruma-common",
@@ -4281,8 +4294,8 @@ dependencies = [
[[package]]
name = "ruma-client-api"
version = "0.23.1"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.24.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"as_variant",
"assign",
@@ -4303,8 +4316,8 @@ dependencies = [
[[package]]
name = "ruma-common"
version = "0.18.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.19.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"as_variant",
"base64 0.22.1",
@@ -4336,8 +4349,8 @@ dependencies = [
[[package]]
name = "ruma-events"
version = "0.33.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.34.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"as_variant",
"indexmap",
@@ -4357,8 +4370,8 @@ dependencies = [
[[package]]
name = "ruma-federation-api"
version = "0.14.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.15.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"bytes",
"headers",
@@ -4381,7 +4394,7 @@ dependencies = [
[[package]]
name = "ruma-identifiers-validation"
version = "0.12.1"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"js_int",
"thiserror",
@@ -4389,8 +4402,8 @@ dependencies = [
[[package]]
name = "ruma-macros"
version = "0.18.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.19.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"as_variant",
"cfg-if",
@@ -4405,8 +4418,8 @@ dependencies = [
[[package]]
name = "ruma-push-gateway-api"
version = "0.14.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.15.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"js_int",
"ruma-common",
@@ -4417,8 +4430,8 @@ dependencies = [
[[package]]
name = "ruma-signatures"
version = "0.20.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.21.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
@@ -4433,8 +4446,8 @@ dependencies = [
[[package]]
name = "ruma-state-res"
version = "0.16.0"
source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b"
version = "0.17.0"
source = "git+https://github.com/gingershaped/ruwuma.git?rev=a0178c4e5e1729d27cf2f1c4dacf77b763987749#a0178c4e5e1729d27cf2f1c4dacf77b763987749"
dependencies = [
"js_int",
"ruma-common",
@@ -5664,6 +5677,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-cookies"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
dependencies = [
"axum-core",
"cookie",
"futures-util",
"http",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.11"
@@ -5712,6 +5741,44 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
dependencies = [
"async-trait",
"http",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
dependencies = [
"async-trait",
"axum-core",
"base64 0.22.1",
"futures",
"http",
"parking_lot",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tracing"
version = "0.1.44"
+6 -2
View File
@@ -343,8 +343,8 @@ version = "1.1.1"
# Used for matrix spec type definitions and helpers
[workspace.dependencies.ruma]
# version = "0.14.1"
git = "https://github.com/ruma/ruma.git"
rev = "3ecd80b92794d2d93f657a7b3db62d4be237526b"
git = "https://github.com/gingershaped/ruwuma.git"
rev = "a0178c4e5e1729d27cf2f1c4dacf77b763987749"
features = [
"appservice-api-c",
"client-api",
@@ -379,6 +379,7 @@ features = [
"unstable-msc4406",
"unstable-msc4439",
"unstable-msc4466",
"unstable-msc4484",
"unstable-extensible-events",
]
@@ -559,6 +560,9 @@ features = ["std"]
[workspace.dependencies.nonzero_ext]
version = "0.3.0"
[workspace.dependencies.serde_urlencoded]
version = "0.7.1"
#
# Patches
#
+1
View File
@@ -0,0 +1 @@
Users may now be forbidden from deactivating their own accounts with the new `allow_deactivation` config option. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Added support for authenticating clients using the new OAuth 2.0 login API. Contributed by @ginger.
+37 -9
View File
@@ -521,17 +521,15 @@
#
#recaptcha_private_site_key =
# Policy documents, such as terms and conditions or a privacy policy,
# which users must agree to when registering an account.
# Controls whether users are allowed to deactivate their own accounts
# through the account management panel or their Matrix clients. Server
# admins can always deactivate users using the relevant admin commands.
#
# 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" }
# ```
# Note that, in some jurisdictions, you may be legally required to honor
# users who request to deactivate their accounts if you set this option
# to `false`.
#
#registration_terms = {}
#allow_deactivation = true
# Controls whether encrypted rooms and events are allowed.
#
@@ -1987,3 +1985,33 @@
# `require_email_for_registration`.
#
#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 = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
# ```
#
#documents = {}
#[global.oauth]
# The compatibility mode to use for OAuth.
#
# - "disabled": OAuth will be unavailable. Users will only be able to log
# in using legacy authentication.
# - "hybrid": OAuth and legacy authentication will both be available. Some
# clients may only use one or the other.
# - "exclusive": Only OAuth will be available. Clients which require
# legacy authentication will be unable to log in.
#
#compatibility_mode = "hybrid"
+1 -1
View File
@@ -16,7 +16,7 @@
};
#[derive(Debug, Parser)]
#[command(name = conduwuit_core::name(), version = conduwuit_core::version())]
#[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())]
pub enum AdminCommand {
#[command(subcommand)]
/// Commands for managing appservices
+25 -2
View File
@@ -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]
+15 -151
View File
@@ -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<String>) -> 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]
@@ -233,7 +122,7 @@ pub(super) async fn suspend(&self, user_id: String) -> Result {
// TODO: Record the actual user that sent the suspension where possible
self.services
.users
.suspend_account(&user_id, self.sender_or_service_user())
.suspend_account(&user_id, self.sender)
.await;
self.write_str(&format!("User {user_id} has been suspended."))
@@ -302,31 +191,6 @@ pub(super) async fn reset_password(
Ok(())
}
#[admin_command]
pub(super) async fn issue_password_reset_link(&self, username: String) -> Result {
use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM};
self.bail_restricted()?;
let mut reset_url = self
.services
.config
.get_client_domain()
.join(PASSWORD_RESET_PATH)
.unwrap();
let user_id = parse_local_user_id(self.services, &username)?;
let token = self.services.password_reset.issue_token(user_id).await?;
reset_url
.query_pairs_mut()
.append_pair(RESET_TOKEN_QUERY_PARAM, &token.token);
self.write_str(&format!("Password reset link issued for {username}: {reset_url}"))
.await?;
Ok(())
}
#[admin_command]
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
if self.body.len() < 2
@@ -1075,7 +939,7 @@ pub(super) async fn lock(&self, user_id: String) -> Result {
}
self.services
.users
.lock_account(&user_id, self.sender_or_service_user())
.lock_account(&user_id, self.sender)
.await;
self.write_str(&format!("User {user_id} has been locked."))
-6
View File
@@ -29,12 +29,6 @@ pub enum UserCommand {
password: Option<String>,
},
/// Issue a self-service password reset link for a user.
IssuePasswordResetLink {
/// Username of the user who may use the link
username: String,
},
/// Get a user's associated email address.
GetEmail {
user_id: String,
+2
View File
@@ -62,6 +62,8 @@ zstd_compression = [
"reqwest/zstd",
]
admin_api = []
[dependencies]
async-trait.workspace = true
axum-client-ip.workspace = true
-1
View File
@@ -1 +0,0 @@
pub mod rooms;
-36
View File
@@ -1,36 +0,0 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::StreamExt;
use ruma::OwnedRoomId;
use ruminuwuity::admin::continuwuity::rooms;
use crate::Ruma;
/// # `GET /_continuwuity/admin/rooms/list`
///
/// Lists all rooms known to this server, excluding banned ones.
pub(crate) async fn list_rooms(
State(services): State<crate::State>,
body: Ruma<rooms::list::v1::Request>,
) -> Result<rooms::list::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
if !services.users.is_admin(sender_user).await {
return Err!(Request(Forbidden("Only server administrators can use this endpoint")));
}
let mut rooms: Vec<OwnedRoomId> = services
.rooms
.metadata
.iter_ids()
.filter_map(|room_id| async move {
if !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.collect()
.await;
rooms.sort();
Ok(rooms::list::v1::Response::new(rooms))
}
-2
View File
@@ -1,2 +0,0 @@
pub mod ban;
pub mod list;
+28 -45
View File
@@ -24,7 +24,7 @@
power_levels::RoomPowerLevelsEventContent,
},
};
use service::{mailer::messages, uiaa::Identity, users::HashedPassword};
use service::{mailer::messages, uiaa::UiaaInitiator, users::HashedPassword};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::{Ruma, router::ClientIdentity};
@@ -49,39 +49,16 @@ pub(crate) async fn get_register_available_route(
ClientIp(client): ClientIp,
body: Ruma<get_username_availability::v3::Request>,
) -> Result<get_username_availability::v3::Response> {
// Validate user id
let user_id =
match UserId::parse_with_server_name(&body.username, services.globals.server_name()) {
| Ok(user_id) => {
if let Err(e) = user_id.validate_strict() {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {} contains disallowed characters or spaces: {e}",
body.username
))));
}
user_id
},
| Err(e) => {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {} is not valid: {e}",
body.username
))));
},
};
// Check if username is creative enough
if services.users.exists(&user_id).await {
return Err!(Request(UserInUse("User ID is not available.")));
}
if let Some(ClientIdentity::Appservice { appservice_info, .. }) = &body.identity
&& !appservice_info.is_user_match(&user_id)
{
return Err!(Request(Exclusive("Username is not in an appservice namespace.")));
} else if services.appservice.is_exclusive_user_id(&user_id).await {
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
}
let _ = services
.users
.determine_registration_user_id(
Some(body.username.clone()),
None,
body.identity
.as_ref()
.and_then(ClientIdentity::appservice_info),
)
.await?;
Ok(get_username_availability::v3::Response::new(true))
}
@@ -109,12 +86,7 @@ pub(crate) async fn change_password_route(
ClientIp(client): ClientIp,
body: Ruma<change_password::v3::Request>,
) -> Result<change_password::v3::Response> {
let identity = if let Some(user_id) = body
.identity
.as_ref()
.map(ClientIdentity::expect_sender_user)
.transpose()?
{
let identity = if let Some(identity) = body.identity.as_ref() {
// A signed-in user is trying to change their password, prompt them for their
// existing one
@@ -124,7 +96,10 @@ pub(crate) async fn change_password_route(
&body.auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
Some(Identity::from_user_id(user_id)),
Some(UiaaInitiator::new(
identity.expect_sender_user()?,
identity.sender_device(),
)),
)
.await?
} else {
@@ -280,16 +255,24 @@ pub(crate) async fn deactivate_route(
) -> Result<deactivate::v3::Response> {
// Authentication for this endpoint is technically optional,
// but we require the user to be logged in
let sender_user = body
let identity = body
.identity
.as_ref()
.map(ClientIdentity::expect_sender_user)
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))??;
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
let sender_user = identity.expect_sender_user()?;
if !services.config.allow_deactivation {
return Err!(Request(Unauthorized(
"You may not deactivate your own account. Contact your server's administrator for \
assistance."
)));
}
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, identity.sender_device(), None)
.await?;
// Remove profile pictures and display name
+56 -292
View File
@@ -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 @@
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 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,59 @@ 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;
services.users.join_auto_join_rooms(&user_id).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 {
// If UIAA is disabled, we can't create a device. In that case only appservices
// can reach this point in the first place, so we return an error for them.
if !services.config.oauth.compatibility_mode.uiaa_available() {
return Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice registration is not available on this server."
)));
}
// Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
@@ -179,6 +128,7 @@ pub(crate) async fn register_route(
&user_id,
&device_id,
&new_token,
None,
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
@@ -189,118 +139,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(&notice).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(&notice).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,
@@ -372,21 +211,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,
}),
);
@@ -419,81 +258,6 @@ async fn create_registration_uiaa_session(
Ok((flows, params))
}
async fn determine_registration_user_id(
services: &Services,
supplied_username: Option<String>,
emergency_mode_enabled: bool,
) -> Result<OwnedUserId> {
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.
+7 -4
View File
@@ -11,7 +11,7 @@
},
thirdparty::{Medium, ThirdPartyIdentifierInit},
};
use service::{mailer::messages, uiaa::Identity};
use service::mailer::messages;
use crate::{Ruma, router::ClientIdentity};
@@ -124,15 +124,18 @@ pub(crate) async fn add_3pid_route(
.uiaa
.authenticate_password(
&body.auth,
Some(Identity::from_user_id(body.identity.expect_sender_user()?)),
body.identity.expect_sender_user()?,
body.identity.sender_device(),
None,
)
.await?;
let email = services
.threepid
.consume_valid_session(&body.sid, &body.client_secret)
.get_valid_session(&body.sid, &body.client_secret)
.await
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?;
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?
.consume();
services
.threepid
+71
View File
@@ -0,0 +1,71 @@
use axum::extract::State;
use conduwuit::Err;
use ruma::api::client::admin::{is_user_locked, lock_user};
use crate::router::Ruma;
/// # `GET /_matrix/client/v1/admin/lock/{userId}`
///
/// Check the account lock status of a target user
pub(crate) async fn get_locked_status(
State(services): State<crate::State>,
body: Ruma<is_user_locked::v1::Request>,
) -> conduwuit::Result<is_user_locked::v1::Response> {
if !services.users.is_active_local(&body.user_id).await {
return Err!(Request(InvalidParam(
"Can only check the lock status of active local users"
)));
}
Ok(is_user_locked::v1::Response::new(
services.users.is_locked(&body.user_id).await?,
))
}
/// # `PUT /_matrix/client/v1/admin/lock/{userId}`
///
/// Set the account lock status of a target user
pub(crate) async fn put_locked_status(
State(services): State<crate::State>,
body: Ruma<lock_user::v1::Request>,
) -> conduwuit::Result<lock_user::v1::Response> {
if !services.users.is_active_local(&body.user_id).await {
return Err!(Request(InvalidParam(
"Can only set the locked status of active local users"
)));
}
if body.identity.sender_user() == Some(&body.user_id) {
return Err!(Request(Forbidden("You cannot lock yourself")));
}
if services.users.is_admin(&body.user_id).await {
return Err!(Request(Forbidden("You cannot lock another server administrator")));
}
if services.users.is_locked(&body.user_id).await? == body.locked {
// No change
return Ok(lock_user::v1::Response::new(body.locked));
}
let action = if body.locked {
services
.users
.lock_account(&body.user_id, body.identity.sender_user())
.await;
"suspended"
} else {
services.users.unlock_account(&body.user_id).await;
"unsuspended"
};
if services.config.admin_room_notices {
// Notify the admin room that an account has been un/suspended
services
.admin
.send_text(&format!("{} has been {} by {}.", body.user_id, action, body.identity))
.await;
}
Ok(lock_user::v1::Response::new(body.locked))
}
+3 -1
View File
@@ -1,3 +1,5 @@
mod lock;
pub(crate) mod site;
mod suspend;
pub(crate) use self::suspend::*;
pub(crate) use self::{lock::*, suspend::*};
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod rooms;
pub(crate) mod users;
@@ -6,7 +6,7 @@
use crate::{Ruma, client::leave_room};
/// # `PUT /_continuwuity/admin/rooms/{roomID}/ban`
/// # `PUT /_continuwuity/admin/v1/rooms/{roomID}/ban`
///
/// Bans or unbans a room.
pub(crate) async fn ban_room(
+178
View File
@@ -0,0 +1,178 @@
use axum::extract::State;
use conduwuit::{
Event, Result,
utils::stream::{BroadbandExt, WidebandExt},
};
use futures::StreamExt;
use ruma::{
OwnedRoomId,
events::{
StateEventType,
room::{
create::RoomCreateEventContent,
encryption::PossiblyRedactedRoomEncryptionEventContent,
tombstone::PossiblyRedactedRoomTombstoneEventContent,
},
},
};
use ruminuwuity::admin::continuwuity::rooms;
use tokio::join;
use crate::Ruma;
/// # `GET /_continuwuity/admin/rooms`
///
/// Lists all room IDs known to this server, excluding banned ones.
///
/// This is the legacy version of the endpoint, which does not support
/// pagination or including banned rooms. It is recommended to use the
/// `/v1/rooms` endpoint instead. This endpoint may be removed in a future
/// release.
pub(crate) async fn legacy_list_rooms_route(
State(services): State<crate::State>,
body: Ruma<rooms::list::unstable::Request>,
) -> Result<rooms::list::unstable::Response> {
let mut rooms: Vec<OwnedRoomId> = services
.rooms
.metadata
.iter_ids()
.filter_map(|room_id| async move {
if !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.collect()
.await;
rooms.sort();
Ok(rooms::list::unstable::Response::new(rooms))
}
/// # `GET /_continuwuity/admin/v1/rooms`
///
/// Lists rooms known to this server.
pub(crate) async fn list_rooms_route(
State(services): State<crate::State>,
body: Ruma<rooms::list::v1::Request>,
) -> Result<rooms::list::v1::Response> {
let include_banned_rooms = body.include_banned_rooms;
let rooms = services
.rooms
.metadata
.iter_ids()
.wide_filter_map(|room_id| async move {
if include_banned_rooms || !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.skip(body.offset.unwrap_or_default())
.take(body.limit.unwrap_or(100).min(100))
.broad_filter_map(|room_id| async move {
let (
banned,
disabled,
member_count,
local_member_count,
resident_server_count,
published,
create_event,
encryption_event,
name_event,
topic_event,
canonical_alias_event,
join_rules_event,
history_visibility_event,
tombstone_event,
) = join!(
services.rooms.metadata.is_banned(&room_id),
services.rooms.metadata.is_disabled(&room_id),
services.rooms.state_cache.room_joined_count(&room_id),
services
.rooms
.state_cache
.active_local_users_in_room(&room_id)
.count(),
services.rooms.state_cache.room_servers(&room_id).count(),
services.rooms.directory.is_public_room(&room_id),
services.rooms.state_accessor.room_state_get(
&room_id,
&StateEventType::RoomCreate,
""
),
services
.rooms
.state_accessor
.room_state_get_content::<PossiblyRedactedRoomEncryptionEventContent>(
&room_id,
&StateEventType::RoomEncryption,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomName,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomTopic,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomCanonicalAlias,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomJoinRules,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomHistoryVisibility,
""
),
services
.rooms
.state_accessor
.room_state_get_content::<PossiblyRedactedRoomTombstoneEventContent>(
&room_id,
&StateEventType::RoomTombstone,
""
),
);
let Ok(create_event) = create_event else {
return None;
};
let create_content = create_event
.get_content::<RoomCreateEventContent>()
.expect("m.room.create content must be valid");
Some(rooms::list::v1::MinimalRoomInfo {
room_id,
banned,
disabled,
member_count: usize::try_from(member_count.unwrap_or_default())
.expect("u64 should fit in usize"),
local_member_count,
resident_server_count,
creators: vec![create_event.sender],
encrypted: encryption_event.is_ok_and(|c| c.algorithm.is_some()),
federated: create_content.federate,
published,
version: create_content.room_version,
name: name_event.unwrap_or(None),
topic: topic_event.unwrap_or(None),
canonical_alias: canonical_alias_event.unwrap_or(None),
join_rules: join_rules_event.unwrap_or(None),
history_visibility: history_visibility_event.unwrap_or(None),
predecessor: create_content.predecessor.map(|c| c.room_id),
successor: tombstone_event.map_or(None, |c| c.replacement_room),
})
})
.collect()
.await;
Ok(rooms::list::v1::Response::new(rooms))
}
+5
View File
@@ -0,0 +1,5 @@
mod ban;
mod list;
pub(crate) use ban::ban_room;
pub(crate) use list::*;
+119
View File
@@ -0,0 +1,119 @@
use axum::extract::State;
use conduwuit::{
Err, err, error, info,
utils::{IterStream, stream::BroadbandExt},
warn,
};
use futures::{FutureExt, StreamExt};
use ruma::UserId;
use ruminuwuity::admin::continuwuity::users;
use service::users::HashedPassword;
use crate::router::Ruma;
/// # `POST /_continuwuity/admin/v1/users/create`
///
/// Creates a new user.
pub(crate) async fn create_user_route(
State(services): State<crate::State>,
body: Ruma<users::create::v1::Request>,
) -> conduwuit::Result<users::create::v1::Response> {
let email = body
.email
.clone()
.map(lettre::Address::try_from)
.transpose()
.map_err(|e| err!(Request(BadJson("Invalid email address: {e}"))))?;
let ref user_id = services
.users
.determine_registration_user_id(Some(body.localpart.clone()), email.as_ref(), None)
.await?;
services
.users
.create_local_account(user_id, HashedPassword::new(&body.password)?, email)
.await;
if body.suspended {
services
.users
.suspend_account(&user_id, body.identity.sender_user())
.await;
}
if body.locked {
services
.users
.lock_account(user_id, body.identity.sender_user())
.await;
}
if body.login_disabled {
services.users.disable_login(user_id);
}
if let Some(ref value) = body.display_name {
services.users.set_profile_key(
user_id,
"displayname",
Some(serde_json::to_value(value)?),
);
}
if let Some(ref value) = body.avatar_url {
services
.users
.set_profile_key(user_id, "avatar_url", Some(serde_json::to_value(value)?));
}
if body.admin {
services
.admin
.make_user_admin(user_id)
.await
.inspect_err(|e| error!("failed to make new user {user_id} an admin: {e}"))
.ok();
}
if !body.skip_auto_join {
services.users.join_auto_join_rooms(user_id).await;
}
body.auto_join_rooms
.clone()
.into_iter()
.stream()
.broad_filter_map(|room| async move {
services
.rooms
.alias
.resolve_with_servers(&room, None)
.await
.inspect_err(|e| {
warn!(
"Failed to resolve room alias to room ID when attempting to auto join \
{room}: {e}"
);
})
.ok()
})
.for_each_concurrent(None, |(room_id, servers)| async move {
match services
.rooms
.membership
.join_room(
user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
servers.as_ref(),
)
.boxed()
.await
{
| Err(e) => {
warn!("Failed to automatically join {user_id} to {room_id}: {e}");
},
| _ => {
info!("Automatically joined room {user_id} to {room_id}");
},
}
})
.await;
Ok(users::create::v1::Response::new(user_id.to_owned()))
}
+42
View File
@@ -0,0 +1,42 @@
use axum::extract::State;
use conduwuit::utils::stream::WidebandExt;
use futures::StreamExt;
use ruminuwuity::admin::continuwuity::users;
use tokio::join;
use crate::router::Ruma;
/// # `GET /_continuwuity/admin/v1/users`
///
/// Lists all users on this homeserver.
pub(crate) async fn list_users_route(
State(services): State<crate::State>,
body: Ruma<users::list::v1::Request>,
) -> conduwuit::Result<users::list::v1::Response> {
let users = services
.users
.list_local_users()
.skip(body.offset.unwrap_or_default())
.take(body.limit.unwrap_or(100).min(100))
.wide_filter_map(|user_id| async move {
let (deactivated, suspended, locked, admin, login_disabled) = join!(
services.users.is_deactivated(&user_id),
services.users.is_suspended(&user_id),
services.users.is_locked(&user_id),
services.users.is_admin(&user_id),
services.users.is_login_disabled(&user_id),
);
Some(users::list::v1::User {
user_id: user_id.clone(),
deactivated: deactivated.unwrap_or_default(),
suspended: suspended.unwrap_or_default(),
locked: locked.unwrap_or_default(),
admin,
login_disabled,
})
})
.collect()
.await;
Ok(users::list::v1::Response::new(users))
}
+5
View File
@@ -0,0 +1,5 @@
mod create;
mod list;
pub(crate) use create::*;
pub(crate) use list::*;
+9 -9
View File
@@ -1,7 +1,7 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::future::{join, join3};
use ruminuwuity::admin::{get_suspended, set_suspended};
use ruma::api::client::admin::{is_user_suspended, suspend_user};
use crate::Ruma;
@@ -10,8 +10,8 @@
/// Check the suspension status of a target user
pub(crate) async fn get_suspended_status(
State(services): State<crate::State>,
body: Ruma<get_suspended::v1::Request>,
) -> Result<get_suspended::v1::Response> {
body: Ruma<is_user_suspended::v1::Request>,
) -> Result<is_user_suspended::v1::Response> {
let (admin, active) = join(
services.users.is_admin(body.identity.expect_sender_user()?),
services.users.is_active(&body.user_id),
@@ -26,7 +26,7 @@ pub(crate) async fn get_suspended_status(
if !active {
return Err!(Request(NotFound("Unknown user")));
}
Ok(get_suspended::v1::Response::new(
Ok(is_user_suspended::v1::Response::new(
services.users.is_suspended(&body.user_id).await?,
))
}
@@ -36,8 +36,8 @@ pub(crate) async fn get_suspended_status(
/// Set the suspension status of a target user
pub(crate) async fn put_suspended_status(
State(services): State<crate::State>,
body: Ruma<set_suspended::v1::Request>,
) -> Result<set_suspended::v1::Response> {
body: Ruma<suspend_user::v1::Request>,
) -> Result<suspend_user::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
let (sender_admin, active, target_admin) = join3(
@@ -64,13 +64,13 @@ pub(crate) async fn put_suspended_status(
}
if services.users.is_suspended(&body.user_id).await? == body.suspended {
// No change
return Ok(set_suspended::v1::Response::new(body.suspended));
return Ok(suspend_user::v1::Response::new(body.suspended));
}
let action = if body.suspended {
services
.users
.suspend_account(&body.user_id, sender_user)
.suspend_account(&body.user_id, body.identity.sender_user())
.await;
"suspended"
} else {
@@ -86,5 +86,5 @@ pub(crate) async fn put_suspended_status(
.await;
}
Ok(set_suspended::v1::Response::new(body.suspended))
Ok(suspend_user::v1::Response::new(body.suspended))
}
+5 -7
View File
@@ -8,7 +8,6 @@
self, delete_device, delete_devices, get_device, get_devices, update_device,
},
};
use service::uiaa::Identity;
use crate::{Ruma, client::DEVICE_ID_LENGTH};
@@ -95,6 +94,7 @@ pub(crate) async fn update_device_route(
&device_id,
&appservice.registration.as_token,
None,
None,
Some(client.to_string()),
)
.await?;
@@ -119,14 +119,13 @@ pub(crate) async fn delete_device_route(
body: Ruma<delete_device::v3::Request>,
) -> Result<delete_device::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if appservice.is_none() {
if let Some(sender_device) = body.identity.sender_device() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.await?;
}
@@ -155,14 +154,13 @@ pub(crate) async fn delete_devices_route(
body: Ruma<delete_devices::v3::Request>,
) -> Result<delete_devices::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if appservice.is_none() {
if let Some(sender_device) = body.identity.sender_device() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.await?;
}
+7 -2
View File
@@ -26,7 +26,7 @@
serde::Raw,
};
use serde_json::json;
use service::uiaa::Identity;
use service::oauth::OAuthTicket;
use crate::Ruma;
@@ -205,7 +205,12 @@ pub(crate) async fn upload_signing_keys_route(
{
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(
&body.auth,
sender_user,
body.identity.sender_device(),
Some(OAuthTicket::CrossSigningReset),
)
.await?;
}
+3
View File
@@ -16,6 +16,7 @@
pub(super) mod membership;
pub(super) mod message;
pub(super) mod mutual_rooms;
pub(super) mod oauth;
pub(super) mod openid;
pub(super) mod presence;
pub(super) mod profile;
@@ -61,6 +62,7 @@
pub use membership::{leave_all_rooms, leave_room, remote_leave_room};
pub(super) use message::*;
pub(super) use mutual_rooms::*;
pub(super) use oauth::*;
pub(super) use openid::*;
pub(super) use presence::*;
pub(super) use profile::*;
@@ -73,6 +75,7 @@
pub(super) use room::*;
pub(super) use search::*;
pub(super) use send::*;
pub use session::handle_login;
pub(super) use session::*;
pub(super) use space::*;
pub(super) use state::*;
+56
View File
@@ -0,0 +1,56 @@
use axum::{
Json, Router,
extract::{Request, State},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::method_routing::{get, post},
};
use const_str::concat;
use http::StatusCode;
use serde_json::json;
pub(crate) use server_metadata::*;
mod register_client;
mod server_metadata;
mod token;
const BASE_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/oauth2/");
const AUTH_CODE_PATH: &str = "grant/authorization_code";
const JWKS_URI_PATH: &str = "client/keys.json";
const CLIENT_REGISTER_PATH: &str = "client/register";
const TOKEN_REVOKE_PATH: &str = "client/revoke";
const TOKEN_PATH: &str = "grant/token";
const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink");
pub(crate) fn router(state: crate::State) -> Router<crate::State> {
Router::new()
.nest(BASE_PATH, oauth_router())
.route(
"/.well-known/openid-configuration",
get(
// TODO(unspecced): used by old versions of the matrix-js-sdk
async |State(services): State<crate::State>| {
Json(authorization_server_metadata(&services).await)
},
),
)
.layer(middleware::from_fn_with_state(
state,
async |State(state): State<crate::State>, request: Request, next: Next| -> Response {
if state.config.oauth.compatibility_mode.oauth_available() {
next.run(request).await
} else {
(StatusCode::NOT_FOUND, "OAuth is unavailable on this server").into_response()
}
},
))
}
fn oauth_router() -> Router<crate::State> {
Router::new()
.route(concat!("/", CLIENT_REGISTER_PATH), post(register_client::register_client_route))
// TODO(unspecced): used by old versions of the matrix-js-sdk
.route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []}))))
.route(concat!("/", TOKEN_PATH), post(token::token_route))
.route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route))
}
+28
View File
@@ -0,0 +1,28 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use http::StatusCode;
use serde::Serialize;
use service::oauth::client_metadata::ClientMetadata;
#[derive(Serialize)]
struct RegisteredClient {
client_id: String,
#[serde(flatten)]
metadata: ClientMetadata,
}
pub(crate) async fn register_client_route(
State(services): State<crate::State>,
Json(metadata): Json<ClientMetadata>,
) -> Result<Response, Response> {
let client_id = services
.oauth
.register_client(&metadata)
.await
.map_err(|err| (StatusCode::BAD_REQUEST, Json(err)).into_response())?;
Ok(Json(RegisteredClient { client_id, metadata }).into_response())
}
+62
View File
@@ -0,0 +1,62 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use ruma::{
api::client::discovery::get_authorization_server_metadata::{
self, v1::AccountManagementAction,
},
serde::Raw,
};
use serde_json::{Value, json};
use service::Services;
use crate::{
Ruma,
client::oauth::{
ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH,
TOKEN_REVOKE_PATH,
},
};
pub(crate) async fn get_authorization_server_metadata_route(
State(services): State<crate::State>,
_body: Ruma<get_authorization_server_metadata::v1::Request>,
) -> Result<get_authorization_server_metadata::v1::Response> {
if !services.config.oauth.compatibility_mode.oauth_available() {
return Err!(Request(Unrecognized("OAuth is unavailable on this server")));
}
let metadata = Raw::new(&authorization_server_metadata(&services).await).unwrap();
Ok(get_authorization_server_metadata::v1::Response::new(metadata.cast_unchecked()))
}
pub(crate) async fn authorization_server_metadata(services: &Services) -> Value {
let endpoint_base = services
.config
.get_client_domain()
.join(super::BASE_PATH)
.unwrap();
json!({
"account_management_uri": endpoint_base.join(ACCOUNT_MANAGEMENT_PATH).unwrap(),
"account_management_actions_supported": [
AccountManagementAction::AccountDeactivate,
AccountManagementAction::CrossSigningReset,
AccountManagementAction::DeviceDelete,
AccountManagementAction::DeviceView,
AccountManagementAction::DevicesList,
AccountManagementAction::Profile,
],
"authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(),
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"issuer": services.config.get_client_domain(),
"jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(),
"prompt_values_supported": ["create"],
"registration_endpoint": endpoint_base.join(CLIENT_REGISTER_PATH).unwrap(),
"response_modes_supported": ["query", "fragment"],
"response_types_supported": ["code"],
"revocation_endpoint": endpoint_base.join(TOKEN_REVOKE_PATH).unwrap(),
"token_endpoint": endpoint_base.join(TOKEN_PATH).unwrap(),
})
}
+23
View File
@@ -0,0 +1,23 @@
use axum::{Form, Json, extract::State, response::IntoResponse};
use http::StatusCode;
use service::oauth::grant::{RevokeTokenRequest, TokenRequest};
pub(crate) async fn token_route(
State(services): State<crate::State>,
Form(request): Form<TokenRequest>,
) -> impl IntoResponse {
match services.oauth.issue_token(request).await {
| Ok(response) => Ok(Json(response)),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
pub(crate) async fn revoke_token_route(
State(services): State<crate::State>,
Form(request): Form<RevokeTokenRequest>,
) -> impl IntoResponse {
match services.oauth.revoke_token(request.token).await {
| Ok(()) => Ok(StatusCode::OK),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
+12
View File
@@ -31,6 +31,12 @@ pub(crate) async fn get_profile_route(
State(services): State<crate::State>,
body: Ruma<get_profile::v3::Request>,
) -> Result<get_profile::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to view user profiles."
)));
}
let Some(profile) = fetch_full_profile(&services, &body.user_id).await else {
return Err!(Request(NotFound("This user's profile could not be fetched.")));
};
@@ -42,6 +48,12 @@ pub(crate) async fn get_profile_field_route(
State(services): State<crate::State>,
body: Ruma<get_profile_field::v3::Request>,
) -> Result<get_profile_field::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to view user profiles."
)));
}
let value = fetch_profile_field(&services, &body.user_id, body.field.clone()).await?;
Ok(assign!(get_profile_field::v3::Response::default(), { value }))
+31 -20
View File
@@ -21,7 +21,7 @@
},
login::{
self,
v3::{DiscoveryInfo, HomeserverInfo},
v3::{DiscoveryInfo, HomeserverInfo, LoginInfo},
},
logout, logout_all,
},
@@ -29,7 +29,6 @@
},
assign,
};
use service::uiaa::Identity;
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
@@ -44,6 +43,12 @@ pub(crate) async fn get_login_types_route(
ClientIp(client): ClientIp,
_body: Ruma<get_login_types::v3::Request>,
) -> Result<get_login_types::v3::Response> {
if !services.config.oauth.compatibility_mode.uiaa_available() {
return Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)));
}
Ok(get_login_types::v3::Response::new(vec![
get_login_types::v3::LoginType::Password(PasswordLoginType::default()),
get_login_types::v3::LoginType::ApplicationService(ApplicationServiceLoginType::default()),
@@ -53,7 +58,7 @@ pub(crate) async fn get_login_types_route(
]))
}
pub(crate) async fn handle_login(
pub async fn handle_login(
services: &Services,
identifier: Option<&UserIdentifier>,
password: &str,
@@ -87,10 +92,6 @@ pub(crate) async fn handle_login(
return Err!(Request(InvalidParam("User ID does not belong to this homeserver")));
}
if services.users.is_locked(&user_id).await? {
return Err!(Request(UserLocked("This account has been locked.")));
}
if services.users.is_login_disabled(&user_id).await {
warn!(%user_id, "user attempted to log in with a login-disabled account");
return Err!(Request(Forbidden("This account is not permitted to log in.")));
@@ -119,19 +120,29 @@ pub(crate) async fn login_route(
ClientIp(client): ClientIp,
body: Ruma<login::v3::Request>,
) -> Result<login::v3::Response> {
if !services.config.oauth.compatibility_mode.uiaa_available() {
return match body.login_info {
| LoginInfo::ApplicationService(_) => {
Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice login is not available on this server."
)))
},
| _ => {
Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)))
},
};
}
let emergency_mode_enabled = services.config.emergency_password.is_some();
// Validate login method
// TODO: Other login methods
let user_id = match &body.login_info {
#[allow(deprecated)]
| login::v3::LoginInfo::Password(login::v3::Password {
identifier,
password,
user,
..
}) => handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
| login::v3::LoginInfo::Token(login::v3::Token { token, .. }) => {
| LoginInfo::Password(login::v3::Password { identifier, password, user, .. }) =>
handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
| LoginInfo::Token(login::v3::Token { token, .. }) => {
debug!("Got token login type");
if !services.server.config.login_via_existing_session {
return Err!(Request(Unknown("Token login is not enabled.")));
@@ -139,7 +150,7 @@ pub(crate) async fn login_route(
services.users.find_from_login_token(token).await?
},
#[allow(deprecated)]
| login::v3::LoginInfo::ApplicationService(login::v3::ApplicationService {
| LoginInfo::ApplicationService(login::v3::ApplicationService {
identifier,
user,
..
@@ -173,7 +184,6 @@ pub(crate) async fn login_route(
user_id
},
| _ => {
debug!("/login json_body: {:?}", &body.json_body);
return Err!(Request(Unknown(
debug_warn!(?body.login_info, "Invalid or unsupported login type")
)));
@@ -203,7 +213,7 @@ pub(crate) async fn login_route(
if device_exists {
services
.users
.set_token(&user_id, &device_id, &token)
.set_token(&user_id, &device_id, &token, None)
.await?;
} else {
services
@@ -212,6 +222,7 @@ pub(crate) async fn login_route(
&user_id,
&device_id,
&token,
None,
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
@@ -250,7 +261,7 @@ pub(crate) async fn login_token_route(
ClientIp(client): ClientIp,
body: Ruma<get_login_token::v1::Request>,
) -> Result<get_login_token::v1::Response> {
if !services.server.config.login_via_existing_session {
if !services.config.login_via_existing_session {
return Err!(Request(Forbidden("Login via an existing session is not enabled")));
}
@@ -259,7 +270,7 @@ pub(crate) async fn login_token_route(
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, body.identity.sender_device(), None)
.await?;
let login_token = utils::random_string(TOKEN_LENGTH);
-1
View File
@@ -69,7 +69,6 @@ pub(crate) async fn sync_events_v5_route(
ClientIp(client_ip): ClientIp,
body: Ruma<sync_events::v5::Request>,
) -> Result<sync_events::v5::Response> {
debug_assert!(DEFAULT_BUMP_TYPES.is_sorted(), "DEFAULT_BUMP_TYPES is not sorted");
let sender_user = body.identity.expect_sender_user()?;
let sender_device = body.identity.expect_sender_device()?;
+2 -2
View File
@@ -35,8 +35,8 @@ pub(crate) async fn get_supported_versions_route(
/// `/_matrix/federation/v1/version`
pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
Ok(Json(serde_json::json!({
"name": conduwuit::version::name(),
"version": conduwuit::version::version(),
"name": conduwuit::BRANDING,
"version": conduwuit::version(),
})))
}
+2 -42
View File
@@ -3,8 +3,7 @@
use ruma::{
api::client::discovery::{
discover_homeserver::{self, HomeserverInfo},
discover_policy_server,
discover_support::{self, Contact, ContactRole},
discover_policy_server, discover_support,
},
assign,
};
@@ -67,46 +66,7 @@ pub(crate) async fn well_known_support(
.as_ref()
.map(ToString::to_string);
let email_address = services.config.well_known.support_email.clone();
let matrix_id = services.config.well_known.support_mxid.clone();
let pgp_key = services.config.well_known.support_pgp_key.clone();
// TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![];
let role = services
.config
.well_known
.support_role
.clone()
.unwrap_or(ContactRole::Admin);
// Add configured contact if at least one contact method is specified
let configured_contact = match (matrix_id, email_address) {
| (Some(matrix_id), email_address) =>
Some(assign!(Contact::with_matrix_id(role, matrix_id), { email_address })),
| (None, Some(email_address)) => Some(Contact::with_email_address(role, email_address)),
| (None, None) => None,
};
if let Some(mut configured_contact) = configured_contact {
configured_contact.pgp_key = pgp_key;
contacts.push(configured_contact);
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
let admin_users = services.admin.get_admins().await;
for user_id in &admin_users {
if *user_id == services.globals.server_user {
continue;
}
contacts.push(Contact::with_matrix_id(ContactRole::Admin, user_id.to_owned()));
}
}
let contacts = services.admin.get_support_contacts().await;
if contacts.is_empty() && support_page.is_none() {
// No admin room, no configured contacts, and no support page
+1 -2
View File
@@ -1,4 +1,5 @@
#![type_length_limit = "16384"] //TODO: reduce me
#![recursion_limit = "256"] // My Giant Async Function
#![allow(clippy::toplevel_ref_arg)]
extern crate conduwuit_core as conduwuit;
@@ -10,8 +11,6 @@
pub mod router;
pub mod server;
pub mod admin;
pub(crate) use self::router::{Ruma, RumaResponse, State};
conduwuit::mod_ctor! {}
+21 -7
View File
@@ -10,16 +10,18 @@
response::{IntoResponse, Redirect},
routing::{any, get, post},
};
use conduwuit::{Server, err};
use conduwuit::err;
pub(super) use conduwuit_service::state::State;
use http::{Uri, uri};
use self::handler::RouterExt;
pub(super) use self::{args::Args as Ruma, auth::ClientIdentity, response::RumaResponse};
use crate::{admin, client, server};
#[cfg(feature = "admin_api")]
use crate::client::admin::site as admin_api;
use crate::{client, server};
pub fn build(router: Router<State>, server: &Server) -> Router<State> {
let config = &server.config;
pub fn build(router: Router<State>, state: State) -> Router<State> {
let config = &state.server.config;
let mut router = router
.ruma_route(&client::appservice_ping)
.ruma_route(&client::get_supported_versions_route)
@@ -181,15 +183,17 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
.ruma_route(&client::get_room_summary)
.ruma_route(&client::get_suspended_status)
.ruma_route(&client::put_suspended_status)
.ruma_route(&client::get_locked_status)
.ruma_route(&client::put_locked_status)
.ruma_route(&client::well_known_support)
.ruma_route(&client::well_known_client)
.ruma_route(&client::well_known_policy_server)
.ruma_route(&client::get_rtc_transports)
.ruma_route(&client::room_initial_sync_route)
.ruma_route(&client::get_authorization_server_metadata_route)
.merge(client::oauth::router(state))
.route("/_conduwuit/server_version", get(client::conduwuit_server_version))
.route("/_continuwuity/server_version", get(client::conduwuit_server_version))
.ruma_route(&admin::rooms::ban::ban_room)
.ruma_route(&admin::rooms::list::list_rooms);
.route("/_continuwuity/server_version", get(client::conduwuit_server_version));
if config.allow_federation {
router = router
@@ -274,6 +278,16 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
.route("/_matrix/media/r0/preview_url", any(redirect_legacy_preview));
}
#[cfg(feature = "admin_api")]
{
router = router
.ruma_route(&admin_api::users::list_users_route)
.ruma_route(&admin_api::users::create_user_route)
.ruma_route(&admin_api::rooms::ban_room)
.ruma_route(&admin_api::rooms::legacy_list_rooms_route)
.ruma_route(&admin_api::rooms::list_rooms_route);
};
router
}
+189 -129
View File
@@ -1,21 +1,28 @@
use std::any::{Any, TypeId};
use std::{
any::{Any, TypeId},
fmt::Display,
};
use conduwuit::{Err, Result, err};
use conduwuit::{Err, Error, Result, err};
use http::StatusCode;
use ruma::{
DeviceId, OwnedDeviceId, OwnedServerName, OwnedUserId, UserId,
api::{
IncomingRequest,
IncomingRequest, OAuthScope,
auth_scheme::{
AccessToken, AccessTokenOptional, AppserviceToken, AppserviceTokenOptional,
AuthScheme, NoAccessToken, NoAuthentication,
},
client,
error::{ErrorKind, UnknownTokenErrorData},
federation::authentication::ServerSignatures,
},
assign,
};
use service::{
Services,
server_keys::{PubKeyMap, PubKeys},
users::AccessTokenStatus,
};
use crate::{router::args::AuthQueryParams, service::appservice::RegistrationInfo};
@@ -72,68 +79,66 @@ pub(crate) fn appservice_info(&self) -> Option<&RegistrationInfo> {
pub(crate) fn is_appservice(&self) -> bool { matches!(self, Self::Appservice { .. }) }
}
impl Display for ClientIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
| Self::User { sender_user, sender_device } =>
write!(f, "{sender_user} ({sender_device})"),
| Self::Appservice { sender_user, appservice_info, .. } =>
write!(f, "appservice `{}` using {sender_user}", appservice_info.registration.id),
}
}
}
pub(crate) trait CheckAuth: AuthScheme {
type Identity: Send;
fn authenticate<R: IncomingRequest + Any, B: AsRef<[u8]> + Sync>(
fn authenticate<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
incoming_request: &hyper::Request<B>,
query: AuthQueryParams,
) -> impl Future<Output = Result<Self::Identity>> + Send {
async move {
let route = TypeId::of::<R>();
let output = Self::extract_authentication(incoming_request).map_err(|err| {
err!(Request(Unauthorized(warn!(
"Failed to extract authorization: {}",
"Failed to extract request authentication: {}",
err.into()
))))
})?;
Self::verify(services, output, incoming_request, query, route).await
Self::verify::<R, B>(services, output, incoming_request, query).await
}
}
fn verify<B: AsRef<[u8]> + Sync>(
fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> impl Future<Output = Result<Self::Identity>> + Send;
}
impl CheckAuth for ServerSignatures {
type Identity = OwnedServerName;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
let destination = services.globals.server_name();
if output
.destination
.as_ref()
.is_some_and(|supplied_destination| supplied_destination != destination)
{
return Err!(Request(Unauthorized("Destination mismatch.")));
}
let key = services
.server_keys
.get_verify_key(&output.origin, &output.key)
.await
.map_err(|e| {
err!(Request(Unauthorized(warn!("Failed to fetch signing keys: {e}"))))
.map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to fetch signing keys: {err}"))))
})?;
let keys: PubKeys = [(output.key.to_string(), key.key)].into();
let keys: PubKeyMap = [(output.origin.as_str().into(), keys)].into();
match output.verify_request(request, destination, &keys) {
match output.verify_request(request, services.globals.server_name(), &keys) {
| Ok(()) => {
if services
.moderation
@@ -155,95 +160,36 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for AccessToken {
type Identity = ClientIdentity;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
if let Ok((sender_user, sender_device)) = services.users.find_from_token(&output).await {
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(Unauthorized("Your account is locked.")));
}
}
Ok(ClientIdentity::User { sender_user, sender_device })
} else if let Ok(appservice_info) = services.appservice.find_from_token(&output).await {
let Ok(sender_user) = query.user_id.clone().map_or_else(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
// `org.matrix.msc3202.device_id` query parameter.
let sender_device =
if let Some(device_id) = query.device_id.as_deref().map(Into::into) {
// Verify the device exists for this user
if services
.users
.get_device_metadata(&sender_user, device_id)
.await
.is_err()
{
return Err!(Request(Forbidden(
"Device does not exist for user or appservice cannot masquerade as \
this device."
)));
}
Some(device_id.to_owned())
} else {
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err!(Request(Unauthorized("Invalid access token.")))
}
verify_access_token(services, output, query, TypeId::of::<R>(), R::required_scopes())
.await
}
}
impl CheckAuth for AccessTokenOptional {
type Identity = Option<ClientIdentity>;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
_request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
match output {
| Some(token) =>
<AccessToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| Some(token) => verify_access_token(
services,
token,
query,
TypeId::of::<R>(),
R::required_scopes(),
)
.await
.map(Some),
| None => Ok(None),
}
}
@@ -252,36 +198,29 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for AppserviceToken {
type Identity = RegistrationInfo;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
verify_appservice_access_token(services, output).await
}
}
impl CheckAuth for AppserviceTokenOptional {
type Identity = Option<RegistrationInfo>;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
) -> Result<Self::Identity> {
match output {
| Some(token) =>
<AppserviceToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| Some(token) => verify_appservice_access_token(services, token)
.await
.map(Some),
| None => Ok(None),
}
}
@@ -290,12 +229,11 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for NoAuthentication {
type Identity = ();
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
_services: &Services,
_output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
Ok(())
}
@@ -304,31 +242,153 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for NoAccessToken {
type Identity = Option<ClientIdentity>;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
_output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
// We handle these the same as AccessTokenOptional
let token = AccessTokenOptional::extract_authentication(request).map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to extract authorization: {}", err))))
})?;
// Check special access restrictions
if (route == TypeId::of::<client::profile::get_avatar_url::v3::Request>()
|| route == TypeId::of::<client::profile::get_display_name::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile_field::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile::v3::Request>())
&& services.config.require_auth_for_profile_requests
&& token.is_none()
{
return Err!(Request(Unauthorized(
"This server requires authentication to access user profiles."
)));
match token {
| Some(token) => verify_access_token(
services,
token,
query,
TypeId::of::<R>(),
// Assume that no scopes are required for these endpoints since
// ostensibly they don't require authentication
&[],
)
.await
.map(Some),
| None => Ok(None),
}
<AccessTokenOptional as CheckAuth>::verify(services, token, request, query, route).await
}
}
async fn verify_access_token(
services: &Services,
output: String,
query: AuthQueryParams,
route: TypeId,
required_scopes: &[OAuthScope],
) -> Result<ClientIdentity> {
if let Some((sender_user, sender_device, status)) =
services.users.find_from_token(&output).await
{
// If the token is expired we return a soft logout
if matches!(status, AccessTokenStatus::Expired) {
return Err(Error::Request(
ErrorKind::UnknownToken(
assign!(UnknownTokenErrorData::new(), { soft_logout: true }),
),
"This access token has expired.".into(),
StatusCode::UNAUTHORIZED,
));
}
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(UserLocked("Your account is locked.")));
}
}
// If this device is bound to an OAuth session, check its scopes. This will also
// handle admin-only endpoints for OAuth clients.
if let Some(session) = services
.oauth
.get_session_info_for_device(&sender_user, &sender_device)
.await
{
if required_scopes
.iter()
.all(|scope| !session.scopes.contains(scope))
{
return Err!(Request(Forbidden(
"You don't have the necessary scopes to use this endpoint."
)));
}
} else {
// Otherwise, explicitly check if the endpoint is restricted to admins only.
if required_scopes.contains(&OAuthScope::ServerAdministration)
&& !services.users.is_admin(&sender_user).await
{
return Err!(Request(Forbidden(
"Only server administrators can use this endpoint."
)));
}
}
Ok(ClientIdentity::User { sender_user, sender_device })
} else if let Ok(appservice_info) = services.appservice.find_from_token(&output).await {
let Ok(sender_user) = query.user_id.clone().map_or_else(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in this appservice's namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
// `org.matrix.msc3202.device_id` query parameter.
let sender_device = if let Some(device_id) = query.device_id.as_deref().map(Into::into) {
// Verify the device exists for this user
if services
.users
.get_device_metadata(&sender_user, device_id)
.await
.is_err()
{
return Err!(Request(Forbidden("Appservice cannot masquerade as this device.")));
}
Some(device_id.to_owned())
} else {
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid access token.".into(),
StatusCode::UNAUTHORIZED,
))
}
}
async fn verify_appservice_access_token(
services: &Services,
output: String,
) -> Result<RegistrationInfo> {
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
}
+2 -2
View File
@@ -11,8 +11,8 @@ pub(crate) async fn get_server_version_route(
) -> Result<get_server_version::v1::Response> {
Ok(assign!(get_server_version::v1::Response::new(), {
server: Some(assign!(get_server_version::v1::Server::new(), {
name: Some(conduwuit::version::name().into()),
version: Some(conduwuit::version::version().into()),
name: Some(conduwuit::BRANDING.into()),
version: Some(conduwuit::version().into()),
})),
}))
}
+80 -13
View File
@@ -4,7 +4,7 @@
pub mod proxy;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
collections::{BTreeMap, BTreeSet},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
};
@@ -655,19 +655,25 @@ pub struct Config {
/// even if `recaptcha_site_key` is set.
pub recaptcha_private_site_key: Option<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.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<String, HashMap<String, TermsDocument>>,
pub registration_terms: RegistrationTerms,
/// display: nested
#[serde(default)]
pub oauth: OauthConfig,
/// Controls whether users are allowed to deactivate their own accounts
/// through the account management panel or their Matrix clients. Server
/// admins can always deactivate users using the relevant admin commands.
///
/// Note that, in some jurisdictions, you may be legally required to honor
/// users who request to deactivate their accounts if you set this option
/// to `false`.
///
/// default: true
#[serde(default = "true_fn")]
pub allow_deactivation: bool,
/// Controls whether encrypted rooms and events are allowed.
#[serde(default = "true_fn")]
@@ -2351,6 +2357,30 @@ 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 = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
/// ```
///
/// default: {}
pub documents: BTreeMap<String, TermsDocument>,
}
/// A policy document for use with a m.login.terms stage.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TermsDocument {
@@ -2358,6 +2388,43 @@ pub struct TermsDocument {
pub url: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.oauth",
optional = "true"
)]
pub struct OauthConfig {
/// The compatibility mode to use for OAuth.
///
/// - "disabled": OAuth will be unavailable. Users will only be able to log
/// in using legacy authentication.
/// - "hybrid": OAuth and legacy authentication will both be available. Some
/// clients may only use one or the other.
/// - "exclusive": Only OAuth will be available. Clients which require
/// legacy authentication will be unable to log in.
///
/// default: "hybrid"
pub compatibility_mode: OAuthMode,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OAuthMode {
Disabled,
#[default]
Hybrid,
Exclusive,
}
impl OAuthMode {
#[must_use]
pub fn uiaa_available(&self) -> bool { matches!(self, Self::Disabled | Self::Hybrid) }
#[must_use]
pub fn oauth_available(&self) -> bool { matches!(self, Self::Hybrid | Self::Exclusive) }
}
const DEPRECATED_KEYS: &[&str] = &[
"cache_capacity",
"conduit_cache_capacity_modifier",
+2 -1
View File
@@ -118,7 +118,7 @@ pub enum Error {
#[error(transparent)]
Mxid(#[from] ruma::IdParseError),
#[error("from {0}: {1}")]
Redaction(ruma::OwnedServerName, ruma::canonical_json::RedactionError),
Redaction(ruma::OwnedServerName, ruma::canonical_json::CanonicalJsonFieldError),
#[error("{0:?}: {1}")]
Request(ErrorKind, Cow<'static, str>, http::StatusCode),
#[error(transparent)]
@@ -161,6 +161,7 @@ pub fn message(&self) -> String {
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}"),
}
}
+1 -4
View File
@@ -73,11 +73,8 @@ pub(super) fn bad_request_code(kind: &ErrorKind) -> StatusCode {
// 413
| TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
// 405
| Unrecognized => StatusCode::METHOD_NOT_ALLOWED,
// 404
| NotFound => StatusCode::NOT_FOUND,
| Unrecognized | NotFound => StatusCode::NOT_FOUND,
// 403
| GuestAccessForbidden
+6 -9
View File
@@ -7,19 +7,16 @@
use std::sync::OnceLock;
static BRANDING: &str = "continuwuity";
static WEBSITE: &str = "https://continuwuity.org";
static SEMANTIC: &str = env!("CARGO_PKG_VERSION");
pub const BRANDING: &str = "continuwuity";
pub const ROUTE_PREFIX: &str = "/_continuwuity";
pub const WEBSITE: &str = "https://continuwuity.org";
pub const SEMANTIC: &str = env!("CARGO_PKG_VERSION");
static VERSION: OnceLock<String> = OnceLock::new();
static VERSION_UA: OnceLock<String> = OnceLock::new();
static USER_AGENT: OnceLock<String> = OnceLock::new();
static USER_AGENT_MEDIA: OnceLock<String> = OnceLock::new();
#[inline]
#[must_use]
pub fn name() -> &'static str { BRANDING }
#[inline]
pub fn version() -> &'static str { VERSION.get_or_init(init_version) }
@@ -32,10 +29,10 @@ pub fn user_agent() -> &'static str { USER_AGENT.get_or_init(init_user_agent) }
#[inline]
pub fn user_agent_media() -> &'static str { USER_AGENT_MEDIA.get_or_init(init_user_agent_media) }
fn init_user_agent() -> String { format!("{}/{} (bot; +{WEBSITE})", name(), version_ua()) }
fn init_user_agent() -> String { format!("{BRANDING}/{} (bot; +{WEBSITE})", version_ua()) }
fn init_user_agent_media() -> String {
format!("{}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", name(), version_ua())
format!("{BRANDING}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", version_ua())
}
fn init_version_ua() -> String {
+2 -2
View File
@@ -1,4 +1,4 @@
use std::{borrow::Borrow, collections::BTreeSet};
use std::collections::BTreeSet;
use futures::{
Future,
@@ -824,7 +824,7 @@ struct GetThirdPartyInvite {
let prev_event_is_create_event = prev_events
.next()
.is_some_and(|event_id| event_id.borrow() == create_room.event_id().borrow());
.is_some_and(|event_id| event_id == create_room.event_id());
let no_more_prev_events = prev_events.next().is_none();
if prev_event_is_create_event && no_more_prev_events {
+2
View File
@@ -21,6 +21,7 @@ pub fn versions() -> Vec<String> {
"v1.12".to_owned(),
"v1.13".to_owned(),
"v1.14".to_owned(),
"v1.15".to_owned(),
]
}
@@ -43,5 +44,6 @@ pub fn unstable_features() -> BTreeMap<String, bool> {
("uk.timedout.msc4323".to_owned(), true), /* agnostic suspend (https://github.com/matrix-org/matrix-spec-proposals/pull/4323) */
("org.matrix.msc4155".to_owned(), true), /* invite filtering (https://github.com/matrix-org/matrix-spec-proposals/pull/4155) */
("computer.gingershaped.msc4466".to_owned(), true), /* profile change propagation (https://github.com/matrix-org/matrix-spec-proposals/pull/4466) */
("org.continuwuity.msc4484.unstable".to_owned(), true), /* server admin oauth scope (https://github.com/matrix-org/matrix-spec-proposals/pull/4484) */
])
}
+1 -4
View File
@@ -34,10 +34,7 @@ macro_rules! mod_dtor {
pub use conduwuit_build_metadata as build_metadata;
pub use config::Config;
pub use error::Error;
pub use info::{
version,
version::{name, version},
};
pub use info::version::*;
pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res};
pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
pub use server::Server;
+16 -10
View File
@@ -61,17 +61,23 @@ pub fn format(ts: SystemTime, str: &str) -> String {
pub fn pretty(d: Duration) -> String {
use Unit::*;
let fmt = |w, f, u| format!("{w}.{f} {u}");
let gen64 = |w, f, u| fmt(w, (f * 100.0) as u32, u);
let gen128 = |w, f, u| gen64(u64::try_from(w).expect("u128 to u64"), f, u);
let fmt = |w, u| {
if w == 1 {
format!("{w} {u}")
} else {
format!("{w} {u}s")
}
};
let gen64 = |w, u| fmt(w, u);
let gen128 = |w, u| gen64(u64::try_from(w).expect("u128 to u64"), u);
match whole_and_frac(d) {
| (Days(whole), frac) => gen64(whole, frac, "days"),
| (Hours(whole), frac) => gen64(whole, frac, "hours"),
| (Mins(whole), frac) => gen64(whole, frac, "minutes"),
| (Secs(whole), frac) => gen64(whole, frac, "seconds"),
| (Millis(whole), frac) => gen128(whole, frac, "milliseconds"),
| (Micros(whole), frac) => gen128(whole, frac, "microseconds"),
| (Nanos(whole), frac) => gen128(whole, frac, "nanoseconds"),
| (Days(whole), _) => gen64(whole, "day"),
| (Hours(whole), _) => gen64(whole, "hour"),
| (Mins(whole), _) => gen64(whole, "minute"),
| (Secs(whole), _) => gen64(whole, "second"),
| (Millis(whole), _) => gen128(whole, "millisecond"),
| (Micros(whole), _) => gen128(whole, "microsecond"),
| (Nanos(whole), _) => gen128(whole, "nanosecond"),
}
}
+20
View File
@@ -49,6 +49,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "bannedroomids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "clientid_clientmetadata",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "disabledroomids",
..descriptor::RANDOM_SMALL
@@ -157,6 +161,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "referencedevents",
..descriptor::RANDOM
},
Descriptor {
name: "refreshtoken_refreshtokeninfo",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "registrationtoken_info",
..descriptor::RANDOM_SMALL
@@ -371,6 +379,14 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userdevicetxnid_response",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userdeviceid_oauthsessioninfo",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userdeviceid_tokenexpires",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userfilterid_filter",
..descriptor::RANDOM_SMALL
@@ -475,4 +491,8 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "websessionid_session",
..descriptor::RANDOM_SMALL
},
];
+1
View File
@@ -68,6 +68,7 @@ full = [
"jemalloc_prof",
"perf_measurements",
"tokio_console",
"conduwuit-api/admin_api",
]
brotli_compression = [
+1 -1
View File
@@ -15,7 +15,7 @@
#[clap(
about,
long_about = None,
name = conduwuit_core::name(),
name = conduwuit_core::BRANDING,
version = conduwuit_core::version(),
)]
pub struct Args {
+1 -1
View File
@@ -110,7 +110,7 @@ pub(crate) fn init(
.with_batch_exporter(exporter)
.build();
let tracer = provider.tracer(conduwuit_core::name());
let tracer = provider.tracer(conduwuit_core::BRANDING);
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
+1 -1
View File
@@ -47,7 +47,7 @@ fn options(config: &Config) -> ClientOptions {
traces_sample_rate: config.sentry_traces_sample_rate,
debug: cfg!(debug_assertions),
release: release_name(),
user_agent: conduwuit_core::version::user_agent().into(),
user_agent: conduwuit_core::user_agent().into(),
attach_stacktrace: config.sentry_attach_stacktrace,
before_send: Some(Arc::new(before_send)),
before_breadcrumb: Some(Arc::new(before_breadcrumb)),
+3 -1
View File
@@ -112,7 +112,9 @@ fn handle_result(method: &Method, uri: &Uri, result: Response) -> Result<Respons
}
if status == StatusCode::METHOD_NOT_ALLOWED {
return Ok(err!(Request(Unrecognized("Method Not Allowed"))).into_response());
return Ok(
err!(Request(Unrecognized("Method not allowed"), METHOD_NOT_ALLOWED)).into_response()
);
}
Ok(result)
+2 -2
View File
@@ -9,8 +9,8 @@
pub(crate) fn build(services: &Arc<Services>) -> (Router, Guard) {
let router = Router::<state::State>::new();
let (state, guard) = state::create(services.clone());
let router = conduwuit_api::router::build(router, &services.server)
.merge(conduwuit_web::build())
let router = conduwuit_api::router::build(router, state)
.merge(conduwuit_web::build(services))
.fallback(not_found)
.with_state(state);
@@ -1 +1,2 @@
pub mod rooms;
pub mod users;
@@ -1,7 +1,7 @@
pub mod v1 {
use ruma::{
OwnedRoomAliasId, OwnedRoomId, OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
api::{OAuthScope, auth_scheme::AccessToken, request, response},
metadata,
};
@@ -9,8 +9,10 @@ pub mod v1 {
method: PUT,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/rooms/{room_id}/ban",
unstable("org.continuwuity.admin") => "/_continuwuity/admin/rooms/{room_id}/ban",
1.0 => "/_continuwuity/admin/v1/rooms/{room_id}/ban",
}
}
@@ -29,8 +31,11 @@ pub struct Request {
#[response]
pub struct Response {
/// Users who were successfully kicked from this room.
pub kicked_users: Vec<OwnedUserId>,
/// Users who could not be kicked from the room.
pub failed_kicked_users: Vec<OwnedUserId>,
/// Any local aliases that were removed from the room.
pub local_aliases: Vec<OwnedRoomAliasId>,
}
@@ -1,7 +1,7 @@
pub mod v1 {
pub mod unstable {
use ruma::{
OwnedRoomId,
api::{auth_scheme::AccessToken, request, response},
api::{OAuthScope, auth_scheme::AccessToken, request, response},
metadata,
};
@@ -9,8 +9,9 @@ pub mod v1 {
method: GET,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/rooms/list",
unstable => "/_continuwuity/admin/rooms/list",
}
}
@@ -20,6 +21,7 @@ pub mod v1 {
#[response]
pub struct Response {
/// A list of room IDs known to this server.
pub rooms: Vec<OwnedRoomId>,
}
@@ -33,3 +35,133 @@ impl Response {
pub fn new(rooms: Vec<OwnedRoomId>) -> Self { Self { rooms } }
}
}
pub mod v1 {
use ruma::{
OwnedRoomId, OwnedUserId, RoomVersionId,
api::{auth_scheme::AccessToken, request, response},
events::room::{
canonical_alias::PossiblyRedactedRoomCanonicalAliasEventContent,
history_visibility::PossiblyRedactedRoomHistoryVisibilityEventContent,
join_rules::PossiblyRedactedRoomJoinRulesEventContent,
name::PossiblyRedactedRoomNameEventContent,
topic::PossiblyRedactedRoomTopicEventContent,
},
metadata,
serde::{default_true, is_default},
};
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
1.0 => "/_continuwuity/admin/v1/rooms",
}
}
#[request]
#[derive(Default)]
pub struct Request {
/// The maximum number of results to return in this page. Maximum (and
/// default) is 100.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub limit: Option<usize>,
/// The number of results to skip over before returning results. Default
/// is 0.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub offset: Option<usize>,
/// If true, includes banned rooms in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub include_banned_rooms: bool,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MinimalRoomInfo {
/// The room's unique ID.
pub room_id: OwnedRoomId,
/// If true, this room is banned, and cannot be joined by non-admins.
#[serde(default, skip_serializing_if = "is_default")]
pub banned: bool,
/// If true, this room has federation disabled, but can still be locally
/// used.
#[serde(default, skip_serializing_if = "is_default")]
pub disabled: bool,
/// The total number of joined members in this room.
#[serde(default, skip_serializing_if = "is_default")]
pub member_count: usize,
/// The total number of joined members in this room that are local to
/// this server.
#[serde(default, skip_serializing_if = "is_default")]
pub local_member_count: usize,
/// The number of unique homeservers currently joined to this room.
#[serde(default, skip_serializing_if = "is_default")]
pub resident_server_count: usize,
/// The users who created this room.
///
/// The first entry is always the sender of the `m.room.create` event.
/// Any entries thereafter are additional creators in v12+ rooms. An
/// empty vec indicates the room is not known.
#[serde(default, skip_serializing_if = "is_default")]
pub creators: Vec<OwnedUserId>,
/// If true, this room has encryption enabled.
#[serde(default, skip_serializing_if = "is_default")]
pub encrypted: bool,
/// If true, this room is allowed to be federated (`m.federate` is not
/// `false` in `m.room.create`).
#[serde(default = "default_true", skip_serializing_if = "is_default")]
pub federated: bool,
/// If true, this room is published to this server's room directory.
#[serde(default, skip_serializing_if = "is_default")]
pub published: bool,
/// The version of the room.
pub version: RoomVersionId,
/// The event content for the `m.room.name` event, if any is present.
/// May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<PossiblyRedactedRoomNameEventContent>,
/// The event content for the `m.room.topic` event, if any is present.
/// May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<PossiblyRedactedRoomTopicEventContent>,
/// The event content for the `m.room.canonical_alias` event, if any is
/// present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_alias: Option<PossiblyRedactedRoomCanonicalAliasEventContent>,
/// The event content for the `m.room.join_rules` event, if any is
/// present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub join_rules: Option<PossiblyRedactedRoomJoinRulesEventContent>,
/// The event content for the `m.room.history_visibility` event, if any
/// is present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_visibility: Option<PossiblyRedactedRoomHistoryVisibilityEventContent>,
/// The ID of the room which replaces this one, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub successor: Option<OwnedRoomId>,
/// The ID of the room which preceded this one, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predecessor: Option<OwnedRoomId>,
}
#[response]
pub struct Response {
/// A list of rooms known to this server.
pub rooms: Vec<MinimalRoomInfo>,
}
impl Request {
#[must_use]
pub fn new() -> Self { Self::default() }
}
impl Response {
#[must_use]
pub fn new(rooms: Vec<MinimalRoomInfo>) -> Self { Self { rooms } }
}
}
@@ -0,0 +1,105 @@
pub mod v1 {
use ruma::{
OwnedMxcUri, OwnedRoomOrAliasId, OwnedUserId,
api::{OAuthScope, auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: POST,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/v1/users/create",
},
}
#[request]
pub struct Request {
/// The user's localpart (the identifier between `@` and `:`). Cannot be
/// blank.
pub localpart: String,
/// The user's desired password. Cannot be blank.
pub password: String,
/// The user's email address, if any.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub email: Option<String>,
/// The display name to set upon creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub display_name: Option<String>,
/// The avatar URI to set upon creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub avatar_url: Option<OwnedMxcUri>,
/// Suspends the user immediately upon creation. They can still log in.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub suspended: bool,
/// Locks the user immediately upon creation. They will receive
/// M_USER_LOCKED upon login.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub locked: bool,
/// Disables the user's login immediately upon creation.
///
/// The user can still be used if an admin generates an access token for
/// the account, but the user will not be able to use `POST
/// /_matrix/client/v3/login`.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub login_disabled: bool,
/// Promotes the user to a server administrator immediately upon
/// creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub admin: bool,
/// Skips joining rooms in the server's configured auto_join_rooms.
///
/// If this is false, all rooms in the config.toml's `auto_join_rooms`
/// will be automatically joined upon creation. If `auto_join_rooms`
/// is supplied in this request too, those rooms will be joined
/// afterwards.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub skip_auto_join: bool,
/// Additional rooms to auto-join the new user to. If `skip_auto_join`
/// is `true`, these rooms will still be joined.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub auto_join_rooms: Vec<OwnedRoomOrAliasId>,
}
#[response]
pub struct Response {
/// The fully qualified user ID of the newly created user.
pub user_id: OwnedUserId,
}
impl Request {
#[must_use]
pub fn new(localpart: String, password: String) -> Self {
Self {
localpart,
password,
email: None,
display_name: None,
avatar_url: None,
suspended: false,
locked: false,
login_disabled: false,
admin: false,
skip_auto_join: false,
auto_join_rooms: Vec::new(),
}
}
}
impl Response {
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self { Self { user_id } }
}
}
@@ -0,0 +1,139 @@
pub mod v1 {
use ruma::{
OwnedUserId,
api::{OAuthScope, auth_scheme::AccessToken, request, response},
metadata,
};
use serde::Deserialize;
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/v1/users",
}
}
#[request]
#[derive(Default)]
pub struct Request {
/// If true, includes deactivated users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_deactivated: bool,
/// If true, includes locked users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_locked: bool,
/// If true, includes suspended users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_suspended: bool,
/// The maximum number of results to return in this page. Maximum (and
/// default) is 100.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub limit: Option<usize>,
/// The number of results to skip over before returning results. Default
/// is 0.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub offset: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)]
pub struct User {
/// The full user ID of the user.
pub user_id: OwnedUserId,
/// Whether this user is deactivated.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub deactivated: bool,
/// Whether this user is suspended.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub suspended: bool,
/// Whether this user is locked.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub locked: bool,
/// Whether this user is an admin.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub admin: bool,
/// Whether this user has their login disabled.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub login_disabled: bool,
}
impl User {
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self {
Self {
user_id,
deactivated: false,
suspended: false,
locked: false,
admin: false,
login_disabled: false,
}
}
}
#[response]
#[derive(Default)]
pub struct Response {
pub users: Vec<User>,
}
impl Request {
#[must_use]
pub fn new() -> Self { Self::default() }
}
impl Response {
#[must_use]
pub fn new(users: Vec<User>) -> Self { Self { users } }
}
#[cfg(test)]
mod tests {
use assign::assign;
use serde_json::json;
use super::*;
#[test]
fn request_defaults() {
let req = Request::new();
assert!(!req.include_deactivated && !req.include_locked && !req.include_suspended);
}
#[test]
fn user_serialize_omits_default_values() {
let user_id = OwnedUserId::try_from("@alice:example.org".to_owned()).unwrap();
let user = User::new(user_id.clone());
let expected = json!({ "user_id": user_id.to_string() });
assert_eq!(serde_json::to_value(&user).expect("failed to serialize user"), expected);
let suspended_user = assign!(user, {suspended: true});
let expected2 = json!({ "user_id": "@alice:example.org", "suspended": true});
assert_eq!(
serde_json::to_value(&suspended_user).expect("failed to serialize user"),
expected2
);
}
#[test]
fn response_defaults() {
let response = Response::default();
assert!(response.users.is_empty());
}
}
}
@@ -0,0 +1,2 @@
pub mod create;
pub mod list;
-53
View File
@@ -1,53 +0,0 @@
//! `GET /_matrix/client/v1/admin/suspend/{userId}`
//!
//! Check the suspension status of a target user
pub mod v1 {
//! `/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{userID}`
//! ([msc])
//!
//! [msc]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
use ruma::{
OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{user_id}",
1.18 => "/_matrix/client/v1/admin/suspend/{user_id}",
}
}
/// Request type for the get & set user suspension status endpoint.
#[request(error = ruma::api::error::Error)]
pub struct Request {
/// The user to look up.
#[ruma_api(path)]
pub user_id: OwnedUserId,
}
/// Response type for the suspension endpoints
#[response(error = ruma::api::error::Error)]
pub struct Response {
/// Whether the user is currently suspended.
pub suspended: bool,
}
impl Request {
/// Creates a new `Request` with the given user id.
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self { Self { user_id } }
}
impl Response {
/// Creates a new `Response` with the given suspension status.
#[must_use]
pub fn new(suspended: bool) -> Self { Self { suspended } }
}
}
-2
View File
@@ -1,3 +1 @@
pub mod continuwuity;
pub mod get_suspended;
pub mod set_suspended;
-55
View File
@@ -1,55 +0,0 @@
//! `PUT /_matrix/client/v1/admin/suspend/{userId}`
//!
//! Set the suspension status of a target user
pub mod v1 {
//! `/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{userID}`
//! ([msc])
//!
//! [msc]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
use ruma::{
OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{user_id}",
1.18 => "/_matrix/client/v1/admin/suspend/{user_id}",
}
}
/// Request type for the set user suspension status endpoint.
#[request(error = ruma::api::error::Error)]
pub struct Request {
/// The user to look up.
#[ruma_api(path)]
pub user_id: OwnedUserId,
pub suspended: bool,
}
/// Response type for the suspension endpoints
#[response(error = ruma::api::error::Error)]
pub struct Response {
/// Whether the user is currently suspended.
pub suspended: bool,
}
impl Request {
/// Creates a new `Request` with the given user id.
#[must_use]
pub fn new(user_id: OwnedUserId, suspended: bool) -> Self { Self { user_id, suspended } }
}
impl Response {
/// Creates a new `Response` with the given suspension status.
#[must_use]
pub fn new(suspended: bool) -> Self { Self { suspended } }
}
}
+1
View File
@@ -119,6 +119,7 @@ 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
yansi.workspace = true
lettre.workspace = true
serde_urlencoded.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true
+53 -1
View File
@@ -18,6 +18,8 @@
use loole::{Receiver, Sender};
use ruma::{
OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UInt, UserId,
api::client::discovery::discover_support::{Contact, ContactRole},
assign,
events::{
Mentions,
room::message::{
@@ -28,7 +30,7 @@
use tokio::sync::RwLock;
use crate::{
Dep, account_data, globals,
Dep, account_data, config, globals,
media::{MXC_LENGTH, mxc::Mxc},
rooms::{self, state::RoomMutexGuard},
};
@@ -44,6 +46,7 @@ pub struct Service {
struct Services {
server: Arc<Server>,
config: Dep<config::Service>,
globals: Dep<globals::Service>,
alias: Dep<rooms::alias::Service>,
timeline: Dep<rooms::timeline::Service>,
@@ -115,6 +118,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
server: args.server.clone(),
config: args.depend::<config::Service>("config"),
globals: args.depend::<globals::Service>("globals"),
alias: args.depend::<rooms::alias::Service>("rooms::alias"),
timeline: args.depend::<rooms::timeline::Service>("rooms::timeline"),
@@ -619,4 +623,52 @@ pub(super) fn set_services(&self, services: Option<&Arc<crate::Services>>) {
let weak = services.map(Arc::downgrade);
*receiver = weak;
}
/// Get the server's configured support contacts.
pub async fn get_support_contacts(&self) -> Vec<Contact> {
let email_address = self.services.config.well_known.support_email.clone();
let matrix_id = self.services.config.well_known.support_mxid.clone();
let pgp_key = self.services.config.well_known.support_pgp_key.clone();
// TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![];
let role = self
.services
.config
.well_known
.support_role
.clone()
.unwrap_or(ContactRole::Admin);
// Add configured contact if at least one contact method is specified
let configured_contact = match (matrix_id, email_address) {
| (Some(matrix_id), email_address) =>
Some(assign!(Contact::with_matrix_id(role, matrix_id), { email_address })),
| (None, Some(email_address)) =>
Some(Contact::with_email_address(role, email_address)),
| (None, None) => None,
};
if let Some(mut configured_contact) = configured_contact {
configured_contact.pgp_key = pgp_key;
contacts.push(configured_contact);
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
let admin_users = self.get_admins().await;
for user_id in &admin_users {
if *user_id == self.services.globals.server_user {
continue;
}
contacts.push(Contact::with_matrix_id(ContactRole::Admin, user_id.to_owned()));
}
}
contacts
}
}
+2 -2
View File
@@ -67,7 +67,7 @@ async fn worker(self: Arc<Self>) -> Result {
for (id, registration) in appservices {
// During startup, resolve any token collisions in favour of appservices
// by logging out conflicting user devices
if let Ok((user_id, device_id)) = self
if let Some((user_id, device_id, _)) = self
.services
.users
.find_from_token(&registration.as_token)
@@ -158,7 +158,7 @@ pub async fn register_appservice(
.users
.find_from_token(&registration.as_token)
.await
.is_ok()
.is_some()
{
return Err(err!(Request(InvalidParam(
"Cannot register appservice: The provided token is already in use by a user \
+2 -2
View File
@@ -39,7 +39,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
let url_preview_user_agent = config
.url_preview_user_agent
.clone()
.unwrap_or_else(|| conduwuit::version::user_agent_media().to_owned());
.unwrap_or_else(|| conduwuit::user_agent_media().to_owned());
Ok(Arc::new(Self {
default: base(config)?
@@ -149,7 +149,7 @@ fn base(config: &Config) -> Result<reqwest::ClientBuilder> {
.timeout(Duration::from_secs(config.request_total_timeout))
.pool_idle_timeout(Duration::from_secs(config.request_idle_timeout))
.pool_max_idle_per_host(config.request_idle_per_host.into())
.user_agent(conduwuit::version::user_agent())
.user_agent(conduwuit::user_agent())
.redirect(redirect::Policy::limited(6))
.danger_accept_invalid_certs(config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure)
.connection_verbose(cfg!(debug_assertions));
+12 -7
View File
@@ -6,7 +6,7 @@
use askama::Template;
use async_trait::async_trait;
use conduwuit::{Result, info, utils::ReadyExt};
use futures::{FutureExt, StreamExt};
use futures::StreamExt;
use ruma::{UserId, events::room::message::RoomMessageEventContent};
use crate::{
@@ -120,7 +120,7 @@ fn disable_first_run(&self) -> bool {
///
/// 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<bool> {
pub async fn empower_first_user(&self, user: &UserId) -> bool {
#[derive(Template)]
#[template(path = "welcome.md")]
struct WelcomeMessage<'a> {
@@ -130,10 +130,14 @@ struct WelcomeMessage<'a> {
// 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 @@ struct WelcomeMessage<'a> {
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
@@ -181,7 +186,7 @@ pub fn print_first_run_banner(&self) {
eprintln!(
"Welcome to {} {}!",
"Continuwuity".bold().bright_magenta(),
conduwuit::version::version().bold()
conduwuit::version().bold()
);
eprintln!();
eprintln!(
+4 -2
View File
@@ -92,8 +92,8 @@ pub async fn send<Template: MessageTemplate>(
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 @@ pub async fn send<Template: MessageTemplate>(
.await
.map_err(|err: TransportError| err!("Failed to send message: {err}"))?;
info!(recipient = recipient.to_string(), ?subject, "Email sent");
Ok(())
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
pub mod mailer;
pub mod media;
pub mod moderation;
pub mod password_reset;
pub mod oauth;
pub mod presence;
pub mod pusher;
pub mod registration_tokens;
+196
View File
@@ -0,0 +1,196 @@
use std::{collections::BTreeSet, hash::Hash};
use itertools::Itertools;
use serde::{Deserialize, Deserializer, Serialize};
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ClientMetadata {
#[serde(default)]
pub application_type: ApplicationType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
pub client_uri: Url,
#[serde(default, deserialize_with = "btreeset_skip_err")]
pub grant_types: BTreeSet<GrantType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<Url>,
#[serde(default)]
pub redirect_uris: Vec<Url>,
#[serde(default, deserialize_with = "btreeset_skip_err")]
pub response_types: BTreeSet<ResponseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<Url>,
}
impl ClientMetadata {
pub(super) const ACCEPTABLE_LOCALHOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
pub(super) fn validate(&self) -> Result<(), &'static str> {
let Some(client_domain) = self.client_uri.domain() else {
return Err("Client URI must have a domain.");
};
if self.client_uri.scheme() != "https" {
return Err("Client URI must be HTTPS.");
}
if !self.client_uri.username().is_empty() || self.client_uri.password().is_some() {
return Err("Client URI must not include credentials.");
}
for uri in [&self.logo_uri, &self.policy_uri, &self.tos_uri]
.iter()
.filter_map(|uri| uri.as_ref())
{
if uri.scheme() != "https" {
return Err("All metadata URIs must be HTTPS.");
}
if !uri.username().is_empty() || uri.password().is_some() {
return Err("All metadata URIs must not include credentials.");
}
if !uri
.domain()
.is_some_and(|domain| is_subdomain(domain, client_domain))
{
return Err("All metadata URIs must be subdomains of the client URI.");
}
}
for uri in &self.redirect_uris {
match uri.scheme() {
| "https" => {
// HTTPS URIs are okay for native and web clients
if !uri.username().is_empty() || uri.password().is_some() {
return Err("HTTPS redirect URIs must not contain credentials.");
}
},
| "http" if self.application_type == ApplicationType::Native => {
if uri
.host_str()
.is_none_or(|host| !Self::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
return Err("HTTP redirect URIs for native applications must only \
refer to localhost.");
}
if uri.port().is_some() {
return Err("HTTP redirect URIs for native applications do not need to \
specify a port. All ports will be accepted during \
authorization.");
}
},
| private_scheme if self.application_type == ApplicationType::Native => {
let rdns_client_uri = client_domain.split('.').rev().join(".");
if !private_scheme.starts_with(&rdns_client_uri) {
return Err("Private-use scheme URIs for native applications must \
begin with the application's client URI domain in \
reverse-DNS notation.");
}
if uri.has_authority() {
return Err("Private-use scheme URIs for native applications must not \
have an authority.");
}
},
| _ =>
return Err("A redirect URI's scheme is not valid for this application type."),
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplicationType {
#[default]
Web,
Native,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantType {
AuthorizationCode,
RefreshToken,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseType {
Code,
}
/// Deserialize a BTreeSet from a sequence, skipping items which fail to
/// deserialize. This is used as a deserialize helper for ClientMetadata to
/// ignore unknown enum variants in a few fields.
fn btreeset_skip_err<'de, D, V>(de: D) -> Result<BTreeSet<V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de> + Hash + Eq + Ord,
{
use std::marker::PhantomData;
use serde::de::{SeqAccess, Visitor};
struct BTreeSetVisitor<V> {
item: PhantomData<V>,
}
impl<'de, V> Visitor<'de> for BTreeSetVisitor<V>
where
V: Deserialize<'de> + Hash + Eq + Ord,
{
type Value = BTreeSet<V>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "a sequence")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut set = BTreeSet::new();
while let Some(element) = seq.next_element().transpose() {
if let Ok(element) = element {
set.insert(element);
}
}
Ok(set)
}
}
de.deserialize_seq(BTreeSetVisitor { item: PhantomData })
}
fn is_subdomain(subdomain: &str, domain: &str) -> bool {
if subdomain == domain {
return true;
}
subdomain.ends_with(&format!(".{domain}"))
}
+231
View File
@@ -0,0 +1,231 @@
use std::{
borrow::Cow,
collections::BTreeSet,
error::Error,
fmt::{Debug, Display},
hash::Hash,
mem::discriminant,
};
use regex::Regex;
use ruma::{OwnedDeviceId, api::OAuthScope};
use serde::{Deserialize, Serialize};
use url::Url;
use super::client_metadata::ResponseType;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorizationCodeQuery {
pub response_type: ResponseType,
pub client_id: String,
pub redirect_uri: Url,
pub scope: RawScopes,
pub state: String,
#[serde(default)]
pub response_mode: ResponseMode,
pub code_challenge: String,
pub code_challenge_method: CodeChallengeMethod,
#[serde(default)]
pub prompt: Option<Prompt>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseMode {
#[default]
// default for `code` response type, see https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#:~:text=Client%2E-,For,encoding%2E,-See
Query,
Fragment,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
pub enum CodeChallengeMethod {
S256,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Prompt {
Create,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialOrd, Ord)]
pub enum RequestedScope {
Device(OwnedDeviceId),
FullAccess,
ServerAdministration,
}
impl RequestedScope {
pub fn as_granted_scope(&self) -> Option<OAuthScope> {
match self {
| Self::FullAccess => Some(OAuthScope::FullAccess),
| Self::ServerAdministration => Some(OAuthScope::ServerAdministration),
| Self::Device(_) => None,
}
}
}
impl PartialEq for RequestedScope {
fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) }
}
impl Eq for RequestedScope {}
impl Hash for RequestedScope {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { discriminant(self).hash(state); }
}
impl Display for RequestedScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let urn = match self {
| Self::FullAccess => "urn:matrix:client:api:*".to_owned(),
| Self::Device(device_id) => format!("urn:matrix:client:device:{device_id}"),
| Self::ServerAdministration =>
"urn:matrix:client:cc.c10y.msc4484.server_administration".to_owned(),
};
f.write_str(&urn)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RawScopes(String);
impl RawScopes {
pub fn to_scopes(&self) -> Result<BTreeSet<RequestedScope>, String> {
let full_access_regex =
Regex::new(r"urn:matrix:(client|org.matrix.msc2967.client):api:\*").unwrap();
let device_token_regex = Regex::new(
r"urn:matrix:(client|org.matrix.msc2967.client):device:([a-zA-Z0-9-._~]{5,})",
)
.unwrap();
let server_administration_regex =
Regex::new(r"urn:matrix:client:cc.c10y.msc4484.server_administration").unwrap();
let mut scopes = BTreeSet::new();
for token in self.0.split(' ') {
let scope_was_new = {
if full_access_regex.is_match(token) {
scopes.insert(RequestedScope::FullAccess)
} else if let Some(captures) = device_token_regex.captures(token) {
scopes
.insert(RequestedScope::Device(captures.get(2).unwrap().as_str().into()))
} else if server_administration_regex.is_match(token) {
scopes.insert(RequestedScope::ServerAdministration)
} else if token == "openid" {
// TODO(unspecced): Element sets this scope but doesn't use it for anything
true
} else {
return Err(format!("Invalid scope: {token}"));
}
};
if !scope_was_new {
return Err("Scope was specified more than once".to_owned());
}
}
Ok(scopes)
}
}
#[derive(Serialize, Debug, Clone)]
pub struct OAuthError {
pub error: ErrorCode,
pub error_description: Cow<'static, str>,
}
impl OAuthError {
pub const fn invalid_request(error_description: &'static str) -> Self {
Self {
error: ErrorCode::InvalidRequest,
error_description: Cow::Borrowed(error_description),
}
}
pub const fn invalid_grant(error_description: &'static str) -> Self {
Self {
error: ErrorCode::InvalidGrant,
error_description: Cow::Borrowed(error_description),
}
}
}
impl Display for OAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "OAuth error {:?}: {}", self.error, self.error_description)
}
}
impl Error for OAuthError {}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
InvalidRequest,
AccessDenied,
InvalidScope,
InvalidGrant,
InvalidClientMetadata,
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum AuthorizationCodeResponse {
Success {
state: String,
code: String,
},
Error(OAuthError),
}
#[derive(Deserialize)]
#[serde(tag = "grant_type", rename_all = "snake_case")]
pub enum TokenRequest {
AuthorizationCode {
code: String,
redirect_uri: Url,
client_id: String,
code_verifier: String,
},
RefreshToken {
client_id: String,
refresh_token: String,
},
}
impl TokenRequest {
#[must_use]
pub fn client_id(&self) -> &str {
match self {
| Self::AuthorizationCode { client_id, .. }
| Self::RefreshToken { client_id, .. } => client_id,
}
}
}
#[derive(Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: TokenType,
pub expires_in: u64,
pub refresh_token: String,
pub scope: String,
}
#[derive(Serialize)]
pub enum TokenType {
Bearer,
}
#[derive(Deserialize)]
pub struct RevokeTokenRequest {
pub token: String,
}
+534
View File
@@ -0,0 +1,534 @@
use std::{
collections::{BTreeSet, HashMap},
sync::{Arc, Mutex},
time::{Duration, SystemTime},
};
use base64::Engine;
use conduwuit::{
Result, info,
utils::{self, hash::sha256},
};
use database::{Deserialized, Json, Map};
use itertools::Itertools;
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId, api::OAuthScope};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
Dep,
oauth::{
client_metadata::{ApplicationType, ClientMetadata, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode,
OAuthError, RequestedScope, ResponseMode, TokenRequest, TokenResponse, TokenType,
},
},
users,
};
pub mod client_metadata;
pub mod grant;
pub struct Service {
services: Services,
db: Data,
tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_code_grants: tokio::sync::Mutex<HashMap<String, PendingCodeGrant>>,
}
struct Data {
clientid_clientmetadata: Arc<Map>,
userdeviceid_oauthsessioninfo: Arc<Map>,
refreshtoken_refreshtokeninfo: Arc<Map>,
}
struct Services {
users: Dep<users::Service>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<OAuthScope>,
current_refresh_token: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct RefreshTokenInfo {
client_id: String,
user_id: OwnedUserId,
device_id: OwnedDeviceId,
}
struct PendingCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
code_challenge: String,
requested_at: SystemTime,
}
impl PendingCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1);
const RANDOM_CODE_LENGTH: usize = 32;
#[must_use]
pub(crate) fn generate_code() -> String { utils::random_string(Self::RANDOM_CODE_LENGTH) }
#[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
let now = SystemTime::now();
self.expected_client_id == client_id
&& now
.duration_since(self.requested_at)
.is_ok_and(|age| age < Self::MAX_AGE)
}
}
/// A time-limited grant for a client to perform some sensitive action.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OAuthTicket {
CrossSigningReset,
}
impl OAuthTicket {
const MAX_AGE: Duration = Duration::from_mins(10);
#[must_use]
pub fn ticket_issue_path(&self) -> &'static str {
match self {
| Self::CrossSigningReset => "/account/cross_signing_reset",
}
}
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
users: args.depend::<users::Service>("users"),
},
db: Data {
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
userdeviceid_oauthsessioninfo: args.db["userdeviceid_oauthsessioninfo"].clone(),
refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(),
},
tickets: Mutex::default(),
pending_code_grants: tokio::sync::Mutex::default(),
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const ACCESS_TOKEN_MAX_AGE: Duration = Duration::from_hours(1);
const RANDOM_TOKEN_LENGTH: usize = 32;
fn generate_token() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
pub async fn register_client(&self, metadata: &ClientMetadata) -> Result<String, OAuthError> {
metadata.validate().map_err(|error| OAuthError {
error: ErrorCode::InvalidClientMetadata,
error_description: error.into(),
})?;
let client_id = base64::prelude::BASE64_STANDARD
.encode(sha256::hash(serde_json::to_string(metadata).unwrap().as_bytes()));
if self
.db
.clientid_clientmetadata
.exists(&client_id)
.await
.is_err()
{
self.db
.clientid_clientmetadata
.raw_put(&client_id, Json(metadata.clone()));
}
Ok(client_id)
}
pub async fn get_client_metadata(&self, client_id: &str) -> Option<ClientMetadata> {
self.db
.clientid_clientmetadata
.get(client_id)
.await
.deserialized()
.ok()
}
pub async fn get_session_info_for_device(
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> Option<SessionInfo> {
self.db
.userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id))
.await
.deserialized::<SessionInfo>()
.ok()
}
pub async fn request_authorization_code(
&self,
authorizing_user: OwnedUserId,
query: AuthorizationCodeQuery,
) -> Result<String, String> {
let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else {
return Err("Invalid client ID".to_owned());
};
if !(client_metadata
.response_types
.contains(&query.response_type)
&& matches!(query.response_type, ResponseType::Code))
{
return Err("Invalid response type".to_owned());
}
if !matches!(query.code_challenge_method, CodeChallengeMethod::S256) {
return Err("Invalid code challenge type".to_owned());
}
{
let mut stripped_uri = query.redirect_uri.clone();
if client_metadata.application_type == ApplicationType::Native
&& query
.redirect_uri
.host_str()
.is_some_and(|host| ClientMetadata::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
// Remove the port from localhost redirect URIs for native applications when
// checking if it's valid
stripped_uri.set_port(None).unwrap();
}
if !client_metadata.redirect_uris.contains(&stripped_uri) {
return Err("Invalid redirect URI".to_owned());
}
}
let redirect_uri_query_separator = match query.response_mode {
| ResponseMode::Fragment => '#',
| ResponseMode::Query => '?',
};
let response = 'response: {
let requested_scopes = query.scope.to_scopes()?;
if requested_scopes.contains(&RequestedScope::ServerAdministration) {
// Only server admins can request this scope
if !self.services.users.is_admin(&authorizing_user).await {
break 'response AuthorizationCodeResponse::Error(OAuthError {
error: ErrorCode::AccessDenied,
error_description: "You are not a server administrator.".into(),
});
}
}
let code = PendingCodeGrant::generate_code();
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
?authorizing_user,
"Issuing oauth authorization code"
);
let pending_grant = PendingCodeGrant {
authorizing_user,
requested_scopes,
client_name: client_metadata.client_name,
expected_client_id: query.client_id,
expected_redirect_uri: query.redirect_uri.clone(),
code_challenge: query.code_challenge,
requested_at: SystemTime::now(),
};
self.pending_code_grants
.lock()
.await
.insert(code.clone(), pending_grant);
AuthorizationCodeResponse::Success { state: query.state, code }
};
let redirect_uri = format!(
"{}{}{}",
query.redirect_uri,
redirect_uri_query_separator,
serde_urlencoded::to_string(response).unwrap(),
);
Ok(redirect_uri)
}
pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> {
match request {
| TokenRequest::AuthorizationCode {
code,
redirect_uri,
client_id,
code_verifier,
} => {
let mut pending_grants = self.pending_code_grants.lock().await;
let Some(pending_grant) = pending_grants
.remove(&code)
.filter(|grant| grant.is_valid_for(&client_id))
else {
return Err(OAuthError::invalid_grant("Invalid authorization code"));
};
if redirect_uri != pending_grant.expected_redirect_uri {
return Err(OAuthError::invalid_grant("Invalid redirect URI"));
}
let expected_code_challenge =
base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(sha256::hash(&code_verifier));
if expected_code_challenge != pending_grant.code_challenge {
return Err(OAuthError::invalid_grant("Invalid code challenge"));
}
self.create_session(
pending_grant.authorizing_user,
pending_grant.requested_scopes,
pending_grant.client_name,
client_id,
)
.await
},
| TokenRequest::RefreshToken { client_id, refresh_token } =>
self.refresh_session(client_id, refresh_token).await,
}
}
pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> {
let (user_id, device_id) = if let Ok(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&token)
.await
.deserialized::<RefreshTokenInfo>()
{
(refresh_token_info.user_id, refresh_token_info.device_id)
} else if let Some((user_id, device_id, _)) =
self.services.users.find_from_token(&token).await
{
(user_id, device_id)
} else {
return Err(OAuthError::invalid_grant("Invalid access or refersh token"));
};
// This will also call [`Self::remove_session`]
self.services
.users
.remove_device(&user_id, &device_id)
.await;
Ok(())
}
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
let access_token = Self::generate_token();
let refresh_token = Self::generate_token();
let device_id = requested_scopes
.iter()
.find_map(|scope| {
if let RequestedScope::Device(device_id) = scope {
Some(device_id)
} else {
None
}
})
.ok_or_else(|| OAuthError::invalid_grant("No device ID scope supplied"))?;
if self
.services
.users
.get_device_metadata(&authorizing_user, device_id)
.await
.is_ok()
{
return Err(OAuthError {
error: ErrorCode::InvalidScope,
error_description: "A device with the supplied ID already exists for this user"
.into(),
});
}
self.services
.users
.create_device(
&authorizing_user,
device_id,
&access_token,
Some(Self::ACCESS_TOKEN_MAX_AGE),
client_name,
None,
)
.await
// This can only panic if the authorizing user suffered a spontaneous existence
// failure during authentication, which should(?) be impossible(?)
.expect("failed to create device");
self.db.userdeviceid_oauthsessioninfo.put(
(&authorizing_user, device_id),
Json(SessionInfo {
client_id: client_id.clone(),
current_refresh_token: refresh_token.clone(),
scopes: requested_scopes
.iter()
.filter_map(RequestedScope::as_granted_scope)
.collect(),
}),
);
self.db.refreshtoken_refreshtokeninfo.raw_put(
&refresh_token,
Json(RefreshTokenInfo {
client_id: client_id.clone(),
user_id: authorizing_user.clone(),
device_id: device_id.to_owned(),
}),
);
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
Ok(TokenResponse {
access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope: requested_scopes.iter().join(" "),
refresh_token,
})
}
async fn refresh_session(
&self,
client_id: String,
refresh_token: String,
) -> Result<TokenResponse, OAuthError> {
let Some(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&refresh_token)
.await
.deserialized::<RefreshTokenInfo>()
.ok()
else {
return Err(OAuthError::invalid_grant("Invalid refresh token"));
};
assert_eq!(&client_id, &refresh_token_info.client_id, "refresh token client id mismatch");
let mut session_info = self
.get_session_info_for_device(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
)
.await
.expect("session info should exist");
assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch");
let new_access_token = Self::generate_token();
let new_refresh_token = Self::generate_token();
let scope = session_info.scopes.iter().join(" ");
session_info
.current_refresh_token
.clone_from(&new_refresh_token);
self.services
.users
.set_token(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
&new_access_token,
Some(Self::ACCESS_TOKEN_MAX_AGE),
)
.await
.expect("should be able to set token");
self.db.userdeviceid_oauthsessioninfo.put(
(&refresh_token_info.user_id, &refresh_token_info.device_id),
Json(session_info),
);
self.db.refreshtoken_refreshtokeninfo.remove(&refresh_token);
drop(refresh_token);
self.db
.refreshtoken_refreshtokeninfo
.raw_put(&new_refresh_token, Json(refresh_token_info));
Ok(TokenResponse {
access_token: new_access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope,
refresh_token: new_refresh_token,
})
}
pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
let session_info = self.get_session_info_for_device(user_id, device_id).await;
if let Some(session_info) = session_info {
self.db
.refreshtoken_refreshtokeninfo
.remove(&session_info.current_refresh_token);
self.db
.userdeviceid_oauthsessioninfo
.del((user_id, device_id));
info!(?user_id, ?device_id, "Removed OAuth session");
}
}
/// Issue a ticket for `localpart` to perform some action.
pub fn issue_ticket(&self, localpart: String, ticket: OAuthTicket) {
self.tickets
.lock()
.unwrap()
.entry(localpart)
.or_default()
.insert(ticket, SystemTime::now());
}
/// Try to consume an unexpired ticket for `localpart`.
pub fn try_consume_ticket(&self, localpart: &str, ticket: OAuthTicket) -> bool {
let now = SystemTime::now();
self.tickets
.lock()
.unwrap()
.get_mut(localpart)
.and_then(|tickets| tickets.remove(&ticket))
.is_some_and(|issued| {
now.duration_since(issued)
.is_ok_and(|duration| duration < OAuthTicket::MAX_AGE)
})
}
}
-68
View File
@@ -1,68 +0,0 @@
use std::{
sync::Arc,
time::{Duration, SystemTime},
};
use conduwuit::utils::{ReadyExt, stream::TryExpect};
use database::{Database, Deserialized, Json, Map};
use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
pub(super) struct Data {
passwordresettoken_info: Arc<Map>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResetTokenInfo {
pub user: OwnedUserId,
pub issued_at: SystemTime,
}
impl ResetTokenInfo {
// one hour
const MAX_TOKEN_AGE: Duration = Duration::from_hours(1);
pub fn is_valid(&self) -> bool {
let now = SystemTime::now();
now.duration_since(self.issued_at)
.is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE)
}
}
impl Data {
pub(super) fn new(db: &Arc<Database>) -> Self {
Self {
passwordresettoken_info: db["passwordresettoken_info"].clone(),
}
}
/// Associate a reset token with its info in the database.
pub(super) fn save_token(&self, token: &str, info: &ResetTokenInfo) {
self.passwordresettoken_info.raw_put(token, Json(info));
}
/// Lookup the info for a reset token.
pub(super) async fn lookup_token_info(&self, token: &str) -> Option<ResetTokenInfo> {
self.passwordresettoken_info
.get(token)
.await
.deserialized()
.ok()
}
/// Find a user's existing reset token, if any.
pub(super) async fn find_token_for_user(
&self,
user: &UserId,
) -> Option<(String, ResetTokenInfo)> {
self.passwordresettoken_info
.stream::<'_, String, ResetTokenInfo>()
.expect_ok()
.ready_find(|(_, info)| info.user == user)
.await
}
/// Remove a reset token.
pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.remove(token); }
}
-111
View File
@@ -1,111 +0,0 @@
mod data;
use std::{sync::Arc, time::SystemTime};
use conduwuit::{Err, Result, utils};
use data::{Data, ResetTokenInfo};
use ruma::OwnedUserId;
use crate::{
Dep, globals,
users::{self, HashedPassword},
};
pub const PASSWORD_RESET_PATH: &str = "/_continuwuity/account/reset_password";
pub const RESET_TOKEN_QUERY_PARAM: &str = "token";
const RESET_TOKEN_LENGTH: usize = 32;
pub struct Service {
db: Data,
services: Services,
}
struct Services {
users: Dep<users::Service>,
globals: Dep<globals::Service>,
}
#[derive(Debug)]
pub struct ValidResetToken {
pub token: String,
pub info: ResetTokenInfo,
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
db: Data::new(args.db),
services: Services {
users: args.depend::<users::Service>("users"),
globals: args.depend::<globals::Service>("globals"),
},
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
/// Generate a random string suitable to be used as a password reset token.
#[must_use]
pub fn generate_token_string() -> String { utils::random_string(RESET_TOKEN_LENGTH) }
/// Issue a password reset token for `user`, who must be a local user with
/// the `password` origin.
pub async fn issue_token(&self, user_id: OwnedUserId) -> Result<ValidResetToken> {
if !self.services.globals.user_is_local(&user_id) {
return Err!("Cannot issue a password reset token for remote user {user_id}");
}
if user_id == self.services.globals.server_user {
return Err!("Cannot issue a password reset token for the server user");
}
if self.services.users.is_deactivated(&user_id).await? {
return Err!("Cannot issue a password reset token for deactivated user {user_id}");
}
if let Some((existing_token, _)) = self.db.find_token_for_user(&user_id).await {
self.db.remove_token(&existing_token);
}
let token = Self::generate_token_string();
let info = ResetTokenInfo {
user: user_id,
issued_at: SystemTime::now(),
};
self.db.save_token(&token, &info);
Ok(ValidResetToken { token, info })
}
/// Check if `token` represents a valid, non-expired password reset token.
pub async fn check_token(&self, token: &str) -> Option<ValidResetToken> {
self.db.lookup_token_info(token).await.and_then(|info| {
if info.is_valid() {
Some(ValidResetToken { token: token.to_owned(), info })
} else {
self.db.remove_token(token);
None
}
})
}
/// Consume the supplied valid token, using it to change its user's password
/// to `new_password`.
pub async fn consume_token(
&self,
ValidResetToken { token, info }: ValidResetToken,
new_password: &str,
) -> Result<()> {
if info.is_valid() {
self.db.remove_token(&token);
self.services
.users
.set_password(&info.user, Some(HashedPassword::new(new_password)?));
}
Ok(())
}
}
+3 -2
View File
@@ -10,6 +10,7 @@
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 @@ fn eq(&self, other: &str) -> bool { self.token == other }
}
/// 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,
+4 -4
View File
@@ -11,8 +11,8 @@
account_data, admin, announcements, antispam, appservice, client, config, emergency,
federation, firstrun, globals, key_backups, mailer,
manager::Manager,
media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms,
sending, server_keys,
media, moderation, oauth, presence, pusher, registration_tokens, resolver, rooms, sending,
server_keys,
service::{self, Args, Map, Service},
sync, threepid, transactions, uiaa, users,
};
@@ -27,7 +27,7 @@ pub struct Services {
pub globals: Arc<globals::Service>,
pub key_backups: Arc<key_backups::Service>,
pub media: Arc<media::Service>,
pub password_reset: Arc<password_reset::Service>,
pub oauth: Arc<oauth::Service>,
pub mailer: Arc<mailer::Service>,
pub presence: Arc<presence::Service>,
pub pusher: Arc<pusher::Service>,
@@ -84,7 +84,7 @@ macro_rules! build {
globals: build!(globals::Service),
key_backups: build!(key_backups::Service),
media: build!(media::Service),
password_reset: build!(password_reset::Service),
oauth: build!(oauth::Service),
mailer: build!(mailer::Service),
presence: build!(presence::Service),
pusher: build!(pusher::Service),
+29 -7
View File
@@ -9,8 +9,9 @@
ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId,
api::error::{ErrorKind, LimitExceededErrorData},
};
use tokio::sync::MutexGuard;
mod session;
pub mod session;
use crate::{
Args, Dep, config,
@@ -26,6 +27,7 @@ pub struct Service {
ratelimiter: DefaultKeyedRateLimiter<Address>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmailRequirement {
/// Users may change their email, but cannot remove it entirely.
Required,
@@ -219,13 +221,12 @@ pub async fn try_validate_session(
Ok(())
}
/// Consume a validated validation session, removing it from the database
/// and returning the newly validated email address.
pub async fn consume_valid_session(
/// Get a validated validation session.
pub async fn get_valid_session(
&self,
session_id: &SessionId,
client_secret: &ClientSecret,
) -> Result<Address, Cow<'static, str>> {
) -> Result<ValidSession<'_>, Cow<'static, str>> {
let mut sessions = self.sessions.lock().await;
let Some(session) = sessions.get_session(session_id) else {
@@ -235,9 +236,13 @@ pub async fn consume_valid_session(
if session.client_secret == client_secret
&& matches!(session.validation_state, ValidationState::Validated)
{
let session = sessions.remove_session(session_id);
let email = session.email.clone();
Ok(session.email)
Ok(ValidSession {
email,
session_id: session_id.to_owned(),
sessions,
})
} else {
Err("This email address has not been validated. Did you use the link that was sent \
to you?"
@@ -313,3 +318,20 @@ pub async fn get_localpart_for_email(&self, email: &Address) -> Option<String> {
.ok()
}
}
pub struct ValidSession<'lock> {
pub email: Address,
session_id: OwnedSessionId,
sessions: MutexGuard<'lock, ValidationSessions>,
}
impl ValidSession<'_> {
/// Consume this session, removing it from the database and releasing the
/// lock it holds.
#[must_use]
pub fn consume(mut self) -> Address {
self.sessions.remove_session(&self.session_id);
self.email
}
}
+5 -5
View File
@@ -8,14 +8,14 @@
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId};
#[derive(Default)]
pub(super) struct ValidationSessions {
pub struct ValidationSessions {
sessions: HashMap<OwnedSessionId, ValidationSession>,
client_secrets: HashMap<OwnedClientSecret, OwnedSessionId>,
}
/// A pending or completed email validation session.
#[derive(Debug)]
pub(crate) struct ValidationSession {
pub struct ValidationSession {
/// The session's ID
pub session_id: OwnedSessionId,
/// The client's supplied client secret
@@ -28,7 +28,7 @@ pub(crate) struct ValidationSession {
/// The state of an email validation session.
#[derive(Debug)]
pub(crate) enum ValidationState {
pub enum ValidationState {
/// The session is waiting for this validation token to be provided
Pending(ValidationToken),
/// The session has been validated
@@ -36,7 +36,7 @@ pub(crate) enum ValidationState {
}
#[derive(Clone, Debug)]
pub(crate) struct ValidationToken {
pub struct ValidationToken {
pub token: String,
pub issued_at: SystemTime,
}
@@ -69,7 +69,7 @@ impl ValidationSessions {
const RANDOM_SID_LENGTH: usize = 16;
#[must_use]
pub(super) fn generate_session_id() -> OwnedSessionId {
pub fn generate_session_id() -> OwnedSessionId {
SessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap()
}
+303 -151
View File
@@ -7,7 +7,7 @@
use conduwuit::{Err, Error, Result, error, utils};
use lettre::Address;
use ruma::{
UserId,
DeviceId, UserId,
api::{
client::uiaa::{
AuthData, AuthFlow, AuthType, EmailIdentity, EmailUserIdentifier,
@@ -16,11 +16,19 @@
},
error::{ErrorKind, StandardErrorBody},
},
assign,
};
use serde_json::{
json,
value::{RawValue, to_raw_value},
};
use serde_json::value::RawValue;
use tokio::sync::Mutex;
use crate::{Dep, config, globals, registration_tokens, threepid, users};
use crate::{
Dep, config, globals,
oauth::{self, OAuthTicket},
registration_tokens, threepid, users,
};
pub struct Service {
services: Services,
@@ -33,6 +41,7 @@ struct Services {
config: Dep<config::Service>,
registration_tokens: Dep<registration_tokens::Service>,
threepid: Dep<threepid::Service>,
oauth: Dep<oauth::Service>,
}
impl crate::Service for Service {
@@ -45,6 +54,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
registration_tokens: args
.depend::<registration_tokens::Service>("registration_tokens"),
threepid: args.depend::<threepid::Service>("threepid"),
oauth: args.depend::<oauth::Service>("oauth"),
},
uiaa_sessions: Mutex::new(HashMap::new()),
}))
@@ -54,8 +64,56 @@ fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
struct UiaaSession {
session_metadata: UiaaSessionMetadata,
info: UiaaInfo,
identity: Identity,
}
#[derive(Clone)]
enum UiaaSessionMetadata {
Legacy {
identity: Identity,
},
OAuth {
localpart: String,
ticket: OAuthTicket,
},
}
impl UiaaSessionMetadata {
fn into_identity(self) -> Identity {
match self {
| Self::Legacy { identity } => identity,
| Self::OAuth { localpart, .. } =>
assign!(Identity::default(), { localpart: Some(localpart) }),
}
}
}
/// Information about the user which is initiating this UIAA session.
pub struct UiaaInitiator<'a> {
user_id: &'a UserId,
device_id: Option<&'a DeviceId>,
oauth_ticket: Option<OAuthTicket>,
}
impl<'a> UiaaInitiator<'a> {
#[must_use]
pub fn new(user_id: &'a UserId, device_id: Option<&'a DeviceId>) -> Self {
Self { user_id, device_id, oauth_ticket: None }
}
#[must_use]
pub fn with_oauth_ticket(
user_id: &'a UserId,
device_id: Option<&'a DeviceId>,
oauth_ticket: OAuthTicket,
) -> Self {
Self {
user_id,
device_id,
oauth_ticket: Some(oauth_ticket),
}
}
}
/// Information about the authenticated user's identity.
@@ -106,7 +164,7 @@ impl Identity {
/// Create an Identity with the localpart of the provided user ID
/// and all other fields set to None.
#[must_use]
pub fn from_user_id(user_id: &UserId) -> Self {
fn from_user_id(user_id: &UserId) -> Self {
Self {
localpart: Some(user_id.localpart().to_owned()),
..Default::default()
@@ -124,11 +182,11 @@ pub async fn authenticate(
auth: &Option<AuthData>,
flows: Vec<AuthFlow>,
params: Box<RawValue>,
identity: Option<Identity>,
initiator: Option<UiaaInitiator<'_>>,
) -> Result<Identity> {
match auth.as_ref() {
| None => {
let info = self.create_session(flows, params, identity).await;
let info = self.create_session(flows, params, initiator).await?;
Err(Error::Uiaa(info))
},
@@ -140,8 +198,8 @@ pub async fn authenticate(
// session if they want to start the UIAA exchange with existing
// authentication data. If that happens, we create a new session
// here.
self.create_session(flows, params, identity)
.await
self.create_session(flows, params, initiator)
.await?
.session
.unwrap()
.into()
@@ -161,13 +219,15 @@ pub async fn authenticate(
pub async fn authenticate_password(
&self,
auth: &Option<AuthData>,
identity: Option<Identity>,
user_id: &UserId,
device_id: Option<&DeviceId>,
oauth_ticket: Option<OAuthTicket>,
) -> Result<Identity> {
self.authenticate(
auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
identity,
Some(UiaaInitiator { user_id, device_id, oauth_ticket }),
)
.await
}
@@ -183,20 +243,88 @@ async fn create_session(
&self,
flows: Vec<AuthFlow>,
params: Box<RawValue>,
identity: Option<Identity>,
) -> UiaaInfo {
initiator: Option<UiaaInitiator<'_>>,
) -> Result<UiaaInfo> {
let mut uiaa_sessions = self.uiaa_sessions.lock().await;
let session_id = utils::random_string(Self::SESSION_ID_LENGTH);
let mut info = assign::assign!(UiaaInfo::new(flows), {params: Some(params)});
info.session = Some(session_id.clone());
uiaa_sessions.insert(session_id, UiaaSession {
info: info.clone(),
identity: identity.unwrap_or_default(),
});
let mut info = assign!(UiaaInfo::new(flows), { params: Some(params), session: Some(session_id.clone()) });
info
let session_metadata = if let Some(initiator) = initiator {
let is_oauth = if let Some(device_id) = initiator.device_id {
self.services
.oauth
.get_session_info_for_device(initiator.user_id, device_id)
.await
.is_some()
} else {
// Appservices never have oauth sessions
false
};
if is_oauth {
if let Some(oauth_ticket) = initiator.oauth_ticket {
let ticket_url = self
.services
.config
.get_client_domain()
.join(&format!(
"{}{}",
conduwuit_core::ROUTE_PREFIX,
oauth_ticket.ticket_issue_path()
))
.unwrap();
info.flows = vec![AuthFlow::new(vec![AuthType::OAuth])];
info.params = Some(
to_raw_value(&json!({
AuthType::OAuth.as_str(): {
"url": ticket_url,
},
// TODO(compat): This is necessary for older versions of matrix-rust-sdk
"org.matrix.cross_signing_reset": {
"url": ticket_url,
}
}))
.unwrap(),
);
UiaaSessionMetadata::OAuth {
localpart: initiator.user_id.localpart().to_owned(),
ticket: oauth_ticket,
}
} else {
return Err!(Request(Forbidden(
"Clients authorized with OAuth cannot use this route."
)));
}
} else {
UiaaSessionMetadata::Legacy {
identity: Identity::from_user_id(initiator.user_id),
}
}
} else {
UiaaSessionMetadata::Legacy { identity: Identity::default() }
};
// Legacy sessions aren't available if OAuth is required
if matches!(&session_metadata, UiaaSessionMetadata::Legacy { .. })
&& !self
.services
.config
.oauth
.compatibility_mode
.uiaa_available()
{
return Err!(Request(Unrecognized(
"User-interactive authentication is unavailable on this server"
)));
}
uiaa_sessions.insert(session_id, UiaaSession { session_metadata, info: info.clone() });
Ok(info)
}
/// Proceed with UIAA authentication given a client's authorization data.
@@ -225,7 +353,7 @@ async fn continue_session(
}
let completed = {
let UiaaSession { info, identity } = session.get_mut();
let UiaaSession { session_metadata, info } = session.get_mut();
let auth_type = auth.auth_type().expect("auth type should be set");
@@ -258,12 +386,12 @@ async fn continue_session(
// If the provided stage hasn't already been completed, check it for completion
if !completed_stages.contains(auth_type.as_str()) {
match self.check_stage(auth, identity.clone()).await {
| Ok((completed_stage, updated_identity)) => {
match self.check_stage(auth, session_metadata.clone()).await {
| Ok((completed_stage, updated_metadata)) => {
info.auth_error = None;
completed_stages.insert(completed_stage.to_string());
info.completed.push(completed_stage);
*identity = updated_identity;
*session_metadata = updated_metadata;
},
| Err(error) => {
info.auth_error = Some(error);
@@ -279,9 +407,9 @@ async fn continue_session(
if completed {
// This session is complete, remove it and return success
let (_, UiaaSession { identity, .. }) = session.remove_entry();
let (_, UiaaSession { session_metadata, .. }) = session.remove_entry();
Ok(Ok(identity))
Ok(Ok(session_metadata.into_identity()))
} else {
// The client needs to try again, return the updated session
Ok(Err(session.get().info.clone()))
@@ -295,152 +423,176 @@ async fn continue_session(
async fn check_stage(
&self,
auth: &AuthData,
mut identity: Identity,
) -> Result<(AuthType, Identity), StandardErrorBody> {
// Note: This function takes ownership of `identity` because mutations to the
// identity must not be applied unless checking the stage succeeds. The
// updated identity is returned as part of the Ok value, and
// `continue_session` handles saving it to `uiaa_sessions`.
mut session_metadata: UiaaSessionMetadata,
) -> Result<(AuthType, UiaaSessionMetadata), StandardErrorBody> {
// Note: This function takes ownership of `session_metadata` because mutations
// to the identity (if it's a legacy session) must not be applied unless
// checking the stage succeeds. The updated identity is returned as part of
// the Ok value, and `continue_session` handles saving it to `uiaa_sessions`.
//
// This also means it's fine to mutate `identity` at any point in this function,
// because those mutations won't be saved unless the function returns Ok.
match auth {
| AuthData::Dummy(_) => Ok(AuthType::Dummy),
| AuthData::EmailIdentity(EmailIdentity {
thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. },
..
}) => {
match self
.services
.threepid
.consume_valid_session(sid, client_secret)
.await
{
| Ok(email) => {
if let Some(localpart) =
self.services.threepid.get_localpart_for_email(&email).await
{
identity.try_set_localpart(localpart)?;
}
let completed_auth_type = match &mut session_metadata {
| UiaaSessionMetadata::OAuth { localpart, ticket } => {
// m.oauth is the only valid stage for oauth sessions
assert!(
matches!(auth, AuthData::OAuth(_)),
"got non-oauth auth data for oauth session"
);
identity.try_set_email(email)?;
Ok(AuthType::EmailIdentity)
},
| Err(message) => Err(StandardErrorBody::new(
ErrorKind::ThreepidAuthFailed,
message.into_owned(),
)),
if self.services.oauth.try_consume_ticket(localpart, *ticket) {
Ok(AuthType::OAuth)
} else {
Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"No OAuth ticket available".to_owned(),
))
}
},
#[allow(clippy::useless_let_if_seq)]
| AuthData::Password(Password { identifier, password, .. }) => {
let user_id_or_localpart = match identifier {
| UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) =>
user.to_owned(),
| UserIdentifier::Email(EmailUserIdentifier { address, .. }) => {
let Ok(email) = Address::try_from(address.to_owned()) else {
return Err(StandardErrorBody::new(
ErrorKind::InvalidParam,
"Email is malformed".to_owned(),
));
};
| UiaaSessionMetadata::Legacy { identity } => match auth {
| AuthData::Dummy(_) => Ok(AuthType::Dummy),
| AuthData::EmailIdentity(EmailIdentity {
thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. },
..
}) => {
match self
.services
.threepid
.get_valid_session(sid, client_secret)
.await
{
| Ok(session) => {
let email = session.consume();
if let Some(localpart) =
self.services.threepid.get_localpart_for_email(&email).await
{
identity.try_set_localpart(localpart)?;
}
if let Some(localpart) =
self.services.threepid.get_localpart_for_email(&email).await
{
identity.try_set_email(email)?;
localpart
} else {
return Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"Invalid identifier or password".to_owned(),
));
}
},
| _ =>
return Err(StandardErrorBody::new(
ErrorKind::Unrecognized,
"Identifier type not recognized".to_owned(),
Ok(AuthType::EmailIdentity)
},
| Err(message) => Err(StandardErrorBody::new(
ErrorKind::ThreepidAuthFailed,
message.into_owned(),
)),
};
}
},
#[allow(clippy::useless_let_if_seq)]
| AuthData::Password(Password { identifier, password, .. }) => {
let user_id_or_localpart = match identifier {
| UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) =>
user.to_owned(),
| UserIdentifier::Email(EmailUserIdentifier { address, .. }) => {
let Ok(email) = Address::try_from(address.to_owned()) else {
return Err(StandardErrorBody::new(
ErrorKind::InvalidParam,
"Email is malformed".to_owned(),
));
};
let Ok(user_id) = UserId::parse_with_server_name(
user_id_or_localpart,
self.services.globals.server_name(),
) else {
return Err(StandardErrorBody::new(
ErrorKind::InvalidParam,
"User ID is malformed".to_owned(),
));
};
if let Some(localpart) =
self.services.threepid.get_localpart_for_email(&email).await
{
identity.try_set_email(email)?;
if self
.services
.users
.check_password(&user_id, password)
.await
.is_ok()
{
identity.try_set_localpart(user_id.localpart().to_owned())?;
localpart
} else {
return Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"Invalid identifier or password".to_owned(),
));
}
},
| _ =>
return Err(StandardErrorBody::new(
ErrorKind::Unrecognized,
"Identifier type not recognized".to_owned(),
)),
};
Ok(AuthType::Password)
} else {
Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"Invalid identifier or password".to_owned(),
))
}
},
| AuthData::ReCaptcha(ReCaptcha { response, .. }) => {
let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key
else {
return Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"ReCaptcha is not configured".to_owned(),
));
};
let Ok(user_id) = UserId::parse_with_server_name(
user_id_or_localpart,
self.services.globals.server_name(),
) else {
return Err(StandardErrorBody::new(
ErrorKind::InvalidParam,
"User ID is malformed".to_owned(),
));
};
match recaptcha_verify::verify_v3(private_site_key, response, None).await {
| Ok(()) => Ok(AuthType::ReCaptcha),
| Err(e) => {
error!("ReCaptcha verification failed: {e:?}");
if self
.services
.users
.check_password(&user_id, password)
.await
.is_ok()
{
identity.try_set_localpart(user_id.localpart().to_owned())?;
Ok(AuthType::Password)
} else {
Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"ReCaptcha verification failed".to_owned(),
"Invalid identifier or password".to_owned(),
))
},
}
},
| AuthData::RegistrationToken(RegistrationToken { token, .. }) => {
let token = token.trim().to_owned();
}
},
| AuthData::ReCaptcha(ReCaptcha { response, .. }) => {
let Some(ref private_site_key) =
self.services.config.recaptcha_private_site_key
else {
return Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"ReCaptcha is not configured".to_owned(),
));
};
if let Some(valid_token) = self
.services
.registration_tokens
.validate_token(token)
.await
{
self.services
match recaptcha_verify::verify_v3(private_site_key, response, None).await {
| Ok(()) => Ok(AuthType::ReCaptcha),
| Err(e) => {
error!("ReCaptcha verification failed: {e:?}");
Err(StandardErrorBody::new(
ErrorKind::CaptchaInvalid,
"ReCaptcha verification failed".to_owned(),
))
},
}
},
| AuthData::RegistrationToken(RegistrationToken { token, .. }) => {
let token = token.trim().to_owned();
if let Some(valid_token) = self
.services
.registration_tokens
.mark_token_as_used(valid_token);
.validate_token(token)
.await
{
self.services
.registration_tokens
.mark_token_as_used(valid_token);
Ok(AuthType::RegistrationToken)
} else {
Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"Invalid registration token".to_owned(),
))
}
Ok(AuthType::RegistrationToken)
} else {
Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"Invalid registration token".to_owned(),
))
}
},
| AuthData::Terms(_) => Ok(AuthType::Terms),
| unknown => {
// We already checked that the stage type is one that exists in the flow,
// so we can only get here if we ourselves served a flow with a stage that we
// don't understand.
panic!("tried to check an unsupported stage type: {unknown:?}");
},
},
| AuthData::Terms(_) => Ok(AuthType::Terms),
| _ => Err(StandardErrorBody::new(
ErrorKind::Unrecognized,
"Unsupported stage type".into(),
)),
}
.map(|auth_type| (auth_type, identity))
}?;
Ok((completed_auth_type, session_metadata))
}
}
+1 -1
View File
@@ -54,6 +54,7 @@ pub async fn set_dehydrated_device(&self, user_id: &UserId, request: Request) ->
user_id,
&request.device_id,
"",
None,
request.initial_device_display_name.clone(),
None,
)
@@ -138,7 +139,6 @@ pub async fn get_dehydrated_device_id(&self, user_id: &UserId) -> Result<OwnedDe
level = "debug",
skip_all,
fields(%user_id),
ret,
)]
pub async fn get_dehydrated_device(&self, user_id: &UserId) -> Result<DehydratedDevice> {
self.db
+340 -22
View File
@@ -1,13 +1,21 @@
pub(super) mod dehydrated_device;
use std::{collections::BTreeMap, mem, net::IpAddr, sync::Arc};
use std::{
collections::BTreeMap,
mem,
net::IpAddr,
sync::Arc,
time::{Duration, SystemTime},
};
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,
@@ -18,15 +26,24 @@
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, 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 {
@@ -35,12 +52,13 @@ pub struct UserSuspension {
/// When the user was suspended (Unix timestamp in milliseconds)
pub suspended_at: u64,
/// User ID of who suspended this user
pub suspended_by: String,
pub suspended_by: Option<String>,
}
/// 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 {
@@ -51,19 +69,30 @@ pub fn new(password: &str) -> Result<Self> {
}
}
/// The status of an access token.
pub enum AccessTokenStatus {
Valid,
Expired,
}
pub struct Service {
services: Services,
db: Data,
}
struct Services {
server: Arc<Server>,
account_data: Dep<account_data::Service>,
admin: Dep<admin::Service>,
alias: Dep<alias::Service>,
appservice: Dep<appservice::Service>,
config: Dep<config::Service>,
firstrun: Dep<firstrun::Service>,
globals: Dep<globals::Service>,
membership: Dep<membership::Service>,
oauth: Dep<oauth::Service>,
state_accessor: Dep<rooms::state_accessor::Service>,
state_cache: Dep<rooms::state_cache::Service>,
threepid: Dep<threepid::Service>,
}
struct Data {
@@ -75,6 +104,7 @@ struct Data {
logintoken_expiresatuserid: Arc<Map>,
todeviceid_events: Arc<Map>,
token_userdeviceid: Arc<Map>,
userdeviceid_tokenexpires: Arc<Map>,
userdeviceid_metadata: Arc<Map>,
userdeviceid_token: Arc<Map>,
userfilterid_filter: Arc<Map>,
@@ -97,14 +127,19 @@ impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
server: args.server.clone(),
account_data: args.depend::<account_data::Service>("account_data"),
admin: args.depend::<admin::Service>("admin"),
alias: args.depend::<alias::Service>("alias"),
appservice: args.depend::<appservice::Service>("appservice"),
config: args.depend::<config::Service>("config"),
firstrun: args.depend::<firstrun::Service>("firstrun"),
globals: args.depend::<globals::Service>("globals"),
membership: args.depend::<membership::Service>("membership"),
oauth: args.depend::<oauth::Service>("oauth"),
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
state_cache: args.depend::<rooms::state_cache::Service>("rooms::state_cache"),
threepid: args.depend::<threepid::Service>("threepid"),
},
db: Data {
keychangeid_userid: args.db["keychangeid_userid"].clone(),
@@ -131,6 +166,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
userid_selfsigningkeyid: args.db["userid_selfsigningkeyid"].clone(),
userid_usersigningkeyid: args.db["userid_usersigningkeyid"].clone(),
useridprofilekey_value: args.db["useridprofilekey_value"].clone(),
userdeviceid_tokenexpires: args.db["userdeviceid_tokenexpires"].clone(),
},
}))
}
@@ -192,12 +228,240 @@ pub async fn create(&self, user_id: &UserId, password: Option<HashedPassword>) -
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.
///
/// Does not automatically join the user to auto join rooms. Use
/// `join_auto_join_rooms` for that.
pub async fn create_local_account(
&self,
user_id: &UserId,
password: HashedPassword,
email: Option<Address>,
) {
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(' ');
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, None).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();
}
}
info!("Created new user account for {user_id}");
}
/// Autojoin the user to the configured autojoin rooms
pub async fn join_auto_join_rooms(&self, user_id: &UserId) {
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 auto-room {room} 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}");
},
}
}
}
}
pub async fn determine_registration_user_id(
&self,
supplied_username: Option<String>,
email: Option<&Address>,
appservice_info: Option<&RegistrationInfo>,
) -> Result<OwnedUserId> {
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<()> {
@@ -217,13 +481,13 @@ pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> {
}
/// Suspend account, placing it in a read-only state
pub async fn suspend_account(&self, user_id: &UserId, suspending_user: &UserId) {
pub async fn suspend_account(&self, user_id: &UserId, suspending_user: Option<&UserId>) {
self.db.userid_suspension.raw_put(
user_id,
Json(UserSuspension {
suspended: true,
suspended_at: MilliSecondsSinceUnixEpoch::now().get().into(),
suspended_by: suspending_user.to_string(),
suspended_by: suspending_user.map(ToString::to_string),
}),
);
}
@@ -233,7 +497,7 @@ pub async fn unsuspend_account(&self, user_id: &UserId) {
self.db.userid_suspension.remove(user_id);
}
pub async fn lock_account(&self, user_id: &UserId, locking_user: &UserId) {
pub async fn lock_account(&self, user_id: &UserId, locking_user: Option<&UserId>) {
// NOTE: Locking is basically just suspension with a more severe effect,
// so we'll just re-use the suspension data structure to store the lock state.
let suspension = self
@@ -245,7 +509,7 @@ pub async fn lock_account(&self, user_id: &UserId, locking_user: &UserId) {
.unwrap_or_else(|_| UserSuspension {
suspended: true,
suspended_at: MilliSecondsSinceUnixEpoch::now().get().into(),
suspended_by: locking_user.to_string(),
suspended_by: locking_user.map(ToString::to_string),
});
self.db.userid_lock.raw_put(user_id, Json(suspension));
@@ -337,8 +601,42 @@ pub async fn is_active_local(&self, user_id: &UserId) -> bool {
pub async fn count(&self) -> usize { self.db.userid_password.count().await }
/// Find out which user an access token belongs to.
pub async fn find_from_token(&self, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> {
self.db.token_userdeviceid.get(token).await.deserialized()
pub async fn find_from_token(
&self,
token: &str,
) -> Option<(OwnedUserId, OwnedDeviceId, AccessTokenStatus)> {
let user = self
.db
.token_userdeviceid
.get(token)
.await
.deserialized()
.ok();
// Check if the token has expired
if let Some((user_id, device_id)) = user {
if let Some(expires) = self
.db
.userdeviceid_tokenexpires
.qry(&(&user_id, &device_id))
.await
.deserialized::<u64>()
.ok()
.map(Duration::from_secs)
{
let expires_at = SystemTime::UNIX_EPOCH
.checked_add(expires)
.expect("expiry time should not overflow SystemTime");
if SystemTime::now() > expires_at {
return Some((user_id, device_id, AccessTokenStatus::Expired));
}
}
Some((user_id, device_id, AccessTokenStatus::Valid))
} else {
None
}
}
/// Returns an iterator over all users on this homeserver.
@@ -434,6 +732,7 @@ pub async fn create_device(
user_id: &UserId,
device_id: &DeviceId,
token: &str,
token_max_age: Option<Duration>,
initial_device_display_name: Option<String>,
client_ip: Option<String>,
) -> Result<()> {
@@ -451,7 +750,8 @@ pub async fn create_device(
increment(&self.db.userid_devicelistversion, user_id.as_bytes());
self.db.userdeviceid_metadata.put(key, Json(device));
self.set_token(user_id, device_id, token).await
self.set_token(user_id, device_id, token, token_max_age)
.await
}
/// Removes a device from a user.
@@ -467,6 +767,7 @@ pub async fn remove_device(&self, user_id: &UserId, device_id: &DeviceId) {
if let Ok(old_token) = self.db.userdeviceid_token.qry(&userdeviceid).await {
self.db.userdeviceid_token.del(userdeviceid);
self.db.token_userdeviceid.remove(&old_token);
self.db.userdeviceid_tokenexpires.del(userdeviceid);
}
// Remove todevice events
@@ -480,6 +781,9 @@ pub async fn remove_device(&self, user_id: &UserId, device_id: &DeviceId) {
// TODO: Remove onetimekeys
// Remove OAuth session information
self.services.oauth.remove_session(user_id, device_id).await;
increment(&self.db.userid_devicelistversion, user_id.as_bytes());
self.db.userdeviceid_metadata.del(userdeviceid);
@@ -535,6 +839,7 @@ pub async fn set_token(
user_id: &UserId,
device_id: &DeviceId,
token: &str,
token_max_age: Option<Duration>,
) -> Result<()> {
let key = (user_id, device_id);
if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
@@ -561,6 +866,7 @@ pub async fn set_token(
// Remove old token
if let Ok(old_token) = self.db.userdeviceid_token.qry(&key).await {
self.db.token_userdeviceid.remove(&old_token);
self.db.userdeviceid_tokenexpires.remove(&old_token);
// It will be removed from userdeviceid_token by the insert later
}
@@ -568,6 +874,18 @@ pub async fn set_token(
self.db.userdeviceid_token.put_raw(key, token);
self.db.token_userdeviceid.raw_put(token, key);
if let Some(max_age) = token_max_age {
let expires = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system time should not be before the epoch")
.saturating_add(max_age)
.as_secs();
self.db.userdeviceid_tokenexpires.put(key, expires);
} else {
self.db.userdeviceid_tokenexpires.del(key);
}
Ok(())
}
@@ -1238,7 +1556,7 @@ pub async fn get_filter(
pub fn create_openid_token(&self, user_id: &UserId, token: &str) -> Result<u64> {
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();
@@ -1282,7 +1600,7 @@ pub async fn find_from_openid_token(&self, token: &str) -> Result<OwnedUserId> {
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);
+11
View File
@@ -22,6 +22,8 @@ crate-type = [
conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true
conduwuit-core.workspace = true
conduwuit-database.workspace = true
conduwuit-api.workspace = true
async-trait.workspace = true
askama.workspace = true
axum.workspace = true
@@ -35,9 +37,18 @@ ruma.workspace = true
thiserror.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
lettre.workspace = true
memory-serve = "2.1.0"
validator = { version = "0.20.0", features = ["derive"] }
tower-sec-fetch = { version = "0.1.2", features = ["tracing"] }
tower-sessions = { version = "0.15.0", default-features = false, features = ["axum-core"] }
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
form_urlencoded = "1.2.2"
[build-dependencies]
memory-serve = "2.1.0"
+48
View File
@@ -0,0 +1,48 @@
use axum::{
extract::{FromRequest, FromRequestParts, Request},
http::{Method, request::Parts},
};
use serde::de::DeserializeOwned;
use crate::WebError;
/// An extractor which deserializes a struct from a POST request's body.
/// For GET requests the struct will be None.
#[derive(Debug, Clone, Copy, Default)]
#[must_use]
pub(crate) struct PostForm<T>(pub Option<T>);
impl<T, S> FromRequest<S> for PostForm<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = WebError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if req.method() == Method::POST {
let axum::Form(data) = axum::Form::from_request(req, state).await?;
Ok(Self(Some(data)))
} else {
Ok(Self(None))
}
}
}
/// An extractor which wraps another extractor and converts its errors into
/// `WebError`s.
pub(crate) struct Expect<E>(pub E);
impl<E, S, R> FromRequestParts<S> for Expect<E>
where
E: FromRequestParts<S, Rejection = R>,
WebError: From<R>,
S: Send + Sync,
{
type Rejection = WebError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Ok(Self(E::from_request_parts(parts, state).await?))
}
}
+61 -15
View File
@@ -1,25 +1,34 @@
use std::any::Any;
use std::{any::Any, sync::Once, time::Duration};
use askama::Template;
use axum::{
Router,
extract::rejection::{FormRejection, QueryRejection},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
extract::rejection::{FormRejection, PathRejection, QueryRejection},
http::StatusCode,
middleware::from_fn_with_state,
response::{Html, IntoResponse, Redirect, Response},
};
use conduwuit_service::state;
use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer};
use conduwuit_service::{Services, state};
use tower_http::catch_panic::CatchPanicLayer;
use tower_sec_fetch::SecFetchLayer;
use tower_sessions::{ExpiredDeletion, SessionManagerLayer, cookie::SameSite};
use crate::pages::TemplateContext;
use crate::{
pages::TemplateContext,
session::{LoginQuery, store::RocksDbSessionStore},
};
mod extract;
mod pages;
mod session;
type State = state::State;
const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \
please contact the team @ https://continuwuity.org";
const ROUTE_PREFIX: &str = conduwuit_core::ROUTE_PREFIX;
#[derive(Debug, thiserror::Error)]
enum WebError {
#[error("Failed to validate form body: {0}")]
@@ -29,10 +38,16 @@ enum WebError {
#[error("{0}")]
FormRejection(#[from] FormRejection),
#[error("{0}")]
PathRejection(#[from] PathRejection),
#[error("{0}")]
BadRequest(String),
#[error("This page does not exist.")]
NotFound,
#[error("You are not allowed to request this page: {0}")]
Forbidden(String),
#[error("You must log in to access this page")]
LoginRequired(LoginQuery),
#[error("Failed to render template: {0}")]
Render(#[from] askama::Error),
@@ -52,12 +67,26 @@ struct Error {
context: TemplateContext,
}
if let Self::LoginRequired(query) = self {
return Redirect::to(&format!(
"{}/account/login?{}",
ROUTE_PREFIX,
serde_urlencoded::to_string(query).unwrap()
))
.into_response();
}
let status = match &self {
| Self::ValidationError(_)
| Self::BadRequest(_)
| Self::QueryRejection(_)
| Self::FormRejection(_) => StatusCode::BAD_REQUEST,
| Self::FormRejection(_)
| Self::InternalError(_) => StatusCode::BAD_REQUEST,
| Self::NotFound => StatusCode::NOT_FOUND,
| Self::Forbidden(_) => StatusCode::FORBIDDEN,
| Self::LoginRequired(_) => {
unreachable!("LoginRequired is handled earlier")
},
| _ => StatusCode::INTERNAL_SERVER_ERROR,
};
@@ -67,6 +96,7 @@ struct Error {
context: TemplateContext {
// Statically set false to prevent error pages from being indexed.
allow_indexing: false,
csp_nonce: String::new(),
},
};
@@ -78,21 +108,40 @@ struct Error {
}
}
pub fn build() -> Router<state::State> {
static STORE_CLEANUP_TASK: Once = Once::new();
pub fn build(services: &Services) -> Router<state::State> {
#[allow(clippy::wildcard_imports)]
use pages::*;
let store = RocksDbSessionStore::new(&services.db);
STORE_CLEANUP_TASK.call_once(|| {
services.server.runtime().spawn(
store
.clone()
.continuously_delete_expired(Duration::from_hours(1)),
);
});
Router::new()
.merge(index::build())
.nest(
"/_continuwuity/",
Router::new()
.merge(resources::build())
.merge(password_reset::build())
.nest("/about", about::build())
.nest("/account/", account::build())
.merge(debug::build())
.nest("/oauth2/", oauth::build())
.merge(resources::build())
.merge(threepid::build())
.fallback(async || WebError::NotFound),
)
.layer(
SessionManagerLayer::new(store)
.with_name("_c10y_session")
.with_same_site(SameSite::Lax),
)
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send + 'static>| {
let details = if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
@@ -104,10 +153,7 @@ pub fn build() -> Router<state::State> {
WebError::Panic(details).into_response()
}))
.layer(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; img-src 'self' data:;"),
))
.layer(from_fn_with_state(services.config.clone(), template_context_middleware))
.layer(SecFetchLayer::new(|policy| {
policy.allow_safe_methods().reject_missing_metadata();
}))
+38
View File
@@ -0,0 +1,38 @@
use std::collections::BTreeMap;
use axum::{Extension, Router, extract::State, routing::get};
use conduwuit_core::config::TermsDocument;
use ruma::{
OwnedServerName,
api::client::discovery::discover_support::{Contact, ContactRole},
};
use url::Url;
use crate::{
pages::{Result, TemplateContext},
response, template,
};
pub(crate) fn build() -> Router<crate::State> { Router::new().route("/", get(get_about)) }
template! {
struct About use "about.html.j2" {
server_name: OwnedServerName,
support_page: Option<Url>,
contacts: Vec<Contact>,
terms: BTreeMap<String, TermsDocument>
}
}
async fn get_about(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
) -> Result {
response!(About::new(
context,
services.globals.server_name().to_owned(),
services.config.well_known.support_page.clone(),
services.admin.get_support_contacts().await,
services.config.registration_terms.documents.clone()
))
}
@@ -0,0 +1,47 @@
use axum::{Extension, Router, extract::State, routing::on};
use conduwuit_service::oauth::OAuthTicket;
use crate::{
extract::PostForm,
pages::{GET_POST, Result, TemplateContext, components::UserCard},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_cross_signing_reset))
}
template! {
struct CrossSigningReset use "cross_signing_reset.html.j2" {
user_card: UserCard,
body: CrossSigningResetBody
}
}
#[derive(Debug)]
enum CrossSigningResetBody {
Form,
Success,
}
async fn route_cross_signing_reset(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
PostForm(form): PostForm<()>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::CrossSigningReset)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if form.is_some() {
services
.oauth
.issue_ticket(user_id.localpart().to_owned(), OAuthTicket::CrossSigningReset);
response!(CrossSigningReset::new(context, user_card, CrossSigningResetBody::Success))
} else {
response!(CrossSigningReset::new(context, user_card, CrossSigningResetBody::Form))
}
}
+129
View File
@@ -0,0 +1,129 @@
use axum::{Extension, Router, extract::State, routing::on};
use conduwuit_api::client::full_user_deactivate;
use futures::StreamExt;
use ruma::{OwnedRoomId, OwnedUserId, UserId};
use tower_sessions::Session;
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
extract::PostForm,
form,
pages::{
GET_POST, Result, TemplateContext,
components::{UserCard, form::Form},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_deactivate))
}
template! {
struct Deactivate use "deactivate.html.j2" {
body: DeactivateBody
}
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
enum DeactivateBody {
Unavailable,
Form {
user_id: OwnedUserId,
user_card: UserCard,
form: Form<'static>,
},
Success,
}
form! {
struct DeactivateForm {
password: String where {
input_type: "password",
label: "Enter your password to confirm",
autocomplete: "current-password"
},
#[validate(required(message = "This checkbox must be checked"))]
confirm: Option<String> where {
input_type: "checkbox",
label: "I understand that deactivating my account cannot be undone."
}
submit: "Deactivate my account",
slowdown: true
}
}
async fn route_deactivate(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
session: Session,
PostForm(form): PostForm<DeactivateForm>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::Deactivate)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
let body = {
if !services.config.allow_deactivation {
DeactivateBody::Unavailable
} else if let Some(form) = form {
if let Err(err) = validate_deactivate_form(&services, &user_id, form).await {
DeactivateBody::Form {
user_id,
user_card,
form: DeactivateForm::with_errors(context.clone(), err),
}
} else {
let all_joined_rooms: Vec<OwnedRoomId> = services
.rooms
.state_cache
.rooms_joined(&user_id)
.collect()
.await;
full_user_deactivate(&services, &user_id, &all_joined_rooms).await?;
session.clear().await;
DeactivateBody::Success
}
} else {
DeactivateBody::Form {
user_id,
user_card,
form: DeactivateForm::build(context.clone()),
}
}
};
response!(Deactivate::new(context, body))
}
async fn validate_deactivate_form(
services: &crate::State,
user_id: &UserId,
form: DeactivateForm,
) -> Result<(), ValidationErrors> {
form.validate()?;
if services
.users
.check_password(user_id, &form.password)
.await
.is_err()
{
let mut errors = ValidationErrors::new();
errors.add(
"password",
ValidationError::new("wrong").with_message("Incorrect password".into()),
);
return Err(errors);
}
Ok(())
}
+126
View File
@@ -0,0 +1,126 @@
use axum::{
Extension, Router,
extract::{Path, State},
routing::{get, on},
};
use conduwuit_service::oauth::{SessionInfo, client_metadata::ClientMetadata};
use futures::StreamExt;
use ruma::OwnedDeviceId;
use serde::{Deserialize, Serialize};
use crate::{
WebError,
extract::{Expect, PostForm},
pages::{
GET_POST, Result, TemplateContext,
components::{ClientScopes, DeviceCard, DeviceCardStyle},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/{device}/", get(get_device_info))
.route("/{device}/remove", on(GET_POST, route_remove_device))
}
template! {
struct DeviceInfo use "device_info.html.j2" {
device_card: DeviceCard,
client_metadata: Option<(ClientMetadata, SessionInfo)>
}
}
async fn get_device_info(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
Expect(Path(query)): Expect<Path<DevicePath>>,
) -> Result {
let user_id = user.expect(LoginTarget::RemoveDevice(query.clone()))?;
let Ok(device) = services
.users
.get_device_metadata(&user_id, &query.device)
.await
else {
return response!(WebError::BadRequest("Unknown device".to_owned()));
};
let client_metadata = async {
let session_info = services
.oauth
.get_session_info_for_device(&user_id, &device.device_id)
.await?;
let client_metadata = services
.oauth
.get_client_metadata(&session_info.client_id)
.await?;
Some((client_metadata, session_info))
}
.await;
let device_card =
DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Detailed).await;
response!(DeviceInfo::new(context, device_card, client_metadata))
}
template! {
struct RemoveDevice use "remove_device.html.j2" {
body: RemoveDeviceBody
}
}
#[derive(Debug)]
enum RemoveDeviceBody {
Form {
device_card: Box<DeviceCard>,
last_device: bool,
},
Success,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct DevicePath {
pub device: OwnedDeviceId,
}
async fn route_remove_device(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
Expect(Path(query)): Expect<Path<DevicePath>>,
PostForm(form): PostForm<()>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::RemoveDevice(query.clone()))?;
let Ok(device) = services
.users
.get_device_metadata(&user_id, &query.device)
.await
else {
return response!(WebError::BadRequest("Unknown device".to_owned()));
};
if form.is_some() {
services
.users
.remove_device(&user_id, &device.device_id)
.await;
response!(RemoveDevice::new(context, RemoveDeviceBody::Success))
} else {
let device_card =
DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Minimal).await;
let last_device = services.users.all_devices_metadata(&user_id).count().await <= 1;
response!(RemoveDevice::new(context, RemoveDeviceBody::Form {
device_card: Box::new(device_card),
last_device
}))
}
}
+210
View File
@@ -0,0 +1,210 @@
use axum::{
Extension, Router,
extract::{Query, State},
routing::{get, on, post},
};
use conduwuit_core::warn;
use conduwuit_service::{mailer::messages, threepid::session::ValidationSessions};
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId};
use serde::{Deserialize, Serialize};
use crate::{
WebError,
extract::{Expect, PostForm},
form,
pages::{
GET_POST, Result, TemplateContext,
account::ThreepidQuery,
components::{UserCard, form::Form},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/change/", on(GET_POST, route_change_email))
.route("/change/validate", get(get_change_email_validate))
.route("/change/delete", post(post_delete_email))
}
template! {
struct ChangeEmail use "change_email.html.j2" {
user_card: UserCard,
email: Option<String>,
form: Form<'static>,
may_remove: bool
}
}
form! {
struct ChangeEmailForm {
email: Address where {
input_type: "email",
label: "Email address"
}
submit: "Change email"
}
}
template! {
struct ChangeEmailValidate use "change_email_validate.html.j2" {
user_card: UserCard,
body: ChangeEmailValidateBody
}
}
template! {
struct DeleteEmail use "delete_email.html.j2" {
user_card: UserCard
}
}
#[derive(Debug)]
enum ChangeEmailValidateBody {
ValidationPending {
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
validation_error: bool,
},
Success,
}
async fn route_change_email(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
PostForm(form): PostForm<ChangeEmailForm>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::ChangeEmail)?;
let Some(form) = form else {
return response!(ChangeEmail::new(
context.clone(),
UserCard::for_local_user(&services, user_id.clone()).await,
services
.threepid
.get_email_for_localpart(user_id.localpart())
.await
.map(|address| address.to_string()),
ChangeEmailForm::build(context),
services.threepid.email_requirement().may_remove(),
));
};
let client_secret = ClientSecret::new();
let session_id = {
let display_name = services.users.displayname(&user_id).await.ok();
match services
.threepid
.send_validation_email(
Mailbox::new(display_name, form.email.clone()),
|verification_link| messages::ChangeEmail {
server_name: services.globals.server_name().as_str(),
user_id: Some(&user_id),
verification_link,
},
&client_secret,
0,
)
.await
{
| Ok(session_id) => session_id,
| Err(err) => {
// If we couldn't send an email, generate a random session ID to not give that
// away
warn!(
"Failed to send email change message for {user_id} to {}: {err}",
form.email
);
ValidationSessions::generate_session_id()
},
}
};
response!(ChangeEmailValidate::new(
context,
UserCard::for_local_user(&services, user_id).await,
ChangeEmailValidateBody::ValidationPending {
session_id,
client_secret,
validation_error: false
}
))
}
#[derive(Deserialize, Serialize)]
struct ChangeEmailQuery {
#[serde(flatten)]
threepid: ThreepidQuery,
}
async fn get_change_email_validate(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
Expect(Query(ChangeEmailQuery {
threepid: ThreepidQuery { client_secret, session_id },
})): Expect<Query<ChangeEmailQuery>>,
user: User,
) -> Result {
let user_id = user.expect(LoginTarget::ChangeEmail)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if !services.threepid.email_requirement().may_change() {
return Err(WebError::Forbidden("You may not change your email address.".to_owned()));
}
let Ok(session) = services
.threepid
.get_valid_session(&session_id, &client_secret)
.await
else {
return response!(ChangeEmailValidate::new(
context,
user_card,
ChangeEmailValidateBody::ValidationPending {
session_id,
client_secret,
validation_error: true
}
));
};
let new_email = session.consume();
if let Err(err) = services
.threepid
.associate_localpart_email(user_id.localpart(), &new_email)
.await
{
return response!(BadRequest(err.message()));
}
response!(ChangeEmailValidate::new(context, user_card, ChangeEmailValidateBody::Success))
}
async fn post_delete_email(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
) -> Result {
let user_id = user.expect(LoginTarget::ChangeEmail)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if !services.threepid.email_requirement().may_remove() {
return Err(WebError::Forbidden("You may not remove your email address.".to_owned()));
}
let _ = services
.threepid
.disassociate_localpart_email(user_id.localpart())
.await;
response!(DeleteEmail::new(context, user_card))
}
+155
View File
@@ -0,0 +1,155 @@
use std::time::SystemTime;
use axum::{
Extension, Router,
extract::{Query, RawQuery, State},
response::{IntoResponse, Redirect},
routing::{get, on},
};
use conduwuit_api::client::handle_login;
use ruma::{
OwnedUserId,
api::client::uiaa::{EmailUserIdentifier, MatrixUserIdentifier, UserIdentifier},
};
use serde::Deserialize;
use tower_sessions::Session;
use crate::{
ROUTE_PREFIX, WebError,
extract::{Expect, PostForm},
pages::{
GET_POST, Result, TemplateContext,
account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status},
components::UserCard,
},
response,
session::{LoginQuery, LoginTarget, User, UserSession},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/login", on(GET_POST, route_login))
.route("/logout", get(get_logout))
}
template! {
struct Login use "login.html.j2" {
body: LoginBody,
login_error: Option<String>
}
}
#[derive(Debug)]
enum LoginBody {
Unauthenticated {
server_name: String,
registration_available: bool,
next: Option<LoginTarget>,
},
Authenticated {
user_card: UserCard,
},
}
#[derive(Deserialize)]
struct LoginForm {
identifier: Option<String>,
password: String,
}
async fn route_login(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
Expect(Query(LoginQuery { next, reauthenticate })): Expect<Query<LoginQuery>>,
session_store: Session,
user: User<true>,
PostForm(form): PostForm<LoginForm>,
) -> Result {
let user_id = user.into_session().map(|session| session.user_id);
let body = match &user_id {
| None => {
let (trusted_flow_status, untrusted_flow_status) =
registration_flow_status(&services).await;
let registration_available =
matches!(trusted_flow_status, TrustedFlowStatus::Available)
|| matches!(untrusted_flow_status, UntrustedFlowStatus::Available { .. });
LoginBody::Unauthenticated {
server_name: services.globals.server_name().to_string(),
registration_available,
next: next.clone(),
}
},
| Some(user_id) => {
if !reauthenticate {
return response!(Redirect::to(&next.unwrap_or_default().target_path()));
}
let user_card = UserCard::for_local_user(&services, user_id.to_owned()).await;
LoginBody::Authenticated { user_card }
},
};
let mut template = Login::new(context, body, None);
if let Some(form) = form {
let login_result = match (user_id, form.identifier) {
| (Some(user_id), _) => {
// The user is already authenticated, we need to check their password
services
.users
.check_password(&user_id, &form.password)
.await
},
| (None, Some(identifier)) => {
// The user isn't authenticated, we need to log them in
let identifier = if identifier.parse::<lettre::Address>().is_ok() {
UserIdentifier::Email(EmailUserIdentifier::new(identifier))
} else {
UserIdentifier::Matrix(MatrixUserIdentifier::new(identifier))
};
handle_login(&services, Some(&identifier), &form.password, None).await
},
| (None, None) => {
// The user isn't authenticated and didn't supply an identity
return response!(WebError::BadRequest("No identity provided".to_owned()));
},
};
let user_id = match login_result {
| Ok(user_id) => user_id,
| Err(err) => {
let error_message = if let conduwuit_core::Error::Request(_, message, _) = err {
message.into_owned()
} else {
"Internal login error".to_owned()
};
template.login_error = Some(error_message);
return response!(template);
},
};
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");
return response!(Redirect::to(&next.unwrap_or_default().target_path()));
}
response!(template)
}
async fn get_logout(session: Session, RawQuery(query): RawQuery) -> impl IntoResponse {
let _ = session.remove::<OwnedUserId>(User::KEY).await;
Redirect::to(&format!("{}/account/login?{}", ROUTE_PREFIX, query.unwrap_or_default()))
}
+173
View File
@@ -0,0 +1,173 @@
use axum::{
Extension, Router,
extract::{Query, State},
response::Redirect,
routing::get,
};
use conduwuit_core::utils::{IterStream, ReadyExt, stream::TryExpect};
use conduwuit_service::threepid::EmailRequirement;
use futures::StreamExt;
use ruma::{
OwnedClientSecret, OwnedDeviceId, OwnedSessionId,
api::client::discovery::get_authorization_server_metadata::v1::AccountManagementAction,
};
use serde::{Deserialize, Serialize};
use crate::{
WebError,
extract::Expect,
pages::{
Result, TemplateContext,
components::{DeviceCard, DeviceCardStyle, UserCard},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) mod cross_signing_reset;
pub(crate) mod deactivate;
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<crate::State> {
#[allow(clippy::wildcard_imports)]
use self::*;
Router::new()
.route("/", get(get_account))
.route("/deeplink", get(get_account_deeplink))
.merge(login::build())
.nest("/password/", password::build())
.nest("/email/", email::build())
.nest("/cross_signing_reset", cross_signing_reset::build())
.nest("/deactivate", deactivate::build())
.nest("/device/", device::build())
.nest("/register/", register::build())
}
#[derive(Deserialize, Serialize)]
struct ThreepidQuery {
client_secret: OwnedClientSecret,
session_id: OwnedSessionId,
}
template! {
struct Account use "account.html.j2" {
user_card: UserCard,
body: AccountBody
}
}
#[derive(Debug)]
enum AccountBody {
Unlocked {
suspended: bool,
email_requirement: EmailRequirement,
email: Option<String>,
devices: Vec<DeviceCard>,
},
Locked,
}
async fn get_account(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User<true>,
) -> Result {
let user_id = user.expect(LoginTarget::Account)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if services.users.is_locked(&user_id).await.unwrap() {
return response!(Account::new(context, user_card, AccountBody::Locked));
}
let email_requirement = services.threepid.email_requirement();
let email = services
.threepid
.get_email_for_localpart(user_id.localpart())
.await
.map(|address| address.to_string());
let dehydrated_device_id = services.users.get_dehydrated_device_id(&user_id).await.ok();
let mut devices: Vec<_> = services
.users
.all_device_ids(&user_id)
.then(async |device_id| {
services
.users
.get_device_metadata(&user_id, &device_id)
.await
})
.expect_ok()
.ready_filter(|device| {
dehydrated_device_id
.as_ref()
.is_none_or(|id| device.device_id != *id)
})
.collect()
.await;
devices.sort_unstable_by(|a, b| a.last_seen_ts.cmp(&b.last_seen_ts).reverse());
let device_cards = devices
.into_iter()
.stream()
.then(async |device| {
DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Minimal).await
})
.collect()
.await;
let suspended = services.users.is_suspended(&user_id).await.unwrap();
response!(Account::new(context, user_card, AccountBody::Unlocked {
suspended,
email_requirement,
email,
devices: device_cards
}))
}
#[derive(Deserialize)]
struct AccountDeeplinkQuery {
action: Option<AccountManagementAction>,
device_id: Option<OwnedDeviceId>,
}
async fn get_account_deeplink(
Expect(Query(query)): Expect<Query<AccountDeeplinkQuery>>,
) -> Result {
let redirect_target = match query.action.unwrap_or(AccountManagementAction::Profile) {
| AccountManagementAction::AccountDeactivate => "deactivate".to_owned(),
| AccountManagementAction::CrossSigningReset => "cross_signing_reset".to_owned(),
| AccountManagementAction::DeviceDelete => {
let Some(device_id) = query.device_id else {
return response!(WebError::BadRequest(
"A device ID is required for this action".to_owned()
));
};
format!("device/{device_id}/delete")
},
| AccountManagementAction::DeviceView => {
let Some(device_id) = query.device_id else {
return response!(WebError::BadRequest(
"A device ID is required for this action".to_owned()
));
};
format!("device/{device_id}/")
},
| AccountManagementAction::DevicesList => "#devices".to_owned(),
| AccountManagementAction::Profile => String::new(),
| _ => return response!(WebError::BadRequest("Unknown action".to_owned())),
};
response!(Redirect::to(&format!("{}/account/{}", crate::ROUTE_PREFIX, redirect_target)))
}
+122
View File
@@ -0,0 +1,122 @@
use axum::{Extension, Router, extract::State, routing::on};
use conduwuit_service::users::HashedPassword;
use ruma::UserId;
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
extract::PostForm,
form,
pages::{
GET_POST, Result, TemplateContext,
components::{UserCard, form::Form},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_change_password))
}
template! {
struct ChangePassword use "change_password.html.j2" {
user_card: UserCard,
body: ChangePasswordBody
}
}
#[derive(Debug)]
enum ChangePasswordBody {
Form(Form<'static>),
Success,
}
form! {
struct ChangePasswordForm {
#[validate(length(min = 1, message = "Current password cannot be empty"))]
current_password: String where {
input_type: "password",
label: "Current password",
autocomplete: "current-password"
},
#[validate(length(min = 1, message = "New password cannot be empty"))]
new_password: String where {
input_type: "password",
label: "New password",
autocomplete: "new-password"
},
#[validate(must_match(other = "new_password", message = "Passwords must match"))]
confirm_new_password: String where {
input_type: "password",
label: "Confirm new password",
autocomplete: "new-password"
}
submit: "Change password"
}
}
async fn route_change_password(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User,
PostForm(form): PostForm<ChangePasswordForm>,
) -> Result {
let user_id = user.expect(LoginTarget::ChangePassword)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
let body = if let Some(form) = form {
match change_password(&services, &user_id, form).await {
| Ok(()) => ChangePasswordBody::Success,
| Err(errors) =>
ChangePasswordBody::Form(ChangePasswordForm::with_errors(context.clone(), errors)),
}
} else {
ChangePasswordBody::Form(ChangePasswordForm::build(context.clone()))
};
response!(ChangePassword::new(context, user_card, body))
}
async fn change_password(
services: &crate::State,
user_id: &UserId,
form: ChangePasswordForm,
) -> Result<(), ValidationErrors> {
form.validate()?;
if services
.users
.check_password(user_id, &form.current_password)
.await
.is_err()
{
let mut errors = ValidationErrors::new();
errors.add(
"current_password",
ValidationError::new("wrong").with_message("Incorrect password".into()),
);
return Err(errors);
}
match HashedPassword::new(&form.new_password) {
| Ok(hash) => {
services.users.set_password(user_id, Some(hash));
},
| Err(err) => {
let mut errors = ValidationErrors::new();
errors.add(
"new_password",
ValidationError::new("malformed").with_message(err.message().into()),
);
return Err(errors);
},
}
Ok(())
}
+13
View File
@@ -0,0 +1,13 @@
use axum::Router;
mod change;
mod reset;
pub(crate) fn build() -> Router<crate::State> {
#[allow(clippy::wildcard_imports)]
use self::*;
Router::new()
.nest("/change", change::build())
.nest("/reset/", reset::build())
}
+252
View File
@@ -0,0 +1,252 @@
use axum::{
Extension, Router,
extract::{Query, State},
routing::on,
};
use conduwuit_core::warn;
use conduwuit_service::{
mailer::messages, threepid::session::ValidationSessions, users::HashedPassword,
};
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, UserId};
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
WebError,
extract::{Expect, PostForm},
form,
pages::{
GET_POST, Result, TemplateContext,
account::ThreepidQuery,
components::{UserCard, form::Form},
},
response,
session::require_active,
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/", on(GET_POST, route_reset_password))
.route("/validate", on(GET_POST, route_reset_password_validate))
}
template! {
struct ResetPassword use "reset_password.html.j2" {
body: ResetPasswordBody
}
}
#[derive(Debug)]
enum ResetPasswordBody {
Form(Form<'static>),
Unavailable,
}
form! {
struct ResetPasswordRequestForm {
email: Address where {
input_type: "email",
label: "Email address"
}
submit: "Send email"
}
}
async fn route_reset_password(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
PostForm(form): PostForm<ResetPasswordRequestForm>,
) -> Result {
// Check if SMTP is configured
if services.mailer.mailer().is_none() {
return response!(ResetPassword::new(context, ResetPasswordBody::Unavailable));
}
let Some(form) = form else {
// For GET requests return the reset request form
return response!(ResetPassword::new(
context.clone(),
ResetPasswordBody::Form(ResetPasswordRequestForm::build(context))
));
};
let client_secret = ClientSecret::new();
let session_id = async {
let Some(localpart) = services.threepid.get_localpart_for_email(&form.email).await else {
warn!("No user is associated with the email address {}", form.email);
return None;
};
let user_id =
UserId::parse(format!("@{localpart}:{}", services.globals.server_name())).unwrap();
let display_name = services.users.displayname(&user_id).await.ok();
match services
.threepid
.send_validation_email(
Mailbox::new(display_name.clone(), form.email.clone()),
|verification_link| messages::PasswordReset {
display_name: display_name.as_deref(),
user_id: &user_id,
verification_link,
},
&client_secret,
0,
)
.await
{
| Ok(session_id) => Some(session_id),
| Err(err) => {
warn!("Failed to send reset email for {localpart} to {}: {err}", form.email);
None
},
}
}
.await
.unwrap_or_else(|| {
// If we couldn't send an email, generate a random session ID to not give that
// away
ValidationSessions::generate_session_id()
});
response!(ResetPasswordValidate::new(
context,
ResetPasswordValidateBody::ValidationPending {
client_secret,
session_id,
validation_error: false
}
))
}
template! {
struct ResetPasswordValidate use "reset_password_validate.html.j2" {
body: ResetPasswordValidateBody
}
}
#[derive(Debug)]
enum ResetPasswordValidateBody {
ValidationPending {
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
validation_error: bool,
},
ValidationSuccess {
user_card: UserCard,
form: Form<'static>,
},
ResetSuccess {
user_card: UserCard,
},
}
form! {
struct ResetPasswordForm {
#[validate(length(min = 1, message = "Password cannot be empty"))]
new_password: String where {
input_type: "password",
label: "New password",
autocomplete: "new-password"
},
#[validate(must_match(other = "new_password", message = "Passwords must match"))]
confirm_new_password: String where {
input_type: "password",
label: "Confirm new password",
autocomplete: "new-password"
}
submit: "Reset password"
}
}
#[derive(Deserialize, Serialize)]
struct ResetPasswordQuery {
#[serde(flatten)]
threepid: ThreepidQuery,
}
async fn route_reset_password_validate(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
Expect(Query(query)): Expect<Query<ResetPasswordQuery>>,
PostForm(form): PostForm<ResetPasswordForm>,
) -> Result {
let body = match services
.threepid
.get_valid_session(&query.threepid.session_id, &query.threepid.client_secret)
.await
{
| Ok(session) => {
let Some(localpart) = services
.threepid
.get_localpart_for_email(&session.email)
.await
else {
return Err(WebError::BadRequest("Inapplicable threepid session.".to_owned()));
};
let user_id =
UserId::parse(format!("@{localpart}:{}", services.globals.server_name()))
.unwrap();
if let Err(response) = require_active(&services, &user_id, true).await {
return Ok(response);
}
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if let Some(form) = form {
if let Err(err) = form.validate() {
ResetPasswordValidateBody::ValidationSuccess {
user_card,
form: ResetPasswordForm::with_errors(context.clone(), err),
}
} else {
match HashedPassword::new(&form.new_password) {
| Ok(hash) => {
let _ = session.consume();
services.users.set_password(&user_id, Some(hash));
ResetPasswordValidateBody::ResetSuccess { user_card }
},
| Err(err) => {
let mut errors = ValidationErrors::new();
errors.add(
"new_password",
ValidationError::new("malformed")
.with_message(err.message().into()),
);
ResetPasswordValidateBody::ValidationSuccess {
user_card,
form: ResetPasswordForm::with_errors(context.clone(), errors),
}
},
}
}
} else {
ResetPasswordValidateBody::ValidationSuccess {
user_card,
form: ResetPasswordForm::build(context.clone()),
}
}
},
| Err(_) => ResetPasswordValidateBody::ValidationPending {
session_id: query.threepid.session_id,
client_secret: query.threepid.client_secret,
validation_error: true,
},
};
response!(ResetPasswordValidate::new(context, body))
}
+592
View File
@@ -0,0 +1,592 @@
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::{FutureExt, StreamExt};
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedServerName, OwnedSessionId, OwnedUserId};
use serde::{Deserialize, Serialize, de::IgnoredAny};
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<crate::State> {
Router::new()
.route("/", on(GET_POST, route_register))
.route("/validate", get(get_register_email_validate))
}
template! {
struct Register use "register.html.j2" {
server_name: OwnedServerName,
is_first_run: bool,
body: RegisterBody
}
}
#[derive(Debug)]
enum RegisterBody {
Unavailable,
UsernamePrompt {
allow_federation: bool,
trusted_flow_status: TrustedFlowStatus,
untrusted_flow_status: UntrustedFlowStatus,
username_error: Option<String>,
next: Option<LoginTarget>,
},
DetailsPrompt {
username: Option<String>,
require_email: bool,
flow: RegistrationFlowParameters,
terms: BTreeMap<String, TermsDocument>,
validation_errors: ValidationErrors,
},
}
#[derive(Debug)]
pub(super) enum TrustedFlowStatus {
Unavailable,
Available,
}
#[derive(Debug)]
pub(super) enum UntrustedFlowStatus {
Unavailable,
Available {
require_email: bool,
},
}
#[derive(Default, Deserialize, Serialize)]
pub(crate) struct RegisterQuery {
pub username: Option<String>,
pub token: Option<String>,
pub flow: Option<RequestedRegistrationFlow>,
#[serde(default)]
pub from_landing: bool,
#[serde(flatten)]
pub next: Option<LoginTarget>,
}
#[derive(Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RequestedRegistrationFlow {
Untrusted,
Trusted,
}
#[derive(Debug)]
enum RegistrationFlowParameters {
Untrusted {
recaptcha_sitekey: Option<String>,
},
Trusted {
registration_token: Option<String>,
},
}
#[derive(Deserialize, Validate)]
struct RegistrationForm {
flow: RequestedRegistrationFlow,
username: String,
email: Option<Address>,
#[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<String>,
#[serde(rename = "g-recaptcha-response")]
recaptcha_response: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct CompletedRegistration {
user_id: OwnedUserId,
password_hash: HashedPassword,
registration_token: Option<ValidToken>,
next: Option<LoginTarget>,
}
async fn route_register(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(query)): Expect<Query<RegisterQuery>>,
PostForm(form): PostForm<RegistrationForm>,
) -> Result {
let is_first_run = services.firstrun.is_first_run();
if session_store
.get::<IgnoredAny>(User::KEY)
.await
.unwrap()
.is_some()
{
// Redirect already logged-in users to the account panel
return response!(Redirect::to(&LoginTarget::Account.target_path()));
}
let validation_errors = if let Some(form) = form {
match form.validate() {
| Ok(()) => {
match begin_registration(
&services,
context.clone(),
session_store,
form,
query.next.clone(),
)
.boxed()
.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(),
services.firstrun.is_first_run(),
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(),
services.firstrun.is_first_run(),
RegisterBody::UsernamePrompt {
allow_federation: services.config.allow_federation,
trusted_flow_status,
untrusted_flow_status,
username_error: Some(err.message()),
next: query.next,
}
));
}
}
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,
},
| (_, token) if is_first_run => RegisterBody::DetailsPrompt {
username: query.username,
require_email: false,
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,
next: query.next,
},
}
};
response!(Register::new(
context,
services.globals.server_name().to_owned(),
is_first_run,
body
))
}
template! {
struct RegisterEmailValidate use "register_email_validate.html.j2" {
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
validation_error: bool
}
}
#[derive(Deserialize, Serialize)]
struct RegisterEmailValidateQuery {
#[serde(flatten)]
threepid: ThreepidQuery,
}
async fn get_register_email_validate(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(RegisterEmailValidateQuery {
threepid: ThreepidQuery { client_secret, session_id },
})): Expect<Query<RegisterEmailValidateQuery>>,
) -> Result {
let Ok(session) = services
.threepid
.get_valid_session(&session_id, &client_secret)
.await
else {
return response!(RegisterEmailValidate::new(context, session_id, client_secret, true));
};
let Some(completed_registration) = session_store
.get::<CompletedRegistration>(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 email = session.consume();
response!(
complete_registration(&services, session_store, completed_registration, Some(email))
.await
)
}
async fn begin_registration(
services: &crate::State,
context: TemplateContext,
session_store: Session,
form: RegistrationForm,
next: Option<LoginTarget>,
) -> Result<Result<Response, ValidationErrors>> {
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,
next,
};
// 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!(
RegisterEmailValidate::new(context, session_id, client_secret, false,)
))
} else {
// If email isn't required we can immediately complete registration
Ok(response!(
complete_registration(services, session_store, completed_registration, None).await
))
}
}
async fn complete_registration(
services: &crate::State,
session_store: Session,
CompletedRegistration {
user_id,
password_hash,
registration_token,
next,
}: CompletedRegistration,
email: Option<Address>,
) -> Redirect {
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);
}
services.users.join_auto_join_rooms(&user_id).await;
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");
Redirect::to(&next.unwrap_or_default().target_path())
}
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 || services.firstrun.is_first_run() {
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)
}
+49 -8
View File
@@ -1,13 +1,34 @@
use askama::{Template, filters::HtmlSafe};
use validator::ValidationErrors;
use crate::pages::TemplateContext;
/// A reusable form component with field validation.
#[derive(Debug, Template)]
#[template(path = "_components/form.html.j2")]
pub(crate) struct Form<'a> {
pub inputs: Vec<FormInput<'a>>,
context: TemplateContext,
inputs: Vec<FormInput<'a>>,
submit_label: &'a str,
slowdown: bool,
pub validation_errors: Option<ValidationErrors>,
pub submit_label: &'a str,
}
impl<'a> Form<'a> {
pub(crate) fn new(
context: TemplateContext,
inputs: Vec<FormInput<'a>>,
submit_label: &'a str,
slowdown: bool,
) -> Self {
Self {
context,
inputs,
submit_label,
slowdown,
validation_errors: None,
}
}
}
impl HtmlSafe for Form<'_> {}
@@ -50,6 +71,16 @@ fn default() -> Self {
}
}
#[macro_export]
macro_rules! default {
($value:expr) => {
$value
};
() => {
Default::default()
};
}
/// Generate a deserializable struct which may be turned into a [`Form`]
/// for inclusion in another template.
#[macro_export]
@@ -63,6 +94,7 @@ struct $struct_name:ident {
),*
submit: $submit_label:expr
$(, slowdown: $slowdown:expr)?
}
) => {
#[derive(Debug, serde::Deserialize, validator::Validate)]
@@ -77,9 +109,10 @@ struct $struct_name {
impl $struct_name {
/// Generate a [`Form`] which matches the shape of this struct.
#[allow(clippy::needless_update)]
fn build(validation_errors: Option<validator::ValidationErrors>) -> $crate::pages::components::form::Form<'static> {
$crate::pages::components::form::Form {
inputs: vec![
fn build(context: TemplateContext) -> $crate::pages::components::form::Form<'static> {
$crate::pages::components::form::Form::new(
context,
vec![
$(
$crate::pages::components::form::FormInput {
id: stringify!($name),
@@ -89,9 +122,17 @@ fn build(validation_errors: Option<validator::ValidationErrors>) -> $crate::page
},
)*
],
validation_errors,
submit_label: $submit_label,
}
$submit_label,
$crate::default!($($slowdown)?)
)
}
/// Generate a [`Form`] with validation errors.
#[allow(unused)]
fn with_errors(context: TemplateContext, errors: validator::ValidationErrors) -> $crate::pages::components::form::Form<'static> {
let mut form = Self::build(context);
form.validation_errors = Some(errors);
form
}
}
};

Some files were not shown because too many files have changed in this diff Show More