Compare commits

..
Author SHA1 Message Date
Ginger 74c453d606 chore: Formatting 2026-07-26 20:23:47 -04:00
Ginger 523e7548d6 fix: Fix backwards logic in auth check 2026-07-26 20:20:14 -04:00
Ginger 6535246094 fix: Update error message wording 2026-07-26 20:20:13 -04:00
Ginger 6115bb3101 refactor: Remove redundant destination check in server auth logic
Ruma already does this check for us
2026-07-26 20:20:11 -04:00
Ginger 61f6930b5d refactor: Update Ruma and adjust auth logic 2026-07-26 20:20:11 -04:00
Ginger 2fb63f4cbc refactor: Use determine_registration_user_id in admin user create route 2026-07-26 19:11:04 -04:00
Ginger fcc8b3d697 feat: Set MSC4484 unstable feature flag 2026-07-26 19:11:04 -04:00
Ginger 5ead900e4a fix: Adjust admin API routes to work with new auth logic 2026-07-26 19:10:57 -04:00
timedoutandGinger 5b0aacb57f feat: Add user creation endpoint 2026-07-26 18:56:55 -04:00
timedoutandGinger 3f2d0ae2ff feat: Include predecessor and successor information in room list 2026-07-26 18:56:54 -04:00
timedoutandGinger 5d461d2023 feat: Add pagination to rooms list & include more information 2026-07-26 18:56:54 -04:00
timedoutandGinger 836748b569 feat: Enable pagination for the users list route 2026-07-26 18:56:54 -04:00
timedoutandGinger 643ca38710 feat: Define routes for listing and creating users 2026-07-26 18:56:54 -04:00
timedoutandGinger ef1506204d feat: Add version part to admin API URLs
This is a surprise tool that will help us later
2026-07-26 18:56:53 -04:00
timedoutandGinger 9589501db5 chore: Add some documentation to API stuff 2026-07-26 18:56:53 -04:00
timedoutandGinger 3a8040ccf1 feat: Drop ruminuwuity msc4323 definitions 2026-07-26 18:56:53 -04:00
67 changed files with 1465 additions and 928 deletions
+7 -51
View File
@@ -18,7 +18,6 @@ jobs:
strategy:
matrix:
container: [ "ubuntu-latest", "ubuntu-previous", "debian-latest", "debian-oldstable" ]
arch: ["amd64", "arm64"]
container:
image: "ghcr.io/tcpipuk/act-runner:${{ matrix.container }}"
@@ -55,9 +54,9 @@ jobs:
path: |
~/.cargo/registry
~/.cargo/git
key: cargo-debian-${{ steps.debian-version.outputs.distribution }}-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
key: cargo-debian-${{ steps.debian-version.outputs.distribution }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-debian-${{ steps.debian-version.outputs.distribution }}-${{ matrix.arch }}-
cargo-debian-${{ steps.debian-version.outputs.distribution }}-
- name: Setup sccache
uses: https://git.tomfos.tr/tom/sccache-action@v1
@@ -71,55 +70,11 @@ jobs:
# Aggressive GC since cache restores don't increment counter
echo "CARGO_INCREMENTAL_GC_TRIGGER=5" >> $GITHUB_ENV
- name: Install cross-compilation tools for arm64
if: matrix.arch == 'arm64'
run: |
dpkg --add-architecture arm64
if ! apt-get update; then
# Older Ubuntu releases (e.g. noble) advertise arm64 in their
# Release files but only serve amd64/i386 from archive.ubuntu.com,
# so apt update 404s. Restrict the main sources to amd64 and
# fetch arm64 from ports.ubuntu.com instead.
CODENAME=$(lsb_release -sc)
# deb822 sources (noble and newer)
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sed -i '/^Components:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
# one-line sources (jammy and older)
if [ -f /etc/apt/sources.list ]; then
sed -i 's/^deb /deb [arch=amd64] /' /etc/apt/sources.list
fi
printf 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports %s main restricted universe multiverse\n' \
"$CODENAME" "$CODENAME-updates" "$CODENAME-security" \
> /etc/apt/sources.list.d/arm64-ports.list
apt-get update
fi
apt-get install -y \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libc6-dev-arm64-cross
- name: Setup Rust
uses: ./.forgejo/actions/setup-rust
with:
github-token: ${{ secrets.GH_PUBLIC_RO }}
- name: Add Rust target
run: |
TARGET=${{ matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}
rustup target add $TARGET
- name: Configure cross-compilation for arm64
if: matrix.arch == 'arm64'
run: |
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> $GITHUB_ENV
# rust-rocksdb's build script probes liburing via pkg-config, which
# refuses to run when host != target unless explicitly allowed
echo "PKG_CONFIG_ALLOW_CROSS=1" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig" >> $GITHUB_ENV
- name: Get package version and component
id: package-meta
run: |
@@ -169,16 +124,17 @@ jobs:
- name: Run cargo-deb
id: cargo-deb
run: |
TARGET=${{ matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}
DEB_PATH=$(cargo deb --target $TARGET --deb-version ${{ steps.package-meta.outputs.version }})
DEB_PATH=$(cargo deb --deb-version ${{ steps.package-meta.outputs.version }})
echo "path=$DEB_PATH" >> $GITHUB_OUTPUT
- name: Test deb installation
if: matrix.arch == 'amd64'
run: |
echo "Installing: ${{ steps.cargo-deb.outputs.path }}"
apt-get install -y ${{ steps.cargo-deb.outputs.path }}
dpkg -s continuwuity
[ -f /usr/bin/conduwuit ] && echo "✅ Binary installed successfully"
[ -f /usr/lib/systemd/system/conduwuit.service ] && echo "✅ Systemd service installed"
[ -f /etc/conduwuit/conduwuit.toml ] && echo "✅ Config file installed"
@@ -186,7 +142,7 @@ jobs:
- name: Upload deb artifact
uses: forgejo/upload-artifact@v4
with:
name: continuwuity-${{ steps.debian-version.outputs.distribution }}-${{ matrix.arch }}
name: continuwuity-${{ steps.debian-version.outputs.distribution }}
path: ${{ steps.cargo-deb.outputs.path }}
- name: Publish to Forgejo package registry
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
name: Renovate
runs-on: ubuntu-latest
container:
image: ghcr.io/renovatebot/renovate:43.281.1@sha256:34c2dd58f58e8976be2024a24fec23bbee805f0bf887837d9aaee7daeb09ccfc
image: ghcr.io/renovatebot/renovate:43.272.6@sha256:e9dee374e7a32827af434362c6e503aa179168a96a8212cc4a5c64bb5c550142
options: --tmpfs /tmp:exec
steps:
- name: Checkout
Generated
+163 -173
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -342,8 +342,9 @@ version = "1.1.1"
# Used for matrix spec type definitions and helpers
[workspace.dependencies.ruma]
git = "https://github.com/ruma/ruma.git"
rev = "e7284c31da289f0a3b885191f5b2b6b307fa059f"
# version = "0.14.1"
git = "https://github.com/gingershaped/ruwuma.git"
rev = "66811f042b1bdc147dab3c45f697383e902f28c0"
features = [
"appservice-api-c",
"client-api",
@@ -356,6 +357,7 @@ features = [
"compat-upload-signatures",
"compat-optional-txn-pdus",
"compat-get-3pids",
"unstable-msc2666",
"unstable-msc2867",
"unstable-msc2870",
"unstable-msc3061",
@@ -378,6 +380,7 @@ features = [
"unstable-msc4406",
"unstable-msc4439",
"unstable-msc4466",
"unstable-msc4484",
"unstable-extensible-events",
]
@@ -501,7 +504,7 @@ default-features = false
version = "0.1"
[workspace.dependencies.syn]
version = "3.0"
version = "2.0"
default-features = false
features = ["full", "extra-traits"]
-1
View File
@@ -1 +0,0 @@
Added support for the stable mutual rooms query endpoint. Contributed by @ginger
-1
View File
@@ -1 +0,0 @@
Build and publish arm64 .deb packages alongside amd64 for all supported Debian and Ubuntu releases.
-1
View File
@@ -1 +0,0 @@
Dehydrated devices are now visible in the account panel. Contributed by @ginger.
-1
View File
@@ -1 +0,0 @@
Added an admin command to issue an access token for a bot account, to allow legacy bots to function while legacy authentication is disabled.
-1
View File
@@ -1 +0,0 @@
Re-introduced admin room registration alerts that were accidentally removed in the OAuth2 update.
+1 -1
View File
@@ -3,7 +3,7 @@
use service::registration_tokens::TokenExpires;
impl crate::Context<'_> {
pub(super) async fn issue_registration_token(&self, expires: super::TokenExpires) -> Result {
pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
let expires = {
if expires.immortal {
None
+1 -1
View File
@@ -10,7 +10,7 @@
pub enum TokenCommand {
/// Issue a new registration token
#[clap(name = "issue")]
IssueRegistrationToken {
IssueToken {
/// When this token will expire.
#[command(flatten)]
expires: TokenExpires,
+16 -33
View File
@@ -20,7 +20,7 @@
tag::{TagEvent, TagEventContent, TagInfo},
},
};
use service::users::{AccountStatus, DeviceToken, HashedPassword};
use service::users::{AccountStatus, HashedPassword};
use crate::{
get_room_info,
@@ -59,42 +59,13 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
self.services
.users
.create_local_account(
&user_id,
Some(HashedPassword::new(password)?),
None,
None,
None,
)
.create_local_account(&user_id, Some(HashedPassword::new(password)?), None)
.await?;
self.write_str(&format!("Created user {user_id} with password `{password}`"))
.await
}
pub(super) async fn issue_access_token(&self, username: String, password: String) -> Result {
let user_id = parse_active_local_user_id(self.services, &username).await?;
let user_id = self
.services
.users
.check_password(&user_id, &password)
.await?;
let token = DeviceToken::new_random();
let device_id = self
.services
.users
.create_device(&user_id, None, Some(token.clone()), None, None)
.await?;
self.write_str(&format!(
"Created device `{device_id}` with access token `{}` for {user_id}",
token.into_token()
))
.await
}
pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) -> Result {
// Validate user id
let user_id = parse_local_user_id(self.services, &user_id)?;
@@ -131,6 +102,7 @@ pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) ->
}
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?;
if user_id == self.services.globals.server_user {
@@ -143,7 +115,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."))
@@ -151,6 +123,7 @@ pub(super) async fn suspend(&self, user_id: String) -> Result {
}
pub(super) async fn unsuspend(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -962,6 +935,7 @@ pub(super) async fn force_leave_remote_room(
}
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?;
if user_id == self.services.globals.server_user {
@@ -974,7 +948,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."))
@@ -982,6 +956,7 @@ pub(super) async fn lock(&self, user_id: String) -> Result {
}
pub(super) async fn unlock(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.users.unlock_account(&user_id).await;
@@ -991,6 +966,7 @@ pub(super) async fn unlock(&self, user_id: String) -> Result {
}
pub(super) async fn logout(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -1016,6 +992,7 @@ pub(super) async fn logout(&self, user_id: String) -> Result {
}
pub(super) async fn disable_login(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
@@ -1034,6 +1011,7 @@ pub(super) async fn disable_login(&self, user_id: String) -> Result {
}
pub(super) async fn enable_login(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.users.enable_login(&user_id);
@@ -1042,6 +1020,7 @@ pub(super) async fn enable_login(&self, user_id: String) -> Result {
}
pub(super) async fn get_email(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_local_user_id(self.services, &user_id)?;
match self
@@ -1060,6 +1039,8 @@ pub(super) async fn get_email(&self, user_id: String) -> Result {
}
pub(super) async fn get_user_by_email(&self, email: String) -> Result {
self.bail_restricted()?;
let Ok(email) = Address::try_from(email) else {
return Err!("Invalid email address.");
};
@@ -1082,6 +1063,8 @@ pub(super) async fn get_user_by_email(&self, email: String) -> Result {
}
pub(super) async fn change_email(&self, user_id: String, email: Option<String>) -> Result {
self.bail_restricted()?;
let user_id = parse_local_user_id(self.services, &user_id)?;
let Ok(new_email) = email.map(Address::try_from).transpose() else {
return Err!("Invalid email address.");
-9
View File
@@ -18,15 +18,6 @@ pub enum UserCommand {
password: Option<String>,
},
/// Issue an access token for a user. This command will not work on
/// shadow users, such as appservice puppets or accounts imported from
/// an identity provider.
#[clap(name = "issue-token")]
IssueAccessToken {
username: String,
password: String,
},
/// Reset user password
ResetPassword {
/// Log out existing sessions
+2
View File
@@ -62,6 +62,8 @@ zstd_compression = [
"reqwest/zstd",
]
admin_api = []
[dependencies]
async-trait.workspace = true
axum-extra.workspace = true
-1
View File
@@ -1 +0,0 @@
pub mod rooms;
-36
View File
@@ -1,36 +0,0 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::StreamExt;
use ruma::OwnedRoomId;
use ruminuwuity::admin::continuwuity::rooms;
use crate::Ruma;
/// # `GET /_continuwuity/admin/rooms/list`
///
/// Lists all rooms known to this server, excluding banned ones.
pub(crate) async fn list_rooms(
State(services): State<crate::State>,
body: Ruma<rooms::list::v1::Request>,
) -> Result<rooms::list::v1::Response> {
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<OwnedRoomId> = services
.rooms
.metadata
.iter_ids()
.filter_map(|room_id| async move {
if !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.collect()
.await;
rooms.sort();
Ok(rooms::list::v1::Response::new(rooms))
}
-2
View File
@@ -1,2 +0,0 @@
pub mod ban;
pub mod list;
+1
View File
@@ -25,6 +25,7 @@
};
use service::{mailer::messages, uiaa::UiaaInitiator, users::HashedPassword};
use super::DEVICE_ID_LENGTH;
use crate::{Ruma, router::ClientIdentity};
pub(crate) mod register;
+14 -11
View File
@@ -1,7 +1,10 @@
use std::collections::HashMap;
use axum::extract::State;
use conduwuit::{Err, Result, debug_info, info};
use conduwuit::{
Err, Result, debug_info, info,
utils::{self},
};
use conduwuit_service::Services;
use futures::StreamExt;
use lettre::{Address, message::Mailbox};
@@ -21,6 +24,7 @@
users::{DeviceToken, HashedPassword},
};
use super::DEVICE_ID_LENGTH;
use crate::{Ruma, client_ip::ClientIp};
/// # `POST /_matrix/client/v3/register`
@@ -95,13 +99,7 @@ pub(crate) async fn register_route(
services
.users
.create_local_account(
&user_id,
Some(password),
identity.email,
Some(&client),
body.initial_device_display_name.as_deref(),
)
.create_local_account(&user_id, Some(password), identity.email)
.await?;
user_id
@@ -116,21 +114,26 @@ pub(crate) async fn register_route(
)));
}
// Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
// Generate new token for the device
let new_token = DeviceToken::new_random();
// Create device for this account
let device_id = services
services
.users
.create_device(
&user_id,
body.device_id.clone(),
&device_id,
Some(new_token.clone()),
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
.await?;
(Some(new_token), Some(device_id))
} else {
// Don't create a device for inhibited logins
+7 -23
View File
@@ -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<crate::State>,
body: Ruma<is_user_locked::v1::Request>,
) -> Result<is_user_locked::v1::Response> {
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,14 @@ pub(crate) async fn put_lock_status(
State(services): State<crate::State>,
body: Ruma<lock_user::v1::Request>,
) -> Result<lock_user::v1::Response> {
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),
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")));
}
let (status, target_admin) =
join!(services.users.status(&body.user_id), services.users.is_admin(&body.user_id),);
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 +63,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;
}
+1
View File
@@ -1,4 +1,5 @@
mod lock;
pub(crate) mod site;
mod suspend;
pub(crate) use self::{lock::*, suspend::*};
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod rooms;
pub(crate) mod users;
@@ -6,7 +6,7 @@
use crate::{Ruma, client::leave_room};
/// # `PUT /_continuwuity/admin/rooms/{roomID}/ban`
/// # `PUT /_continuwuity/admin/v1/rooms/{roomID}/ban`
///
/// Bans or unbans a room.
pub(crate) async fn ban_room(
+178
View File
@@ -0,0 +1,178 @@
use axum::extract::State;
use conduwuit::{
Event, Result,
utils::stream::{BroadbandExt, WidebandExt},
};
use futures::StreamExt;
use ruma::{
OwnedRoomId,
events::{
StateEventType,
room::{
create::RoomCreateEventContent,
encryption::PossiblyRedactedRoomEncryptionEventContent,
tombstone::PossiblyRedactedRoomTombstoneEventContent,
},
},
};
use ruminuwuity::admin::continuwuity::rooms;
use tokio::join;
use crate::Ruma;
/// # `GET /_continuwuity/admin/rooms`
///
/// Lists all room IDs known to this server, excluding banned ones.
///
/// This is the legacy version of the endpoint, which does not support
/// 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(
State(services): State<crate::State>,
_body: Ruma<rooms::list::unstable::Request>,
) -> Result<rooms::list::unstable::Response> {
let mut rooms: Vec<OwnedRoomId> = services
.rooms
.metadata
.iter_ids()
.filter_map(|room_id| async move {
if !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.collect()
.await;
rooms.sort();
Ok(rooms::list::unstable::Response::new(rooms))
}
/// # `GET /_continuwuity/admin/v1/rooms`
///
/// Lists rooms known to this server.
pub(crate) async fn list_rooms(
State(services): State<crate::State>,
body: Ruma<rooms::list::v1::Request>,
) -> Result<rooms::list::v1::Response> {
let include_banned_rooms = body.include_banned_rooms;
let rooms = services
.rooms
.metadata
.iter_ids()
.wide_filter_map(|room_id| async move {
if include_banned_rooms || !services.rooms.metadata.is_banned(&room_id).await {
Some(room_id.clone())
} else {
None
}
})
.skip(body.offset.unwrap_or_default())
.take(body.limit.unwrap_or(100).min(100))
.broad_filter_map(|room_id| async move {
let (
banned,
disabled,
member_count,
local_member_count,
resident_server_count,
published,
create_event,
encryption_event,
name_event,
topic_event,
canonical_alias_event,
join_rules_event,
history_visibility_event,
tombstone_event,
) = join!(
services.rooms.metadata.is_banned(&room_id),
services.rooms.metadata.is_disabled(&room_id),
services.rooms.state_cache.room_joined_count(&room_id),
services
.rooms
.state_cache
.active_local_users_in_room(&room_id)
.count(),
services.rooms.state_cache.room_servers(&room_id).count(),
services.rooms.directory.is_public_room(&room_id),
services.rooms.state_accessor.room_state_get(
&room_id,
&StateEventType::RoomCreate,
""
),
services
.rooms
.state_accessor
.room_state_get_content::<PossiblyRedactedRoomEncryptionEventContent>(
&room_id,
&StateEventType::RoomEncryption,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomName,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomTopic,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomCanonicalAlias,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomJoinRules,
""
),
services.rooms.state_accessor.room_state_get_content(
&room_id,
&StateEventType::RoomHistoryVisibility,
""
),
services
.rooms
.state_accessor
.room_state_get_content::<PossiblyRedactedRoomTombstoneEventContent>(
&room_id,
&StateEventType::RoomTombstone,
""
),
);
let Ok(create_event) = create_event else {
return None;
};
let create_content = create_event
.get_content::<RoomCreateEventContent>()
.expect("m.room.create content must be valid");
Some(rooms::list::v1::MinimalRoomInfo {
room_id,
banned,
disabled,
member_count: usize::try_from(member_count.unwrap_or_default())
.expect("u64 should fit in usize"),
local_member_count,
resident_server_count,
creators: vec![create_event.sender],
encrypted: encryption_event.is_ok_and(|c| c.algorithm.is_some()),
federated: create_content.federate,
published,
version: create_content.room_version,
name: name_event.unwrap_or(None),
topic: topic_event.unwrap_or(None),
canonical_alias: canonical_alias_event.unwrap_or(None),
join_rules: join_rules_event.unwrap_or(None),
history_visibility: history_visibility_event.unwrap_or(None),
predecessor: create_content.predecessor.map(|c| c.room_id),
successor: tombstone_event.map_or(None, |c| c.replacement_room),
})
})
.collect()
.await;
Ok(rooms::list::v1::Response::new(rooms))
}
+5
View File
@@ -0,0 +1,5 @@
mod ban;
mod list;
pub(crate) use ban::ban_room;
pub(crate) use list::*;
+142
View File
@@ -0,0 +1,142 @@
use axum::extract::State;
use conduwuit::{
err, error, info,
utils::{IterStream, stream::BroadbandExt},
warn,
};
use futures::{FutureExt, StreamExt};
use ruma::{api::client::profile::PropagateTo, profile::ProfileFieldValue};
use ruminuwuity::admin::continuwuity::users;
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(
State(services): State<crate::State>,
body: Ruma<users::create::v1::Request>,
) -> conduwuit::Result<users::create::v1::Response> {
let email = body
.email
.clone()
.map(lettre::Address::try_from)
.transpose()
.map_err(|e| err!(Request(BadJson("Invalid email address: {e}"))))?;
let ref user_id = services
.users
.determine_registration_user_id(Some(body.localpart.clone()), email.as_ref(), None)
.await?;
services.users.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, body.identity.sender_user())
.await;
}
if body.locked {
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_field(
user_id,
ProfileFieldChange::Set(ProfileFieldValue::DisplayName(value.to_owned())),
PropagateTo::None,
)
.await?;
}
if let Some(ref value) = body.avatar_url {
services
.users
.set_profile_field(
user_id,
ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(value.to_owned())),
PropagateTo::None,
)
.await?;
}
if body.admin {
services
.admin
.make_user_admin(user_id)
.await
.inspect_err(|e| error!("failed to make new user {user_id} an admin: {e}"))
.ok();
}
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
.alias
.resolve_with_servers(&room, None)
.await
.inspect_err(|e| {
warn!(
"Failed to resolve room alias to room ID when attempting to auto join \
{room}: {e}"
);
})
.ok()
})
.for_each_concurrent(None, |(room_id, servers)| async move {
match services
.rooms
.membership
.join_room(
user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
servers.as_ref(),
)
.boxed()
.await
{
| Err(e) => {
warn!("Failed to automatically join {user_id} to {room_id}: {e}");
},
| _ => {
info!("Automatically joined room {user_id} to {room_id}");
},
}
})
.await;
Ok(users::create::v1::Response::new(user_id.to_owned()))
}
+42
View File
@@ -0,0 +1,42 @@
use axum::extract::State;
use conduwuit::utils::stream::WidebandExt;
use futures::StreamExt;
use ruminuwuity::admin::continuwuity::users;
use tokio::join;
use crate::router::Ruma;
/// # `GET /_continuwuity/admin/v1/users`
///
/// Lists all users on this homeserver.
pub(crate) async fn list_users(
State(services): State<crate::State>,
body: Ruma<users::list::v1::Request>,
) -> conduwuit::Result<users::list::v1::Response> {
let users = services
.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 (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),
services.users.is_login_disabled(&user_id),
);
Some(users::list::v1::User {
user_id: user_id.clone(),
deactivated: !status.is_active(),
suspended: suspended.unwrap_or_default(),
locked: locked.unwrap_or_default(),
admin,
login_disabled,
})
})
.collect()
.await;
Ok(users::list::v1::Response::new(users))
}
+5
View File
@@ -0,0 +1,5 @@
mod create;
mod list;
pub(crate) use create::*;
pub(crate) use list::*;
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -88,7 +88,7 @@ pub(crate) async fn update_device_route(
.users
.create_device(
sender_user,
Some(body.device_id.clone()),
&body.device_id,
None,
body.display_name.clone(),
Some(client.to_string()),
+3
View File
@@ -90,5 +90,8 @@
pub(super) use voip::*;
pub(super) use well_known::*;
/// generated device ID length
const DEVICE_ID_LENGTH: usize = 10;
/// generated user access token length
const TOKEN_LENGTH: usize = 32;
+3 -33
View File
@@ -1,7 +1,7 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::StreamExt;
use ruma::{OwnedRoomId, api::client::membership::mutual_rooms};
use ruma::api::client::membership::mutual_rooms;
use crate::Ruma;
@@ -11,14 +11,14 @@
///
/// An implementation of [MSC2666](https://github.com/matrix-org/matrix-spec-proposals/pull/2666)
#[tracing::instrument(skip_all, name = "mutual_rooms", level = "info")]
pub(crate) async fn get_mutual_rooms_unstable_route(
pub(crate) async fn get_mutual_rooms_route(
State(services): State<crate::State>,
body: Ruma<mutual_rooms::unstable::Request>,
) -> Result<mutual_rooms::unstable::Response> {
let sender_user = body.identity.expect_sender_user()?;
if sender_user == body.user_id {
return Err!(Request(InvalidParam("You cannot request rooms in common with yourself.")));
return Err!(Request(Unknown("You cannot request rooms in common with yourself.")));
}
let mutual_rooms = services
@@ -30,33 +30,3 @@ pub(crate) async fn get_mutual_rooms_unstable_route(
Ok(mutual_rooms::unstable::Response::new(mutual_rooms))
}
/// # `GET /_matrix/client/v1/mutual_rooms`
///
/// Gets all the rooms the sender shares with the specified user.
#[tracing::instrument(skip_all, name = "mutual_rooms", level = "info")]
pub(crate) async fn get_mutual_rooms_route(
State(services): State<crate::State>,
body: Ruma<mutual_rooms::v1::Request>,
) -> Result<mutual_rooms::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
if sender_user == body.user_id {
return Err!(Request(InvalidParam("You cannot request rooms in common with yourself.")));
}
let mutual_rooms: Vec<OwnedRoomId> = services
.rooms
.state_cache
.get_shared_rooms(sender_user, &body.user_id)
.collect()
.await;
Ok(mutual_rooms::v1::Response::new(
mutual_rooms
.len()
.try_into()
.expect("user should be in fewer than 9.1 quadrillion rooms"),
mutual_rooms,
))
}
+12
View File
@@ -29,6 +29,12 @@ pub(crate) async fn get_profile_route(
State(services): State<crate::State>,
body: Ruma<get_profile::v3::Request>,
) -> Result<get_profile::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to view user profiles."
)));
}
let Some(profile) = fetch_full_profile(&services, &body.user_id).await else {
return Err!(Request(NotFound("This user's profile could not be fetched.")));
};
@@ -40,6 +46,12 @@ pub(crate) async fn get_profile_field_route(
State(services): State<crate::State>,
body: Ruma<get_profile_field::v3::Request>,
) -> Result<get_profile_field::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to view user profiles."
)));
}
let value = fetch_profile_field(&services, &body.user_id, body.field.clone()).await?;
Ok(assign!(get_profile_field::v3::Response::default(), { value }))
+16 -11
View File
@@ -3,7 +3,7 @@
use axum::extract::State;
use conduwuit::{
Err, Result, debug, err, info,
utils::{ReadyExt, stream::BroadbandExt},
utils::{self, ReadyExt, stream::BroadbandExt},
warn,
};
use conduwuit_service::Services;
@@ -30,6 +30,7 @@
};
use service::users::DeviceToken;
use super::DEVICE_ID_LENGTH;
use crate::{Ruma, client_ip::ClientIp};
/// # `GET /_matrix/client/v3/login`
@@ -188,39 +189,43 @@ pub(crate) async fn login_route(
},
};
// Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
// Generate a new token for the device
let token = DeviceToken::new_random();
// Determine if device_id was provided and exists in the db for this user
let existing_device_id = if let Some(device_id) = &body.device_id {
let device_exists = if body.device_id.is_some() {
services
.users
.all_device_ids(&user_id)
.ready_find(|v| v == device_id)
.ready_any(|v| v == device_id)
.await
} else {
None
false
};
let device_id = if let Some(existing_device_id) = existing_device_id {
if device_exists {
services
.users
.set_token(&user_id, &existing_device_id, token.clone())
.set_token(&user_id, &device_id, token.clone())
.await?;
existing_device_id
} else {
services
.users
.create_device(
&user_id,
body.device_id.clone(),
&device_id,
Some(token.clone()),
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
.await?
};
.await?;
}
// send client well-known if specified so the client knows to reconfigure itself
let client_discovery_info: Option<DiscoveryInfo> = services
-2
View File
@@ -13,8 +13,6 @@
pub mod client_ip;
pub mod admin;
pub(crate) use self::router::{Ruma, RumaResponse, State};
conduwuit::mod_ctor! {}
+21 -4
View File
@@ -16,7 +16,12 @@
use self::handler::RouterExt;
pub(super) use self::{args::Args as Ruma, auth::ClientIdentity, response::RumaResponse};
use crate::{admin, client, server};
#[cfg(feature = "admin_api")]
use crate::client::admin::site as admin_api;
use crate::{
client::{self, admin},
server,
};
pub fn build(router: Router<State>, state: State) -> Router<State> {
let config = &state.server.config;
@@ -177,7 +182,6 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.ruma_route(&client::get_relating_events_with_rel_type_route)
.ruma_route(&client::get_relating_events_route)
.ruma_route(&client::get_hierarchy_route)
.ruma_route(&client::get_mutual_rooms_unstable_route)
.ruma_route(&client::get_mutual_rooms_route)
.ruma_route(&client::get_room_summary)
.ruma_route(&client::get_suspended_status)
@@ -192,8 +196,11 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.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
@@ -276,6 +283,16 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.route("/_matrix/media/r0/preview_url", any(redirect_legacy_preview));
}
#[cfg(feature = "admin_api")]
{
router = router
.ruma_route(&admin_api::users::list_users_route)
.ruma_route(&admin_api::users::create_user_route)
.ruma_route(&admin_api::rooms::ban_room)
.ruma_route(&admin_api::rooms::legacy_list_rooms_route)
.ruma_route(&admin_api::rooms::list_rooms_route);
};
router
}
+221 -186
View File
@@ -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,68 +80,67 @@ 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;
fn authenticate<R: IncomingRequest + Any, B: AsRef<[u8]> + Sync>(
fn authenticate<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
incoming_request: &hyper::Request<B>,
query: AuthQueryParams,
) -> impl Future<Output = Result<Self::Identity>> + Send {
async move {
let route = TypeId::of::<R>();
let output = Self::extract_authentication(incoming_request).map_err(|err| {
err!(Request(Unauthorized(warn!(
"Failed to extract authorization: {}",
"Failed to extract request authentication: {}",
err.into()
))))
})?;
Self::verify(services, output, incoming_request, query, route).await
Self::verify::<R, B>(services, output, incoming_request, query)
.await
}
}
fn verify<B: AsRef<[u8]> + Sync>(
fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> impl Future<Output = Result<Self::Identity>> + Send;
}
impl CheckAuth for ServerSignatures {
type Identity = OwnedServerName;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
let destination = services.globals.server_name();
if output
.destination
.as_ref()
.is_some_and(|supplied_destination| supplied_destination != destination)
{
return Err!(Request(Unauthorized("Destination mismatch.")));
}
let key = services
.server_keys
.get_verify_key(&output.origin, &output.key)
.await
.map_err(|e| {
err!(Request(Unauthorized(warn!("Failed to fetch signing keys: {e}"))))
.map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to fetch signing keys: {err}"))))
})?;
let keys: PubKeys = [(output.key.to_string(), key.key)].into();
let keys: PubKeyMap = [(output.origin.as_str().into(), keys)].into();
match output.verify_request(request, destination, &keys) {
match output.verify_request(request, services.globals.server_name(), &keys) {
| Ok(()) => {
if services
.moderation
@@ -160,61 +162,194 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for AccessToken {
type Identity = ClientIdentity;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
if output.is_empty() {
return Err!(Request(Unauthorized("Missing access token.")));
verify_access_token(services, output, query, TypeId::of::<R>(), R::required_scopes())
.await
}
}
impl CheckAuth for AccessTokenOptional {
type Identity = Option<ClientIdentity>;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
query: AuthQueryParams,
) -> Result<Self::Identity> {
match output {
| Some(token) => verify_access_token(
services,
token,
query,
TypeId::of::<R>(),
R::required_scopes(),
)
.await
.map(Some),
| None => Ok(None),
}
if let Some((sender_user, sender_device, status)) =
services.users.find_from_token(&output).await
{
// If the token is expired we return a soft logout
if matches!(status, AccessTokenStatus::Expired) {
return Err(Error::Request(
ErrorKind::UnknownToken(
assign!(UnknownTokenErrorData::new(), { soft_logout: true }),
),
"This token has expired".into(),
StatusCode::UNAUTHORIZED,
));
}
}
}
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
impl CheckAuth for AppserviceToken {
type Identity = RegistrationInfo;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
) -> Result<Self::Identity> {
verify_appservice_access_token(services, output).await
}
}
impl CheckAuth for AppserviceTokenOptional {
type Identity = Option<RegistrationInfo>;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
) -> Result<Self::Identity> {
match output {
| Some(token) => verify_appservice_access_token(services, token)
.await
.is_ok_and(std::convert::identity)
.map(Some),
| None => Ok(None),
}
}
}
impl CheckAuth for NoAuthentication {
type Identity = ();
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
_services: &Services,
_output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
) -> Result<Self::Identity> {
Ok(())
}
}
impl CheckAuth for NoAccessToken {
type Identity = Option<ClientIdentity>;
async fn verify<R: IncomingRequest<Authentication = Self> + Any, B: AsRef<[u8]> + Sync>(
services: &Services,
_output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
) -> Result<Self::Identity> {
// We handle these the same as AccessTokenOptional
let token = AccessTokenOptional::extract_authentication(request).map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to extract authorization: {}", err))))
})?;
match token {
| Some(token) => verify_access_token(
services,
token,
query,
TypeId::of::<R>(),
// Assume that no scopes are required for these endpoints since
// ostensibly they don't require authentication
&[],
)
.await
.map(Some),
| None => Ok(None),
}
}
}
async fn verify_access_token(
services: &Services,
output: String,
query: AuthQueryParams,
route: TypeId,
required_scopes: &[OAuthClientScope],
) -> Result<ClientIdentity> {
if let Some((sender_user, sender_device, status)) =
services.users.find_from_token(&output).await
{
// If the token is expired we return a soft logout
if matches!(status, AccessTokenStatus::Expired) {
return Err(Error::Request(
ErrorKind::UnknownToken(
assign!(UnknownTokenErrorData::new(), { soft_logout: true }),
),
"This access token has expired.".into(),
StatusCode::UNAUTHORIZED,
));
}
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(UserLocked("Your account is locked.")));
}
return Err!(Request(UserLocked("Your account is locked.")));
}
}
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(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in namespace.")));
// 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()
.all(|scope| !session.scopes.contains(scope))
{
return Err!(Request(Forbidden(
"You don't have the necessary scopes to use this endpoint."
)));
}
} else {
// Otherwise, explicitly check 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(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in this appservice's namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
@@ -232,10 +367,7 @@ async fn verify<B: AsRef<[u8]> + Sync>(
.await
.is_err()
{
return Err!(Request(Forbidden(
"Device does not exist for user or appservice cannot masquerade as this \
device."
)));
return Err!(Request(Forbidden("Appservice cannot masquerade as this device.")));
}
Some(device_id.to_owned())
@@ -243,124 +375,27 @@ async fn verify<B: AsRef<[u8]> + Sync>(
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid token".into(),
StatusCode::UNAUTHORIZED,
))
}
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid access token.".into(),
StatusCode::UNAUTHORIZED,
))
}
}
impl CheckAuth for AccessTokenOptional {
type Identity = Option<ClientIdentity>;
async fn verify_appservice_access_token(
services: &Services,
output: String,
) -> Result<RegistrationInfo> {
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
match output {
| Some(token) =>
<AccessToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| None => Ok(None),
}
}
}
impl CheckAuth for AppserviceToken {
type Identity = RegistrationInfo;
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
if output.is_empty() {
return Err!(Request(Unauthorized("Missing access token.")));
}
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
}
}
impl CheckAuth for AppserviceTokenOptional {
type Identity = Option<RegistrationInfo>;
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
match output {
| Some(token) =>
<AppserviceToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| None => Ok(None),
}
}
}
impl CheckAuth for NoAuthentication {
type Identity = ();
async fn verify<B: AsRef<[u8]> + Sync>(
_services: &Services,
_output: Self::Output,
_request: &hyper::Request<B>,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
Ok(())
}
}
impl CheckAuth for NoAccessToken {
type Identity = Option<ClientIdentity>;
async fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
_output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
// We handle these the same as AccessTokenOptional
let token = AccessTokenOptional::extract_authentication(request).map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to extract authorization: {}", err))))
})?;
// Check special access restrictions
if (route == TypeId::of::<client::profile::get_avatar_url::v3::Request>()
|| route == TypeId::of::<client::profile::get_display_name::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile_field::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile::v3::Request>())
&& services.config.require_auth_for_profile_requests
&& token.is_none()
{
return Err!(Request(Unauthorized(
"This server requires authentication to access user profiles."
)));
}
<AccessTokenOptional as CheckAuth>::verify(services, token, request, query, route).await
}
Ok(appservice_info)
}
+2 -1
View File
@@ -34,7 +34,6 @@ pub fn unstable_features() -> BTreeMap<String, bool> {
// query mutual rooms (https://github.com/matrix-org/matrix-spec-proposals/pull/2666)
// Expected for spec v1.19
("uk.half-shot.msc2666.query_mutual_rooms".to_owned(), true),
("uk.half-shot.msc2666.query_mutual_rooms.stable".to_owned(), true),
// Simplified Sliding sync (https://github.com/matrix-org/matrix-spec-proposals/pull/4186)
// Expected for spec v1.19
("org.matrix.simplified_msc3575".to_owned(), true),
@@ -42,5 +41,7 @@ pub fn unstable_features() -> BTreeMap<String, bool> {
("org.matrix.msc4155".to_owned(), true),
// profile change propagation (https://github.com/matrix-org/matrix-spec-proposals/pull/4466)
("computer.gingershaped.msc4466".to_owned(), true),
// server admin oauth scope (https://github.com/matrix-org/matrix-spec-proposals/pull/4484)
("org.continuwuity.msc4484.unstable".to_owned(), true),
])
}
+2 -4
View File
@@ -35,10 +35,7 @@ systemd-units = { unit-name = "conduwuit", start = false, unit-scripts = "../../
assets = [
["../../pkg/debian/README.md", "usr/share/doc/conduwuit/README.Debian", "644"],
["../../README.md", "usr/share/doc/conduwuit/", "644"],
# cargo-deb only treats the exact prefix "target/release/" as the magic
# path that resolves to the real build dir (e.g. target/<triple>/release
# when cross-compiling); a ../../ prefix would be read as a literal path
["target/release/conduwuit", "usr/bin/conduwuit", "755"],
["../../target/release/conduwuit", "usr/bin/conduwuit", "755"],
["../../conduwuit-example.toml", "etc/conduwuit/conduwuit.toml", "640"],
]
@@ -71,6 +68,7 @@ full = [
"jemalloc_prof",
"perf_measurements",
"tokio_console",
"conduwuit-api/admin_api",
]
brotli_compression = [
@@ -1 +1,2 @@
pub mod rooms;
pub mod users;
@@ -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,8 +9,10 @@ pub mod v1 {
method: PUT,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthClientScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/rooms/{room_id}/ban",
unstable("org.continuwuity.admin") => "/_continuwuity/admin/rooms/{room_id}/ban",
1.0 => "/_continuwuity/admin/v1/rooms/{room_id}/ban",
}
}
@@ -29,8 +31,11 @@ pub struct Request {
#[response]
pub struct Response {
/// Users who were successfully kicked from this room.
pub kicked_users: Vec<OwnedUserId>,
/// Users who could not be kicked from the room.
pub failed_kicked_users: Vec<OwnedUserId>,
/// Any local aliases that were removed from the room.
pub local_aliases: Vec<OwnedRoomAliasId>,
}
@@ -1,7 +1,7 @@
pub mod v1 {
pub mod unstable {
use ruma::{
OwnedRoomId,
api::{auth_scheme::AccessToken, request, response},
api::{OAuthClientScope, auth_scheme::AccessToken, request, response},
metadata,
};
@@ -9,8 +9,9 @@ pub mod v1 {
method: GET,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthClientScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/rooms/list",
unstable => "/_continuwuity/admin/rooms/list",
}
}
@@ -20,6 +21,7 @@ pub mod v1 {
#[response]
pub struct Response {
/// A list of room IDs known to this server.
pub rooms: Vec<OwnedRoomId>,
}
@@ -33,3 +35,133 @@ impl Response {
pub fn new(rooms: Vec<OwnedRoomId>) -> Self { Self { rooms } }
}
}
pub mod v1 {
use ruma::{
OwnedRoomId, OwnedUserId, RoomVersionId,
api::{auth_scheme::AccessToken, request, response},
events::room::{
canonical_alias::PossiblyRedactedRoomCanonicalAliasEventContent,
history_visibility::PossiblyRedactedRoomHistoryVisibilityEventContent,
join_rules::PossiblyRedactedRoomJoinRulesEventContent,
name::PossiblyRedactedRoomNameEventContent,
topic::PossiblyRedactedRoomTopicEventContent,
},
metadata,
serde::{default_true, is_default},
};
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
1.0 => "/_continuwuity/admin/v1/rooms",
}
}
#[request]
#[derive(Default)]
pub struct Request {
/// The maximum number of results to return in this page. Maximum (and
/// default) is 100.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub limit: Option<usize>,
/// The number of results to skip over before returning results. Default
/// is 0.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub offset: Option<usize>,
/// If true, includes banned rooms in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "is_default")]
pub include_banned_rooms: bool,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MinimalRoomInfo {
/// The room's unique ID.
pub room_id: OwnedRoomId,
/// If true, this room is banned, and cannot be joined by non-admins.
#[serde(default, skip_serializing_if = "is_default")]
pub banned: bool,
/// If true, this room has federation disabled, but can still be locally
/// used.
#[serde(default, skip_serializing_if = "is_default")]
pub disabled: bool,
/// The total number of joined members in this room.
#[serde(default, skip_serializing_if = "is_default")]
pub member_count: usize,
/// The total number of joined members in this room that are local to
/// this server.
#[serde(default, skip_serializing_if = "is_default")]
pub local_member_count: usize,
/// The number of unique homeservers currently joined to this room.
#[serde(default, skip_serializing_if = "is_default")]
pub resident_server_count: usize,
/// The users who created this room.
///
/// The first entry is always the sender of the `m.room.create` event.
/// Any entries thereafter are additional creators in v12+ rooms. An
/// empty vec indicates the room is not known.
#[serde(default, skip_serializing_if = "is_default")]
pub creators: Vec<OwnedUserId>,
/// If true, this room has encryption enabled.
#[serde(default, skip_serializing_if = "is_default")]
pub encrypted: bool,
/// If true, this room is allowed to be federated (`m.federate` is not
/// `false` in `m.room.create`).
#[serde(default = "default_true", skip_serializing_if = "is_default")]
pub federated: bool,
/// If true, this room is published to this server's room directory.
#[serde(default, skip_serializing_if = "is_default")]
pub published: bool,
/// The version of the room.
pub version: RoomVersionId,
/// The event content for the `m.room.name` event, if any is present.
/// May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<PossiblyRedactedRoomNameEventContent>,
/// The event content for the `m.room.topic` event, if any is present.
/// May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<PossiblyRedactedRoomTopicEventContent>,
/// The event content for the `m.room.canonical_alias` event, if any is
/// present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_alias: Option<PossiblyRedactedRoomCanonicalAliasEventContent>,
/// The event content for the `m.room.join_rules` event, if any is
/// present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub join_rules: Option<PossiblyRedactedRoomJoinRulesEventContent>,
/// The event content for the `m.room.history_visibility` event, if any
/// is present. May be redacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_visibility: Option<PossiblyRedactedRoomHistoryVisibilityEventContent>,
/// The ID of the room which replaces this one, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub successor: Option<OwnedRoomId>,
/// The ID of the room which preceded this one, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predecessor: Option<OwnedRoomId>,
}
#[response]
pub struct Response {
/// A list of rooms known to this server.
pub rooms: Vec<MinimalRoomInfo>,
}
impl Request {
#[must_use]
pub fn new() -> Self { Self::default() }
}
impl Response {
#[must_use]
pub fn new(rooms: Vec<MinimalRoomInfo>) -> Self { Self { rooms } }
}
}
@@ -0,0 +1,105 @@
pub mod v1 {
use ruma::{
OwnedMxcUri, OwnedRoomOrAliasId, OwnedUserId,
api::{OAuthClientScope, auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: POST,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthClientScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/v1/users/create",
},
}
#[request]
pub struct Request {
/// The user's localpart (the identifier between `@` and `:`). Cannot be
/// blank.
pub localpart: String,
/// The user's desired password. Cannot be blank.
pub password: String,
/// The user's email address, if any.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub email: Option<String>,
/// The display name to set upon creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub display_name: Option<String>,
/// The avatar URI to set upon creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub avatar_url: Option<OwnedMxcUri>,
/// Suspends the user immediately upon creation. They can still log in.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub suspended: bool,
/// Locks the user immediately upon creation. They will receive
/// M_USER_LOCKED upon login.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub locked: bool,
/// Disables the user's login immediately upon creation.
///
/// The user can still be used if an admin generates an access token for
/// the account, but the user will not be able to use `POST
/// /_matrix/client/v3/login`.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub login_disabled: bool,
/// Promotes the user to a server administrator immediately upon
/// creation.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub admin: bool,
/// Skips joining rooms in the server's configured auto_join_rooms.
///
/// If this is false, all rooms in the config.toml's `auto_join_rooms`
/// will be automatically joined upon creation. If `auto_join_rooms`
/// is supplied in this request too, those rooms will be joined
/// afterwards.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub skip_auto_join: bool,
/// Additional rooms to auto-join the new user to. If `skip_auto_join`
/// is `true`, these rooms will still be joined.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub auto_join_rooms: Vec<OwnedRoomOrAliasId>,
}
#[response]
pub struct Response {
/// The fully qualified user ID of the newly created user.
pub user_id: OwnedUserId,
}
impl Request {
#[must_use]
pub fn new(localpart: String, password: String) -> Self {
Self {
localpart,
password,
email: None,
display_name: None,
avatar_url: None,
suspended: false,
locked: false,
login_disabled: false,
admin: false,
skip_auto_join: false,
auto_join_rooms: Vec::new(),
}
}
}
impl Response {
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self { Self { user_id } }
}
}
@@ -0,0 +1,139 @@
pub mod v1 {
use ruma::{
OwnedUserId,
api::{OAuthClientScope, auth_scheme::AccessToken, request, response},
metadata,
};
use serde::Deserialize;
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
required_scopes: [OAuthClientScope::ServerAdministration],
history: {
1.0 => "/_continuwuity/admin/v1/users",
}
}
#[request]
#[derive(Default)]
pub struct Request {
/// If true, includes deactivated users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_deactivated: bool,
/// If true, includes locked users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_locked: bool,
/// If true, includes suspended users in the response.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub include_suspended: bool,
/// The maximum number of results to return in this page. Maximum (and
/// default) is 100.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub limit: Option<usize>,
/// The number of results to skip over before returning results. Default
/// is 0.
#[ruma_api(query)]
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub offset: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)]
pub struct User {
/// The full user ID of the user.
pub user_id: OwnedUserId,
/// Whether this user is deactivated.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub deactivated: bool,
/// Whether this user is suspended.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub suspended: bool,
/// Whether this user is locked.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub locked: bool,
/// Whether this user is an admin.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub admin: bool,
/// Whether this user has their login disabled.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
pub login_disabled: bool,
}
impl User {
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self {
Self {
user_id,
deactivated: false,
suspended: false,
locked: false,
admin: false,
login_disabled: false,
}
}
}
#[response]
#[derive(Default)]
pub struct Response {
pub users: Vec<User>,
}
impl Request {
#[must_use]
pub fn new() -> Self { Self::default() }
}
impl Response {
#[must_use]
pub fn new(users: Vec<User>) -> Self { Self { users } }
}
#[cfg(test)]
mod tests {
use assign::assign;
use serde_json::json;
use super::*;
#[test]
fn request_defaults() {
let req = Request::new();
assert!(!req.include_deactivated && !req.include_locked && !req.include_suspended);
}
#[test]
fn user_serialize_omits_default_values() {
let user_id = OwnedUserId::try_from("@alice:example.org".to_owned()).unwrap();
let user = User::new(user_id.clone());
let expected = json!({ "user_id": user_id.to_string() });
assert_eq!(serde_json::to_value(&user).expect("failed to serialize user"), expected);
let suspended_user = assign!(user, {suspended: true});
let expected2 = json!({ "user_id": "@alice:example.org", "suspended": true});
assert_eq!(
serde_json::to_value(&suspended_user).expect("failed to serialize user"),
expected2
);
}
#[test]
fn response_defaults() {
let response = Response::default();
assert!(response.users.is_empty());
}
}
}
@@ -0,0 +1,2 @@
pub mod create;
pub mod list;
-53
View File
@@ -1,53 +0,0 @@
//! `GET /_matrix/client/v1/admin/suspend/{userId}`
//!
//! Check the suspension status of a target user
pub mod v1 {
//! `/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{userID}`
//! ([msc])
//!
//! [msc]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
use ruma::{
OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{user_id}",
1.18 => "/_matrix/client/v1/admin/suspend/{user_id}",
}
}
/// Request type for the get & set user suspension status endpoint.
#[request(error = ruma::api::error::Error)]
pub struct Request {
/// The user to look up.
#[ruma_api(path)]
pub user_id: OwnedUserId,
}
/// Response type for the suspension endpoints
#[response(error = ruma::api::error::Error)]
pub struct Response {
/// Whether the user is currently suspended.
pub suspended: bool,
}
impl Request {
/// Creates a new `Request` with the given user id.
#[must_use]
pub fn new(user_id: OwnedUserId) -> Self { Self { user_id } }
}
impl Response {
/// Creates a new `Response` with the given suspension status.
#[must_use]
pub fn new(suspended: bool) -> Self { Self { suspended } }
}
}
-2
View File
@@ -1,3 +1 @@
pub mod continuwuity;
pub mod get_suspended;
pub mod set_suspended;
-55
View File
@@ -1,55 +0,0 @@
//! `PUT /_matrix/client/v1/admin/suspend/{userId}`
//!
//! Set the suspension status of a target user
pub mod v1 {
//! `/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{userID}`
//! ([msc])
//!
//! [msc]: https://github.com/matrix-org/matrix-spec-proposals/pull/4323
use ruma::{
OwnedUserId,
api::{auth_scheme::AccessToken, request, response},
metadata,
};
metadata! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/uk.timedout.msc4323/admin/suspend/{user_id}",
1.18 => "/_matrix/client/v1/admin/suspend/{user_id}",
}
}
/// Request type for the set user suspension status endpoint.
#[request(error = ruma::api::error::Error)]
pub struct Request {
/// The user to look up.
#[ruma_api(path)]
pub user_id: OwnedUserId,
pub suspended: bool,
}
/// Response type for the suspension endpoints
#[response(error = ruma::api::error::Error)]
pub struct Response {
/// Whether the user is currently suspended.
pub suspended: bool,
}
impl Request {
/// Creates a new `Request` with the given user id.
#[must_use]
pub fn new(user_id: OwnedUserId, suspended: bool) -> Self { Self { user_id, suspended } }
}
impl Response {
/// Creates a new `Response` with the given suspension status.
#[must_use]
pub fn new(suspended: bool) -> Self { Self { suspended } }
}
}
+42 -16
View File
@@ -8,7 +8,7 @@
};
use regex::Regex;
use ruma::OwnedDeviceId;
use ruma::{OwnedDeviceId, api::OAuthClientScope};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -30,8 +30,15 @@ pub struct AuthorizationCodeQuery {
pub prompt: Option<Prompt>,
}
#[derive(Deserialize, Serialize)]
pub struct AuthorizationCodeResponse {
#[derive(Serialize)]
#[serde(untagged)]
pub enum AuthorizationCodeResponse {
Success(AuthorizationCodeData),
Error(OAuthError),
}
#[derive(Serialize, Deserialize)]
pub struct AuthorizationCodeData {
pub state: String,
pub code: String,
}
@@ -83,26 +90,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<OAuthClientScope> {
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<H: std::hash::Hasher>(&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)
@@ -113,22 +134,27 @@ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
pub struct RawScopes(String);
impl RawScopes {
pub fn to_scopes(&self) -> Result<BTreeSet<Scope>, String> {
let client_api_token_regex =
pub fn to_scopes(&self) -> Result<BTreeSet<RequestedScope>, 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
@@ -253,4 +279,4 @@ pub enum TokenType {
#[derive(Deserialize)]
pub struct RevokeTokenRequest {
pub token: String,
}
}
+68 -54
View File
@@ -13,7 +13,7 @@
use itertools::Itertools;
use lru_cache::LruCache;
use rand::distr::{Distribution, slice::Choose};
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId};
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId, api::OAuthClientScope};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -22,8 +22,9 @@
oauth::{
client_metadata::{ApplicationType, ClientMetadata, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, ResponseMode, Scope,
AuthorizationCodeData, AuthorizationCodeQuery, AuthorizationCodeResponse,
CodeChallengeMethod,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RequestedScope, ResponseMode,
TokenRequest, TokenRequestType, TokenResponse, TokenType,
},
},
@@ -55,7 +56,7 @@ struct Services {
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<Scope>,
pub scopes: BTreeSet<OAuthClientScope>,
current_refresh_token: String,
}
@@ -68,7 +69,7 @@ struct RefreshTokenInfo {
struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
@@ -92,7 +93,7 @@ pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
struct PendingDeviceCodeGrant {
state: DeviceCodeGrantState,
requested_scopes: BTreeSet<Scope>,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
client_id: String,
requested_at: SystemTime,
@@ -124,7 +125,7 @@ pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
pub struct DeviceCodeGrantInfo {
pub device_code: String,
pub client_metadata: ClientMetadata,
pub requested_scopes: BTreeSet<Scope>,
pub requested_scopes: BTreeSet<RequestedScope>,
}
/// A time-limited grant for a client to perform some sensitive action.
@@ -275,49 +276,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 = Self::generate_token();
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 = Self::generate_token();
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
?authorizing_user,
"Issuing OAuth authorization code"
);
let pending_grant = PendingAuthCodeGrant {
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_auth_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 = PendingAuthCodeGrant {
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_auth_code_grants
.lock()
.await
.insert(code, pending_grant);
Ok(redirect_uri)
}
@@ -536,7 +547,7 @@ pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> {
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
requested_scopes: BTreeSet<RequestedScope>,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
@@ -546,8 +557,8 @@ async fn create_session(
let device_id = requested_scopes
.iter()
.find_map(|scope| {
if let Scope::Device(device_id) = scope {
Some(device_id.to_owned())
if let RequestedScope::Device(device_id) = scope {
Some(device_id)
} else {
None
}
@@ -557,7 +568,7 @@ async fn create_session(
if self
.services
.users
.get_device_metadata(&authorizing_user, &device_id)
.get_device_metadata(&authorizing_user, device_id)
.await
.is_ok()
{
@@ -567,11 +578,11 @@ async fn create_session(
));
}
let device_id = self.services
self.services
.users
.create_device(
&authorizing_user,
Some(device_id),
device_id,
Some(access_token.clone()),
client_name,
None,
@@ -581,20 +592,15 @@ async fn create_session(
// failure during authentication, which should(?) be impossible(?)
.expect("failed to create device");
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
self.db.userdeviceid_oauthsessioninfo.put(
(&authorizing_user, &device_id),
(&authorizing_user, device_id),
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(),
}),
);
@@ -603,10 +609,18 @@ async fn create_session(
Json(RefreshTokenInfo {
client_id: client_id.clone(),
user_id: authorizing_user.clone(),
device_id,
device_id: device_id.to_owned(),
}),
);
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
Ok(TokenResponse {
access_token: access_token.into_token(),
token_type: TokenType::Bearer,
+3 -3
View File
@@ -33,7 +33,7 @@
use crate::{
Dep, config, globals, media,
oauth::grant::AuthorizationCodeResponse,
oauth::grant::AuthorizationCodeData,
threepid,
users::{self, AccountStatus, ProfileFieldChange},
};
@@ -261,7 +261,7 @@ pub async fn begin_session(&self, prompt: Option<CoreAuthPrompt>) -> (PendingSes
pub async fn exchange_code(
&self,
session: PendingSession,
response: AuthorizationCodeResponse,
response: AuthorizationCodeData,
) -> Result<Claims, &'static str> {
let Some(OidcClient { machine, client, .. }) = self.client.as_ref() else {
return Err("Delegated authentication is not enabled on this server.");
@@ -367,7 +367,7 @@ pub async fn complete_session(
// Create a new shadow user
self.services
.users
.create_local_account(&user_id, None, None, None, None)
.create_local_account(&user_id, None, None)
.await
.map_err(|err| {
error!("Failed to create a shadow user for {user_id}: {err}");
+5 -4
View File
@@ -6,8 +6,7 @@
};
use ruma::api::{
IncomingResponse, OutgoingRequest, OutgoingRequestExt,
appservice::Registration,
auth_scheme::{AccessToken, SendAccessToken},
appservice::{HomeserverToken, Registration},
path_builder::SinglePath,
};
@@ -22,7 +21,9 @@ pub async fn send_appservice_request<T>(
request: T,
) -> Result<Option<T::IncomingResponse>>
where
T: OutgoingRequest<Authentication = AccessToken, PathBuilder = SinglePath> + Debug + Send,
T: OutgoingRequest<Authentication = HomeserverToken, PathBuilder = SinglePath>
+ Debug
+ Send,
{
let Some(dest) = registration.url else {
return Ok(None);
@@ -36,7 +37,7 @@ pub async fn send_appservice_request<T>(
let hs_token = registration.hs_token.as_str();
let mut http_request = request
.try_into_http_request::<BytesMut>(&dest, SendAccessToken::Appservice(hs_token), ())
.try_into_http_request::<BytesMut>(&dest, hs_token, ())
.map_err(|e| {
err!(BadServerResponse(
warn!(appservice = %registration.id, "Failed to find destination {dest}: {e:?}")
-4
View File
@@ -2,7 +2,6 @@
use ruma::{
CanonicalJsonObject, CanonicalJsonValue, OwnedServerName, OwnedServerSigningKeyId,
events::room::policy::POLICY_SERVER_ED25519_SIGNING_KEY_ID,
room_version_rules::SignaturesRules,
signatures::{VerificationError, required_server_signatures_to_verify_event},
};
@@ -28,9 +27,6 @@ pub(super) fn required_keys(
.cloned()
.map(TryInto::try_into)
.filter_map(Result::ok)
.filter(|key_id: &OwnedServerSigningKeyId| {
key_id.as_str() != POLICY_SERVER_ED25519_SIGNING_KEY_ID
})
.for_each(|key_id| entry.push(key_id));
}
+6 -27
View File
@@ -1,7 +1,4 @@
use std::{
net::IpAddr,
time::{Duration, SystemTime},
};
use std::time::{Duration, SystemTime};
use conduwuit::{
Err, Result, debug_error, debug_warn, err, error, info, trace,
@@ -139,8 +136,6 @@ pub async fn create_local_account(
user_id: &UserId,
password: Option<HashedPassword>,
email: Option<Address>,
client: Option<&IpAddr>,
device_name: Option<&str>,
) -> Result<()> {
self.create_shadow_account(user_id).await?;
@@ -148,22 +143,6 @@ pub async fn create_local_account(
self.convert_to_local_account(user_id, password).await?;
}
if let Some(client) = client {
let notice = if let Some(device_name) = device_name {
format!(
"New user \"{user_id}\" registered on this server from IP {client} and \
device display name \"{device_name}\".",
)
} else {
format!("New user \"{user_id}\" registered on this server from IP {client}.")
};
info!("{notice}");
if self.services.config.admin_room_notices {
self.services.admin.notice(&notice).await;
}
}
// Set an initial display name
{
let mut displayname = user_id.localpart().to_owned();
@@ -218,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
@@ -410,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),
}),
);
}
@@ -427,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
@@ -439,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));
+1 -1
View File
@@ -40,7 +40,7 @@ pub async fn set_dehydrated_device(&self, user_id: &UserId, request: Request) ->
self.create_device(
user_id,
Some(request.device_id.clone()),
&request.device_id,
None,
request.initial_device_display_name.clone(),
None,
+6 -13
View File
@@ -42,39 +42,32 @@ pub fn into_token(self) -> String { self.token }
impl super::Service {
/// Adds a new device to a user.
///
/// If no `device_id` is provided, a random one will be generated.
///
/// If no `token` is provided, the device will not be able to be logged
/// into.
pub async fn create_device(
&self,
user_id: &UserId,
device_id: Option<OwnedDeviceId>,
device_id: &DeviceId,
token: Option<DeviceToken>,
initial_device_display_name: Option<String>,
client_ip: Option<String>,
) -> Result<OwnedDeviceId> {
const DEVICE_ID_LENGTH: usize = 10;
) -> Result<()> {
self.status(user_id).await.ensure_active()?;
let device_id =
device_id.unwrap_or_else(|| utils::random_string(DEVICE_ID_LENGTH).into());
let mut device = Device::new(device_id.clone());
let key = (user_id, device_id);
let mut device = Device::new(device_id.into());
device.display_name = initial_device_display_name;
device.last_seen_ip = client_ip;
device.last_seen_ts = Some(MilliSecondsSinceUnixEpoch::now());
let key = (user_id, &device_id);
increment(&self.db.userid_devicelistversion, user_id.as_bytes());
self.db.userdeviceid_metadata.put(key, Json(device));
if let Some(token) = token {
self.set_token(user_id, &device_id, token).await?;
self.set_token(user_id, device_id, token).await?;
}
Ok(device_id)
Ok(())
}
/// Removes a device from a user.
+1 -1
View File
@@ -32,7 +32,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<String>,
}
/// A password hash. This is only for use when setting a user's password,
+1 -3
View File
@@ -70,7 +70,6 @@ enum AccountBody {
email_requirement: EmailRequirement,
email: Option<String>,
devices: Vec<DeviceCard>,
dehydrated_device_id: Option<OwnedDeviceId>,
},
Locked,
}
@@ -133,8 +132,7 @@ async fn get_account(
oidc_enabled: services.oidc.enabled(),
email_requirement,
email,
devices: device_cards,
dehydrated_device_id,
devices: device_cards
}))
}
+5 -18
View File
@@ -1,4 +1,4 @@
use std::{collections::BTreeMap, net::IpAddr, time::SystemTime};
use std::{collections::BTreeMap, time::SystemTime};
use axum::{
Extension, Router,
@@ -6,7 +6,6 @@
response::{Redirect, Response},
routing::{get, on},
};
use conduwuit_api::client_ip::ClientIp;
use conduwuit_core::{config::TermsDocument, warn};
use conduwuit_service::{
mailer::messages,
@@ -117,7 +116,6 @@ struct CompletedRegistration {
async fn route_register(
State(services): State<crate::State>,
ClientIp(client): ClientIp, // NOTE: Required for metadata.
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(query)): Expect<Query<RegisterQuery>>,
@@ -146,7 +144,6 @@ async fn route_register(
session_store,
form,
query.next.clone(),
&client,
)
.boxed()
.await?
@@ -279,7 +276,6 @@ struct RegisterEmailValidateQuery {
async fn get_register_email_validate(
State(services): State<crate::State>,
ClientIp(client): ClientIp, // NOTE: Required for metadata.
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(RegisterEmailValidateQuery {
@@ -307,14 +303,8 @@ async fn get_register_email_validate(
let email = session.consume();
response!(
complete_registration(
&services,
session_store,
completed_registration,
Some(email),
&client
)
.await?
complete_registration(&services, session_store, completed_registration, Some(email))
.await?
)
}
@@ -324,7 +314,6 @@ async fn begin_registration(
session_store: Session,
form: RegistrationForm,
next: Option<LoginTarget>,
client: &IpAddr,
) -> Result<Result<Response, ValidationErrors>> {
let open_registration = services
.config
@@ -507,8 +496,7 @@ async fn begin_registration(
} else {
// If email isn't required we can immediately complete registration
Ok(response!(
complete_registration(services, session_store, completed_registration, None, client)
.await?
complete_registration(services, session_store, completed_registration, None).await?
))
}
}
@@ -523,11 +511,10 @@ async fn complete_registration(
next,
}: CompletedRegistration,
email: Option<Address>,
client: &IpAddr,
) -> Result<Redirect> {
services
.users
.create_local_account(&user_id, Some(password_hash), email, Some(client), None)
.create_local_account(&user_id, Some(password_hash), email)
.await?;
if let Some(registration_token) = registration_token {
+7 -22
View File
@@ -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;
@@ -67,15 +66,12 @@ pub(super) async fn for_local_user(services: &Services, user_id: &UserId) -> Sel
pub(super) fn for_device(
client_metadata: Option<&ClientMetadata>,
display_name: Option<&str>,
dehydrated: bool,
) -> Self {
let avatar_src = client_metadata
.and_then(|metadata| metadata.logo_uri.as_ref())
.map(|uri| uri.as_str().to_owned());
let avatar_type = if dehydrated {
AvatarType::Initial('⊡')
} else if let Some(avatar_src) = avatar_src {
let avatar_type = if let Some(avatar_src) = avatar_src {
AvatarType::Image(avatar_src)
} else if let Some(initial) = display_name.and_then(|name| name.chars().next()) {
if client_metadata.is_some() {
@@ -133,7 +129,6 @@ pub(super) struct DeviceCard {
pub last_active: String,
pub oauth_metadata: Option<ClientMetadata>,
pub style: DeviceCardStyle,
pub dehydrated: bool,
}
impl HtmlSafe for DeviceCard {}
@@ -167,21 +162,12 @@ pub(super) async fn for_device(
}
.await;
let dehydrated_device_id = services
.users
.get_dehydrated_device(user_id)
.await
.ok()
.map(|device| device.device_id);
let dehydrated = dehydrated_device_id.as_ref() == Some(&device.device_id);
let display_name = oauth_metadata
.as_ref()
.and_then(|metadata| metadata.client_name.clone())
.or_else(|| device.display_name.clone());
let avatar =
Avatar::for_device(oauth_metadata.as_ref(), display_name.as_deref(), dehydrated);
let avatar = Avatar::for_device(oauth_metadata.as_ref(), display_name.as_deref());
let last_active = device.last_seen_ts.map_or_else(
|| "unknown".to_owned(),
@@ -203,7 +189,6 @@ pub(super) async fn for_device(
last_active,
oauth_metadata,
style,
dehydrated,
}
}
}
@@ -211,7 +196,7 @@ pub(super) async fn for_device(
#[derive(Debug, Template)]
#[template(path = "_components/client_scopes.html.j2")]
pub(super) struct ClientScopes {
pub scopes: BTreeSet<Scope>,
pub scopes: BTreeSet<OAuthClientScope>,
}
impl HtmlSafe for ClientScopes {}
+14 -3
View File
@@ -6,7 +6,7 @@
};
use conduwuit_service::oauth::{
client_metadata::ClientMetadata,
grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery, Prompt},
grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery, Prompt, RequestedScope},
};
use ruma::OwnedUserId;
use serde::{Deserialize, de::IgnoredAny};
@@ -102,7 +102,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 user_avatar = Avatar::for_local_user(&services, &user_id).await;
@@ -185,6 +191,11 @@ async fn route_device_code(
let user_avatar = Avatar::for_local_user(&services, &user_id).await;
let scopes = grant_info.requested_scopes
.iter()
.filter_map(RequestedScope::as_granted_scope)
.collect();
response!(Grant::new(
context,
serde_urlencoded::to_string(LoginQuery {
@@ -196,7 +207,7 @@ async fn route_device_code(
user_id,
user_avatar,
grant_info.client_metadata,
ClientScopes { scopes: grant_info.requested_scopes },
ClientScopes { scopes },
Some(grant_info.device_code),
))
},
+2 -2
View File
@@ -7,7 +7,7 @@
routing::on,
};
use conduwuit_service::{
oauth::grant::AuthorizationCodeResponse,
oauth::grant::AuthorizationCodeData,
oidc::{ClaimedLocalUser, SessionCompletionStatus},
};
use futures::FutureExt;
@@ -60,7 +60,7 @@ struct LoginForm {
async fn route_complete(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
Expect(Query(query)): Expect<Query<AuthorizationCodeResponse>>,
Expect(Query(query)): Expect<Query<AuthorizationCodeData>>,
session_store: Session,
user: User<true>,
PostForm(form): PostForm<LoginForm>,
@@ -1,10 +1,13 @@
<ul>
{% for scope in scopes %}
{% match scope %}
{% when Scope::ClientApi %}
{% when OAuthClientScope::ApiFullAccess %}
<li>Send messages and interact with chatrooms on your behalf</li>
{% when Scope::Device(_) %}
<li>Access your Matrix account</li>
{% when OAuthClientScope::ServerAdministration %}
<li>⚠️ Administrate this homeserver
<br><em class="negative">This is a dangerous permission. Make sure you trust this app.</em></li>
{% when _ %}
<li>missingno</li>
{% endmatch %}
{% endfor %}
</ul>
@@ -1,41 +1,37 @@
<div class="card">
{{ avatar }}
<div class="info">
{% if dehydrated %}
<div class="name">Dehydrated device</div>
{% else %}
<div class="name">
<span>
{% if let Some(display_name) = display_name %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
</span>
{% if style == DeviceCardStyle::Detailed %}
<span class="id">
<span class="mobile-hidden">•</span>
<ul class="bullet-separated">
<li>{{ device_id }}</li>
<li>
{% if let Some(metadata) = oauth_metadata %}
<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
legacy
{% endif %}
</li>
</span>
</span>
<div class="name">
<span>
{% if let Some(display_name) = display_name %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
</div>
<div>
Last active: {{ last_active }}
</div>
<div>
{% if style != DeviceCardStyle::Detailed %}
<a href="{{ crate::ROUTE_PREFIX }}/account/device/{{ device_id }}/">Details</a>
</span>
{% if style == DeviceCardStyle::Detailed %}
<span class="id">
<span class="mobile-hidden">•</span>
<ul class="bullet-separated">
<li>{{ device_id }}</li>
<li>
{% if let Some(metadata) = oauth_metadata %}
<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
legacy
{% endif %}
</li>
</span>
</span>
{% endif %}
</div>
</div>
<div>
Last active: {{ last_active }}
</div>
<div>
{% if style != DeviceCardStyle::Detailed %}
<a href="{{ crate::ROUTE_PREFIX }}/account/device/{{ device_id }}/">Details</a>
{% endif %}
</div>
</div>
</div>
+1 -4
View File
@@ -9,7 +9,7 @@ Your account
<h1>Manage your account</h1>
{{ user_card }}
{% match body %}
{% when AccountBody::Unlocked { suspended, email_requirement, email, devices, oidc_enabled, dehydrated_device_id } %}
{% when AccountBody::Unlocked { suspended, email_requirement, email, devices, oidc_enabled } %}
{% if suspended %}
<p class="card danger">
⚠️ Your account has been suspended by your homeserver's administrator.
@@ -52,9 +52,6 @@ Your account
and sign in to start chatting on Matrix.
</span>
{% endfor %}
{% if let Some(dehydrated_device_id) = dehydrated_device_id %}
<small>⊡ Your account has a dehydrated device. <a href="device/{{ dehydrated_device_id | urlencode_strict }}/">View details</a></small>
{% endif %}
</div>
</details>
</section>
@@ -27,8 +27,6 @@ Device information
{% if let Some((_, session_info)) = client_metadata %}
This device has permission to:
{{ ClientScopes { scopes: session_info.scopes.clone() } }}
{% else if device_card.dehydrated %}
This is your dehydrated device. It saves encryption keys for you while you're offline.
{% else %}
This device can access and control all features of your Matrix account.
<br>
+2 -6
View File
@@ -4,7 +4,6 @@
time::{Duration, SystemTime},
};
use askama::filters::urlencode_strict;
use axum::{
extract::FromRequestParts,
http::request::Parts,
@@ -64,11 +63,8 @@ pub(crate) fn target_path(&self) -> String {
| Self::DeviceCode(code) =>
format!("oauth2/grant/device_code?{}", serde_urlencoded::to_string(code).unwrap())
.into(),
| Self::DeviceInfo(path) =>
format!("account/device/{}/", urlencode_strict(&path.device).unwrap()).into(),
| Self::RemoveDevice(path) =>
format!("account/device/{}/remove", urlencode_strict(&path.device).unwrap())
.into(),
| Self::DeviceInfo(path) => format!("account/device/{}/", path.device).into(),
| Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(),
};
format!("{ROUTE_PREFIX}/{path}")