From 7502a944d7ff5ef3128c7b06158813f6cdec829a Mon Sep 17 00:00:00 2001 From: timedout Date: Mon, 5 Jan 2026 19:28:25 +0000 Subject: [PATCH] feat: Add user locking and unlocking commands and functionality Also corrects the response code returned by UserSuspended --- src/admin/user/commands.rs | 31 +++++++++++++++++++++++++++++ src/admin/user/mod.rs | 20 +++++++++++++++++++ src/api/router/auth.rs | 30 ++++++++++++++++++++++------ src/core/error/response.rs | 4 +++- src/database/maps.rs | 4 ++++ src/service/users/mod.rs | 40 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 4e9c38c6..3cfe2bc4 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -974,3 +974,34 @@ pub(super) async fn force_leave_remote_room( self.write_str(&format!("{user_id} successfully left {room_id} via remote server.")) .await } + +#[admin_command] +pub(super) async fn lock(&self, user_id: String) -> Result { + 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" + ); + + self.services + .users + .lock_account(&user_id, self.sender_or_service_user()) + .await; + + self.write_str(&format!("User {user_id} has been locked.")) + .await +} + +#[admin_command] +pub(super) async fn unlock(&self, user_id: String) -> Result { + 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" + ); + + self.services.users.unlock_account(&user_id).await; + + self.write_str(&format!("User {user_id} has been unlocked.")) + .await +} diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index c1067bf5..a39a854d 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -81,6 +81,26 @@ pub enum UserCommand { user_id: String, }, + /// - Lock a user + /// + /// Locked users are unable to use their accounts beyond logging out. This + /// is akin to a temporary deactivation that does not change the user's + /// password. This can be used to quickly prevent a user from accessing + /// their account. + Lock { + /// Username of the user to lock + user_id: String, + }, + + /// - Unlock a user + /// + /// Reverses the effects of the `lock` command, allowing the user to use + /// their account again. + Unlock { + /// Username of the user to unlock + user_id: String, + }, + /// - List local users in the database #[clap(alias = "list")] ListUsers, diff --git a/src/api/router/auth.rs b/src/api/router/auth.rs index 4baf0148..fe1416ec 100644 --- a/src/api/router/auth.rs +++ b/src/api/router/auth.rs @@ -137,12 +137,30 @@ pub(super) async fn auth( | ( AuthScheme::AccessToken | AuthScheme::AccessTokenOptional | AuthScheme::None, Token::User((user_id, device_id)), - ) => Ok(Auth { - origin: None, - sender_user: Some(user_id), - sender_device: Some(device_id), - appservice_info: None, - }), + ) => { + let is_locked = services.users.is_locked(&user_id).await.map_err(|e| { + err!(Request(Forbidden(warn!("Failed to check user lock status: {e}")))) + })?; + if is_locked { + // Only /logout and /logout/all are allowed for locked users + if !matches!( + metadata, + &ruma::api::client::session::logout::v3::Request::METADATA + | &ruma::api::client::session::logout_all::v3::Request::METADATA + ) { + return Err(Error::BadRequest( + ErrorKind::UserLocked, + "This account has been locked.", + )); + } + } + Ok(Auth { + origin: None, + sender_user: Some(user_id), + sender_device: Some(device_id), + appservice_info: None, + }) + }, | (AuthScheme::ServerSignatures, Token::None) => Ok(auth_server(services, request, json_body).await?), | ( diff --git a/src/core/error/response.rs b/src/core/error/response.rs index 3f8704c1..82dc8f9b 100644 --- a/src/core/error/response.rs +++ b/src/core/error/response.rs @@ -75,10 +75,12 @@ pub(super) fn bad_request_code(kind: &ErrorKind) -> StatusCode { | ThreepidDenied | InviteBlocked | WrongRoomKeysVersion { .. } + | UserSuspended | Forbidden { .. } => StatusCode::FORBIDDEN, // 401 - | UnknownToken { .. } | MissingToken | Unauthorized => StatusCode::UNAUTHORIZED, + | UnknownToken { .. } | MissingToken | Unauthorized | UserLocked => + StatusCode::UNAUTHORIZED, // 400 | _ => StatusCode::BAD_REQUEST, diff --git a/src/database/maps.rs b/src/database/maps.rs index eee5f5e4..d63a6fc8 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -386,6 +386,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "userid_suspension", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "userid_lock", + ..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 eed00f96..22b7440b 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -77,6 +77,7 @@ struct Data { userid_origin: Arc, userid_password: Arc, userid_suspension: Arc, + userid_lock: Arc, userid_selfsigningkeyid: Arc, userid_usersigningkeyid: Arc, useridprofilekey_value: Arc, @@ -115,6 +116,7 @@ impl crate::Service for Service { userid_origin: args.db["userid_origin"].clone(), userid_password: args.db["userid_password"].clone(), userid_suspension: args.db["userid_suspension"].clone(), + userid_lock: args.db["userid_lock"].clone(), userid_selfsigningkeyid: args.db["userid_selfsigningkeyid"].clone(), userid_usersigningkeyid: args.db["userid_usersigningkeyid"].clone(), useridprofilekey_value: args.db["useridprofilekey_value"].clone(), @@ -220,6 +222,26 @@ impl Service { self.db.userid_suspension.remove(user_id); } + pub async fn lock_account(&self, user_id: &UserId, locking_user: &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 + .db + .userid_lock + .get(user_id) + .await + .deserialized::() + .unwrap_or_else(|_| UserSuspension { + suspended: true, + suspended_at: MilliSecondsSinceUnixEpoch::now().get().into(), + suspended_by: locking_user.to_string(), + }); + + self.db.userid_lock.raw_put(user_id, Json(suspension)); + } + + pub async fn unlock_account(&self, user_id: &UserId) { self.db.userid_lock.remove(user_id); } + /// Check if a user has an account on this homeserver. #[inline] pub async fn exists(&self, user_id: &UserId) -> bool { @@ -255,6 +277,24 @@ impl Service { } } + pub async fn is_locked(&self, user_id: &UserId) -> Result { + match self + .db + .userid_lock + .get(user_id) + .await + .deserialized::() + { + | Ok(s) => Ok(s.suspended), + | Err(e) => + if e.is_not_found() { + Ok(false) + } else { + Err(e) + }, + } + } + /// 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)