diff --git a/Cargo.lock b/Cargo.lock index d1389aa8a..0c300b5df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4601,7 +4601,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.16.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "assign", "js_int", @@ -4620,8 +4620,9 @@ dependencies = [ [[package]] name = "ruma-appservice-api" version = "0.16.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ + "http", "js_int", "ruma-common", "ruma-events", @@ -4632,7 +4633,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.24.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "as_variant", "assign", @@ -4654,7 +4655,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.19.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "as_variant", "base64 0.22.1", @@ -4687,7 +4688,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.34.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "as_variant", "indexmap 2.14.0", @@ -4708,7 +4709,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.15.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "bytes", "headers", @@ -4731,7 +4732,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.12.1" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "js_int", "thiserror 2.0.18", @@ -4740,7 +4741,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.19.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "as_variant", "cfg-if", @@ -4756,7 +4757,7 @@ dependencies = [ [[package]] name = "ruma-push-gateway-api" version = "0.15.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "js_int", "ruma-common", @@ -4768,7 +4769,7 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.21.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "base64 0.22.1", "ed25519-dalek", @@ -4785,7 +4786,7 @@ dependencies = [ [[package]] name = "ruma-state-res" version = "0.17.0" -source = "git+https://github.com/ruma/ruma.git?rev=fca1dbc060f730c1aaf84c9c2fb1e8a587455be8#fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +source = "git+https://github.com/gingershaped/ruwuma.git?rev=0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff#0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" dependencies = [ "js_int", "ruma-common", diff --git a/Cargo.toml b/Cargo.toml index ea3fa1699..84d53ab7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -349,8 +349,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 = "fca1dbc060f730c1aaf84c9c2fb1e8a587455be8" +git = "https://github.com/gingershaped/ruwuma.git" +rev = "0713b97ed53b7cb5e639f5e2f067c0fe6ac525ff" features = [ "appservice-api-c", "client-api", @@ -386,6 +386,7 @@ features = [ "unstable-msc4406", "unstable-msc4439", "unstable-msc4466", + "unstable-msc4484", "unstable-extensible-events", ] diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 65a637088..a2ecee138 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -101,7 +101,8 @@ pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) -> .await } - pub(super) async fn suspend(&self, user_id: String) -> Result { + +pub(super) async fn suspend(&self, user_id: String) -> Result { self.bail_restricted()?; let user_id = parse_active_local_user_id(self.services, &user_id).await?; @@ -115,7 +116,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.")) @@ -934,7 +935,8 @@ pub(super) async fn force_leave_remote_room( .await } - pub(super) async fn lock(&self, user_id: String) -> Result { + +pub(super) async fn lock(&self, user_id: String) -> Result { self.bail_restricted()?; let user_id = parse_active_local_user_id(self.services, &user_id).await?; @@ -948,7 +950,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.")) diff --git a/src/api/client/admin/lock.rs b/src/api/client/admin/lock.rs index 81b95dd61..e67262258 100644 --- a/src/api/client/admin/lock.rs +++ b/src/api/client/admin/lock.rs @@ -1,6 +1,6 @@ use axum::extract::State; use conduwuit::{Err, Result}; -use futures::future::{join, join3}; +use futures::join; use ruma::api::client::admin::{is_user_locked, lock_user}; use crate::Ruma; @@ -12,15 +12,7 @@ pub(crate) async fn get_lock_status( State(services): State, body: Ruma, ) -> Result { - let (admin, status) = join( - services.users.is_admin(body.identity.expect_sender_user()?), - services.users.status(&body.user_id), - ) - .await; - - if !admin { - return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); - } + let status = services.users.status(&body.user_id).await; status.ensure_active()?; @@ -36,22 +28,16 @@ pub(crate) async fn put_lock_status( State(services): State, body: Ruma, ) -> Result { - let sender_user = body.identity.expect_sender_user()?; + let sender_user = body.identity.sender_user(); - let (sender_admin, status, target_admin) = join3( - services.users.is_admin(sender_user), + let (status, target_admin) = join!( services.users.status(&body.user_id), services.users.is_admin(&body.user_id), - ) - .await; - - if !sender_admin { - return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); - } + ); status.ensure_active()?; - if body.user_id == *sender_user { + if sender_user.is_some_and(|sender_user| body.user_id == sender_user) { return Err!(Request(Forbidden("You cannot lock yourself"))); } @@ -79,7 +65,7 @@ pub(crate) async fn put_lock_status( // Notify the admin room that an account has been un/suspended services .admin - .send_text(&format!("{} has been {} by {}.", body.user_id, action, sender_user)) + .send_text(&format!("{} has been {} by {}.", body.user_id, action, body.identity)) .await; } diff --git a/src/api/client/admin/site/rooms/list.rs b/src/api/client/admin/site/rooms/list.rs index 0a2810812..937fa699d 100644 --- a/src/api/client/admin/site/rooms/list.rs +++ b/src/api/client/admin/site/rooms/list.rs @@ -1,6 +1,6 @@ use axum::extract::State; use conduwuit::{ - Err, Event, Result, + Event, Result, utils::stream::{BroadbandExt, WidebandExt}, }; use futures::StreamExt; @@ -28,15 +28,10 @@ /// 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( +pub(crate) async fn legacy_list_rooms( State(services): State, - body: Ruma, + _body: Ruma, ) -> Result { - 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 = services .rooms .metadata @@ -57,15 +52,10 @@ pub(crate) async fn legacy_list_rooms_route( /// # `GET /_continuwuity/admin/v1/rooms` /// /// Lists rooms known to this server. -pub(crate) async fn list_rooms_route( +pub(crate) async fn list_rooms( State(services): State, body: Ruma, ) -> Result { - let sender_user = body.sender_user(); - if !services.users.is_admin(sender_user).await { - return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); - } - let include_banned_rooms = body.include_banned_rooms; let rooms = services .rooms diff --git a/src/api/client/admin/site/users/create.rs b/src/api/client/admin/site/users/create.rs index 422821235..56a9cc532 100644 --- a/src/api/client/admin/site/users/create.rs +++ b/src/api/client/admin/site/users/create.rs @@ -1,66 +1,71 @@ use axum::extract::State; use conduwuit::{ - Err, err, error, info, + err, error, info, utils::{IterStream, stream::BroadbandExt}, warn, }; use futures::{FutureExt, StreamExt}; -use ruma::UserId; +use ruma::{UserId, api::client::profile::PropagateTo, profile::ProfileFieldValue}; use ruminuwuity::admin::continuwuity::users; -use service::users::HashedPassword; +use service::users::{HashedPassword, ProfileFieldChange}; use crate::router::Ruma; /// # `POST /_continuwuity/admin/v1/users/create` /// /// Creates a new user. -pub(crate) async fn create_user_route( +pub(crate) async fn create_user( State(services): State, body: Ruma, ) -> conduwuit::Result { - let sender_user = body.sender_user(); - - if !services.users.is_admin(sender_user).await { - return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); - } let user_id = &UserId::parse_with_server_name(&body.localpart, services.globals.server_name())?; - if services.users.is_active_local(user_id).await { - return Err!(Conflict("A user with this username already exists")); - } + + let email = body.email + .clone() + .map(lettre::Address::try_from) + .transpose() + .map_err(|e| err!(Request(BadJson("Invalid email address: {e}"))))?; services .users - .create_local_account( - user_id, - HashedPassword::new(&body.password)?, - body.email - .clone() - .map(lettre::Address::try_from) - .transpose() - .map_err(|e| err!(Request(BadJson("Invalid email address: {e}"))))?, - ) - .await; + .create_shadow_account(user_id) + .await?; + + services.users.convert_to_local_account(user_id, HashedPassword::new(&body.password)?).await?; + + if let Some(email) = &email { + services.threepid.associate_localpart_email(user_id.localpart(), email).await?; + } + if body.suspended { - services.users.suspend_account(user_id, sender_user).await; + services + .users + .suspend_account(user_id, body.identity.sender_user()) + .await; } if body.locked { - services.users.lock_account(user_id, sender_user).await; + 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( + services.users.set_profile_field( user_id, - "displayname", - Some(serde_json::to_value(value)?), + ProfileFieldChange::Set(ProfileFieldValue::DisplayName(value.to_owned())), + PropagateTo::None, ); } if let Some(ref value) = body.avatar_url { - services - .users - .set_profile_key(user_id, "avatar_url", Some(serde_json::to_value(value)?)); + services.users.set_profile_field( + user_id, + ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(value.to_owned())), + PropagateTo::None, + ); } if body.admin { services @@ -70,14 +75,18 @@ pub(crate) async fn create_user_route( .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() + .chain( + if body.skip_auto_join { + vec![] + } else { + services.config.auto_join_rooms.clone() + }.into_iter().stream() + ) .broad_filter_map(|room| async move { services .rooms diff --git a/src/api/client/admin/site/users/list.rs b/src/api/client/admin/site/users/list.rs index 6094ba16a..25bfc3793 100644 --- a/src/api/client/admin/site/users/list.rs +++ b/src/api/client/admin/site/users/list.rs @@ -1,5 +1,5 @@ use axum::extract::State; -use conduwuit::{Err, utils::stream::WidebandExt}; +use conduwuit::utils::stream::WidebandExt; use futures::StreamExt; use ruminuwuity::admin::continuwuity::users; use tokio::join; @@ -9,24 +9,18 @@ /// # `GET /_continuwuity/admin/v1/users` /// /// Lists all users on this homeserver. -pub(crate) async fn list_users_route( +pub(crate) async fn list_users( State(services): State, body: Ruma, ) -> conduwuit::Result { - let sender_user = body.sender_user(); - - if !services.users.is_admin(sender_user).await { - return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); - } - let users = services .users - .list_local_users() + .stream_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), + let (status, suspended, locked, admin, login_disabled) = join!( + services.users.status(&user_id), services.users.is_suspended(&user_id), services.users.is_locked(&user_id), services.users.is_admin(&user_id), @@ -34,7 +28,7 @@ pub(crate) async fn list_users_route( ); Some(users::list::v1::User { user_id: user_id.clone(), - deactivated: deactivated.unwrap_or_default(), + deactivated: !status.is_active(), suspended: suspended.unwrap_or_default(), locked: locked.unwrap_or_default(), admin, diff --git a/src/api/client/admin/suspend.rs b/src/api/client/admin/suspend.rs index fac32f12e..cfd26b65f 100644 --- a/src/api/client/admin/suspend.rs +++ b/src/api/client/admin/suspend.rs @@ -67,7 +67,7 @@ pub(crate) async fn put_suspended_status( 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 { diff --git a/src/api/router.rs b/src/api/router.rs index f00a420f4..9cfe6d838 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -18,7 +18,7 @@ pub(super) use self::{args::Args as Ruma, auth::ClientIdentity, response::RumaResponse}; #[cfg(feature = "admin_api")] use crate::client::admin::site as admin_api; -use crate::{client, server}; +use crate::{client::{self, admin}, server}; pub fn build(router: Router, state: State) -> Router { let config = &state.server.config; @@ -193,8 +193,11 @@ pub fn build(router: Router, state: State) -> Router { .ruma_route(&client::get_authorization_server_metadata_route) .merge(client::oauth::router(state)) .route("/_continuwuity/server_version", get(client::continuwuity_server_version)) - .ruma_route(&admin::rooms::ban::ban_room) - .ruma_route(&admin::rooms::list::list_rooms); + .ruma_route(&admin::site::rooms::ban_room) + .ruma_route(&admin::site::rooms::list_rooms) + .ruma_route(&admin::site::rooms::legacy_list_rooms) + .ruma_route(&admin::site::users::create_user) + .ruma_route(&admin::site::users::list_users); if config.allow_federation { router = router diff --git a/src/api/router/auth.rs b/src/api/router/auth.rs index 2b52e93ee..cc7803c88 100644 --- a/src/api/router/auth.rs +++ b/src/api/router/auth.rs @@ -1,11 +1,14 @@ -use std::any::{Any, TypeId}; +use std::{ + any::{Any, TypeId}, + fmt::Display, +}; use conduwuit::{Err, Error, Result, err}; use http::StatusCode; use ruma::{ DeviceId, OwnedDeviceId, OwnedServerName, OwnedUserId, UserId, api::{ - IncomingRequest, + IncomingRequest, OAuthClientScope, auth_scheme::{ AccessToken, AccessTokenOptional, AppserviceToken, AppserviceTokenOptional, AuthScheme, NoAccessToken, NoAuthentication, @@ -77,6 +80,17 @@ 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; @@ -95,7 +109,8 @@ fn authenticate + Sync>( )))) })?; - Self::verify(services, output, incoming_request, query, route).await + Self::verify(services, output, incoming_request, query, route, R::REQUIRED_SCOPES) + .await } } @@ -105,6 +120,7 @@ fn verify + Sync>( request: &hyper::Request, query: AuthQueryParams, route: TypeId, + required_scopes: &[OAuthClientScope], ) -> impl Future> + Send; } @@ -117,6 +133,7 @@ async fn verify + Sync>( request: &hyper::Request, _query: AuthQueryParams, _route: TypeId, + _required_scopes: &[OAuthClientScope], ) -> Result { let destination = services.globals.server_name(); if output @@ -166,6 +183,7 @@ async fn verify + Sync>( _request: &hyper::Request, query: AuthQueryParams, route: TypeId, + required_scopes: &[OAuthClientScope], ) -> Result { if output.is_empty() { return Err!(Request(Unauthorized("Missing access token."))); @@ -198,6 +216,32 @@ async fn verify + Sync>( } } + // 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() + .any(|scope| session.scopes.contains(scope)) + { + return Err!(Request(Forbidden( + "You don't have the necessary scopes to use this endpoint." + ))); + } + } else { + // Otherwise, explicitly if the endpoint is restricted to admins only. + if required_scopes.contains(&OAuthClientScope::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( @@ -263,12 +307,19 @@ async fn verify + Sync>( request: &hyper::Request, query: AuthQueryParams, route: TypeId, + required_scopes: &[OAuthClientScope], ) -> Result { match output { - | Some(token) => - ::verify(services, token, request, query, route) - .await - .map(Some), + | Some(token) => ::verify( + services, + token, + request, + query, + route, + required_scopes, + ) + .await + .map(Some), | None => Ok(None), } } @@ -283,6 +334,7 @@ async fn verify + Sync>( _request: &hyper::Request, _query: AuthQueryParams, _route: TypeId, + _required_scopes: &[OAuthClientScope], ) -> Result { if output.is_empty() { return Err!(Request(Unauthorized("Missing access token."))); @@ -304,12 +356,19 @@ async fn verify + Sync>( request: &hyper::Request, query: AuthQueryParams, route: TypeId, + required_scopes: &[OAuthClientScope], ) -> Result { match output { - | Some(token) => - ::verify(services, token, request, query, route) - .await - .map(Some), + | Some(token) => ::verify( + services, + token, + request, + query, + route, + required_scopes, + ) + .await + .map(Some), | None => Ok(None), } } @@ -324,6 +383,7 @@ async fn verify + Sync>( _request: &hyper::Request, _query: AuthQueryParams, _route: TypeId, + _required_scopes: &[OAuthClientScope], ) -> Result { Ok(()) } @@ -338,6 +398,7 @@ async fn verify + Sync>( request: &hyper::Request, query: AuthQueryParams, route: TypeId, + required_scopes: &[OAuthClientScope], ) -> Result { // We handle these the same as AccessTokenOptional let token = AccessTokenOptional::extract_authentication(request).map_err(|err| { @@ -357,6 +418,14 @@ async fn verify + Sync>( ))); } - ::verify(services, token, request, query, route).await + ::verify( + services, + token, + request, + query, + route, + required_scopes, + ) + .await } } diff --git a/src/ruminuwuity/admin/continuwuity/rooms/ban.rs b/src/ruminuwuity/admin/continuwuity/rooms/ban.rs index d9e1d142a..17f7ac176 100644 --- a/src/ruminuwuity/admin/continuwuity/rooms/ban.rs +++ b/src/ruminuwuity/admin/continuwuity/rooms/ban.rs @@ -1,7 +1,7 @@ pub mod v1 { use ruma::{ OwnedRoomAliasId, OwnedRoomId, OwnedUserId, - api::{auth_scheme::AccessToken, request, response}, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, metadata, }; @@ -9,6 +9,7 @@ pub mod v1 { method: PUT, rate_limited: false, authentication: AccessToken, + required_scopes: [OAuthClientScope::ServerAdministration], history: { unstable("org.continuwuity.admin") => "/_continuwuity/admin/rooms/{room_id}/ban", 1.0 => "/_continuwuity/admin/v1/rooms/{room_id}/ban", diff --git a/src/ruminuwuity/admin/continuwuity/rooms/list.rs b/src/ruminuwuity/admin/continuwuity/rooms/list.rs index 2c6604c65..1c4c2c04b 100644 --- a/src/ruminuwuity/admin/continuwuity/rooms/list.rs +++ b/src/ruminuwuity/admin/continuwuity/rooms/list.rs @@ -1,7 +1,7 @@ pub mod unstable { use ruma::{ OwnedRoomId, - api::{auth_scheme::AccessToken, request, response}, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, metadata, }; @@ -9,6 +9,7 @@ pub mod unstable { method: GET, rate_limited: false, authentication: AccessToken, + required_scopes: [OAuthClientScope::ServerAdministration], history: { unstable => "/_continuwuity/admin/rooms/list", } diff --git a/src/ruminuwuity/admin/continuwuity/users/create.rs b/src/ruminuwuity/admin/continuwuity/users/create.rs index 1b8c61c6d..1339f5813 100644 --- a/src/ruminuwuity/admin/continuwuity/users/create.rs +++ b/src/ruminuwuity/admin/continuwuity/users/create.rs @@ -1,7 +1,7 @@ pub mod v1 { use ruma::{ OwnedMxcUri, OwnedRoomOrAliasId, OwnedUserId, - api::{auth_scheme::AccessToken, request, response}, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, metadata, }; @@ -9,6 +9,7 @@ pub mod v1 { method: POST, rate_limited: false, authentication: AccessToken, + required_scopes: [OAuthClientScope::ServerAdministration], history: { 1.0 => "/_continuwuity/admin/v1/users/create", }, diff --git a/src/ruminuwuity/admin/continuwuity/users/list.rs b/src/ruminuwuity/admin/continuwuity/users/list.rs index b573aff25..7ac9db0cd 100644 --- a/src/ruminuwuity/admin/continuwuity/users/list.rs +++ b/src/ruminuwuity/admin/continuwuity/users/list.rs @@ -1,7 +1,7 @@ pub mod v1 { use ruma::{ OwnedUserId, - api::{auth_scheme::AccessToken, request, response}, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, metadata, }; use serde::Deserialize; @@ -10,6 +10,7 @@ pub mod v1 { method: GET, rate_limited: false, authentication: AccessToken, + required_scopes: [OAuthClientScope::ServerAdministration], history: { 1.0 => "/_continuwuity/admin/v1/users", } diff --git a/src/service/oauth/grant.rs b/src/service/oauth/grant.rs index 0a027f637..e1cc507fb 100644 --- a/src/service/oauth/grant.rs +++ b/src/service/oauth/grant.rs @@ -8,7 +8,7 @@ }; use regex::Regex; -use ruma::OwnedDeviceId; +use ruma::{OwnedDeviceId, api::OAuthClientScope}; use serde::{Deserialize, Serialize}; use url::Url; @@ -55,26 +55,40 @@ pub enum Prompt { } #[derive(Debug, Clone, Deserialize, Serialize, PartialOrd, Ord)] -pub enum Scope { +pub enum RequestedScope { Device(OwnedDeviceId), - ClientApi, + ApiFullAccess, + ServerAdministration, } -impl PartialEq for Scope { +impl RequestedScope { + #[must_use] + pub fn as_granted_scope(&self) -> Option { + match self { + | Self::ApiFullAccess => Some(OAuthClientScope::ApiFullAccess), + | Self::ServerAdministration => Some(OAuthClientScope::ServerAdministration), + | Self::Device(_) => None, + } + } +} + +impl PartialEq for RequestedScope { fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) } } -impl Eq for Scope {} +impl Eq for RequestedScope {} -impl Hash for Scope { +impl Hash for RequestedScope { fn hash(&self, state: &mut H) { discriminant(self).hash(state); } } -impl Display for Scope { +impl Display for RequestedScope { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let urn = match self { - | Self::ClientApi => "urn:matrix:client:api:*".to_owned(), + | Self::ApiFullAccess => "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) @@ -85,22 +99,27 @@ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { pub struct RawScopes(String); impl RawScopes { - pub fn to_scopes(&self) -> Result, String> { - let client_api_token_regex = + pub fn to_scopes(&self) -> Result, 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 client_api_token_regex.is_match(token) { - scopes.insert(Scope::ClientApi) + if full_access_regex.is_match(token) { + scopes.insert(RequestedScope::ApiFullAccess) } else if let Some(captures) = device_token_regex.captures(token) { - scopes.insert(Scope::Device(captures.get(2).unwrap().as_str().into())) + 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 @@ -160,8 +179,15 @@ pub enum ErrorCode { InvalidClientMetadata, } +#[derive(Serialize)] +#[serde(untagged)] +pub enum AuthorizationCodeResponse { + Success(AuthorizationCodeData), + Error(OAuthError), +} + #[derive(Serialize, Deserialize)] -pub struct AuthorizationCodeResponse { +pub struct AuthorizationCodeData { pub state: String, pub code: String, } diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index cde2f0d75..302174d21 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -12,7 +12,7 @@ use database::{Deserialized, Json, Map}; use itertools::Itertools; use lru_cache::LruCache; -use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId}; +use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId, api::OAuthClientScope}; use serde::{Deserialize, Serialize}; use url::Url; @@ -21,8 +21,7 @@ oauth::{ client_metadata::{ApplicationType, ClientMetadata, ResponseType}, grant::{ - AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode, - OAuthError, ResponseMode, Scope, TokenRequest, TokenResponse, TokenType, + AuthorizationCodeData, AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode, OAuthError, RequestedScope, ResponseMode, TokenRequest, TokenResponse, TokenType }, }, users, @@ -51,7 +50,7 @@ struct Services { #[derive(Debug, Deserialize, Serialize)] pub struct SessionInfo { pub client_id: String, - pub scopes: BTreeSet, + pub scopes: BTreeSet, current_refresh_token: String, } @@ -64,7 +63,7 @@ struct RefreshTokenInfo { struct PendingCodeGrant { authorizing_user: OwnedUserId, - requested_scopes: BTreeSet, + requested_scopes: BTreeSet, client_name: Option, expected_client_id: String, expected_redirect_uri: Url, @@ -224,49 +223,59 @@ pub async fn request_authorization_code( } } - let requested_scopes = query.scope.to_scopes()?; - let redirect_uri_query_separator = match query.response_mode { | ResponseMode::Fragment => '#', | ResponseMode::Query => '?', }; - let code = PendingCodeGrant::generate_code(); + let response = 'response: { + let requested_scopes = query.scope.to_scopes()?; - info!( - client_id = &query.client_id, - client_name = &client_metadata.client_name, - ?requested_scopes, - ?authorizing_user, - "Issuing oauth authorization code" - ); + 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(AuthorizationCodeData { state: query.state, code }) + }; let redirect_uri = format!( "{}{}{}", query.redirect_uri, redirect_uri_query_separator, - serde_urlencoded::to_string(AuthorizationCodeResponse { - state: query.state, - code: code.clone(), - }) - .unwrap(), + serde_urlencoded::to_string(response).unwrap(), ); - 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, - code_challenge: query.code_challenge, - requested_at: SystemTime::now(), - }; - - self.pending_code_grants - .lock() - .await - .insert(code, pending_grant); - Ok(redirect_uri) } @@ -339,7 +348,7 @@ pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> { async fn create_session( &self, authorizing_user: OwnedUserId, - requested_scopes: BTreeSet, + requested_scopes: BTreeSet, client_name: Option, client_id: String, ) -> Result { @@ -349,7 +358,7 @@ async fn create_session( let device_id = requested_scopes .iter() .find_map(|scope| { - if let Scope::Device(device_id) = scope { + if let RequestedScope::Device(device_id) = scope { Some(device_id) } else { None @@ -391,7 +400,10 @@ async fn create_session( Json(SessionInfo { client_id: client_id.clone(), current_refresh_token: refresh_token.clone(), - scopes: requested_scopes.clone(), + scopes: requested_scopes + .iter() + .filter_map(RequestedScope::as_granted_scope) + .collect(), }), ); diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 3dc786c68..9febf6fd8 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -33,7 +33,7 @@ use crate::{ Dep, config, globals, media, - oauth::grant::AuthorizationCodeResponse, + oauth::grant::AuthorizationCodeData, threepid, users::{self, AccountStatus, ProfileFieldChange}, }; @@ -245,7 +245,7 @@ pub async fn begin_session(&self, prompt: Option) -> (PendingSes pub async fn exchange_code( &self, session: PendingSession, - response: AuthorizationCodeResponse, + response: AuthorizationCodeData, ) -> Result { let Some(OidcClient { machine, client, .. }) = self.client.as_ref() else { return Err("Delegated authentication is not enabled on this server."); diff --git a/src/service/sending/appservice.rs b/src/service/sending/appservice.rs index e7242af42..2f7a2213a 100644 --- a/src/service/sending/appservice.rs +++ b/src/service/sending/appservice.rs @@ -6,8 +6,7 @@ }; use ruma::api::{ IncomingResponse, OutgoingRequest, - appservice::Registration, - auth_scheme::{AccessToken, SendAccessToken}, + appservice::{HomeserverToken, Registration}, path_builder::SinglePath, }; @@ -22,7 +21,7 @@ pub async fn send_appservice_request( request: T, ) -> Result> where - T: OutgoingRequest + Debug + Send, + T: OutgoingRequest + Debug + Send, { let Some(dest) = registration.url else { return Ok(None); @@ -36,7 +35,7 @@ pub async fn send_appservice_request( let hs_token = registration.hs_token.as_str(); let mut http_request = request - .try_into_http_request::(&dest, SendAccessToken::Appservice(hs_token), ()) + .try_into_http_request::(&dest, hs_token, ()) .map_err(|e| { err!(BadServerResponse( warn!(appservice = %registration.id, "Failed to find destination {dest}: {e:?}") diff --git a/src/service/users/account.rs b/src/service/users/account.rs index f74c672a1..b2e404d84 100644 --- a/src/service/users/account.rs +++ b/src/service/users/account.rs @@ -197,7 +197,7 @@ pub async fn create_local_account( // 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, &self.services.globals.server_user) + self.suspend_account(user_id, Some(&self.services.globals.server_user)) .await; // And send an @room notice to the admin room, to prompt admins to review the @@ -389,13 +389,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), }), ); } @@ -406,7 +406,7 @@ pub async fn unsuspend_account(&self, user_id: &UserId) { } /// Locks an account, preventing it being used until it is unlocked. - 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 @@ -418,7 +418,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)); diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index f784ae878..b20b8ee3b 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -31,7 +31,7 @@ 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, } /// A password hash. This is only for use when setting a user's password, diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index a95cf4502..4d5139a4f 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -3,12 +3,11 @@ use askama::{Template, filters::HtmlSafe}; use base64::Engine; use conduwuit_core::{result::FlatOk, utils}; -use conduwuit_service::{ - Services, - media::mxc::Mxc, - oauth::{client_metadata::ClientMetadata, grant::Scope}, +use conduwuit_service::{Services, media::mxc::Mxc, oauth::client_metadata::ClientMetadata}; +use ruma::{ + OwnedDeviceId, OwnedUserId, UserId, + api::{OAuthClientScope, client::device::Device}, }; -use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device}; pub(super) mod form; @@ -183,7 +182,7 @@ pub(super) async fn for_device( #[derive(Debug, Template)] #[template(path = "_components/client_scopes.html.j2")] pub(super) struct ClientScopes { - pub scopes: BTreeSet, + pub scopes: BTreeSet, } impl HtmlSafe for ClientScopes {} diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index aa2825e1d..9866107f5 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -4,7 +4,7 @@ response::Redirect, routing::on, }; -use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, Prompt}; +use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, Prompt, RequestedScope}; use ruma::OwnedUserId; use url::Url; @@ -100,7 +100,13 @@ async fn route_authorization_code( return Err(WebError::BadRequest("Invalid client ID".to_owned())); }; - let scopes = query.scope.to_scopes().map_err(WebError::BadRequest)?; + let scopes = query + .scope + .to_scopes() + .map_err(WebError::BadRequest)? + .iter() + .filter_map(RequestedScope::as_granted_scope) + .collect(); let client_name = if let Some(name) = &client.client_name { name diff --git a/src/web/pages/oidc/complete.rs b/src/web/pages/oidc/complete.rs index 62314e48f..74048bb31 100644 --- a/src/web/pages/oidc/complete.rs +++ b/src/web/pages/oidc/complete.rs @@ -6,7 +6,7 @@ response::Redirect, routing::on, }; -use conduwuit_service::{oauth::grant::AuthorizationCodeResponse, oidc::SessionCompletionStatus}; +use conduwuit_service::{oauth::grant::AuthorizationCodeData, oidc::SessionCompletionStatus}; use futures::FutureExt; use ruma::{OwnedServerName, UserId}; use serde::{Deserialize, de::IgnoredAny}; @@ -57,7 +57,7 @@ struct LoginForm { async fn route_complete( State(services): State, Extension(context): Extension, - Expect(Query(query)): Expect>, + Expect(Query(query)): Expect>, session_store: Session, user: User, PostForm(form): PostForm, diff --git a/src/web/pages/templates/_components/client_scopes.html.j2 b/src/web/pages/templates/_components/client_scopes.html.j2 index bf7df3aec..3080f2418 100644 --- a/src/web/pages/templates/_components/client_scopes.html.j2 +++ b/src/web/pages/templates/_components/client_scopes.html.j2 @@ -1,10 +1,13 @@
    {% for scope in scopes %} {% match scope %} - {% when Scope::ClientApi %} + {% when OAuthClientScope::ApiFullAccess %}
  • Send messages and interact with chatrooms on your behalf
  • - {% when Scope::Device(_) %} -
  • Access your Matrix account
  • + {% when OAuthClientScope::ServerAdministration %} +
  • ⚠️ Administrate this homeserver +
    This is a dangerous permission. Make sure you trust this app.
  • + {% when _ %} +
  • missingno
  • {% endmatch %} {% endfor %}