From 1c816850edc2c635091e6897ded749d290b3a1e0 Mon Sep 17 00:00:00 2001 From: timedout Date: Tue, 6 Jan 2026 20:28:12 +0000 Subject: [PATCH] feat: Allow admins to disable the login capability of an account # Conflicts: # src/admin/user/commands.rs --- src/admin/user/commands.rs | 62 ++++++++++++++++++++++++++++++++++++-- src/admin/user/mod.rs | 19 ++++++++++++ src/api/client/session.rs | 6 ++++ src/database/maps.rs | 4 +++ src/service/users/mod.rs | 14 +++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index f92d4565..5fa76b88 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -280,7 +280,12 @@ pub(super) async fn unsuspend(&self, user_id: String) -> Result { } #[admin_command] -pub(super) async fn reset_password(&self, username: String, password: Option) -> Result { +pub(super) async fn reset_password( + &self, + logout: bool, + username: String, + password: Option, +) -> Result { let user_id = parse_local_user_id(self.services, &username)?; if user_id == self.services.globals.server_user { @@ -303,7 +308,18 @@ pub(super) async fn reset_password(&self, username: String, password: Option Result { self.write_str(&format!("User {user_id} has been logged out from all devices.")) .await } + +#[admin_command] +pub(super) async fn disable_login(&self, user_id: String) -> Result { + self.bail_restricted()?; + let user_id = parse_local_user_id(self.services, &user_id)?; + assert!( + self.services.globals.user_is_local(&user_id), + "Parsed user_id must be a local user" + ); + if user_id == self.services.globals.server_user { + return Err!("Not allowed to disable login for the server service account.",); + } + + if !self.services.users.exists(&user_id).await { + return Err!("User {user_id} does not exist."); + } + if self.services.users.is_admin(&user_id).await { + return Err!("Admin users cannot have their login disallowed."); + } + self.services.users.disable_login(&user_id); + + self.write_str(&format!( + "{user_id} can no longer log in. Their existing sessions remain unaffected." + )) + .await +} + +#[admin_command] +pub(super) async fn enable_login(&self, user_id: String) -> Result { + self.bail_restricted()?; + let user_id = parse_local_user_id(self.services, &user_id)?; + assert!( + self.services.globals.user_is_local(&user_id), + "Parsed user_id must be a local user" + ); + if !self.services.users.exists(&user_id).await { + return Err!("User {user_id} does not exist."); + } + self.services.users.enable_login(&user_id); + + self.write_str(&format!("{user_id} can now log in.")).await +} diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index f16aeb8a..d9c9ca16 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -20,6 +20,9 @@ pub enum UserCommand { /// - Reset user password ResetPassword { + /// Log out existing sessions + #[arg(short, long)] + logout: bool, /// Username of the user for whom the password should be reset username: String, /// New password for the user, if unspecified one is generated @@ -113,6 +116,22 @@ pub enum UserCommand { user_id: String, }, + /// - Enable login for a user + EnableLogin { + /// Username of the user to enable login for + user_id: String, + }, + + /// - Disable login for a user + /// + /// Disables login for the specified user without deactivating or locking + /// their account. This prevents the user from obtaining new access tokens, + /// but does not invalidate existing sessions. + DisableLogin { + /// Username of the user to disable login for + user_id: String, + }, + /// - List local users in the database #[clap(alias = "list")] ListUsers, diff --git a/src/api/client/session.rs b/src/api/client/session.rs index d1036e21..8cd61410 100644 --- a/src/api/client/session.rs +++ b/src/api/client/session.rs @@ -5,6 +5,7 @@ use axum_client_ip::InsecureClientIp; use conduwuit::{ Err, Error, Result, debug, err, info, utils::{self, ReadyExt, hash}, + warn, }; use conduwuit_core::{debug_error, debug_warn}; use conduwuit_service::{Services, uiaa::SESSION_ID_LENGTH}; @@ -184,6 +185,11 @@ pub(crate) async fn handle_login( return Err!(Request(Unknown("User ID does not belong to this homeserver"))); } + 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."))); + } + if cfg!(feature = "ldap") && services.config.ldap.enable { match Box::pin(ldap_login(services, &user_id, &lowercased_user_id, password)).await { | Ok(user_id) => Ok(user_id), diff --git a/src/database/maps.rs b/src/database/maps.rs index b5a7d05a..88d50c2c 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -394,6 +394,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "userid_lock", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "userid_login_disabled", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "userid_presenceid", ..descriptor::RANDOM_SMALL diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 22b7440b..956c92fc 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -78,6 +78,7 @@ struct Data { userid_password: Arc, userid_suspension: Arc, userid_lock: Arc, + userid_login_disabled: Arc, userid_selfsigningkeyid: Arc, userid_usersigningkeyid: Arc, useridprofilekey_value: Arc, @@ -117,6 +118,7 @@ impl crate::Service for Service { userid_password: args.db["userid_password"].clone(), userid_suspension: args.db["userid_suspension"].clone(), userid_lock: args.db["userid_lock"].clone(), + userid_login_disabled: args.db["userid_login_disabled"].clone(), userid_selfsigningkeyid: args.db["userid_selfsigningkeyid"].clone(), userid_usersigningkeyid: args.db["userid_usersigningkeyid"].clone(), useridprofilekey_value: args.db["useridprofilekey_value"].clone(), @@ -295,6 +297,18 @@ impl Service { } } + pub fn disable_login(&self, user_id: &UserId) { + self.db.userid_login_disabled.insert(user_id, "1"); + } + + pub fn enable_login(&self, user_id: &UserId) { + self.db.userid_login_disabled.remove(user_id); + } + + pub async fn is_login_disabled(&self, user_id: &UserId) -> bool { + self.db.userid_login_disabled.get(user_id).await.is_ok() + } + /// Check if account is active, infallible pub async fn is_active(&self, user_id: &UserId) -> bool { !self.is_deactivated(user_id).await.unwrap_or(true)