Compare commits

...

9 Commits

Author SHA1 Message Date
Ginger e212c91ebf fix: Address review comments 2026-05-05 13:35:35 -04:00
Ginger 83f3314f08 chore: News fragment 2026-05-05 09:10:51 -04:00
Ginger 8c2cf67783 refactor: Remove support for guest user registration 2026-05-05 09:09:48 -04:00
Ginger 7436e2f4e1 chore: Update admin command docs 2026-05-05 09:05:43 -04:00
timedout 9ba406761b chore: Regenerate example config 2026-05-04 21:14:22 +01:00
nex 97f49d6357 chore: Remove news fragment checkbox from PR template
Requiring them is now handled by an action
2026-05-04 20:06:40 +00:00
new-years-eve 1a49bc6f87 docs: Add changelog 2026-05-04 20:05:26 +00:00
new-years-eve 833216256b feat: Add support for fallback keys
Fallback keys can be provided by client devices to be used in case the
supply of one-time keys run out. The server will store one fallback key
per user, per device, per algorithm. The server will keep track of
whether this fallback key has been used or not.

The  /keys/claim endpoint now provides a fallback key
if no one-time key is found

The /keys/upload endpoint now accepts fallback keys

The /sync endpoint now informs the client of the algorithms for which it
has an unused fallback key in stock.
2026-05-04 20:05:26 +00:00
new-years-eve 5fa3087401 feat: Implement serialization/deserialization for booleans 2026-05-04 20:05:26 +00:00
28 changed files with 313 additions and 470 deletions
-2
View File
@@ -45,7 +45,6 @@
- [ ] I have [tested my contribution][c1t] (or proof-read it for documentation-only changes)
myself, if applicable. This includes ensuring code compiles.
- [ ] My commit messages follow the [commit message format][c1cm] and are descriptive.
- [ ] I have written a [news fragment][n1] for this PR, if applicable<!--(can be done after hitting open!)-->.
<!--
Notes on these requirements:
@@ -79,4 +78,3 @@
[c1pc]: https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/CONTRIBUTING.md#pre-commit-checks
[c1t]: https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/CONTRIBUTING.md#running-tests-locally
[c1cm]: https://forgejo.ellis.link/continuwuation/continuwuity/src/branch/main/CONTRIBUTING.md#commit-messages
[n1]: https://towncrier.readthedocs.io/en/stable/tutorial.html#creating-news-fragments
+1
View File
@@ -0,0 +1 @@
Removed support for guest user registration, a little-used and deprecated approach to room previews.
+1
View File
@@ -0,0 +1 @@
Added support for fallback encryption keys.
-2
View File
@@ -7,7 +7,6 @@
[global]
address = "0.0.0.0"
allow_device_name_federation = true
allow_guest_registration = true
allow_public_room_directory_over_federation = true
allow_registration = true
database_path = "/database"
@@ -32,7 +31,6 @@ rocksdb_log_level = "info"
rocksdb_max_log_files = 1
rocksdb_recovery_mode = 0
rocksdb_paranoid_file_checks = true
log_guest_registrations = false
allow_legacy_media = true
startup_netburst = true
startup_netburst_keep = -1
-111
View File
@@ -1271,21 +1271,6 @@
#
#brotli_compression = false
# Set to true to allow user type "guest" registrations. Some clients like
# Element attempt to register guest users automatically.
#
#allow_guest_registration = false
# Set to true to log guest registrations in the admin room. Note that
# these may be noisy or unnecessary if you're a public homeserver.
#
#log_guest_registrations = false
# Set to true to allow guest registrations/users to auto join any rooms
# specified in `auto_join_rooms`.
#
#allow_guests_auto_join_rooms = false
# Enable the legacy unauthenticated Matrix media repository endpoints.
# These endpoints consist of:
# - /_matrix/media/*/config
@@ -1934,102 +1919,6 @@
#
#foci = []
[global.ldap]
# Whether to enable LDAP login.
#
# example: "true"
#
#enable = false
# Whether to force LDAP authentication or authorize classical password
# login.
#
# example: "true"
#
#ldap_only = false
# URI of the LDAP server.
#
# example: "ldap://ldap.example.com:389"
#
#uri = ""
# StartTLS for LDAP connections.
#
#use_starttls = false
# Skip TLS certificate verification, possibly dangerous.
#
#disable_tls_verification = false
# Root of the searches.
#
# example: "ou=users,dc=example,dc=org"
#
#base_dn = ""
# Bind DN if anonymous search is not enabled.
#
# You can use the variable `{username}` that will be replaced by the
# entered username. In such case, the password used to bind will be the
# one provided for the login and not the one given by
# `bind_password_file`. Beware: automatically granting admin rights will
# not work if you use this direct bind instead of a LDAP search.
#
# example: "cn=ldap-reader,dc=example,dc=org" or
# "cn={username},ou=users,dc=example,dc=org"
#
#bind_dn = ""
# Path to a file on the system that contains the password for the
# `bind_dn`.
#
# The server must be able to access the file, and it must not be empty.
#
#bind_password_file = ""
# Search filter to limit user searches.
#
# You can use the variable `{username}` that will be replaced by the
# entered username for more complex filters.
#
# example: "(&(objectClass=person)(memberOf=matrix))"
#
#filter = "(objectClass=*)"
# Attribute to use to uniquely identify the user.
#
# example: "uid" or "cn"
#
#uid_attribute = "uid"
# Attribute containing the display name of the user.
#
# example: "givenName" or "sn"
#
#name_attribute = "givenName"
# Root of the searches for admin users.
#
# Defaults to `base_dn` if empty.
#
# example: "ou=admins,dc=example,dc=org"
#
#admin_base_dn = ""
# The LDAP search filter to find administrative users for continuwuity.
#
# If left blank, administrative state must be configured manually for each
# user.
#
# You can use the variable `{username}` that will be replaced by the
# entered username for more complex filters.
#
# example: "(objectClass=conduwuitAdmin)" or "(uid={username})"
#
#admin_filter = ""
#[global.antispam]
#[global.antispam.meowlnir]
+1 -1
View File
@@ -7,7 +7,7 @@ ## Running commands
* All commands listed here may be used by server administrators in the admin room by sending them as messages.
* If the `admin_escape_commands` configuration option is enabled, server administrators may run certain commands in public rooms by prefixing them with a single backslash. These commands will only run on _their_ homeserver, even if they are a member of another homeserver's admin room. Some sensitive commands cannot be used outside the admin room and will return an error.
* All commands listed here may be used in the server's console, if it is enabled. Commands entered in the console do not require the `!admin` prefix. If Continuwuity is deployed via Docker, be sure to set the appropriate options detailed in [the Docker deployment guide](../../deploying/docker.mdx#accessing-the-servers-console) to enable access to the server's console.
* All commands listed here may be used in the server's console, if it is enabled. Commands entered in the console do not require the `!admin` prefix.
## Categories
-14
View File
@@ -15,10 +15,6 @@ pub enum UsersCommand {
IterUsers2,
PasswordHash {
user_id: OwnedUserId,
},
ListDevices {
user_id: OwnedUserId,
},
@@ -235,16 +231,6 @@ async fn count_users(&self) -> Result {
.await
}
#[admin_command]
async fn password_hash(&self, user_id: OwnedUserId) -> Result {
let timer = tokio::time::Instant::now();
let result = self.services.users.password_hash(&user_id).await;
let query_time = timer.elapsed();
self.write_str(&format!("Query completed in {query_time:?}:\n\n```rs\n{result:#?}\n```"))
.await
}
#[admin_command]
async fn list_devices(&self, user_id: OwnedUserId) -> Result {
let timer = tokio::time::Instant::now();
+9 -15
View File
@@ -24,6 +24,7 @@
tag::{TagEvent, TagEventContent, TagInfo},
},
};
use service::users::HashedPassword;
use crate::{
admin_command, get_room_info,
@@ -70,7 +71,7 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
// Create user
self.services
.users
.create(&user_id, Some(password.as_str()))
.create(&user_id, Some(HashedPassword::new(&password)?))
.await?;
// Default to pretty displayname
@@ -143,7 +144,6 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
self.services.globals.server_name().to_owned(),
room_server_name.to_owned(),
],
&None,
)
.await
{
@@ -274,17 +274,13 @@ pub(super) async fn reset_password(
let new_password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
match self
.services
self.services
.users
.set_password(&user_id, Some(new_password.as_str()))
.await
{
| Err(e) => return Err!("Couldn't reset the password for user {user_id}: {e}"),
| Ok(()) => {
write!(self, "Successfully reset the password for user {user_id}: `{new_password}`")
},
}
.set_password(&user_id, Some(HashedPassword::new(&new_password)?));
self.write_str(&format!(
"Successfully reset the password for user {user_id}: `{new_password}`"
))
.await?;
if logout {
@@ -562,7 +558,6 @@ pub(super) async fn force_join_list_of_local_users(
&room_id,
Some(String::from(BULK_JOIN_REASON)),
&servers,
&None,
)
.await
{
@@ -646,7 +641,6 @@ pub(super) async fn force_join_all_local_users(
&room_id,
Some(String::from(BULK_JOIN_REASON)),
&servers,
&None,
)
.await
{
@@ -685,7 +679,7 @@ pub(super) async fn force_join_room(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
join_room_by_id_helper(self.services, &user_id, &room_id, None, &servers, &None).await?;
join_room_by_id_helper(self.services, &user_id, &room_id, None, &servers).await?;
self.write_str(&format!("{user_id} has been joined to {room_id}."))
.await
+1 -1
View File
@@ -48,7 +48,7 @@ pub(crate) fn parse_local_user_id(services: &Services, user_id: &str) -> Result<
Ok(user_id)
}
/// Parses user ID that is an active (not guest or deactivated) local user
/// Parses user ID that is an active (not deactivated) local user
pub(crate) async fn parse_active_local_user_id(
services: &Services,
user_id: &str,
+5 -14
View File
@@ -24,7 +24,7 @@
power_levels::RoomPowerLevelsEventContent,
},
};
use service::{mailer::messages, uiaa::Identity};
use service::{mailer::messages, uiaa::Identity, users::HashedPassword};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH, join_room_by_id_helper};
use crate::Ruma;
@@ -150,8 +150,7 @@ pub(crate) async fn change_password_route(
services
.users
.set_password(&sender_user, Some(&body.new_password))
.await?;
.set_password(&sender_user, Some(HashedPassword::new(&body.new_password)?));
if body.logout_devices {
// Logout all devices except the current one
@@ -239,19 +238,11 @@ pub(crate) async fn request_password_change_token_via_email_route(
///
/// Note: Also works for Application Services
pub(crate) async fn whoami_route(
State(services): State<crate::State>,
State(_): State<crate::State>,
body: Ruma<whoami::v3::Request>,
) -> Result<whoami::v3::Response> {
let is_guest = services
.users
.is_deactivated(body.sender_user())
.await
.map_err(|_| {
err!(Request(Forbidden("Application service has not registered this user.")))
})? && body.appservice_info.is_none();
Ok(assign!(whoami::v3::Response::new(body.sender_user().to_owned(), is_guest), {
device_id: body.sender_device.clone(),
Ok(assign!(whoami::v3::Response::new(body.sender_user().to_owned(), false), {
device_id: body.sender_device,
}))
}
+48 -140
View File
@@ -10,12 +10,11 @@
use conduwuit_service::Services;
use futures::{FutureExt, StreamExt};
use lettre::{Address, message::Mailbox};
use register::RegistrationKind;
use ruma::{
OwnedUserId, UserId,
api::client::{
account::{
register::{self, LoginType},
register::{self, LoginType, RegistrationKind},
request_registration_token_via_email,
},
uiaa::{AuthFlow, AuthType},
@@ -28,7 +27,7 @@
push,
};
use serde_json::value::RawValue;
use service::mailer::messages;
use service::{mailer::messages, users::HashedPassword};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH, join_room_by_id_helper};
use crate::Ruma;
@@ -42,16 +41,6 @@
/// You can use [`GET
/// /_matrix/client/v3/register/available`](fn.get_register_available_route.
/// html) to check if the user id is valid and available.
///
/// - Only works if registration is enabled
/// - If type is guest: ignores all parameters except
/// initial_device_display_name
/// - If sender is not appservice: Requires UIAA (but we only use a dummy stage)
/// - If type is not guest and no username is given: Always fails after UIAA
/// check
/// - Creates a new account and populates it with default account data
/// - If `inhibit_login` is false: Creates a device and returns device id and
/// access_token
#[allow(clippy::doc_markdown)]
#[tracing::instrument(skip_all, fields(%client), name = "register", level = "info")]
pub(crate) async fn register_route(
@@ -59,7 +48,10 @@ pub(crate) async fn register_route(
ClientIp(client): ClientIp,
body: Ruma<register::v3::Request>,
) -> Result<register::v3::Response> {
let is_guest = body.kind == RegistrationKind::Guest;
if body.kind != RegistrationKind::User {
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
@@ -68,69 +60,19 @@ pub(crate) async fn register_route(
services.config.allow_registration || services.firstrun.is_first_run();
if !allow_registration && body.appservice_info.is_none() {
match (body.username.as_ref(), body.initial_device_display_name.as_ref()) {
| (Some(username), Some(device_display_name)) => {
info!(
%is_guest,
user = %username,
device_name = %device_display_name,
"Rejecting registration attempt as registration is disabled"
);
},
| (Some(username), _) => {
info!(
%is_guest,
user = %username,
"Rejecting registration attempt as registration is disabled"
);
},
| (_, Some(device_display_name)) => {
info!(
%is_guest,
device_name = %device_display_name,
"Rejecting registration attempt as registration is disabled"
);
},
| (None, _) => {
info!(
%is_guest,
"Rejecting registration attempt as registration is disabled"
);
},
}
return Err!(Request(Forbidden(
"This server is not accepting registrations at this time."
)));
}
if is_guest && !services.config.allow_guest_registration {
info!(
"Guest registration disabled, rejecting guest registration attempt, initial device \
name: \"{}\"",
body.initial_device_display_name.as_deref().unwrap_or("")
?body.username,
?body.initial_device_display_name,
"Rejecting registration attempt as registration is disabled"
);
return Err!(Request(GuestAccessForbidden("Guest registration is disabled.")));
}
// forbid guests from registering if there is not a real admin user yet. give
// generic user error.
if is_guest && services.firstrun.is_first_run() {
warn!(
"Guest account attempted to register before a real admin user has been registered, \
rejecting registration. Guest's initial device name: \"{}\"",
body.initial_device_display_name.as_deref().unwrap_or("")
);
return Err!(Request(Forbidden(
"This server is not accepting registrations at this time."
)));
}
// Appeservices and guests get to skip auth
let skip_auth = body.appservice_info.is_some() || is_guest;
let identity = if skip_auth {
// Appservices and guests have no identity
let identity = if body.appservice_info.is_some() {
// Appservices can skip auth
None
} else {
// Perform UIAA to determine the user's identity
@@ -157,13 +99,9 @@ pub(crate) async fn register_route(
}
});
let user_id = determine_registration_user_id(
&services,
supplied_username,
is_guest,
emergency_mode_enabled,
)
.await?;
let user_id =
determine_registration_user_id(&services, supplied_username, emergency_mode_enabled)
.await?;
if body.body.login_type == Some(LoginType::ApplicationService) {
// For appservice logins, make sure that the user ID is in the appservice's
@@ -187,7 +125,13 @@ pub(crate) async fn register_route(
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
}
let password = if is_guest { None } else { body.password.as_deref() };
let password = if body.appservice_info.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")));
};
// Create user
services.users.create(&user_id, password).await?;
@@ -222,7 +166,9 @@ pub(crate) async fn register_route(
// Generate new device id if the user didn't specify one
let (token, device) = if !body.inhibit_login {
let device_id = if is_guest { None } else { body.device_id.clone() }
let device_id = body
.device_id
.clone()
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
// Generate new token for the device
@@ -263,8 +209,7 @@ pub(crate) async fn register_route(
let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
// log in conduit admin channel if a non-guest user registered
if body.appservice_info.is_none() && !is_guest {
if body.appservice_info.is_none() {
if !device_display_name.is_empty() {
let notice = format!(
"New user \"{user_id}\" registered on this server from IP {client} and device \
@@ -285,65 +230,32 @@ pub(crate) async fn register_route(
}
}
// log in conduit admin channel if a guest registered
if body.appservice_info.is_none() && is_guest && services.config.log_guest_registrations {
debug_info!("New guest user \"{user_id}\" registered on this server.");
// 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 !device_display_name.is_empty() {
if services.server.config.admin_room_notices {
services
.admin
.notice(&format!(
"Guest user \"{user_id}\" with device display name \
\"{device_display_name}\" registered on this server from IP {client}"
))
.await;
}
} else {
#[allow(clippy::collapsible_else_if)]
if services.server.config.admin_room_notices {
services
.admin
.notice(&format!(
"Guest user \"{user_id}\" with no device display name registered on \
this server from IP {client}",
))
.await;
}
}
}
if !is_guest {
// 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
// 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
.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();
}
.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.appservice_info.is_none()
&& !services.server.config.auto_join_rooms.is_empty()
&& (services.config.allow_guests_auto_join_rooms || !is_guest)
{
if body.appservice_info.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!(
@@ -372,7 +284,6 @@ pub(crate) async fn register_route(
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
&body.appservice_info,
)
.boxed()
.await
@@ -511,12 +422,9 @@ async fn create_registration_uiaa_session(
async fn determine_registration_user_id(
services: &Services,
supplied_username: Option<String>,
is_guest: bool,
emergency_mode_enabled: bool,
) -> Result<OwnedUserId> {
if let Some(supplied_username) = supplied_username
&& !is_guest
{
if let Some(supplied_username) = supplied_username {
// The user gets to pick their username. Do some validation to make sure it's
// acceptable.
@@ -569,7 +477,7 @@ async fn determine_registration_user_id(
Ok(user_id)
} else {
// The user is a guest or didn't specify a username. Generate a username for
// The user didn't specify a username. Generate a username for
// them.
loop {
-10
View File
@@ -122,16 +122,6 @@ pub(crate) async fn set_room_visibility_route(
return Err!(Request(UserSuspended("You cannot perform this action while suspended.")));
}
if services
.users
.is_deactivated(sender_user)
.await
.unwrap_or(false)
&& body.appservice_info.is_none()
{
return Err!(Request(Forbidden("Guests cannot publish to room directories")));
}
if !user_can_publish_room(&services, sender_user, &body.room_id).await? {
return Err!(Request(Forbidden("User is not allowed to publish this room")));
}
+21
View File
@@ -64,6 +64,27 @@ pub(crate) async fn upload_keys_route(
.await?;
}
for (key_id, fallback_key) in &body.fallback_keys {
if fallback_key
.deserialize()
.inspect_err(|e| {
debug_warn!(
%key_id,
?fallback_key,
"Invalid one time key JSON submitted by client, skipping: {e}"
);
})
.is_err()
{
continue;
}
services
.users
.add_fallback_key(sender_user, sender_device, key_id, fallback_key, false)
.await?;
}
if let Some(device_keys) = &body.device_keys {
let deser_device_keys = device_keys.deserialize().map_err(|e| {
err!(Request(BadJson(debug_warn!(
+7 -34
View File
@@ -39,7 +39,6 @@
};
use service::{
Services,
appservice::RegistrationInfo,
rooms::{
state::RoomMutexGuard,
state_compressor::{CompressedState, HashSetCompressStateEvent},
@@ -112,16 +111,9 @@ pub(crate) async fn join_room_by_id_route(
shuffle(&mut servers);
let servers = deprioritize(servers, &services.config.deprioritize_joins_through_servers);
join_room_by_id_helper(
&services,
sender_user,
&body.room_id,
body.reason.clone(),
&servers,
&body.appservice_info,
)
.boxed()
.await
join_room_by_id_helper(&services, sender_user, &body.room_id, body.reason.clone(), &servers)
.boxed()
.await
}
/// # `POST /_matrix/client/r0/join/{roomIdOrAlias}`
@@ -140,7 +132,6 @@ pub(crate) async fn join_room_by_id_or_alias_route(
body: Ruma<join_room_by_id_or_alias::v3::Request>,
) -> Result<join_room_by_id_or_alias::v3::Response> {
let sender_user = body.sender_user();
let appservice_info = &body.appservice_info;
let body = &body.body;
if services.users.is_suspended(sender_user).await? {
return Err!(Request(UserSuspended("You cannot perform this action while suspended.")));
@@ -235,16 +226,10 @@ pub(crate) async fn join_room_by_id_or_alias_route(
};
let servers = deprioritize(servers, &services.config.deprioritize_joins_through_servers);
let join_room_response = join_room_by_id_helper(
&services,
sender_user,
&room_id,
body.reason.clone(),
&servers,
appservice_info,
)
.boxed()
.await?;
let join_room_response =
join_room_by_id_helper(&services, sender_user, &room_id, body.reason.clone(), &servers)
.boxed()
.await?;
Ok(join_room_by_id_or_alias::v3::Response::new(join_room_response.room_id))
}
@@ -255,21 +240,9 @@ pub async fn join_room_by_id_helper(
room_id: &RoomId,
reason: Option<String>,
servers: &[OwnedServerName],
appservice_info: &Option<RegistrationInfo>,
) -> Result<join_room_by_id::v3::Response> {
let state_lock = services.rooms.state.mutex.lock(room_id).await;
let user_is_guest = services
.users
.is_deactivated(sender_user)
.await
.unwrap_or(false)
&& appservice_info.is_none();
if user_is_guest && !services.rooms.state_accessor.guest_can_join(room_id).await {
return Err!(Request(Forbidden("Guests are not allowed to join this room")));
}
if services
.rooms
.state_cache
+2 -9
View File
@@ -238,15 +238,8 @@ async fn knock_room_by_id_helper(
// join_room_by_id_helper We need to release the lock here and let
// join_room_by_id_helper acquire it again
drop(state_lock);
match join_room_by_id_helper(
services,
sender_user,
room_id,
reason.clone(),
servers,
&None,
)
.await
match join_room_by_id_helper(services, sender_user, room_id, reason.clone(), servers)
.await
{
| Ok(_) => return Ok(knock_room::v3::Response::new(room_id.to_owned())),
| Err(e) => {
+3 -43
View File
@@ -4,10 +4,9 @@
use axum_client_ip::ClientIp;
use conduwuit::{
Err, Result, debug, err, info,
utils::{self, ReadyExt, hash, stream::BroadbandExt},
utils::{self, ReadyExt, stream::BroadbandExt},
warn,
};
use conduwuit_core::debug_error;
use conduwuit_service::Services;
use futures::StreamExt;
use lettre::Address;
@@ -54,37 +53,6 @@ pub(crate) async fn get_login_types_route(
]))
}
/// Authenticates the given user by its ID and its password.
///
/// Returns the user ID if successful, and an error otherwise.
#[tracing::instrument(skip_all, fields(%user_id), name = "password", level = "debug")]
pub(crate) async fn password_login(
services: &Services,
user_id: &UserId,
lowercased_user_id: &UserId,
password: &str,
) -> Result<OwnedUserId> {
let (hash, user_id) = match services.users.password_hash(user_id).await {
| Ok(hash) => (hash, user_id),
| Err(_) => services
.users
.password_hash(lowercased_user_id)
.await
.map(|hash| (hash, lowercased_user_id))
.map_err(|_| err!(Request(Forbidden("Invalid identifier or password."))))?,
};
if hash.is_empty() {
return Err!(Request(UserDeactivated("The user has been deactivated")));
}
hash::verify_password(password, &hash)
.inspect_err(|e| debug_error!("{e}"))
.map_err(|_| err!(Request(Forbidden("Invalid identifier or password."))))?;
Ok(user_id.to_owned())
}
pub(crate) async fn handle_login(
services: &Services,
identifier: Option<&UserIdentifier>,
@@ -115,15 +83,7 @@ pub(crate) async fn handle_login(
UserId::parse_with_server_name(user_id_or_localpart, &services.config.server_name)
.map_err(|_| err!(Request(InvalidUsername("User ID is malformed"))))?;
let lowercased_user_id = UserId::parse_with_server_name(
user_id.localpart().to_lowercase(),
&services.config.server_name,
)
.unwrap();
if !services.globals.user_is_local(&user_id)
|| !services.globals.user_is_local(&lowercased_user_id)
{
if !services.globals.user_is_local(&user_id) {
return Err!(Request(InvalidParam("User ID does not belong to this homeserver")));
}
@@ -136,7 +96,7 @@ pub(crate) async fn handle_login(
return Err!(Request(Forbidden("This account is not permitted to log in.")));
}
password_login(services, &user_id, &lowercased_user_id, password).await
services.users.check_password(&user_id, password).await
}
/// # `POST /_matrix/client/v3/login`
+8 -3
View File
@@ -395,6 +395,10 @@ pub(crate) async fn build_sync_events(
.users
.count_one_time_keys(syncing_user, syncing_device);
let unused_fallback_key_types = services
.users
.list_unused_fallback_key_types(syncing_user, syncing_device);
let (
(joined_rooms, mut device_list_updates),
left_rooms,
@@ -405,6 +409,7 @@ pub(crate) async fn build_sync_events(
to_device_events,
keys_changed,
device_one_time_keys_count,
unused_fallback_key_types,
) = async {
futures::join!(
joined_rooms,
@@ -415,7 +420,8 @@ pub(crate) async fn build_sync_events(
account_data,
to_device_events,
keys_changed,
device_one_time_keys_count
device_one_time_keys_count,
unused_fallback_key_types,
)
}
.boxed()
@@ -433,8 +439,7 @@ pub(crate) async fn build_sync_events(
account_data: assign!(GlobalAccountData::new(), { events: account_data }),
device_lists: device_list_updates.into(),
device_one_time_keys_count,
// Fallback keys are not yet supported
device_unused_fallback_key_types: None,
device_unused_fallback_key_types: Some(unused_fallback_key_types),
presence: assign!(Presence::new(), {
events: presence_updates
.into_iter()
+1 -1
View File
@@ -80,7 +80,7 @@ pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
///
/// conduwuit-specific API to return the amount of users registered on this
/// homeserver. Endpoint is disabled if federation is disabled for privacy. This
/// only includes active users (not deactivated, no guests, etc)
/// only includes active users (not deactivated, etc)
pub(crate) async fn conduwuit_local_user_count(
State(services): State<crate::State>,
) -> Result<impl IntoResponse> {
-15
View File
@@ -1486,21 +1486,6 @@ pub struct Config {
#[serde(default)]
pub brotli_compression: bool,
/// Set to true to allow user type "guest" registrations. Some clients like
/// Element attempt to register guest users automatically.
#[serde(default)]
pub allow_guest_registration: bool,
/// Set to true to log guest registrations in the admin room. Note that
/// these may be noisy or unnecessary if you're a public homeserver.
#[serde(default)]
pub log_guest_registrations: bool,
/// Set to true to allow guest registrations/users to auto join any rooms
/// specified in `auto_join_rooms`.
#[serde(default)]
pub allow_guests_auto_join_rooms: bool,
/// Enable the legacy unauthenticated Matrix media repository endpoints.
/// These endpoints consist of:
/// - /_matrix/media/*/config
+8 -2
View File
@@ -288,8 +288,14 @@ fn deserialize_option<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value> {
}
#[cfg_attr(unabridged, tracing::instrument(level = "trace", skip_all))]
fn deserialize_bool<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Value> {
unhandled!("deserialize bool not implemented")
fn deserialize_bool<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value> {
let byte = self
.buf
.get(self.pos)
.ok_or(Self::Error::SerdeDe("bool buffer underflow".into()))?;
self.inc_pos(1);
visitor.visit_bool(*byte != 0x00)
}
#[cfg_attr(unabridged, tracing::instrument(level = "trace", skip_all))]
+4
View File
@@ -120,6 +120,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "onetimekeyid_onetimekeys",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "fallbackkeyid_fallbackkey",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "passwordresettoken_info",
..descriptor::RANDOM_SMALL
+2 -2
View File
@@ -297,8 +297,8 @@ fn serialize_u16(self, _v: u16) -> Result<Self::Ok> {
fn serialize_u8(self, v: u8) -> Result<Self::Ok> { self.write(&[v]) }
fn serialize_bool(self, _v: bool) -> Result<Self::Ok> {
unhandled!("serialize bool not implemented")
fn serialize_bool(self, v: bool) -> Result<Self::Ok> {
if v { self.write(&[0x01]) } else { self.write(&[0x00]) }
}
fn serialize_unit(self) -> Result<Self::Ok> { unhandled!("serialize unit not implemented") }
+1 -4
View File
@@ -121,10 +121,7 @@ async fn start_appservice(&self, id: String, registration: Registration) -> Resu
.unwrap_or(false)
{
// Reactivate the appservice user if it was accidentally deactivated
self.services
.users
.set_password(&appservice_user_id, None)
.await?;
self.services.users.set_password(&appservice_user_id, None);
}
self.registration_info
+13 -5
View File
@@ -9,7 +9,10 @@
push::Ruleset,
};
use crate::{Dep, account_data, config, globals, users};
use crate::{
Dep, account_data, config, globals,
users::{self, HashedPassword},
};
pub struct Service {
services: Services,
@@ -51,10 +54,15 @@ impl Service {
async fn set_emergency_access(&self) -> Result {
let server_user = &self.services.globals.server_user;
self.services
.users
.set_password(server_user, self.services.config.emergency_password.as_deref())
.await?;
self.services.users.set_password(
server_user,
self.services
.config
.emergency_password
.as_deref()
.map(HashedPassword::new)
.transpose()?,
);
let (ruleset, pwd_set) = match self.services.config.emergency_password {
| Some(_) => (Ruleset::server_default(server_user), true),
+5 -3
View File
@@ -6,7 +6,10 @@
use data::{Data, ResetTokenInfo};
use ruma::OwnedUserId;
use crate::{Dep, globals, users};
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";
@@ -100,8 +103,7 @@ pub async fn consume_token(
self.db.remove_token(&token);
self.services
.users
.set_password(&info.user, Some(new_password))
.await?;
.set_password(&info.user, Some(HashedPassword::new(new_password)?));
}
Ok(())
+2 -2
View File
@@ -238,7 +238,7 @@ pub async fn room_joined_count(&self, room_id: &RoomId) -> Result<u64> {
#[implement(Service)]
#[tracing::instrument(skip(self), level = "debug")]
/// Returns an iterator of all our local users in the room, even if they're
/// deactivated/guests
/// deactivated
pub fn local_users_in_room<'a>(
&'a self,
room_id: &'a RoomId,
@@ -248,7 +248,7 @@ pub fn local_users_in_room<'a>(
}
/// Returns an iterator of all our local joined users in a room who are
/// active (not deactivated, not guest)
/// active (not deactivated)
#[implement(Service)]
#[tracing::instrument(skip(self), level = "trace")]
pub fn active_local_users_in_room<'a>(
+8 -10
View File
@@ -4,7 +4,7 @@
sync::Arc,
};
use conduwuit::{Err, Error, Result, error, utils, utils::hash};
use conduwuit::{Err, Error, Result, error, utils};
use lettre::Address;
use ruma::{
UserId,
@@ -377,15 +377,13 @@ async fn check_stage(
));
};
// Check if password is correct
let mut password_verified = false;
// First try local password hash verification
if let Ok(hash) = self.services.users.password_hash(&user_id).await {
password_verified = hash::verify_password(password, &hash).is_ok();
}
if password_verified {
if self
.services
.users
.check_password(&user_id, password)
.await
.is_ok()
{
identity.try_set_localpart(user_id.localpart().to_owned())?;
Ok(AuthType::Password)
+162 -27
View File
@@ -3,14 +3,14 @@
use std::{collections::BTreeMap, mem, net::IpAddr, sync::Arc};
use conduwuit::{
Err, Error, Result, Server, debug_warn, err, trace,
Err, Error, Result, Server, debug_error, debug_warn, err, trace,
utils::{self, ReadyExt, stream::TryIgnore, string::Unquoted},
};
use database::{Deserialized, Ignore, Interfix, Json, Map};
use futures::{Stream, StreamExt, TryFutureExt};
use ruma::{
DeviceId, KeyId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId,
OneTimeKeyName, OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedUserId, RoomId, UInt, UserId,
DeviceId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId, OneTimeKeyName,
OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedOneTimeKeyId, OwnedUserId, RoomId, UInt, UserId,
api::{
client::{device::Device, filter::FilterDefinition},
error::ErrorKind,
@@ -38,6 +38,19 @@ pub struct UserSuspension {
pub suspended_by: 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.
pub struct HashedPassword(String);
impl HashedPassword {
pub fn new(password: &str) -> Result<Self> {
Ok(Self(utils::hash::password(password).map_err(|e| {
err!(Request(InvalidParam("Password does not meet the requirements: {e}")))
})?))
}
}
pub struct Service {
services: Services,
db: Data,
@@ -57,6 +70,7 @@ struct Data {
keychangeid_userid: Arc<Map>,
keyid_key: Arc<Map>,
onetimekeyid_onetimekeys: Arc<Map>,
fallbackkeyid_fallbackkey: Arc<Map>,
openidtoken_expiresatuserid: Arc<Map>,
logintoken_expiresatuserid: Arc<Map>,
todeviceid_events: Arc<Map>,
@@ -97,6 +111,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
keychangeid_userid: args.db["keychangeid_userid"].clone(),
keyid_key: args.db["keyid_key"].clone(),
onetimekeyid_onetimekeys: args.db["onetimekeyid_onetimekeys"].clone(),
fallbackkeyid_fallbackkey: args.db["fallbackkeyid_fallbackkey"].clone(),
openidtoken_expiresatuserid: args.db["openidtoken_expiresatuserid"].clone(),
logintoken_expiresatuserid: args.db["logintoken_expiresatuserid"].clone(),
todeviceid_events: args.db["todeviceid_events"].clone(),
@@ -169,16 +184,23 @@ pub async fn is_admin(&self, user_id: &UserId) -> bool {
/// Create a new user account on this homeserver.
#[inline]
pub async fn create(&self, user_id: &UserId, password: Option<&str>) -> Result<()> {
pub async fn create(&self, user_id: &UserId, password: Option<HashedPassword>) -> Result<()> {
if !self.services.globals.user_is_local(user_id) && password.is_some() {
return Err!("Cannot create a nonlocal user with a set password");
}
self.set_password(user_id, password).await?;
self.set_password(user_id, password);
Ok(())
}
// /// Create a new account for a local human or bot user.
// pub async fn create_local_account(
// &self,
// username: String,
// password:
// )
/// Deactivate account
pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> {
// Remove all associated devices
@@ -190,7 +212,7 @@ pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> {
// result in an empty string, so the user will not be able to log in again.
// Systems like changing the password without logging in should check if the
// account is deactivated.
self.set_password(user_id, None).await?;
self.set_password(user_id, None);
// TODO: Unhook 3PID
Ok(())
@@ -336,25 +358,42 @@ pub fn list_local_users(&self) -> impl Stream<Item = OwnedUserId> + Send + '_ {
.ready_filter_map(|(u, p): (OwnedUserId, &[u8])| (!p.is_empty()).then_some(u))
}
/// Returns the password hash for the given user.
pub async fn password_hash(&self, user_id: &UserId) -> Result<String> {
self.db.userid_password.get(user_id).await.deserialized()
/// Set a user's password.
pub fn set_password(&self, user_id: &UserId, password: Option<HashedPassword>) {
if let Some(hash) = password {
self.db.userid_password.insert(user_id, hash.0);
} else {
self.db.userid_password.insert(user_id, b"");
}
}
/// Hash and set the user's password to the Argon2 hash
pub async fn set_password(&self, user_id: &UserId, password: Option<&str>) -> Result<()> {
password
.map(utils::hash::password)
.transpose()
.map_err(|e| {
err!(Request(InvalidParam("Password does not meet the requirements: {e}")))
})?
.map_or_else(
|| self.db.userid_password.insert(user_id, b""),
|hash| self.db.userid_password.insert(user_id, hash),
);
/// Check a user's password.
pub async fn check_password(&self, user_id: &UserId, password: &str) -> Result<OwnedUserId> {
let (hash, user_id): (String, OwnedUserId) = if let Ok(hash) =
self.db.userid_password.get(user_id).await.deserialized()
{
(hash, user_id.to_owned())
} else {
// We also check the lowercased version of the user ID to handle legacy user IDs
// better
let lowercase_user_id = UserId::parse(user_id.as_str().to_lowercase()).unwrap();
Ok(())
if let Ok(hash) = self.db.userid_password.get(user_id).await.deserialized() {
(hash, lowercase_user_id)
} else {
return Err!(Request(InvalidParam("This user cannot log in with a password.")));
}
};
if hash.is_empty() {
return Err!(Request(UserDeactivated("This user is deactivated")));
}
utils::hash::verify_password(password, &hash)
.inspect_err(|e| debug_error!("{e}"))
.map_err(|_| err!(Request(Forbidden("Invalid identifier or password."))))?;
Ok(user_id)
}
/// Returns the displayname of a user on this homeserver.
@@ -550,7 +589,7 @@ pub async fn add_one_time_key(
&self,
user_id: &UserId,
device_id: &DeviceId,
one_time_key_key: &KeyId<OneTimeKeyAlgorithm, OneTimeKeyName>,
one_time_key_key: &OneTimeKeyId,
one_time_key_value: &Raw<OneTimeKey>,
) -> Result {
// All devices have metadata
@@ -587,6 +626,39 @@ pub async fn add_one_time_key(
Ok(())
}
/// Save a fallback key for the given user, device, and algorithm
/// This key will replace an existing fallback key
pub async fn add_fallback_key(
&self,
user_id: &UserId,
device_id: &DeviceId,
fallback_key_id: &OneTimeKeyId,
fallback_key: &Raw<OneTimeKey>,
used: bool,
) -> Result {
// All devices have metadata
// Only existing devices should be able to call this, but we shouldn't assert
// either...
let key = (user_id, device_id);
if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
return Err!(Database(error!(
%user_id,
%device_id,
"User does not exist or device has no metadata."
)));
}
// There is one fallback key slot per user, per device, per algorithm
// Therefore we use this as the DB key for this column
let db_key = (user_id, device_id, fallback_key_id.algorithm());
self.db
.fallbackkeyid_fallbackkey
.put(db_key, (used, fallback_key_id.as_str(), Json(fallback_key)));
Ok(())
}
pub async fn last_one_time_keys_update(&self, user_id: &UserId) -> u64 {
self.db
.userid_lastonetimekeyupdate
@@ -618,6 +690,8 @@ pub async fn take_one_time_key(
.onetimekeyid_onetimekeys
.raw_stream_prefix(&prefix)
.ignore_err()
.next()
.await
.map(|(key, val)| {
self.db.onetimekeyid_onetimekeys.remove(key);
@@ -636,11 +710,44 @@ pub async fn take_one_time_key(
.unwrap();
(key, val)
})
.next()
.await;
});
one_time_key.ok_or_else(|| err!(Request(NotFound("No one-time-key found"))))
if let Some(result) = one_time_key {
return Ok(result);
}
// No one-time key has been found. Look for a fallback key.
let db_key = (user_id, device_id, key_algorithm);
let fallback_key = self
.db
.fallbackkeyid_fallbackkey
.qry(&db_key)
.await
.ok()
.and_then(|handle| {
handle
.deserialized::<(bool, OwnedOneTimeKeyId, Raw<OneTimeKey>)>()
.ok()
});
if let Some((used, fallback_key_id, fallback_key_value)) = fallback_key {
if !used {
// write the key to the database again to mark it as used
self.add_fallback_key(
user_id,
device_id,
&fallback_key_id,
&fallback_key_value,
true,
)
.await?;
}
return Ok((fallback_key_id, fallback_key_value));
}
Err(err!(Request(NotFound("No one-time key or fallback key found"))))
}
pub async fn count_one_time_keys(
@@ -673,6 +780,34 @@ pub async fn count_one_time_keys(
algorithm_counts
}
pub async fn list_unused_fallback_key_types(
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> Vec<OneTimeKeyAlgorithm> {
type KeyVal = ((String, String, OneTimeKeyAlgorithm), (bool, String, Ignore));
let mut query = user_id.as_bytes().to_vec();
query.push(0xFF);
query.extend_from_slice(device_id.as_bytes());
query.push(0xFF);
let mut unused_algorithms = Vec::new();
self.db
.fallbackkeyid_fallbackkey
.stream_prefix(&query)
.ignore_err()
.ready_for_each(|((_, _, fallback_key_algorithm), (used, ..)): KeyVal| {
if !used {
unused_algorithms.push(fallback_key_algorithm);
}
})
.await;
unused_algorithms
}
pub async fn add_device_keys(
&self,
user_id: &UserId,