refactor: Split remote and deactivated users into their own columns

This commit is contained in:
Ginger committed 2026-07-02 16:24:07 -04:00
1 parent d4f6c053bc
commit 69163b5391
32 files changed
+391 -278

No files matched your search

+111 -61
View File
@@ -6,7 +6,7 @@ use conduwuit::{
warn,
};
use database::{Deserialized, Json};
use futures::{FutureExt, Stream, StreamExt, TryFutureExt};
use futures::{FutureExt, Stream, StreamExt};
use lettre::Address;
use ruma::{
MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, UserId,
@@ -29,6 +29,28 @@ pub enum AccessTokenStatus {
Expired,
}
/// The status of a user account.
#[derive(Clone, Copy)]
pub enum AccountStatus {
NotFound,
Active,
Deactivated,
}
impl AccountStatus {
pub fn is_found(&self) -> bool { !matches!(self, Self::NotFound) }
pub fn is_active(&self) -> bool { matches!(self, Self::Active) }
pub fn ensure_active(&self) -> Result<()> {
match self {
| Self::Active => Ok(()),
| Self::Deactivated => Err!(Request(UserDeactivated("This account is deactivated."))),
| Self::NotFound => Err!(Request(NotFound("This account does not exist."))),
}
}
}
impl super::Service {
/// Returns true/false based on whether the recipient/receiving user has
/// ignored the sender.
@@ -89,16 +111,16 @@ impl super::Service {
self.services.admin.user_is_admin(user_id).await
}
/// Create a new user account on this homeserver. Set the password to `None`
/// to create a non-local user. Non-local users with a password will return
/// an error.
#[inline]
pub 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");
/// Create a new shadow user account on this server. Shadow accounts
/// have no password and cannot be logged into.
pub async fn create_shadow_account(&self, user_id: &UserId) -> Result<()> {
assert!(self.services.globals.user_is_local(user_id), "user id must be local");
if self.status(user_id).await.is_found() {
return Err!(Request(UserInUse("An account with this user ID already exists.")));
}
self.set_password(user_id, password);
self.db.userid_password.insert(user_id, "");
Ok(())
}
@@ -109,9 +131,9 @@ impl super::Service {
user_id: &UserId,
password: HashedPassword,
email: Option<Address>,
) {
self.create(user_id, Some(password))
.expect("should be able to save a new local user. what happened?");
) -> Result<()> {
self.create_shadow_account(user_id).await?;
self.convert_to_local_account(user_id, password).await?;
// Set an initial display name
{
@@ -230,6 +252,8 @@ impl super::Service {
}
info!("Created new user account for {user_id}");
Ok(())
}
pub async fn determine_registration_user_id(
@@ -297,7 +321,7 @@ impl super::Service {
},
};
if self.exists(&user_id).await {
if self.status(&user_id).await.is_found() {
return Err!(Request(UserInUse("User ID is not available.")));
}
@@ -329,26 +353,24 @@ impl super::Service {
)
.unwrap();
if !self.exists(&user_id).await {
if !self.status(&user_id).await.is_found() {
break Ok(user_id);
}
}
}
}
/// Deactivates an account, removing all of their device IDs and unsetting
/// their password.
/// Deactivates an account, removing all of their device IDs.
pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> {
self.status(user_id).await.ensure_active()?;
// Remove all associated devices
self.all_device_ids(user_id)
.for_each(async |device_id| self.remove_device(user_id, &device_id).await)
.await;
// Set the password to "" to indicate a deactivated account. Hashes will never
// 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);
// Mark user as deactivated
self.db.userid_deactivated.insert(user_id, "");
// TODO: Unhook 3PID
Ok(())
@@ -393,25 +415,6 @@ impl super::Service {
/// Unlocks an account, allowing the user to log in and use it again.
pub async fn unlock_account(&self, user_id: &UserId) { self.db.userid_lock.remove(user_id); }
/// Check if the provided user ID belongs to an existing (possibly
/// deactivated) account on this homeserver.
#[inline]
pub async fn exists(&self, user_id: &UserId) -> bool {
self.services.globals.user_is_local(user_id)
&& self.db.userid_password.get(user_id).await.is_ok()
}
/// Check if account is deactivated (has an empty password). Returns a
/// NotFound error if the user does not exist.
pub async fn is_deactivated(&self, user_id: &UserId) -> Result<bool> {
self.db
.userid_password
.get(user_id)
.map_ok(|val| val.is_empty())
.map_err(|_| err!(Request(NotFound("User does not exist."))))
.await
}
/// Check if account is suspended. Returns false if the user does not exist.
pub async fn is_suspended(&self, user_id: &UserId) -> Result<bool> {
match self
@@ -469,18 +472,33 @@ impl super::Service {
.is_ok()
}
/// Check if account is active (not deactivated)
pub async fn is_active(&self, user_id: &UserId) -> bool {
!self.is_deactivated(user_id).await.unwrap_or(true)
/// Checks the activation status of the provided user ID's account.
pub async fn status(&self, user_id: &UserId) -> AccountStatus {
if !self.services.globals.user_is_local(user_id) {
AccountStatus::NotFound
} else if self.db.userid_password.exists(user_id).await.is_ok() {
if self.db.userid_deactivated.exists(user_id).await.is_ok() {
AccountStatus::Deactivated
} else {
AccountStatus::Active
}
} else {
AccountStatus::NotFound
}
}
/// Check if account is a local user, and is active (not deactivated)
pub async fn is_active_local(&self, user_id: &UserId) -> bool {
self.services.globals.user_is_local(user_id) && self.is_active(user_id).await
/// Checks if a user is a shadow.
pub async fn is_shadow(&self, user_id: &UserId) -> bool {
self.db
.userid_password
.get(user_id)
.await
.deserialized::<String>()
.is_ok_and(|hash| hash.is_empty())
}
/// Returns the number of users registered on this server, including
/// deactivated users.
/// deactivated and shadow users.
#[inline]
pub async fn count(&self) -> usize { self.db.userid_password.count().await }
@@ -522,8 +540,8 @@ impl super::Service {
Some((user_id, device_id, AccessTokenStatus::Valid))
}
/// Returns an iterator over all users on this homeserver.
pub fn stream(&self) -> impl Stream<Item = OwnedUserId> + Send {
/// Returns an iterator over all local users on this homeserver.
pub fn stream_local_users(&self) -> impl Stream<Item = OwnedUserId> + Send {
self.db.userid_password.keys().ignore_err()
}
@@ -540,16 +558,49 @@ impl super::Service {
}
/// 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"");
pub async fn set_password(&self, user_id: &UserId, password: HashedPassword) -> Result {
self.status(user_id).await.ensure_active()?;
self.db.userid_password.insert(user_id, password.0);
Ok(())
}
/// Convert an existing user to an active local account. This will turn
/// shadow accounts into normal accounts and will clear the deactivation
/// flag on the user if it is set.
pub async fn convert_to_local_account(
&self,
user_id: &UserId,
password: HashedPassword,
) -> Result {
if !self.status(user_id).await.is_found() {
return Err!(Request(NotFound("This account does not exist.")));
}
self.db.userid_deactivated.remove(user_id);
self.db.userid_password.insert(user_id, password.0);
Ok(())
}
/// Convert an existing user to a shadow account. This will clear their
/// password and reactivate them if they are deactivated.
pub async fn convert_to_shadow_account(&self, user_id: &UserId) -> Result {
if !self.status(user_id).await.is_found() {
return Err!(Request(NotFound("This account does not exist.")));
}
self.db.userid_deactivated.remove(user_id);
self.db.userid_password.insert(user_id, "");
Ok(())
}
/// Check a user's password.
pub async fn check_password(&self, user_id: &UserId, password: &str) -> Result<OwnedUserId> {
self.status(user_id).await.ensure_active()?;
let (hash, user_id): (String, OwnedUserId) =
if let Ok(hash) = self.db.userid_password.get(user_id).await.deserialized() {
(hash, user_id.to_owned())
@@ -558,21 +609,20 @@ impl super::Service {
// better
let lowercase_user_id = UserId::parse(user_id.as_str().to_lowercase()).unwrap();
if let Ok(hash) = self
let hash = self
.db
.userid_password
.get(lowercase_user_id.as_str())
.await
.deserialized()
{
(hash, lowercase_user_id)
} else {
return Err!(Request(Forbidden("This user cannot log in with a password.")));
}
.expect("user should exist");
(hash, lowercase_user_id)
};
if hash.is_empty() {
return Err!(Request(UserDeactivated("This user is deactivated")));
// Cannot log into shadow users
return Err!(Request(Forbidden("This user cannot log in with a password.")));
}
utils::hash::verify_password(password, &hash)
+1 -4
View File
@@ -21,10 +21,7 @@ pub struct DehydratedDevice {
impl super::Service {
/// Creates or recreates the user's dehydrated device.
pub async fn set_dehydrated_device(&self, user_id: &UserId, request: Request) -> Result {
assert!(
self.exists(user_id).await,
"Tried to create dehydrated device for non-existent user"
);
self.status(user_id).await.ensure_active()?;
let existing_id = self.get_dehydrated_device_id(user_id).await;
+12 -21
View File
@@ -4,8 +4,8 @@ use std::{
};
use conduwuit::{
Err, utils,
utils::{ReadyExt, stream::TryIgnore},
Err, Result,
utils::{self, ReadyExt, stream::TryIgnore},
};
use database::{Deserialized, Ignore, Interfix, Json};
use futures::{Stream, StreamExt};
@@ -18,8 +18,7 @@ use serde_json::json;
use crate::users::increment;
impl super::Service {
/// Adds a new device to a user. The user must exist, otherwise InvalidParam
/// is returned.
/// Adds a new device to a user.
pub async fn create_device(
&self,
user_id: &UserId,
@@ -28,12 +27,8 @@ impl super::Service {
token_max_age: Option<Duration>,
initial_device_display_name: Option<String>,
client_ip: Option<String>,
) -> conduwuit::Result<()> {
if !self.exists(user_id).await {
return Err!(Request(InvalidParam(error!(
"Called create_device for non-existent user {user_id}"
))));
}
) -> Result<()> {
self.status(user_id).await.ensure_active()?;
let key = (user_id, device_id);
let mut device = Device::new(device_id.into());
@@ -50,7 +45,7 @@ impl super::Service {
/// Removes a device from a user.
pub async fn remove_device(&self, user_id: &UserId, device_id: &DeviceId) {
// Remove dehydrated device if this is the dehydrated device
let _: conduwuit::Result<_> = self
let _ = self
.remove_dehydrated_device(user_id, Some(device_id))
.await;
@@ -97,11 +92,7 @@ impl super::Service {
}
/// Gets the access token associated with a device.
pub async fn get_token(
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> conduwuit::Result<String> {
pub async fn get_token(&self, user_id: &UserId, device_id: &DeviceId) -> Result<String> {
let key = (user_id, device_id);
self.db.userdeviceid_token.qry(&key).await.deserialized()
}
@@ -131,7 +122,7 @@ impl super::Service {
device_id: &DeviceId,
token: &str,
token_max_age: Option<Duration>,
) -> conduwuit::Result<()> {
) -> Result<()> {
let key = (user_id, device_id);
if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
return Err!(Database(error!(
@@ -259,7 +250,7 @@ impl super::Service {
user_id: &UserId,
device_id: &DeviceId,
device: &Device,
) -> conduwuit::Result<()> {
) -> Result<()> {
increment(&self.db.userid_devicelistversion, user_id.as_bytes());
self.update_device_metadata_no_increment(user_id, device_id, device)
}
@@ -273,7 +264,7 @@ impl super::Service {
user_id: &UserId,
device_id: &DeviceId,
device: &Device,
) -> conduwuit::Result<()> {
) -> Result<()> {
let key = (user_id, device_id);
self.db.userdeviceid_metadata.put(key, Json(device));
@@ -312,7 +303,7 @@ impl super::Service {
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> conduwuit::Result<Device> {
) -> Result<Device> {
self.db
.userdeviceid_metadata
.qry(&(user_id, device_id))
@@ -321,7 +312,7 @@ impl super::Service {
}
/// Gets the most recent device list version for a user.
pub async fn get_devicelist_version(&self, user_id: &UserId) -> conduwuit::Result<u64> {
pub async fn get_devicelist_version(&self, user_id: &UserId) -> Result<u64> {
self.db
.userid_devicelistversion
.get(user_id)
+6 -1
View File
@@ -4,10 +4,11 @@ pub(super) mod device;
pub(super) mod filters;
pub(super) mod keys;
pub(super) mod profile;
pub(super) mod remote;
use std::{mem, sync::Arc};
pub use account::AccessTokenStatus;
pub use account::{AccessTokenStatus, AccountStatus};
use conduwuit::{
Err, Error, Result, err,
utils::{self},
@@ -75,11 +76,13 @@ struct Data {
logintoken_expiresatuserid: Arc<Map>,
todeviceid_events: Arc<Map>,
token_userdeviceid: Arc<Map>,
remoteuserid_remoteuser: Arc<Map>,
userdeviceid_tokenexpires: Arc<Map>,
userdeviceid_metadata: Arc<Map>,
userdeviceid_token: Arc<Map>,
userfilterid_filter: Arc<Map>,
userid_avatarurl: Arc<Map>,
userid_deactivated: Arc<Map>,
userid_dehydrateddevice: Arc<Map>,
userid_devicelistversion: Arc<Map>,
userid_displayname: Arc<Map>,
@@ -121,10 +124,12 @@ impl crate::Service for Service {
logintoken_expiresatuserid: args.db["logintoken_expiresatuserid"].clone(),
todeviceid_events: args.db["todeviceid_events"].clone(),
token_userdeviceid: args.db["token_userdeviceid"].clone(),
remoteuserid_remoteuser: args.db["remoteuserid_remoteuser"].clone(),
userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(),
userdeviceid_token: args.db["userdeviceid_token"].clone(),
userfilterid_filter: args.db["userfilterid_filter"].clone(),
userid_avatarurl: args.db["userid_avatarurl"].clone(),
userid_deactivated: args.db["userid_deactivated"].clone(),
userid_dehydrateddevice: args.db["userid_dehydrateddevice"].clone(),
userid_devicelistversion: args.db["userid_devicelistversion"].clone(),
userid_displayname: args.db["userid_displayname"].clone(),
+17
View File
@@ -0,0 +1,17 @@
use conduwuit::utils::stream::TryIgnore;
use futures::Stream;
use ruma::{OwnedUserId, UserId};
impl super::Service {
/// Record the existence of a remote user.
pub fn record_remote_user(&self, user_id: &UserId) {
assert!(self.services.globals.user_is_local(user_id), "user is not remote");
self.db.remoteuserid_remoteuser.insert(user_id, "");
}
/// Returns a stream over all remote users this server has ever seen.
pub fn stream_remote_users(&self) -> impl Stream<Item = OwnedUserId> + Send {
self.db.remoteuserid_remoteuser.keys().ignore_err()
}
}