Compare commits

..

107 Commits

Author SHA1 Message Date
Ginger f1b7710984 fix: Mobile styling improvements 2026-07-02 17:13:14 -04:00
Ginger 514c3bbcbf fix: Properly show email in account page when OIDC email_claim is set 2026-07-02 16:57:21 -04:00
Ginger da7d8c9821 fix: Prevent register page from being accessible when OIDC is enabled 2026-07-02 16:34:56 -04:00
Ginger 45809b7970 feat: Add support for reading the OIDC client secret from a file 2026-07-02 16:34:56 -04:00
Ginger 910925b906 fix: Config file formatting 2026-07-02 16:34:38 -04:00
Ginger 28263982cd feat: Add support for requesting additional scopes 2026-07-02 16:34:38 -04:00
Ginger 52a4b9d586 chore: News fragment 2026-07-02 16:34:38 -04:00
Ginger 786890baa0 feat: Add support for importing email addresses from the IDP 2026-07-02 16:34:38 -04:00
Ginger 3282ba8eab fix: Miscellaneous fixes 2026-07-02 16:34:38 -04:00
Ginger f66c1d8f35 fix: Hide bare oidc config option 2026-07-02 16:34:38 -04:00
Ginger 3d004b1923 fix: Adjust OIDC config section comment position 2026-07-02 16:34:38 -04:00
Ginger 06afe4f1a3 feat: Add support for importing profile data from claims 2026-07-02 16:34:38 -04:00
Ginger 53d6887b86 refactor: Move profile field setting logic into users service 2026-07-02 16:34:38 -04:00
Ginger 8c77bd61e5 fix: Hide password change link when OIDC is enabled 2026-07-02 16:34:38 -04:00
Ginger e649af33b9 feat: Send account selection prompt to IDP when account switch link is clicked 2026-07-02 16:34:38 -04:00
Ginger e2fe166d63 feat: Speedbump when logging in with OIDC with no next target 2026-07-02 16:34:38 -04:00
Ginger 4ee0bb7533 feat: Allow existing legacy accounts to be linked interactively 2026-07-02 16:34:38 -04:00
Ginger 9a9cc94702 chore: Update admin command docs 2026-07-02 16:34:38 -04:00
Ginger 58a6e1232c feat: Implement !admin oidc unlink 2026-07-02 16:34:38 -04:00
Ginger c0cb4afd8a feat: Initial implementation of OIDC 2026-07-02 16:34:38 -04:00
Ginger 69163b5391 refactor: Split remote and deactivated users into their own columns 2026-07-02 16:24:07 -04:00
Jade Ellis d4f6c053bc fix: Log the set password when creating users
Closes #1894
2026-07-02 16:41:33 +01:00
Renovate Bot 4f72326b07 chore(deps): update rust crate rand to v0.10.2 2026-07-02 10:19:35 +00:00
Renovate Bot b7e449f490 chore(deps): update actions/cache action to v6 2026-07-02 10:14:57 +00:00
renovate 2a1d7e49d9 chore(Nix): Updated flake hashes 2026-07-02 10:14:36 +00:00
Renovate Bot b7792f9e86 chore(deps): update rust to v1.96.1 2026-07-02 10:14:36 +00:00
Henry-Hiles adfe9f9f53 chore: disable doctests in conduwuit_core
These aren't wanted and are failing anyways. Fixes nix package build.
2026-07-01 23:39:28 -04:00
Renovate Bot 8fe1715019 chore(deps): update rust crate aws-lc-rs to v1.17.1 2026-07-01 19:46:32 +00:00
Renovate Bot 9a91dce600 chore(deps): update rust crate serde_html_form to v0.4.1 2026-07-01 19:45:14 +00:00
Jade Ellis eee4ef50d2 chore: merge 1818 nex/perf/get-missing-events 2026-07-01 18:45:08 +00:00
timedout 62e0b53f52 feat: Prioritise sending dummy events when the extremity count reaches 20 or more 2026-07-01 19:15:35 +01:00
timedout 5fa3d5f6c8 fix: Don't create fake objects when defaulting a partial PDU build 2026-07-01 19:15:35 +01:00
timedout 9b80a99aa7 style: De-duplicate build_local_dag definitions, replace outdated comment on unwrap safety 2026-07-01 19:15:22 +01:00
timedout bd525c100f style: Re-use room_id parameter instead of potentially recalculating 2026-07-01 16:31:59 +01:00
timedout e6225f3265 fix: Use infallible room ID getter 2026-07-01 16:13:55 +01:00
Charlotte 🦝 Deleńkec 54c94ca9ad fix(appservice): Re-add room membership conditions for event sending
They accidentally got removed in a recent refactor

Fixes #1890
2026-07-01 10:20:57 +01:00
Ginger 017d4b3894 fix: Enable compat-get-3pids ruma feature 2026-06-30 12:27:49 -04:00
Renovate Bot 3b3eaf8744 chore(deps): update rust crate cargo_toml to v1 2026-06-30 13:51:11 +00:00
Renovate Bot 0a634907f2 chore(deps): update github-actions-digest 2026-06-30 05:03:24 +00:00
Henry-Hiles 2f2848728f fix: add missing brackets in nix devshell 2026-06-30 01:24:33 +00:00
timedout 19a83c6891 style: Add docstrings to squasher functions 2026-06-29 18:37:04 +01:00
timedout 2bfd678c71 feat: Add jitter to extremity squasher 2026-06-29 18:29:04 +01:00
timedout e936d18324 feat: Don't panic in build_local_dag
Previously the function assumed the caller had performed proper validation on the inputs (and all current callers do), but this is a poor reason to panic when sane error handling is available.
Events with no prev events now return an error, and prev events which are illegal are simply skipped.
2026-06-29 18:12:16 +01:00
timedout 90797fa3cd fix: Avoid string roundtrip when fetching timestamps in build_local_dag 2026-06-29 17:44:13 +01:00
Ginger 5eea8fe880 chore: News fragment 2026-06-29 16:46:47 +01:00
Ginger 12491dcc26 feat: Mark spec version 1.17 as supported 2026-06-29 16:46:39 +01:00
Ginger 2eff454787 feat: Finish implementing MSC4190 2026-06-29 16:46:07 +01:00
timedout e5dc5bedfc fix: MSC2666 is stable but not yet in a release 2026-06-29 13:56:22 +00:00
timedout 496ca52987 style: Missing semicolon on line 50 2026-06-29 13:56:22 +00:00
timedout 72f4c3cc53 chore: Remove unstable features that are now stable or abandoned 2026-06-29 13:56:22 +00:00
timedout 6a8a114197 feat: Update capabilities and advertise spec version v1.18 2026-06-29 13:56:22 +00:00
timedout 549864a052 feat: Support MSC4323 locking endpoints 2026-06-29 13:56:22 +00:00
Ginger 52026bb0f1 chore: Formatting 2026-06-29 09:41:59 -04:00
Ginger aed8e7d769 fix: Adjust error code 2026-06-29 09:35:51 -04:00
Ginger 32fb6f247a fix: Use a LRU cache for storing pending auth code grants 2026-06-29 09:35:51 -04:00
Ginger 90e14abecb fix: Spacing 2026-06-29 09:35:51 -04:00
Ginger 6ced2511e4 chore: Clippy fixes 2026-06-29 09:35:51 -04:00
Ginger 93407ade03 fix: Accessibility improvements 2026-06-29 09:35:51 -04:00
Ginger 6742d9237a fix: More CSS tweaks 2026-06-29 09:35:51 -04:00
Ginger a32c7d0d9a fix: Set correct default for registration terms config section 2026-06-29 09:35:51 -04:00
Ginger 761a6a53d0 fix: Check for existing device when creating oauth session 2026-06-29 09:35:51 -04:00
Ginger c56c035c15 fix: Use RFC-compliant error responses for OAuth endpoints 2026-06-29 09:35:51 -04:00
Ginger c804bf5780 fix: Force trusted flow UI for first-run registration 2026-06-29 09:35:51 -04:00
Ginger b87afeef80 fix: Panic when trying to check an unknown UIAA stage type 2026-06-29 09:35:51 -04:00
Ginger d0c7a62ce7 fix: Use the right error code for CAPTCHA errors 2026-06-29 09:35:50 -04:00
Ginger 47582d1922 refactor: Update logic for checking if a username is available 2026-06-29 09:35:50 -04:00
Ginger 36a30f16b1 fix: CSS adjustments 2026-06-29 09:35:50 -04:00
Ginger 52ec6bc6da fix: Adjust error codes to comply with MSC4190 2026-06-29 09:35:50 -04:00
Ginger 74788dcb19 feat: Mark spec version 1.15 as supported 2026-06-29 09:35:50 -04:00
Ginger b35b72c34a feat: Add a page with some information about the server 2026-06-29 09:35:50 -04:00
Ginger c1fc76040a fix: Correct config file example section name 2026-06-29 09:35:50 -04:00
Ginger d1449d6575 chore: My Giant Future 2026-06-29 09:35:50 -04:00
Ginger 9fdeb61c1a feat: Improve account panel UI for locked and suspended accounts 2026-06-29 09:35:50 -04:00
Ginger 7c7d6762e2 fix: Include query parameters in link back to login on register page 2026-06-29 09:35:50 -04:00
Ginger eda46d63a8 fix: CSS tweaks 2026-06-29 09:35:50 -04:00
Ginger 9fc9063c83 feat: Improve registration UI in first-run mode 2026-06-29 09:35:50 -04:00
Ginger 430347fc66 fix: Minor wording improvements 2026-06-29 09:35:50 -04:00
Ginger 9df5c68d3c fix: Set default for allow_deactivation 2026-06-29 09:35:50 -04:00
Ginger 3ca068c985 fix: Fix registration terms example in config 2026-06-29 09:35:50 -04:00
Ginger 138ec05cf2 feat: Implement support for prompt=create in the authorization code flow 2026-06-29 09:35:50 -04:00
Ginger c7e5c8df22 fix: Don't let logged-in users access the registration page 2026-06-29 09:35:50 -04:00
Ginger 91afbd122f feat: Allow self-service deactivation to be disabled 2026-06-29 09:35:50 -04:00
Ginger daaf09cb26 refactor: Use more consistent terminology for email validation pages 2026-06-29 09:35:50 -04:00
Ginger e8d6fa565e feat: Add support for registering accounts with the web UI 2026-06-29 09:35:50 -04:00
Ginger 47af5d3a39 refactor: Change template context to allow using a CSP nonce 2026-06-29 09:35:49 -04:00
Ginger 63b5fd04be fix: Minor CSS improvements 2026-06-29 09:35:49 -04:00
Ginger d59a5c63f9 fix: Remove errant whitespace in device details 2026-06-29 09:35:48 -04:00
Ginger 16aaeae21c chore: News fragment 2026-06-29 09:35:48 -04:00
Ginger e60310eacc feat: Allow configuring the OAuth compatibility mode 2026-06-29 09:35:48 -04:00
Ginger 7b98ae54b5 fix: Use button styling for account management link on index page 2026-06-29 09:35:48 -04:00
Ginger d67d6ca895 fix: Use the right text color on input elements 2026-06-29 09:35:48 -04:00
Ginger 86d9e9e6a9 feat: Add support for account management deeplinks 2026-06-29 09:35:48 -04:00
Ginger b37480d6b2 fix: Return the correct error code for expired access tokens 2026-06-29 09:35:48 -04:00
Ginger 0d806a2038 feat: Add a page for viewing a device's details 2026-06-29 09:35:48 -04:00
Ginger 19700a081e fix: Use SameSite=Lax for session cookie 2026-06-29 09:35:48 -04:00
Ginger ed1b175fa4 feat: Allow devices to be removed from the account panel 2026-06-29 09:35:48 -04:00
Ginger a91fc7041a feat: Implement oauth token revocation 2026-06-29 09:35:48 -04:00
Ginger 18c59e036f chore: Clippy fixes 2026-06-29 09:35:48 -04:00
Ginger df3f646c92 feat: Implement oauth auth code and refresh token flows 2026-06-29 09:35:48 -04:00
Ginger fb6e5d2838 chore: Clippy fixes 2026-06-29 09:35:48 -04:00
Ginger 33be65a865 feat: Implement a web-based account management dashboard 2026-06-29 09:35:45 -04:00
Ginger 975ec24167 feat: Implement oauth service and client registration 2026-06-29 09:03:54 -04:00
Renovate Bot e8378e86fd chore(deps): update rust-zerover-patch-updates 2026-06-29 13:02:39 +00:00
Renovate Bot 57366ac90a chore(deps): update rust crate serde-saphyr to 0.0.28 2026-06-29 13:02:24 +00:00
Renovate Bot 9f8a716dc1 chore(deps): update node-patch-updates to v2.0.15 2026-06-29 13:01:10 +00:00
Renovate Bot 27367bac18 chore(deps): update github-actions-non-major 2026-06-29 05:03:03 +00:00
Jade Ellis 5199cde870 feat: Allow sending dummy events to clients 2026-06-28 03:44:23 +01:00
181 changed files with 9342 additions and 2292 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ runs:
echo "version=$(rustup --version)" >> $GITHUB_OUTPUT
- name: Cache rustup toolchains
if: steps.rustup-version.outputs.version == ''
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.rustup
@@ -57,7 +57,7 @@ runs:
- name: Check for LLVM cache
id: cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/usr/bin/clang-*
+2 -2
View File
@@ -65,7 +65,7 @@ runs:
- name: Cache toolchain binaries
id: toolchain-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
.cargo/bin
@@ -76,7 +76,7 @@ runs:
- name: Cache Cargo registry and git
id: registry-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
.cargo/registry/index
+5 -5
View File
@@ -31,7 +31,7 @@ runs:
- name: Restore binary cache
id: binary-cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/usr/share/rust/.cargo/bin
@@ -71,13 +71,13 @@ runs:
- name: Install timelord-cli and git-warp-time
if: steps.check-binaries.outputs.need-install == 'true'
uses: https://github.com/taiki-e/install-action@9e1e5806d4a4822de933115878265be9aaa786d9 # v2
uses: https://github.com/taiki-e/install-action@9bcaee1dcae34154180f412e2fa69355a7cda9f6 # v2
with:
tool: git-warp-time,timelord-cli@3.0.1
- name: Save binary cache
if: steps.check-binaries.outputs.need-install == 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/usr/share/rust/.cargo/bin
@@ -87,7 +87,7 @@ runs:
- name: Restore timelord cache with fallbacks
id: timelord-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ env.TIMELORD_CACHE_PATH }}
key: ${{ env.TIMELORD_KEY }}
@@ -114,7 +114,7 @@ runs:
timelord sync --source-dir ${{ env.TIMELORD_PATH }} --cache-dir ${{ env.TIMELORD_CACHE_PATH }}
- name: Save updated timelord cache immediately
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ env.TIMELORD_CACHE_PATH }}
key: ${{ env.TIMELORD_KEY }}
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
ref: ${{ github.ref_name }}
- name: Cache Cargo registry
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
- name: Cache DNF packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/var/cache/dnf
@@ -47,7 +47,7 @@ jobs:
dnf-fedora${{ steps.fedora.outputs.version }}-
- name: Cache Cargo registry
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
@@ -57,7 +57,7 @@ jobs:
cargo-fedora${{ steps.fedora.outputs.version }}-
- name: Cache Rust build dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/rpmbuild/BUILD/*/target/release/deps
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
node-version: 22
- name: Cache npm dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.npm
key: continuwuity-rspress-${{ steps.runner-env.outputs.slug }}-${{ steps.runner-env.outputs.arch }}-node-${{ steps.runner-env.outputs.node_version }}-${{ hashFiles('package-lock.json') }}
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
# repositories: continuwuity
- name: Install regsync
uses: https://github.com/regclient/actions/regsync-installer@4b4db1dcc7dad75ad67a788a380f75a20cc8a040 # main
uses: https://github.com/regclient/actions/regsync-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main
- name: Check what images need mirroring
run: |
+6 -6
View File
@@ -43,7 +43,7 @@ jobs:
name: Renovate
runs-on: ubuntu-latest
container:
image: ghcr.io/renovatebot/renovate:43.234.0@sha256:bff111bfe347c559c615b658b28721eba5b7bb32a7b7901ea321336767209fe1
image: ghcr.io/renovatebot/renovate:43.246.1@sha256:5965c08f8ca5baff8dc9bf3a32c44ca71fef843ad94880e9696d46e1d722b0fa
options: --tmpfs /tmp:exec
steps:
- name: Checkout
@@ -55,7 +55,7 @@ jobs:
run: /usr/local/renovate/node -e 'console.log(`node heap limit = ${require("v8").getHeapStatistics().heap_size_limit / (1024 * 1024)} Mb`)'
- name: Restore renovate repo cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/tmp/renovate/cache/renovate/repository
@@ -64,7 +64,7 @@ jobs:
renovate-repo-cache-
- name: Restore renovate package cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/tmp/renovate/cache/renovate/renovate-cache-sqlite
@@ -73,7 +73,7 @@ jobs:
renovate-package-cache-
- name: Restore renovate OSV cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/tmp/osv
@@ -117,7 +117,7 @@ jobs:
- name: Save renovate package cache
if: always()
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/tmp/renovate/cache/renovate/renovate-cache-sqlite
@@ -125,7 +125,7 @@ jobs:
- name: Save renovate OSV cache
if: always()
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
/tmp/osv
Generated
+734 -119
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -45,7 +45,7 @@ version = "1.0.6"
version = "1.0.0"
[workspace.dependencies.cargo_toml]
version = "0.22"
version = "1.0"
default-features = false
features = ["features"]
@@ -164,7 +164,7 @@ features = ["raw_value"]
# Used for appservice registration files
[workspace.dependencies.serde-saphyr]
version = "0.0.27"
version = "0.0.28"
# Used to load forbidden room/user regex from config
[workspace.dependencies.serde_regex]
@@ -356,6 +356,7 @@ features = [
"ring-compat",
"compat-upload-signatures",
"compat-optional-txn-pdus",
"compat-get-3pids",
"unstable-msc2666",
"unstable-msc2867",
"unstable-msc2870",
@@ -402,6 +403,9 @@ default-features = false
version = "0.11.0"
default-features = false
[workspace.dependencies.openidconnect]
version = "4.0.1"
# optional opentelemetry, performance measurements, flamegraphs, etc for performance measurements and monitoring
[workspace.dependencies.opentelemetry]
version = "0.32.0"
@@ -559,6 +563,9 @@ features = ["std"]
[workspace.dependencies.nonzero_ext]
version = "0.3.0"
[workspace.dependencies.serde_urlencoded]
version = "0.7.1"
#
# Patches
#
+1
View File
@@ -0,0 +1 @@
Appservice device management as outlined in MSC4190 (part of Matrix 1.17) is now fully supported. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Users may now be forbidden from deactivating their own accounts with the new `allow_deactivation` config option. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Added support for authenticating clients using the new OAuth 2.0 login API. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Added support for linking an external identity provider with OIDC. Contributed by @ginger.
+135 -9
View File
@@ -521,17 +521,15 @@
#
#recaptcha_private_site_key =
# Policy documents, such as terms and conditions or a privacy policy,
# which users must agree to when registering an account.
# Controls whether users are allowed to deactivate their own accounts
# through the account management panel or their Matrix clients. Server
# admins can always deactivate users using the relevant admin commands.
#
# Example:
# ```ignore
# [global.registration_terms.privacy_policy]
# en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
# es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
# ```
# Note that, in some jurisdictions, you may be legally required to honor
# users who request to deactivate their accounts if you set this option
# to `false`.
#
#registration_terms = {}
#allow_deactivation = true
# Controls whether encrypted rooms and events are allowed.
#
@@ -1436,6 +1434,11 @@
#
#send_messages_from_ignored_users_to_client = false
# Send "org.matrix.dummy_event" events to the client. This is a debugging
# option.
#
#send_dummy_events_to_clients = false
# Vector list of IPv4 and IPv6 CIDR ranges / subnets *in quotes* that you
# do not want continuwuity to send outbound requests to. Defaults to
# RFC1918, unroutable, loopback, multicast, and testnet addresses for
@@ -1995,3 +1998,126 @@
# `require_email_for_registration`.
#
#require_email_for_token_registration = false
#[global.registration_terms]
# The language code to provide to clients along with the policy documents.
#
#language = "en"
# Policy documents, such as terms and conditions or a privacy policy,
# which users must agree to when registering an account.
#
# Example:
# ```ignore
# [global.registration_terms.documents]
# privacy_policy = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
# ```
#
#documents =
#[global.oauth]
# The compatibility mode to use for OAuth.
#
# - "disabled": OAuth will be unavailable. Users will only be able to log
# in using legacy authentication.
# - "hybrid": OAuth and legacy authentication will both be available. Some
# clients may only use one or the other.
# - "exclusive": Only OAuth will be available. Clients which require
# legacy authentication will be unable to log in.
#
#compatibility_mode = "hybrid"
#[global.oauth.oidc]
# Uncommenting this section will enable Continuwuity's support for
# authenticating users using an OpenID Connect-compatible identity provider.
# This is referred to as "delegated authentication".
#
# IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if
# the `global.oauth.compatibility_mode` setting is set to `exclusive`.
# Matrix clients which do not support OAuth login (also referred to as "next-gen auth") will NOT be able
# to log in while delegated authentication is active.
# The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to
# automatically fetch the identity provider's metadata from this URL.
# Generally you should set this to the base domain your identity provider
# runs on.
#
#discovery_url =
# The OAuth client ID for Continuwuity to use when communicating with the
# identity provider.
#
#client_id =
# The OAuth client secret for Continuwuity to use when communicating with
# the identity provider.
#
#client_secret =
# A path to a file which Continuwuity will read the client secret from.
# If this option is set, it will override `client_secret`.
#
# The server will fail to start if the file cannot be read.
#
#client_secret_file =
# Additional scopes Continuwuity should request from the IDP. This may be
# necessary to access certain claims. Continuwuity always requests the
# `openid` scope.
#
#additional_scopes = []
# Whether the user should be prompted to choose a localpart
# when signing in for the first time. If this is `false`, Continuwuity
# will attempt to use the value of the `preferred_username_claim`
# (see below) as the user's localpart. Authentication will
# fail if this claim is missing or is not a valid localpart.
#
#prompt_for_localpart = true
# The claim to use for the user's localpart, if `prompt_for_localpart` is
# false.
#
#preferred_username_claim = "preferred_username"
# The claim which will be used to set the user's email address,
# either on initial registration or on every login depending on
# the value of `profile_key_import_mode`. Continuwuity assumes that
# the IDP has taken care of verifying that the user controls the email
# address it provides.
#
# This option does nothing if SMTP is not configured.
#
# If this option is set, and `profile_key_import_mode` is `on_login`,
# users will not be able to change their email addresses themselves.
#
#email_claim = "email"
# Defines how claims returned from the IDP should be mapped to a user's
# profile data. The profile field named in each key will be set from the
# claim named in the corresponding value when the user first registers,
# and possibly on subsequent logins as well, depending on the value of
# `profile_key_import_mode` (see below).
#
# Per-room overrides to the user's display name or avatar will be
# preserved by the import process.
#
# SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will
# perform a HTTP GET to the URL in the mapped claim and use the returned
# file as the user's profile picture. Make sure your users are not able
# to set the value of the mapped claim to an arbitrary URL.
#
#profile_key_map = { displayname = "name" }
# When profile keys should be imported from the IDP's claims.
#
# - "on_registration": Listed keys will be imported once, when the user
# logs in for the first time and their shadow account is created.
# - "on_login": Listed keys will be imported every time the user logs in.
# Additionally, users will not be able to manually edit any listed keys
# through their Matrix client.
#
#profile_key_import_mode = "on_registration"
+13 -1
View File
@@ -10,7 +10,13 @@ ## `!admin debug echo`
## `!admin debug get-auth-chain`
Get the auth_chain of a PDU
Loads the auth_chain of a PDU, reporting how long it took
## `!admin debug show-auth-chain`
Walks & displays the auth_chain of a PDU in a mermaid graph format.
This is useless to basically anyone but developers, and is also probably slow and memory hungry.
## `!admin debug parse-pdu`
@@ -44,6 +50,12 @@ ## `!admin debug get-room-state`
Of course the check is still done on the actual client API.
## `!admin debug get-state-at`
Gets all the room state events at the specified event.
State at event might not be available for some PDUs, such as rejected ones.
## `!admin debug get-signing-keys`
Get and display signing keys from local cache or remote server
+1
View File
@@ -14,6 +14,7 @@ ## Categories
- [`!admin appservices`](appservices/): Commands for managing appservices
- [`!admin users`](users/): Commands for managing local users
- [`!admin token`](token/): Commands for managing registration tokens
- [`!admin oidc`](oidc/): Commands for managing OIDC
- [`!admin rooms`](rooms/): Commands for managing rooms
- [`!admin federation`](federation/): Commands for managing federation
- [`!admin server`](server/): Commands for managing the server
+13
View File
@@ -0,0 +1,13 @@
<!-- This file is generated by `cargo xtask generate-docs`. Do not edit. -->
# `!admin oidc`
Commands for managing OIDC
## `!admin oidc link`
Link a user ID to the given subject claim
## `!admin oidc unlink`
Unlink the given subject claim from its associated user ID
+8 -4
View File
@@ -12,10 +12,6 @@ ## `!admin users reset-password`
Reset user password
## `!admin users issue-password-reset-link`
Issue a self-service password reset link for a user
## `!admin users get-email`
Get a user's associated email address
@@ -96,6 +92,14 @@ ## `!admin users list-users`
List local users in the database
## `!admin users list-invited-rooms`
Lists all the rooms (local and remote) that the specified user is invited to
## `!admin users reject-all-invites`
Manually make a user reject all current invites
## `!admin users list-joined-rooms`
Lists all the rooms (local and remote) that the specified user is joined in
+1 -1
View File
@@ -9,7 +9,7 @@
{
# basic nix shell containing all things necessary to build continuwuity in all flavors manually (on x86_64-linux)
devShells.default =
(inputs.crane.mkLib pkgs).overrideToolchain (pkgs: self'.packages.stable-toolchain).devShell
((inputs.crane.mkLib pkgs).overrideToolchain (pkgs: self'.packages.stable-toolchain)).devShell
{
packages = [
self'.packages.rocksdb
+1 -1
View File
@@ -12,7 +12,7 @@
target:
target.fromToolchainName {
name = (lib.importTOML "${inputs.self}/rust-toolchain.toml").toolchain.channel;
sha256 = "sha256-mvUGEOHYJpn3ikC5hckneuGixaC+yGrkMM/liDIDgoU=";
sha256 = "sha256-h+t2xTBz5yt2YIO+1VMIIGlCU7gyp2LYOFvaV1nwOXU=";
};
in
{
+25 -25
View File
@@ -383,18 +383,18 @@
}
},
"node_modules/@rspress/core": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.14.tgz",
"integrity": "sha512-k59i08zwBGgHrjHw8CK1m4CeTrKPvZRmV54bxubQl6AdDdmhJK6WrNg3UthwWmd38scKtqF40ATXDE8RMiNcNA==",
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.15.tgz",
"integrity": "sha512-epLmUXYscNRw/GtQZx2oknoBE9wKbCrUGEOrQEDI4Qq8X32GdM4d7itzuHsliY7q3IbffKx8rMVbvlmygEocTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@rsbuild/core": "^2.0.9",
"@rsbuild/core": "^2.0.15",
"@rsbuild/plugin-react": "~2.0.1",
"@rspress/shared": "2.0.14",
"@shikijs/rehype": "^4.0.2",
"@rspress/shared": "2.0.15",
"@shikijs/rehype": "^4.2.0",
"@types/unist": "^3.0.3",
"@unhead/react": "^2.1.15",
"body-scroll-lock": "4.0.0-beta.0",
@@ -407,22 +407,22 @@
"mdast-util-mdxjs-esm": "^2.0.1",
"medium-zoom": "1.1.0",
"nprogress": "^0.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-lazy-with-preload": "^2.2.1",
"react-reconciler": "0.33.0",
"react-render-to-markdown": "19.1.0",
"react-router-dom": "^7.15.1",
"rehype-external-links": "^3.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^2.0.1",
"remark-cjk-friendly-gfm-strikethrough": "^2.0.1",
"remark-cjk-friendly": "^2.3.1",
"remark-cjk-friendly-gfm-strikethrough": "^2.1.0",
"remark-gfm": "^4.0.1",
"remark-mdx": "^3.1.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"shiki": "^4.0.2",
"shiki": "^4.2.0",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.1.0",
@@ -436,9 +436,9 @@
}
},
"node_modules/@rspress/plugin-client-redirects": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.14.tgz",
"integrity": "sha512-/WpbWUiepQglpPeplxCnELe2c7VdBUxPiICPAVnS1ZxAFdYkIpW0C+Vbk1t08kZqx8EAZGu+s6Zy43zyQpjdxg==",
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.15.tgz",
"integrity": "sha512-bPf/KIHH7Y6huLTtK6JXwRfxM7zKjksoxm46+IBsF1wisw0doKkEKR9HwJydxWnykyKBbA2cuZOaoT4h174Z1w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -449,9 +449,9 @@
}
},
"node_modules/@rspress/plugin-sitemap": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.14.tgz",
"integrity": "sha512-Gpone22PvXGfGRSyi/WM8IXgsvKhNspXqHjtPD3g62jX8SJL3kpj2YZ2V28WEkg672fICauUYXrpre74Rddcsw==",
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.15.tgz",
"integrity": "sha512-z1hbyGP79ZXdSGJxiWw7ZjmX8qW0q9nXMDxr14cVEg/wdj7ToVzGtZHw0wvTPE0YiKG3BMiGkVNfE1rdOaPXiQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -462,14 +462,14 @@
}
},
"node_modules/@rspress/shared": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.14.tgz",
"integrity": "sha512-sCe9tAo+s9tR4DmFSjMyHOxQvhzTSYXkkMUfVEo5w+uMCNXXGAIC6D0xAVDMHq1jIFF9ix47VxzlCo+CYNS14g==",
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.15.tgz",
"integrity": "sha512-o8aYwEzNuTmWnmKe91ntPv+34u3RbtAe+rcK9XC5MANOlgncwOaCs3bUa8/B1/llwyLoNgrpi+VB9bEiU11ZRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rsbuild/core": "^2.0.9",
"@shikijs/rehype": "^4.0.2",
"@rsbuild/core": "^2.0.15",
"@shikijs/rehype": "^4.2.0",
"unified": "^11.0.5"
}
},
@@ -3058,9 +3058,9 @@
}
},
"node_modules/remark-cjk-friendly": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.1.0.tgz",
"integrity": "sha512-ZWGDfTJNLEZ1gap+pd33K13ZhRAWgVDqxKA7JIlBs5IDu+qvbiWl/pEbeuxzRrWyrrkeFFoTnvNw00iW9mBcow==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.3.1.tgz",
"integrity": "sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -10,7 +10,7 @@
[toolchain]
profile = "minimal"
channel = "1.96.0"
channel = "1.96.1"
components = [
# For rust-analyzer
"rust-src",
+26 -11
View File
@@ -1,5 +1,5 @@
use clap::Parser;
use conduwuit::Result;
use conduwuit::{Err, Result};
use crate::{
appservice::{self, AppserviceCommand},
@@ -8,6 +8,7 @@
debug::{self, DebugCommand},
federation::{self, FederationCommand},
media::{self, MediaCommand},
oidc::{self, OidcCommand},
query::{self, QueryCommand},
room::{self, RoomCommand},
server::{self, ServerCommand},
@@ -16,46 +17,50 @@
};
#[derive(Debug, Parser)]
#[command(name = conduwuit_core::name(), version = conduwuit_core::version())]
#[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())]
pub enum AdminCommand {
#[command(subcommand)]
/// Commands for managing appservices
#[command(subcommand)]
Appservices(AppserviceCommand),
#[command(subcommand)]
/// Commands for managing local users
#[command(subcommand)]
Users(UserCommand),
#[command(subcommand)]
/// Commands for managing registration tokens
#[command(subcommand)]
Token(TokenCommand),
/// Commands for managing OIDC
#[command(subcommand)]
Oidc(OidcCommand),
/// Commands for managing rooms
#[command(subcommand)]
Rooms(RoomCommand),
#[command(subcommand)]
/// Commands for managing federation
#[command(subcommand)]
Federation(FederationCommand),
#[command(subcommand)]
/// Commands for managing the server
#[command(subcommand)]
Server(ServerCommand),
#[command(subcommand)]
/// Commands for managing media
#[command(subcommand)]
Media(MediaCommand),
#[command(subcommand)]
/// Commands for checking integrity
#[command(subcommand)]
Check(CheckCommand),
#[command(subcommand)]
/// Commands for debugging things
#[command(subcommand)]
Debug(DebugCommand),
#[command(subcommand)]
/// Low-level queries for database getters and iterators
#[command(subcommand)]
Query(QueryCommand),
}
@@ -80,6 +85,16 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res
context.bail_restricted()?;
token::process(command, context).await
},
| Oidc(command) => {
// OIDC commands are all restricted
context.bail_restricted()?;
if !context.services.oidc.enabled() {
return Err!("OIDC is not configured");
}
oidc::process(command, context).await
},
| Rooms(command) => room::process(command, context).await,
| Federation(command) => federation::process(command, context).await,
| Server(command) => server::process(command, context).await,
+6 -1
View File
@@ -6,7 +6,12 @@
impl Context<'_> {
pub(super) async fn check_all_users(&self) -> Result {
let timer = tokio::time::Instant::now();
let users = self.services.users.stream().collect::<Vec<_>>().await;
let users = self
.services
.users
.stream_local_users()
.collect::<Vec<_>>()
.await;
let query_time = timer.elapsed();
let total = users.len();
+1 -1
View File
@@ -612,7 +612,7 @@ pub(super) async fn force_device_list_updates(&self) -> Result {
// Force E2EE device list updates for all users
self.services
.users
.stream()
.stream_local_users()
.for_each(async |user_id| self.services.users.mark_device_key_update(&user_id).await)
.await;
+1
View File
@@ -16,6 +16,7 @@
pub(crate) mod debug;
pub(crate) mod federation;
pub(crate) mod media;
pub(crate) mod oidc;
pub(crate) mod query;
pub(crate) mod room;
pub(crate) mod server;
+25
View File
@@ -0,0 +1,25 @@
use conduwuit::Result;
use crate::utils::parse_active_local_user_id;
impl crate::Context<'_> {
pub(super) async fn oidc_link(&self, user_id: String, subject: String) -> Result {
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.oidc.link_user(&user_id, &subject);
self.write_str(&format!("Subject `{subject}` linked to account `{user_id}`."))
.await?;
Ok(())
}
pub(super) async fn oidc_unlink(&self, subject: String) -> Result {
self.services.oidc.unlink_user(&subject);
self.write_str(&format!("Subject `{subject}` unlinked."))
.await?;
Ok(())
}
}
+22
View File
@@ -0,0 +1,22 @@
mod commands;
use clap::Subcommand;
use conduwuit::Result;
use conduwuit_macros::admin_command_dispatch;
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
pub enum OidcCommand {
/// Link a user ID to the given subject claim.
#[clap(name = "link")]
OidcLink {
user_id: String,
subject: String,
},
/// Unlink the given subject claim from its associated user ID.
#[clap(name = "unlink")]
OidcUnlink {
subject: String,
},
}
+8 -3
View File
@@ -191,8 +191,13 @@ async fn get_latest_backup(&self, user_id: OwnedUserId) -> Result {
async fn iter_users(&self) -> Result {
let timer = tokio::time::Instant::now();
let result: Vec<OwnedUserId> =
self.services.users.stream().map(Into::into).collect().await;
let result: Vec<OwnedUserId> = self
.services
.users
.stream_local_users()
.map(Into::into)
.collect()
.await;
let query_time = timer.elapsed();
@@ -202,7 +207,7 @@ async fn iter_users(&self) -> Result {
async fn iter_users2(&self) -> Result {
let timer = tokio::time::Instant::now();
let result: Vec<_> = self.services.users.stream().collect().await;
let result: Vec<_> = self.services.users.stream_local_users().collect().await;
let result: Vec<_> = result
.into_iter()
.map(|user_id| String::from_utf8_lossy(user_id.as_bytes()).into_owned())
+25 -2
View File
@@ -31,14 +31,37 @@ pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
.issue_token(self.sender_or_service_user().into(), expires);
self.write_str(&format!(
"New registration token issued: `{token}`. {}.",
"New registration token issued: `{token}` . {}.",
if let Some(expires) = info.expires {
format!("{expires}")
} else {
"Never expires".to_owned()
}
))
.await
.await?;
if self
.services
.config
.oauth
.compatibility_mode()
.oauth_available()
{
self.write_str(&format!(
"\nInvite link using this token: {}",
self.services
.config
.get_client_domain()
.join(&format!(
"{}/account/register/?flow=trusted&token={token}",
conduwuit::ROUTE_PREFIX
))
.unwrap()
))
.await?;
}
Ok(())
}
pub(super) async fn revoke_token(&self, token: String) -> Result {
+59 -208
View File
@@ -1,13 +1,10 @@
use std::{
collections::{BTreeMap, HashSet},
fmt::Write as _,
};
use std::collections::{BTreeMap, HashSet};
use api::client::{
full_user_deactivate, leave_room, recreate_push_rules_and_return, remote_leave_room,
};
use conduwuit::{
Err, Result, debug_warn, error, info,
Err, Result, debug_warn, info,
matrix::{Event, pdu::PartialPdu},
utils::{self, ReadyExt},
warn,
@@ -23,7 +20,7 @@
tag::{TagEvent, TagEventContent, TagInfo},
},
};
use service::users::HashedPassword;
use service::users::{AccountStatus, HashedPassword};
use crate::{
get_room_info,
@@ -51,134 +48,22 @@ pub(super) async fn list_users(&self) -> Result {
}
pub(super) async fn create_user(&self, username: String, password: Option<String>) -> Result {
// Validate user id
let user_id = parse_local_user_id(self.services, &username)?;
if let Err(e) = user_id.validate_strict() {
if self.services.config.emergency_password.is_none() {
return Err!("Username {user_id} contains disallowed characters or spaces: {e}");
}
}
if self.services.users.exists(&user_id).await {
return Err!("User {user_id} already exists");
}
let password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
// Create user
self.services
.users
.create(&user_id, Some(HashedPassword::new(&password)?))?;
// Default to pretty displayname
let mut displayname = user_id.localpart().to_owned();
// If `new_user_displayname_suffix` is set, registration will push whatever
// content is set to the user's display name with a space before it
if !self
let user_id = self
.services
.server
.config
.new_user_displayname_suffix
.is_empty()
{
write!(displayname, " {}", self.services.server.config.new_user_displayname_suffix)?;
}
self.services
.users
.set_displayname(&user_id, Some(displayname));
// Initial account data
self.services
.account_data
.update(
None,
&user_id,
ruma::events::GlobalAccountDataEventType::PushRules
.to_string()
.into(),
&serde_json::to_value(ruma::events::push_rules::PushRulesEvent::new(
ruma::events::push_rules::PushRulesEventContent::new(
ruma::push::Ruleset::server_default(&user_id),
),
))
.unwrap(),
)
.determine_registration_user_id(Some(username), None, None)
.await?;
if !self.services.server.config.auto_join_rooms.is_empty() {
for room in &self.services.server.config.auto_join_rooms {
let Ok(room_id) = self.services.rooms.alias.resolve(room).await else {
error!(
%user_id,
"Failed to resolve room alias to room ID when attempting to auto join {room}, skipping"
);
continue;
};
let password =
&password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
if !self
.services
.rooms
.state_cache
.server_in_room(self.services.globals.server_name(), &room_id)
.await
{
warn!(
"Skipping room {room} to automatically join as we have never joined \
before."
);
continue;
}
self.services
.users
.create_local_account(&user_id, Some(HashedPassword::new(password)?), None)
.await?;
if let Some(room_server_name) = room.server_name() {
match self
.services
.rooms
.membership
.join_room(
&user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
&[
self.services.globals.server_name().to_owned(),
room_server_name.to_owned(),
],
)
.await
{
| Ok(_response) => {
info!("Automatically joined room {room} for user {user_id}");
},
| Err(e) => {
// don't return this error so we don't fail registrations
error!(
"Failed to automatically join room {room} for user {user_id}: \
{e}"
);
self.services
.admin
.send_text(&format!(
"Failed to automatically join room {room} for user \
{user_id}: {e}"
))
.await;
},
}
}
}
}
// we dont add a device since we're not the user, just the creator
// Make the first user to register an administrator and disable first-run mode.
self.services.firstrun.empower_first_user(&user_id).await?;
self.write_str(&format!(
"Created user with user_id: {user_id} and password: `{password}`"
))
.await
self.write_str(&format!("Created user {user_id} with password `{password}`"))
.await
}
pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) -> Result {
@@ -218,15 +103,12 @@ 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_local_user_id(self.services, &user_id)?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
return Err!("Not allowed to suspend the server service account.",);
}
if !self.services.users.exists(&user_id).await {
return Err!("User {user_id} does not exist.");
}
if self.services.users.is_admin(&user_id).await {
return Err!("Admin users cannot be suspended.");
}
@@ -242,15 +124,12 @@ 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_local_user_id(self.services, &user_id)?;
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
return Err!("Not allowed to unsuspend the server service account.",);
}
if !self.services.users.exists(&user_id).await {
return Err!("User {user_id} does not exist.");
}
self.services.users.unsuspend_account(&user_id).await;
self.write_str(&format!("User {user_id} has been unsuspended."))
@@ -262,6 +141,7 @@ pub(super) async fn reset_password(
logout: bool,
username: String,
password: Option<String>,
convert_to_local_account: bool,
) -> Result {
let user_id = parse_local_user_id(self.services, &username)?;
@@ -274,15 +154,37 @@ pub(super) async fn reset_password(
let new_password =
password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
let new_password_hash = HashedPassword::new(&new_password)?;
self.services
.users
.set_password(&user_id, Some(HashedPassword::new(&new_password)?));
if convert_to_local_account {
self.services
.users
.convert_to_local_account(&user_id, new_password_hash)
.await?;
} else {
match self.services.users.status(&user_id).await {
| AccountStatus::Active if !self.services.users.is_shadow(&user_id).await => {
self.services
.users
.set_password(&user_id, new_password_hash)
.await?;
},
| AccountStatus::NotFound => {
return Err!("The provided user does not exist.");
},
| _ => {
return Err!(
"The provided user is a shadow or deactivated account. To convert it to \
a local account, pass the --convert-to-local-account flag."
);
},
}
self.write_str(&format!(
"Successfully reset the password for user {user_id}: `{new_password}`"
))
.await?;
self.write_str(&format!(
"Successfully reset the password for user {user_id}: `{new_password}`"
))
.await?;
}
if logout {
self.services
@@ -301,30 +203,6 @@ pub(super) async fn reset_password(
Ok(())
}
pub(super) async fn issue_password_reset_link(&self, username: String) -> Result {
use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM};
self.bail_restricted()?;
let mut reset_url = self
.services
.config
.get_client_domain()
.join(PASSWORD_RESET_PATH)
.unwrap();
let user_id = parse_local_user_id(self.services, &username)?;
let token = self.services.password_reset.issue_token(user_id).await?;
reset_url
.query_pairs_mut()
.append_pair(RESET_TOKEN_QUERY_PARAM, &token.token);
self.write_str(&format!("Password reset link issued for {username}: {reset_url}"))
.await?;
Ok(())
}
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
if self.body.len() < 2
|| !self.body[0].trim().starts_with("```")
@@ -1058,21 +936,16 @@ 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_local_user_id(self.services, &user_id)?;
assert!(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
return Err!("Not allowed to lock the server service account.",);
}
if !self.services.users.exists(&user_id).await {
return Err!("User {user_id} does not exist.");
}
if self.services.users.is_admin(&user_id).await {
return Err!("Admin users cannot be locked.");
}
self.services
.users
.lock_account(&user_id, self.sender_or_service_user())
@@ -1084,11 +957,8 @@ 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_local_user_id(self.services, &user_id)?;
assert!(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.users.unlock_account(&user_id).await;
self.write_str(&format!("User {user_id} has been unlocked."))
@@ -1097,21 +967,16 @@ 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_local_user_id(self.services, &user_id)?;
assert!(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
return Err!("Not allowed to log out the server service account.",);
}
if !self.services.users.exists(&user_id).await {
return Err!("User {user_id} does not exist.");
}
if self.services.users.is_admin(&user_id).await {
return Err!("You cannot forcefully log out admin users.");
}
self.services
.users
.all_device_ids(&user_id)
@@ -1128,18 +993,12 @@ 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_local_user_id(self.services, &user_id)?;
assert!(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
if user_id == self.services.globals.server_user {
return Err!("Not allowed to disable login for the server service account.",);
}
if !self.services.users.exists(&user_id).await {
return Err!("User {user_id} does not exist.");
}
if self.services.users.is_admin(&user_id).await {
return Err!("Admin users cannot have their login disallowed.");
}
@@ -1153,14 +1012,8 @@ 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_local_user_id(self.services, &user_id)?;
assert!(
self.services.globals.user_is_local(&user_id),
"Parsed user_id must be a local user"
);
if !self.services.users.exists(&user_id).await {
return Err!("User {user_id} does not exist.");
}
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
self.services.users.enable_login(&user_id);
self.write_str(&format!("{user_id} can now log in.")).await
@@ -1268,10 +1121,8 @@ pub(super) async fn change_email(&self, user_id: String, email: Option<String>)
}
pub(super) async fn reset_push_rules(&self, user_id: String) -> Result {
let user_id = parse_local_user_id(self.services, &user_id)?;
if !self.services.users.is_active(&user_id).await {
return Err!("User is not active.");
}
let user_id = parse_active_local_user_id(self.services, &user_id).await?;
recreate_push_rules_and_return(self.services, &user_id).await?;
self.write_str("Reset user's push rules to the server default.")
.await
+2 -5
View File
@@ -27,12 +27,9 @@ pub enum UserCommand {
username: String,
/// New password for the user, if unspecified one is generated
password: Option<String>,
},
/// Issue a self-service password reset link for a user.
IssuePasswordResetLink {
/// Username of the user who may use the link
username: String,
#[arg(long)]
convert_to_local_account: bool,
},
/// Get a user's associated email address.
+1 -8
View File
@@ -54,14 +54,7 @@ pub(crate) async fn parse_active_local_user_id(
user_id: &str,
) -> Result<OwnedUserId> {
let user_id = parse_local_user_id(services, user_id)?;
if !services.users.exists(&user_id).await {
return Err!("User {user_id:?} does not exist on this server.");
}
if services.users.is_deactivated(&user_id).await? {
return Err!("User {user_id:?} is deactivated.");
}
services.users.status(&user_id).await.ensure_active()?;
Ok(user_id)
}
+30 -46
View File
@@ -24,7 +24,7 @@
power_levels::RoomPowerLevelsEventContent,
},
};
use service::{mailer::messages, uiaa::Identity, users::HashedPassword};
use service::{mailer::messages, uiaa::UiaaInitiator, users::HashedPassword};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::{Ruma, router::ClientIdentity};
@@ -49,39 +49,16 @@ pub(crate) async fn get_register_available_route(
ClientIp(client): ClientIp,
body: Ruma<get_username_availability::v3::Request>,
) -> Result<get_username_availability::v3::Response> {
// Validate user id
let user_id =
match UserId::parse_with_server_name(&body.username, services.globals.server_name()) {
| Ok(user_id) => {
if let Err(e) = user_id.validate_strict() {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {} contains disallowed characters or spaces: {e}",
body.username
))));
}
user_id
},
| Err(e) => {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {} is not valid: {e}",
body.username
))));
},
};
// Check if username is creative enough
if services.users.exists(&user_id).await {
return Err!(Request(UserInUse("User ID is not available.")));
}
if let Some(ClientIdentity::Appservice { appservice_info, .. }) = &body.identity
&& !appservice_info.is_user_match(&user_id)
{
return Err!(Request(Exclusive("Username is not in an appservice namespace.")));
} else if services.appservice.is_exclusive_user_id(&user_id).await {
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
}
let _ = services
.users
.determine_registration_user_id(
Some(body.username.clone()),
None,
body.identity
.as_ref()
.and_then(ClientIdentity::appservice_info),
)
.await?;
Ok(get_username_availability::v3::Response::new(true))
}
@@ -109,12 +86,7 @@ pub(crate) async fn change_password_route(
ClientIp(client): ClientIp,
body: Ruma<change_password::v3::Request>,
) -> Result<change_password::v3::Response> {
let identity = if let Some(user_id) = body
.identity
.as_ref()
.map(ClientIdentity::expect_sender_user)
.transpose()?
{
let identity = if let Some(identity) = body.identity.as_ref() {
// A signed-in user is trying to change their password, prompt them for their
// existing one
@@ -124,7 +96,10 @@ pub(crate) async fn change_password_route(
&body.auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
Some(Identity::from_user_id(user_id)),
Some(UiaaInitiator::new(
identity.expect_sender_user()?,
identity.sender_device(),
)),
)
.await?
} else {
@@ -153,7 +128,8 @@ pub(crate) async fn change_password_route(
services
.users
.set_password(&sender_user, Some(HashedPassword::new(&body.new_password)?));
.set_password(&sender_user, HashedPassword::new(&body.new_password)?)
.await?;
if body.logout_devices {
// Logout all devices except the current one
@@ -280,16 +256,24 @@ pub(crate) async fn deactivate_route(
) -> Result<deactivate::v3::Response> {
// Authentication for this endpoint is technically optional,
// but we require the user to be logged in
let sender_user = body
let identity = body
.identity
.as_ref()
.map(ClientIdentity::expect_sender_user)
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))??;
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
let sender_user = identity.expect_sender_user()?;
if !services.config.allow_deactivation {
return Err!(Request(Forbidden(
"You may not deactivate your own account. Contact your server's administrator for \
assistance."
)));
}
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, identity.sender_device(), None)
.await?;
// Remove profile pictures and display name
+56 -292
View File
@@ -1,17 +1,15 @@
use std::{collections::HashMap, fmt::Write};
use std::collections::HashMap;
use axum::extract::State;
use axum_client_ip::ClientIp;
use conduwuit::{
Err, Result, debug_info, error, info,
Err, Result, debug_info, info,
utils::{self},
warn,
};
use conduwuit_service::Services;
use futures::{FutureExt, StreamExt};
use futures::StreamExt;
use lettre::{Address, message::Mailbox};
use ruma::{
OwnedUserId, UserId,
api::client::{
account::{
register::{self, LoginType, RegistrationKind},
@@ -20,11 +18,6 @@
uiaa::{AuthFlow, AuthType},
},
assign,
events::{
GlobalAccountDataEventType, push_rules::PushRulesEvent,
room::message::RoomMessageEventContent,
},
push,
};
use serde_json::value::RawValue;
use service::{mailer::messages, users::HashedPassword};
@@ -32,8 +25,6 @@
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
const RANDOM_USER_ID_LENGTH: usize = 10;
/// # `POST /_matrix/client/v3/register`
///
/// Register an account on this homeserver.
@@ -52,8 +43,6 @@ pub(crate) async fn register_route(
return Err!(Request(GuestAccessForbidden("Guests may not register on this server.")));
}
let emergency_mode_enabled = services.config.emergency_password.is_some();
// Allow registration if it's enabled in the config file or if this is the first
// run (so the first user account can be created)
let allow_registration =
@@ -71,99 +60,59 @@ pub(crate) async fn register_route(
)));
}
let identity = if body.identity.is_some() {
// Appservices can skip auth
None
let user_id = if body.body.login_type == Some(LoginType::ApplicationService) {
let Some(appservice_info) = &body.identity else {
return Err!(Request(Forbidden(
"Only appservices can use the appservice login type."
)));
};
let user_id = services
.users
.determine_registration_user_id(body.username.clone(), None, Some(appservice_info))
.await?;
services.users.create_shadow_account(&user_id).await?;
user_id
} else {
// Perform UIAA to determine the user's identity
let (flows, params) = create_registration_uiaa_session(&services).await?;
Some(
services
.uiaa
.authenticate(&body.auth, flows, params, None)
.await?,
)
};
// If the user didn't supply a username but did supply an email, use
// the email's user as their initial localpart to avoid falling back to
// a randomly generated localpart
let supplied_username = body.username.clone().or_else(|| {
if let Some(identity) = &identity
&& let Some(email) = &identity.email
{
Some(email.user().to_owned())
} else {
None
}
});
let user_id =
determine_registration_user_id(&services, supplied_username, emergency_mode_enabled)
let identity = services
.uiaa
.authenticate(&body.auth, flows, params, None)
.await?;
if body.body.login_type == Some(LoginType::ApplicationService) {
// For appservice logins, make sure that the user ID is in the appservice's
// namespace
let password = if let Some(password) = &body.password {
HashedPassword::new(password)?
} else {
return Err!(Request(InvalidParam("A password must be provided.")));
};
match body.identity {
| Some(ref info) =>
if !info.is_user_match(&user_id) && !emergency_mode_enabled {
return Err!(Request(Exclusive(
"Username is not in an appservice namespace."
)));
},
| _ => {
return Err!(Request(MissingToken("Missing appservice token.")));
},
}
} else if services.appservice.is_exclusive_user_id(&user_id).await && !emergency_mode_enabled
{
// For non-appservice logins, ban user IDs which are in an appservice's
// namespace (unless emergency mode is enabled)
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
}
let user_id = services
.users
.determine_registration_user_id(body.username.clone(), identity.email.as_ref(), None)
.await?;
let password = if body.identity.is_some() {
None
} else if let Some(password) = body.password.as_deref() {
Some(HashedPassword::new(password)?)
} else {
return Err!(Request(InvalidParam("A password must be provided")));
services
.users
.create_local_account(&user_id, Some(password), identity.email)
.await?;
user_id
};
// Create user
services.users.create(&user_id, password)?;
// Set an initial display name
let mut displayname = user_id.localpart().to_owned();
// Apply the new user displayname suffix, if it's set
if !services.globals.new_user_displayname_suffix().is_empty() && body.identity.is_none() {
write!(displayname, " {}", services.server.config.new_user_displayname_suffix)?;
}
services
.users
.set_displayname(&user_id, Some(displayname.clone()));
// Initial account data
services
.account_data
.update(
None,
&user_id,
GlobalAccountDataEventType::PushRules.to_string().into(),
&serde_json::to_value(PushRulesEvent::new(
push::Ruleset::server_default(&user_id).into(),
))
.expect("should be able to serialize push rules"),
)
.await?;
// Generate new device id if the user didn't specify one
let (token, device) = if !body.inhibit_login {
// If UIAA is disabled, we can't create a device. In that case only appservices
// can reach this point in the first place, so we return an error for them.
if !services.config.oauth.compatibility_mode().uiaa_available() {
return Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice registration is not available on this server."
)));
}
// Generate new device id if the user didn't specify one
let device_id = body
.device_id
.clone()
@@ -179,6 +128,7 @@ pub(crate) async fn register_route(
&user_id,
&device_id,
&new_token,
None,
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
@@ -189,118 +139,7 @@ pub(crate) async fn register_route(
(None, None)
};
debug_info!(%user_id, ?device, "User account was created");
// If the user registered with an email, associate it with their account.
if let Some(identity) = identity
&& let Some(email) = identity.email
{
// This may fail if the email is already in use, but we already check for that
// in `/requestToken`, so ignoring the error is acceptable here in the rare case
// that an email is sniped by another user between the `/requestToken` request
// and the `/register` request.
let _ = services
.threepid
.associate_localpart_email(user_id.localpart(), &email)
.await;
}
let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
if body.identity.is_none() {
if !device_display_name.is_empty() {
let notice = format!(
"New user \"{user_id}\" registered on this server from IP {client} and device \
display name \"{device_display_name}\""
);
info!("{notice}");
if services.server.config.admin_room_notices {
services.admin.notice(&notice).await;
}
} else {
let notice = format!("New user \"{user_id}\" registered on this server.");
info!("{notice}");
if services.server.config.admin_room_notices {
services.admin.notice(&notice).await;
}
}
}
// Make the first user to register an administrator and disable first-run mode.
let was_first_user = services.firstrun.empower_first_user(&user_id).await?;
// If the registering user was not the first and we're suspending users on
// register, suspend them.
if !was_first_user && services.config.suspend_on_register {
// Note that we can still do auto joins for suspended users
services
.users
.suspend_account(&user_id, &services.globals.server_user)
.await;
// And send an @room notice to the admin room, to prompt admins to review the
// new user and ideally unsuspend them if deemed appropriate.
if services.server.config.admin_room_notices {
services
.admin
.send_loud_message(RoomMessageEventContent::text_plain(format!(
"User {user_id} has been suspended as they are not the first user on this \
server. Please review and unsuspend them if appropriate."
)))
.await
.ok();
}
}
if body.identity.is_none() && !services.server.config.auto_join_rooms.is_empty() {
for room in &services.server.config.auto_join_rooms {
let Ok(room_id) = services.rooms.alias.resolve(room).await else {
error!(
"Failed to resolve room alias to room ID when attempting to auto join \
{room}, skipping"
);
continue;
};
if !services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), &room_id)
.await
{
warn!(
"Skipping room {room} to automatically join as we have never joined before."
);
continue;
}
if let Some(room_server_name) = room.server_name() {
match services
.rooms
.membership
.join_room(
&user_id,
&room_id,
Some("Automatically joining this room upon registration".to_owned()),
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
)
.boxed()
.await
{
| Err(e) => {
// don't return this error so we don't fail registrations
error!(
"Failed to automatically join room {room} for user {user_id}: {e}"
);
},
| _ => {
info!("Automatically joined room {room} for user {user_id}");
},
}
}
}
}
debug_info!(%user_id, ?device, "New account created via legacy registration");
Ok(assign!(register::v3::Response::new(user_id), {
access_token: token,
@@ -372,21 +211,21 @@ async fn create_registration_uiaa_session(
// Require all users to agree to the terms and conditions, if configured
let terms = &services.config.registration_terms;
if !terms.is_empty() {
let mut terms =
serde_json::to_value(terms.clone()).expect("failed to serialize terms");
if !terms.documents.is_empty() {
let mut terms_map = HashMap::new();
// Insert a dummy `version` field
for (_, documents) in terms.as_object_mut().unwrap() {
let documents = documents.as_object_mut().unwrap();
documents.insert("version".to_owned(), "latest".into());
for (id, document) in &terms.documents {
terms_map.insert(id.to_owned(), serde_json::json!({
terms.language.clone(): serde_json::to_value(document).expect("should be able to serialize document")
}));
}
terms_map.insert("version".to_owned(), "latest".into());
params.insert(
AuthType::Terms.as_str().to_owned(),
serde_json::json!({
"policies": terms,
"policies": terms_map,
}),
);
@@ -419,81 +258,6 @@ async fn create_registration_uiaa_session(
Ok((flows, params))
}
async fn determine_registration_user_id(
services: &Services,
supplied_username: Option<String>,
emergency_mode_enabled: bool,
) -> Result<OwnedUserId> {
if let Some(supplied_username) = supplied_username {
// The user gets to pick their username. Do some validation to make sure it's
// acceptable.
// Don't allow registration with forbidden usernames.
if services
.globals
.forbidden_usernames()
.is_match(&supplied_username)
&& !emergency_mode_enabled
{
return Err!(Request(Forbidden("Username is forbidden")));
}
// Create and validate the user ID
let user_id = match UserId::parse_with_server_name(
&supplied_username,
services.globals.server_name(),
) {
| Ok(user_id) => {
if let Err(e) = user_id.validate_strict() {
// Unless we are in emergency mode, we should follow synapse's behaviour on
// not allowing things like spaces and UTF-8 characters in usernames
if !emergency_mode_enabled {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {supplied_username} contains disallowed characters or \
spaces: {e}"
))));
}
}
// Don't allow registration with user IDs that aren't local
if !services.globals.user_is_local(&user_id) {
return Err!(Request(InvalidUsername(
"Username {supplied_username} is not local to this server"
)));
}
user_id
},
| Err(e) => {
return Err!(Request(InvalidUsername(debug_warn!(
"Username {supplied_username} is not valid: {e}"
))));
},
};
if services.users.exists(&user_id).await {
return Err!(Request(UserInUse("User ID is not available.")));
}
Ok(user_id)
} else {
// The user didn't specify a username. Generate a username for
// them.
loop {
let user_id = UserId::parse_with_server_name(
utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
services.globals.server_name(),
)
.unwrap();
if !services.users.exists(&user_id).await {
break Ok(user_id);
}
}
}
}
/// # `POST /_matrix/client/v3/register/email/requestToken`
///
/// Requests a validation email for the purpose of registering a new account.
+11 -4
View File
@@ -11,7 +11,7 @@
},
thirdparty::{Medium, ThirdPartyIdentifierInit},
};
use service::{mailer::messages, uiaa::Identity};
use service::mailer::messages;
use crate::{Ruma, router::ClientIdentity};
@@ -25,6 +25,10 @@ pub(crate) async fn third_party_route(
let sender_user = body.identity.expect_sender_user()?;
let mut threepids = vec![];
if !services.threepid.email_requirement().may_view() {
return Ok(get_3pids::v3::Response::new(vec![]));
}
if let Some(email) = services
.threepid
.get_email_for_localpart(sender_user.localpart())
@@ -124,15 +128,18 @@ pub(crate) async fn add_3pid_route(
.uiaa
.authenticate_password(
&body.auth,
Some(Identity::from_user_id(body.identity.expect_sender_user()?)),
body.identity.expect_sender_user()?,
body.identity.sender_device(),
None,
)
.await?;
let email = services
.threepid
.consume_valid_session(&body.sid, &body.client_secret)
.get_valid_session(&body.sid, &body.client_secret)
.await
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?;
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?
.consume();
services
.threepid
+87
View File
@@ -0,0 +1,87 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::future::{join, join3};
use ruma::api::client::admin::{is_user_locked, lock_user};
use crate::Ruma;
/// # `GET /_matrix/client/v1/admin/lock/{userId}`
///
/// Check the account lock status of a target user
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")));
}
status.ensure_active()?;
Ok(is_user_locked::v1::Response::new(
services.users.is_locked(&body.user_id).await?,
))
}
/// # `PUT /_matrix/client/v1/admin/lock/{userId}`
///
/// Set the account lock status of a target user
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_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")));
}
status.ensure_active()?;
if body.user_id == *sender_user {
return Err!(Request(Forbidden("You cannot lock yourself")));
}
if target_admin {
return Err!(Request(Forbidden("You cannot lock another server administrator")));
}
if services.users.is_locked(&body.user_id).await? == body.locked {
// No change
return Ok(lock_user::v1::Response::new(body.locked));
}
let action = if body.locked {
services
.users
.suspend_account(&body.user_id, sender_user)
.await;
"locked"
} else {
services.users.unsuspend_account(&body.user_id).await;
"unlocked"
};
if services.config.admin_room_notices {
// 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))
.await;
}
Ok(lock_user::v1::Response::new(body.locked))
}
+2 -1
View File
@@ -1,3 +1,4 @@
mod lock;
mod suspend;
pub(crate) use self::suspend::*;
pub(crate) use self::{lock::*, suspend::*};
+21 -24
View File
@@ -1,7 +1,7 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use futures::future::{join, join3};
use ruminuwuity::admin::{get_suspended, set_suspended};
use ruma::api::client::admin::{is_user_suspended, suspend_user};
use crate::Ruma;
@@ -10,23 +10,21 @@
/// Check the suspension status of a target user
pub(crate) async fn get_suspended_status(
State(services): State<crate::State>,
body: Ruma<get_suspended::v1::Request>,
) -> Result<get_suspended::v1::Response> {
let (admin, active) = join(
body: Ruma<is_user_suspended::v1::Request>,
) -> Result<is_user_suspended::v1::Response> {
let (admin, status) = join(
services.users.is_admin(body.identity.expect_sender_user()?),
services.users.is_active(&body.user_id),
services.users.status(&body.user_id),
)
.await;
if !admin {
return Err!(Request(Forbidden("Only server administrators can use this endpoint")));
}
if !services.globals.user_is_local(&body.user_id) {
return Err!(Request(InvalidParam("Can only check the suspended status of local users")));
}
if !active {
return Err!(Request(NotFound("Unknown user")));
}
Ok(get_suspended::v1::Response::new(
status.ensure_active()?;
Ok(is_user_suspended::v1::Response::new(
services.users.is_suspended(&body.user_id).await?,
))
}
@@ -36,13 +34,13 @@ pub(crate) async fn get_suspended_status(
/// Set the suspension status of a target user
pub(crate) async fn put_suspended_status(
State(services): State<crate::State>,
body: Ruma<set_suspended::v1::Request>,
) -> Result<set_suspended::v1::Response> {
body: Ruma<suspend_user::v1::Request>,
) -> Result<suspend_user::v1::Response> {
let sender_user = body.identity.expect_sender_user()?;
let (sender_admin, active, target_admin) = join3(
let (sender_admin, status, target_admin) = join3(
services.users.is_admin(sender_user),
services.users.is_active(&body.user_id),
services.users.status(&body.user_id),
services.users.is_admin(&body.user_id),
)
.await;
@@ -50,21 +48,20 @@ pub(crate) async fn put_suspended_status(
if !sender_admin {
return Err!(Request(Forbidden("Only server administrators can use this endpoint")));
}
if !services.globals.user_is_local(&body.user_id) {
return Err!(Request(InvalidParam("Can only set the suspended status of local users")));
}
if !active {
return Err!(Request(NotFound("Unknown user")));
}
status.ensure_active()?;
if body.user_id == *sender_user {
return Err!(Request(Forbidden("You cannot suspend yourself")));
}
if target_admin {
return Err!(Request(Forbidden("You cannot suspend another server administrator")));
}
if services.users.is_suspended(&body.user_id).await? == body.suspended {
// No change
return Ok(set_suspended::v1::Response::new(body.suspended));
return Ok(suspend_user::v1::Response::new(body.suspended));
}
let action = if body.suspended {
@@ -86,5 +83,5 @@ pub(crate) async fn put_suspended_status(
.await;
}
Ok(set_suspended::v1::Response::new(body.suspended))
Ok(suspend_user::v1::Response::new(body.suspended))
}
+10 -12
View File
@@ -7,12 +7,12 @@
api::client::discovery::get_capabilities::{
self,
v3::{
Capabilities, GetLoginTokenCapability, RoomVersionStability, RoomVersionsCapability,
ThirdPartyIdChangesCapability,
Capabilities, GetLoginTokenCapability, ProfileFieldsCapability, RoomVersionStability,
RoomVersionsCapability, ThirdPartyIdChangesCapability,
},
},
assign,
};
use serde_json::json;
use crate::Ruma;
@@ -40,22 +40,20 @@ pub(crate) async fn get_capabilities_route(
capabilities.get_login_token =
GetLoginTokenCapability::new(services.server.config.login_via_existing_session);
// MSC4133 capability
capabilities.set("uk.tcpip.msc4133.profile_fields", json!({"enabled": true}))?;
capabilities.set(
"org.matrix.msc4267.forget_forced_upon_leave",
json!({"enabled": services.config.forget_forced_upon_leave}),
)?;
capabilities.forget_forced_upon_leave.enabled = true;
if services
.users
.is_admin(body.identity.expect_sender_user()?)
.await
{
// Advertise suspension API
capabilities.set("uk.timedout.msc4323", json!({"suspend": true, "lock": false}))?;
capabilities.account_moderation.lock = true;
capabilities.account_moderation.suspend = true;
}
capabilities.profile_fields = Some(
assign!(ProfileFieldsCapability::new(true), { disallowed: Some(services.oidc.restricted_profile_fields()) }),
);
Ok(get_capabilities::v3::Response::new(capabilities))
}
+7 -7
View File
@@ -8,7 +8,6 @@
self, delete_device, delete_devices, get_device, get_devices, update_device,
},
};
use service::uiaa::Identity;
use crate::{Ruma, client::DEVICE_ID_LENGTH};
@@ -95,6 +94,7 @@ pub(crate) async fn update_device_route(
&device_id,
&appservice.registration.as_token,
None,
None,
Some(client.to_string()),
)
.await?;
@@ -119,14 +119,15 @@ pub(crate) async fn delete_device_route(
body: Ruma<delete_device::v3::Request>,
) -> Result<delete_device::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if appservice.is_none() {
if !body.identity.is_appservice() {
let sender_device = body.identity.expect_sender_device()?;
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.await?;
}
@@ -155,14 +156,13 @@ pub(crate) async fn delete_devices_route(
body: Ruma<delete_devices::v3::Request>,
) -> Result<delete_devices::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if appservice.is_none() {
if let Some(sender_device) = body.identity.sender_device() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.await?;
}
+14 -2
View File
@@ -26,7 +26,7 @@
serde::Raw,
};
use serde_json::json;
use service::uiaa::Identity;
use service::oauth::OAuthTicket;
use crate::Ruma;
@@ -197,6 +197,7 @@ pub(crate) async fn upload_signing_keys_route(
if uiaa_needed_to_upload_keys(
services,
sender_user,
body.identity.is_appservice(),
body.self_signing_key.as_ref(),
body.user_signing_key.as_ref(),
body.master_key.as_ref(),
@@ -205,7 +206,12 @@ pub(crate) async fn upload_signing_keys_route(
{
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(
&body.auth,
sender_user,
body.identity.sender_device(),
Some(OAuthTicket::CrossSigningReset),
)
.await?;
}
@@ -226,10 +232,16 @@ pub(crate) async fn upload_signing_keys_route(
async fn uiaa_needed_to_upload_keys(
services: crate::State,
user_id: &UserId,
is_appservice: bool,
self_signing_key: Option<&Raw<CrossSigningKey>>,
user_signing_key: Option<&Raw<CrossSigningKey>>,
master_signing_key: Option<&Raw<CrossSigningKey>>,
) -> bool {
if is_appservice {
// Appservices can skip UIAA for this endpoint
return false;
}
let (self_signing_key, user_signing_key, master_signing_key) = (
self_signing_key.map(Raw::deserialize).flat_ok(),
user_signing_key.map(Raw::deserialize).flat_ok(),
+3 -1
View File
@@ -291,7 +291,9 @@ pub(crate) async fn is_ignored_pdu<Pdu>(
{
// exclude Synapse's dummy events from bloating up response bodies. clients
// don't need to see this.
if event.kind().to_string() == "org.matrix.dummy_event" {
if !services.config.send_dummy_events_to_clients
&& event.kind().to_string() == "org.matrix.dummy_event"
{
return Ok(true);
}
+3
View File
@@ -16,6 +16,7 @@
pub(super) mod membership;
pub(super) mod message;
pub(super) mod mutual_rooms;
pub(super) mod oauth;
pub(super) mod openid;
pub(super) mod presence;
pub(super) mod profile;
@@ -61,6 +62,7 @@
pub use membership::{leave_all_rooms, leave_room, remote_leave_room};
pub(super) use message::*;
pub(super) use mutual_rooms::*;
pub(super) use oauth::*;
pub(super) use openid::*;
pub(super) use presence::*;
pub(super) use profile::*;
@@ -73,6 +75,7 @@
pub(super) use room::*;
pub(super) use search::*;
pub(super) use send::*;
pub use session::handle_login;
pub(super) use session::*;
pub(super) use space::*;
pub(super) use state::*;
+56
View File
@@ -0,0 +1,56 @@
use axum::{
Json, Router,
extract::{Request, State},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::method_routing::{get, post},
};
use const_str::concat;
use http::StatusCode;
use serde_json::json;
pub(crate) use server_metadata::*;
mod register_client;
mod server_metadata;
mod token;
const BASE_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/oauth2/");
const AUTH_CODE_PATH: &str = "grant/authorization_code";
const JWKS_URI_PATH: &str = "client/keys.json";
const CLIENT_REGISTER_PATH: &str = "client/register";
const TOKEN_REVOKE_PATH: &str = "client/revoke";
const TOKEN_PATH: &str = "grant/token";
const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink");
pub(crate) fn router(state: crate::State) -> Router<crate::State> {
Router::new()
.nest(BASE_PATH, oauth_router())
.route(
"/.well-known/openid-configuration",
get(
// TODO(unspecced): used by old versions of the matrix-js-sdk
async |State(services): State<crate::State>| {
Json(authorization_server_metadata(&services).await)
},
),
)
.layer(middleware::from_fn_with_state(
state,
async |State(state): State<crate::State>, request: Request, next: Next| -> Response {
if state.config.oauth.compatibility_mode().oauth_available() {
next.run(request).await
} else {
(StatusCode::NOT_FOUND, "OAuth is unavailable on this server").into_response()
}
},
))
}
fn oauth_router() -> Router<crate::State> {
Router::new()
.route(concat!("/", CLIENT_REGISTER_PATH), post(register_client::register_client_route))
// TODO(unspecced): used by old versions of the matrix-js-sdk
.route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []}))))
.route(concat!("/", TOKEN_PATH), post(token::token_route))
.route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route))
}
+28
View File
@@ -0,0 +1,28 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use http::StatusCode;
use serde::Serialize;
use service::oauth::client_metadata::ClientMetadata;
#[derive(Serialize)]
struct RegisteredClient {
client_id: String,
#[serde(flatten)]
metadata: ClientMetadata,
}
pub(crate) async fn register_client_route(
State(services): State<crate::State>,
Json(metadata): Json<ClientMetadata>,
) -> Result<Response, Response> {
let client_id = services
.oauth
.register_client(&metadata)
.await
.map_err(|err| (StatusCode::BAD_REQUEST, Json(err)).into_response())?;
Ok(Json(RegisteredClient { client_id, metadata }).into_response())
}
+62
View File
@@ -0,0 +1,62 @@
use axum::extract::State;
use conduwuit::{Err, Result};
use ruma::{
api::client::discovery::get_authorization_server_metadata::{
self, v1::AccountManagementAction,
},
serde::Raw,
};
use serde_json::{Value, json};
use service::Services;
use crate::{
Ruma,
client::oauth::{
ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH,
TOKEN_REVOKE_PATH,
},
};
pub(crate) async fn get_authorization_server_metadata_route(
State(services): State<crate::State>,
_body: Ruma<get_authorization_server_metadata::v1::Request>,
) -> Result<get_authorization_server_metadata::v1::Response> {
if !services.config.oauth.compatibility_mode().oauth_available() {
return Err!(Request(Unrecognized("OAuth is unavailable on this server")));
}
let metadata = Raw::new(&authorization_server_metadata(&services).await).unwrap();
Ok(get_authorization_server_metadata::v1::Response::new(metadata.cast_unchecked()))
}
pub(crate) async fn authorization_server_metadata(services: &Services) -> Value {
let endpoint_base = services
.config
.get_client_domain()
.join(super::BASE_PATH)
.unwrap();
json!({
"account_management_uri": endpoint_base.join(ACCOUNT_MANAGEMENT_PATH).unwrap(),
"account_management_actions_supported": [
AccountManagementAction::AccountDeactivate,
AccountManagementAction::CrossSigningReset,
AccountManagementAction::DeviceDelete,
AccountManagementAction::DeviceView,
AccountManagementAction::DevicesList,
AccountManagementAction::Profile,
],
"authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(),
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"issuer": services.config.get_client_domain(),
"jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(),
"prompt_values_supported": ["create"],
"registration_endpoint": endpoint_base.join(CLIENT_REGISTER_PATH).unwrap(),
"response_modes_supported": ["query", "fragment"],
"response_types_supported": ["code"],
"revocation_endpoint": endpoint_base.join(TOKEN_REVOKE_PATH).unwrap(),
"token_endpoint": endpoint_base.join(TOKEN_PATH).unwrap(),
})
}
+23
View File
@@ -0,0 +1,23 @@
use axum::{Form, Json, extract::State, response::IntoResponse};
use http::StatusCode;
use service::oauth::grant::{RevokeTokenRequest, TokenRequest};
pub(crate) async fn token_route(
State(services): State<crate::State>,
Form(request): Form<TokenRequest>,
) -> impl IntoResponse {
match services.oauth.issue_token(request).await {
| Ok(response) => Ok(Json(response)),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
pub(crate) async fn revoke_token_route(
State(services): State<crate::State>,
Form(request): Form<RevokeTokenRequest>,
) -> impl IntoResponse {
match services.oauth.revoke_token(request.token).await {
| Ok(()) => Ok(StatusCode::OK),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
+58 -289
View File
@@ -1,9 +1,8 @@
use std::collections::BTreeMap;
use axum::extract::State;
use conduwuit::{Err, Result, matrix::pdu::PartialPdu, utils::to_canonical_object};
use conduwuit::{Err, Result};
use conduwuit_service::Services;
use futures::StreamExt;
use ruma::{
UserId,
api::{
@@ -13,11 +12,10 @@
federation,
},
assign,
events::room::member::MembershipState,
presence::PresenceState,
profile::{ProfileFieldName, ProfileFieldValue},
};
use serde_json::{Value, to_value};
use serde_json::Value;
use service::users::ProfileFieldChange;
use crate::Ruma;
@@ -65,13 +63,24 @@ pub(crate) async fn set_profile_field_route(
return Err!(Request(InvalidParam("You may not change a remote user's profile data.")));
}
set_profile_field(
&services,
&body.user_id,
ProfileFieldChange::Set(body.value.clone()),
body.propagate_to.clone(),
)
.await?;
if services
.oidc
.restricted_profile_fields()
.contains(&body.value.field_name())
{
return Err!(Request(Forbidden(
"This profile field is controlled by your identity provider."
)));
}
services
.users
.set_profile_field(
&body.user_id,
ProfileFieldChange::Set(body.value.clone()),
body.propagate_to.clone(),
)
.await?;
Ok(set_profile_field::v3::Response::new())
}
@@ -94,13 +103,24 @@ pub(crate) async fn delete_profile_field_route(
return Err!(Request(InvalidParam("You may not change a remote user's profile data.")));
}
set_profile_field(
&services,
&body.user_id,
ProfileFieldChange::Delete(body.field.clone()),
body.propagate_to.clone(),
)
.await?;
if services
.oidc
.restricted_profile_fields()
.contains(&body.field)
{
return Err!(Request(Forbidden(
"This profile field is controlled by your identity provider."
)));
}
services
.users
.set_profile_field(
&body.user_id,
ProfileFieldChange::Delete(body.field.clone()),
body.propagate_to.clone(),
)
.await?;
Ok(delete_profile_field::v3::Response::new())
}
@@ -110,8 +130,8 @@ async fn fetch_full_profile(
user_id: &UserId,
) -> Option<BTreeMap<String, Value>> {
// If the user exists locally, fetch their local profile
if services.users.exists(user_id).await {
return Some(get_local_profile(services, user_id).await);
if services.users.status(user_id).await.is_found() {
return Some(services.users.get_local_profile(user_id).await);
}
// Otherwise ask their homeserver
@@ -135,13 +155,10 @@ async fn fetch_full_profile(
continue;
};
let _ = set_profile_field(
services,
user_id,
ProfileFieldChange::Set(value),
PropagateTo::None,
)
.await;
let _ = services
.users
.set_profile_field(user_id, ProfileFieldChange::Set(value), PropagateTo::None)
.await;
}
Some(BTreeMap::from_iter(response))
@@ -154,7 +171,7 @@ async fn fetch_profile_field(
) -> Result<Option<ProfileFieldValue>> {
// If the user exists locally, fetch their local profile field
if services.globals.user_is_local(user_id) {
return Ok(get_local_profile_field(services, user_id, field).await);
return Ok(services.users.get_local_profile_field(user_id, field).await);
}
// Otherwise ask their homeserver
@@ -175,13 +192,14 @@ async fn fetch_profile_field(
if let Some(value) = response.get(field.as_str()).map(ToOwned::to_owned) {
if let Ok(value) = ProfileFieldValue::new(field.as_str(), value) {
let _ = set_profile_field(
services,
user_id,
ProfileFieldChange::Set(value.clone()),
PropagateTo::None,
)
.await;
let _ = services
.users
.set_profile_field(
user_id,
ProfileFieldChange::Set(value.clone()),
PropagateTo::None,
)
.await;
Ok(Some(value))
} else {
@@ -190,260 +208,11 @@ async fn fetch_profile_field(
)))
}
} else {
let _ = set_profile_field(
services,
user_id,
ProfileFieldChange::Delete(field),
PropagateTo::None,
)
.await;
let _ = services
.users
.set_profile_field(user_id, ProfileFieldChange::Delete(field), PropagateTo::None)
.await;
Ok(None)
}
}
pub(crate) async fn get_local_profile(
services: &Services,
user_id: &UserId,
) -> BTreeMap<String, Value> {
let mut profile = BTreeMap::new();
// Get displayname and avatar_url independently because `all_profile_keys`
// doesn't include them
for field in [ProfileFieldName::AvatarUrl, ProfileFieldName::DisplayName] {
let key = field.as_str().to_owned();
if let Some(value) = get_local_profile_field(services, user_id, field).await {
profile.insert(key, value.value().into_owned());
}
}
// Insert all other profile fields
let mut all_fields = services.users.all_profile_keys(user_id);
while let Some((key, value)) = all_fields.next().await {
profile.insert(key, value);
}
profile
}
pub(crate) async fn get_local_profile_field(
services: &Services,
user_id: &UserId,
field: ProfileFieldName,
) -> Option<ProfileFieldValue> {
let value = match field.clone() {
| ProfileFieldName::AvatarUrl => services
.users
.avatar_url(user_id)
.await
.ok()
.map(to_value)
.transpose()
.expect("converting avatar url to value should succeed"),
| ProfileFieldName::DisplayName => services
.users
.displayname(user_id)
.await
.ok()
.map(to_value)
.transpose()
.expect("converting displayname to value should succeed"),
| other => services
.users
.profile_key(user_id, other.as_str())
.await
.ok(),
}?;
Some(
ProfileFieldValue::new(field.as_str(), value)
.expect("local profile field should be valid"),
)
}
enum ProfileFieldChange {
Set(ProfileFieldValue),
Delete(ProfileFieldName),
}
impl ProfileFieldChange {
fn field_name(&self) -> ProfileFieldName {
match self {
| &Self::Delete(ref name) => name.clone(),
| &Self::Set(ref value) => value.field_name(),
}
}
fn value(&self) -> Option<Value> {
if let Self::Set(value) = self {
Some(value.value().into_owned())
} else {
None
}
}
}
async fn set_profile_field(
services: &Services,
user_id: &UserId,
change: ProfileFieldChange,
propagate_to: PropagateTo,
) -> Result<()> {
const MAX_KEY_LENGTH_BYTES: usize = 255;
const MAX_PROFILE_LENGTH_BYTES: usize = 65536;
let field_name = change.field_name();
// TODO: The spec mentions special error codes (M_PROFILE_TOO_LARGE,
// M_KEY_TOO_LARGE) for profile field size limits, but they're not in its list
// of error codes and Ruma doesn't have them. Should we return those, or is
// M_TOO_LARGE okay?
if field_name.as_str().len() > MAX_KEY_LENGTH_BYTES {
return Err!(Request(TooLarge(
"Individual profile keys must not exceed {MAX_KEY_LENGTH_BYTES} bytes in length."
)));
}
// Serialize the entire profile as canonical JSON, including the new change,
// to check if it exceeds 64 KiB
{
let mut full_profile = get_local_profile(services, user_id).await;
match &change {
| ProfileFieldChange::Set(value) => {
full_profile.insert(
value.field_name().as_str().to_owned(),
value.value().clone().into_owned(),
);
},
| ProfileFieldChange::Delete(key) => {
full_profile.remove(key.as_str());
},
}
if let Ok(canonical_profile) = to_canonical_object(full_profile) {
if serde_json::to_string(&canonical_profile)
.expect("should be able to serialize to string")
.len() > MAX_PROFILE_LENGTH_BYTES
{
return Err!(
"Profile data must not exceed {MAX_PROFILE_LENGTH_BYTES} bytes in length."
);
}
} else {
return Err!(Request(BadJson("Failed to canonicalize profile.")));
}
}
// If the user is local and changed their displayname or avatar_url, update it
// in all their joined rooms. This is done before updating their profile data
// so we can check the old value of the field if `propagate_to` is `unchanged`.
if matches!(field_name, ProfileFieldName::AvatarUrl | ProfileFieldName::DisplayName)
&& matches!(propagate_to, PropagateTo::All | PropagateTo::Unchanged)
&& services.globals.user_is_local(user_id)
{
let current_displayname = services.users.displayname(user_id).await.ok();
let current_avatar_url = services.users.avatar_url(user_id).await.ok();
let mut all_joined_rooms = services.rooms.state_cache.rooms_joined(user_id);
while let Some(room_id) = all_joined_rooms.next().await {
// TODO: this clobbers any custom fields on the event content
let mut current_membership = services
.rooms
.state_accessor
.get_member(&room_id, user_id)
.await
.expect("should be able to fetch membership event for joined room");
assert_eq!(
current_membership.membership,
MembershipState::Join,
"user should be joined"
);
// If `propagate_to` is `unchanged`, and the current value of the field we're
// updating was changed from its global value in this room, skip it.
if matches!(propagate_to, PropagateTo::Unchanged) {
let field_changed_from_global = match field_name {
| ProfileFieldName::AvatarUrl =>
current_membership.avatar_url.as_ref() != current_avatar_url.as_ref(),
| ProfileFieldName::DisplayName =>
current_membership.displayname.as_ref() != current_displayname.as_ref(),
| _ => unreachable!(),
};
if field_changed_from_global {
continue;
}
}
let state_lock = services.rooms.state.mutex.lock(room_id.as_str()).await;
// Preserve keys in accordance with the key copying rules
current_membership.reason = None;
current_membership.join_authorized_via_users_server = None;
match &change {
| ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => {
current_membership.avatar_url = Some(avatar_url.clone());
},
| ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => {
current_membership.displayname = Some(displayname.clone());
},
| ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => {
current_membership.avatar_url = None;
},
| ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => {
current_membership.displayname = None;
},
| _ => unreachable!(),
}
let _ = services
.rooms
.timeline
.build_and_append_pdu(
PartialPdu::state(user_id.to_string(), &current_membership),
user_id,
Some(&room_id),
&state_lock,
)
.await;
}
if services.config.allow_local_presence {
// Send a presence EDU to indicate the profile changed
let _ = services
.presence
.ping_presence(user_id, &PresenceState::Online)
.await;
}
}
match change {
| ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => {
services
.users
.set_displayname(user_id, Some(displayname).filter(|dn| !dn.is_empty()));
},
| ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => {
services
.users
.set_avatar_url(user_id, Some(avatar_url).filter(|av| av.is_valid()));
},
| ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => {
services.users.set_displayname(user_id, None);
},
| ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => {
services.users.set_avatar_url(user_id, None);
},
| other =>
services
.users
.set_profile_key(user_id, other.field_name().as_str(), other.value()),
}
Ok(())
}
+1 -1
View File
@@ -149,7 +149,7 @@ pub(crate) async fn report_user_route(
delay_response().await;
if !services.users.is_active_local(&body.user_id).await {
if !services.users.status(&body.user_id).await.is_found() {
// return 200 as to not reveal if the user exists. Recommended by spec.
return Ok(report_user::v3::Response::new());
}
+31 -24
View File
@@ -21,7 +21,7 @@
},
login::{
self,
v3::{DiscoveryInfo, HomeserverInfo},
v3::{DiscoveryInfo, HomeserverInfo, LoginInfo},
},
logout, logout_all,
},
@@ -29,7 +29,6 @@
},
assign,
};
use service::uiaa::Identity;
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
@@ -44,6 +43,12 @@ pub(crate) async fn get_login_types_route(
ClientIp(client): ClientIp,
_body: Ruma<get_login_types::v3::Request>,
) -> Result<get_login_types::v3::Response> {
if !services.config.oauth.compatibility_mode().uiaa_available() {
return Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)));
}
Ok(get_login_types::v3::Response::new(vec![
get_login_types::v3::LoginType::Password(PasswordLoginType::default()),
get_login_types::v3::LoginType::ApplicationService(ApplicationServiceLoginType::default()),
@@ -53,7 +58,7 @@ pub(crate) async fn get_login_types_route(
]))
}
pub(crate) async fn handle_login(
pub async fn handle_login(
services: &Services,
identifier: Option<&UserIdentifier>,
password: &str,
@@ -83,14 +88,6 @@ pub(crate) async fn handle_login(
UserId::parse_with_server_name(user_id_or_localpart, &services.config.server_name)
.map_err(|_| err!(Request(InvalidUsername("User ID is malformed"))))?;
if !services.globals.user_is_local(&user_id) {
return Err!(Request(InvalidParam("User ID does not belong to this homeserver")));
}
if services.users.is_deactivated(&user_id).await? {
return Err!(Request(UserDeactivated("This account has been deactivated.")));
}
if services.users.is_locked(&user_id).await? {
return Err!(Request(UserLocked("This account has been locked.")));
}
@@ -123,19 +120,29 @@ pub(crate) async fn login_route(
ClientIp(client): ClientIp,
body: Ruma<login::v3::Request>,
) -> Result<login::v3::Response> {
if !services.config.oauth.compatibility_mode().uiaa_available() {
return match body.login_info {
| LoginInfo::ApplicationService(_) => {
Err!(Request(AppserviceLoginUnsupported(
"User-interactive appservice login is not available on this server."
)))
},
| _ => {
Err!(Request(Unrecognized(
"User-interactive authentication is not available on this server."
)))
},
};
}
let emergency_mode_enabled = services.config.emergency_password.is_some();
// Validate login method
// TODO: Other login methods
let user_id = match &body.login_info {
#[allow(deprecated)]
| login::v3::LoginInfo::Password(login::v3::Password {
identifier,
password,
user,
..
}) => handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
| login::v3::LoginInfo::Token(login::v3::Token { token, .. }) => {
| LoginInfo::Password(login::v3::Password { identifier, password, user, .. }) =>
handle_login(&services, identifier.as_ref(), password, user.as_ref()).await?,
| LoginInfo::Token(login::v3::Token { token, .. }) => {
debug!("Got token login type");
if !services.server.config.login_via_existing_session {
return Err!(Request(Unknown("Token login is not enabled.")));
@@ -143,7 +150,7 @@ pub(crate) async fn login_route(
services.users.find_from_login_token(token).await?
},
#[allow(deprecated)]
| login::v3::LoginInfo::ApplicationService(login::v3::ApplicationService {
| LoginInfo::ApplicationService(login::v3::ApplicationService {
identifier,
user,
..
@@ -177,7 +184,6 @@ pub(crate) async fn login_route(
user_id
},
| _ => {
debug!("/login json_body: {:?}", &body.json_body);
return Err!(Request(Unknown(
debug_warn!(?body.login_info, "Invalid or unsupported login type")
)));
@@ -207,7 +213,7 @@ pub(crate) async fn login_route(
if device_exists {
services
.users
.set_token(&user_id, &device_id, &token)
.set_token(&user_id, &device_id, &token, None)
.await?;
} else {
services
@@ -216,6 +222,7 @@ pub(crate) async fn login_route(
&user_id,
&device_id,
&token,
None,
body.initial_device_display_name.clone(),
Some(client.to_string()),
)
@@ -254,7 +261,7 @@ pub(crate) async fn login_token_route(
ClientIp(client): ClientIp,
body: Ruma<get_login_token::v1::Request>,
) -> Result<get_login_token::v1::Response> {
if !services.server.config.login_via_existing_session {
if !services.config.login_via_existing_session {
return Err!(Request(Forbidden("Login via an existing session is not enabled")));
}
@@ -263,7 +270,7 @@ pub(crate) async fn login_token_route(
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, body.identity.sender_device(), None)
.await?;
let login_token = utils::random_string(TOKEN_LENGTH);
-1
View File
@@ -70,7 +70,6 @@ pub(crate) async fn sync_events_v5_route(
ClientIp(client_ip): ClientIp,
body: Ruma<sync_events::v5::Request>,
) -> Result<sync_events::v5::Response> {
debug_assert!(DEFAULT_BUMP_TYPES.is_sorted(), "DEFAULT_BUMP_TYPES is not sorted");
let sender_user = body.identity.expect_sender_user()?;
let sender_device = body.identity.expect_sender_device()?;
+2 -2
View File
@@ -35,8 +35,8 @@ pub(crate) async fn get_supported_versions_route(
/// `/_matrix/federation/v1/version`
pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
Ok(Json(serde_json::json!({
"name": conduwuit::version::name(),
"version": conduwuit::version::version(),
"name": conduwuit::BRANDING,
"version": conduwuit::version(),
})))
}
+30 -26
View File
@@ -32,22 +32,26 @@ pub(crate) async fn search_users_route(
.min(LIMIT_MAX);
let search_term = body.search_term.to_lowercase();
let mut users = services.users.stream().broad_filter_map(async |user_id| {
let display_name = services.users.displayname(&user_id).await.ok();
let user_id_matches = user_id.as_str().to_lowercase().contains(&search_term);
let mut users = services
.users
.stream_local_users()
.chain(services.users.stream_remote_users())
.broad_filter_map(async |user_id| {
let display_name = services.users.displayname(&user_id).await.ok();
let display_name_matches = display_name
.as_deref()
.map(str::to_lowercase)
.is_some_and(|display_name| display_name.contains(&search_term));
let user_id_matches = user_id.as_str().to_lowercase().contains(&search_term);
if !user_id_matches && !display_name_matches {
return None;
}
let display_name_matches = display_name
.as_deref()
.map(str::to_lowercase)
.is_some_and(|display_name| display_name.contains(&search_term));
let user_in_public_room =
services
if !user_id_matches && !display_name_matches {
return None;
}
let user_in_public_room = services
.rooms
.state_cache
.rooms_joined(&user_id)
@@ -60,22 +64,22 @@ pub(crate) async fn search_users_route(
.await
});
let user_sees_user = services
.rooms
.state_cache
.user_sees_user(sender_user, &user_id);
let user_sees_user = services
.rooms
.state_cache
.user_sees_user(sender_user, &user_id);
pin_mut!(user_in_public_room, user_sees_user);
pin_mut!(user_in_public_room, user_sees_user);
if user_in_public_room.or(user_sees_user).await {
Some(assign!(search_users::v3::User::new(user_id.clone()), {
display_name,
avatar_url: services.users.avatar_url(&user_id).await.ok(),
}))
} else {
None
}
});
if user_in_public_room.or(user_sees_user).await {
Some(assign!(search_users::v3::User::new(user_id.clone()), {
display_name,
avatar_url: services.users.avatar_url(&user_id).await.ok(),
}))
} else {
None
}
});
let results = users.by_ref().take(limit).collect().await;
let limited = users.next().await.is_some();
+2 -42
View File
@@ -3,8 +3,7 @@
use ruma::{
api::client::discovery::{
discover_homeserver::{self, HomeserverInfo},
discover_policy_server,
discover_support::{self, Contact, ContactRole},
discover_policy_server, discover_support,
},
assign,
};
@@ -67,46 +66,7 @@ pub(crate) async fn well_known_support(
.as_ref()
.map(ToString::to_string);
let email_address = services.config.well_known.support_email.clone();
let matrix_id = services.config.well_known.support_mxid.clone();
let pgp_key = services.config.well_known.support_pgp_key.clone();
// TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![];
let role = services
.config
.well_known
.support_role
.clone()
.unwrap_or(ContactRole::Admin);
// Add configured contact if at least one contact method is specified
let configured_contact = match (matrix_id, email_address) {
| (Some(matrix_id), email_address) =>
Some(assign!(Contact::with_matrix_id(role, matrix_id), { email_address })),
| (None, Some(email_address)) => Some(Contact::with_email_address(role, email_address)),
| (None, None) => None,
};
if let Some(mut configured_contact) = configured_contact {
configured_contact.pgp_key = pgp_key;
contacts.push(configured_contact);
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
let admin_users = services.admin.get_admins().await;
for user_id in &admin_users {
if *user_id == services.globals.server_user {
continue;
}
contacts.push(Contact::with_matrix_id(ContactRole::Admin, user_id.to_owned()));
}
}
let contacts = services.admin.get_support_contacts().await;
if contacts.is_empty() && support_page.is_none() {
// No admin room, no configured contacts, and no support page
+1
View File
@@ -1,4 +1,5 @@
#![type_length_limit = "16384"] //TODO: reduce me
#![recursion_limit = "256"] // My Giant Async Function
#![allow(clippy::toplevel_ref_arg)]
extern crate conduwuit_core as conduwuit;
+7 -3
View File
@@ -10,7 +10,7 @@
response::{IntoResponse, Redirect},
routing::{any, get, post},
};
use conduwuit::{Server, err};
use conduwuit::err;
pub(super) use conduwuit_service::state::State;
use http::{Uri, uri};
@@ -18,8 +18,8 @@
pub(super) use self::{args::Args as Ruma, auth::ClientIdentity, response::RumaResponse};
use crate::{admin, client, server};
pub fn build(router: Router<State>, server: &Server) -> Router<State> {
let config = &server.config;
pub fn build(router: Router<State>, state: State) -> Router<State> {
let config = &state.server.config;
let mut router = router
.ruma_route(&client::appservice_ping)
.ruma_route(&client::get_supported_versions_route)
@@ -181,11 +181,15 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
.ruma_route(&client::get_room_summary)
.ruma_route(&client::get_suspended_status)
.ruma_route(&client::put_suspended_status)
.ruma_route(&client::get_lock_status)
.ruma_route(&client::put_lock_status)
.ruma_route(&client::well_known_support)
.ruma_route(&client::well_known_client)
.ruma_route(&client::well_known_policy_server)
.ruma_route(&client::get_rtc_transports)
.ruma_route(&client::room_initial_sync_route)
.ruma_route(&client::get_authorization_server_metadata_route)
.merge(client::oauth::router(state))
.route("/_conduwuit/server_version", get(client::conduwuit_server_version))
.route("/_continuwuity/server_version", get(client::conduwuit_server_version))
.ruma_route(&admin::rooms::ban::ban_room)
+25 -4
View File
@@ -1,6 +1,7 @@
use std::any::{Any, TypeId};
use conduwuit::{Err, Result, err};
use conduwuit::{Err, Error, Result, err};
use http::StatusCode;
use ruma::{
DeviceId, OwnedDeviceId, OwnedServerName, OwnedUserId, UserId,
api::{
@@ -10,12 +11,15 @@
AuthScheme, NoAccessToken, NoAuthentication,
},
client,
error::{ErrorKind, UnknownTokenErrorData},
federation::authentication::ServerSignatures,
},
assign,
};
use service::{
Services,
server_keys::{PubKeyMap, PubKeys},
users::AccessTokenStatus,
};
use crate::{router::args::AuthQueryParams, service::appservice::RegistrationInfo};
@@ -165,7 +169,20 @@ async fn verify<B: AsRef<[u8]> + Sync>(
if output.is_empty() {
return Err!(Request(Unauthorized("Missing access token.")));
}
if let Ok((sender_user, sender_device)) = services.users.find_from_token(&output).await {
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
@@ -176,7 +193,7 @@ async fn verify<B: AsRef<[u8]> + Sync>(
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(Unauthorized("Your account is locked.")));
return Err!(Request(UserLocked("Your account is locked.")));
}
}
@@ -227,7 +244,11 @@ async fn verify<B: AsRef<[u8]> + Sync>(
appservice_info: Box::new(appservice_info),
})
} else {
Err!(Request(Unauthorized("Invalid access token.")))
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid token".into(),
StatusCode::UNAUTHORIZED,
))
}
}
}
+8 -7
View File
@@ -7,10 +7,7 @@
api::federation::query::{get_profile_information, get_room_information},
};
use crate::{
Ruma,
client::{get_local_profile, get_local_profile_field},
};
use crate::Ruma;
/// # `GET /_matrix/federation/v1/query/directory`
///
@@ -75,15 +72,19 @@ pub(crate) async fn get_profile_information_route(
let response = if let Some(field) = &body.field {
let mut response = get_profile_information::v1::Response::new();
if let Some(value) =
get_local_profile_field(&services, &body.user_id, field.to_owned()).await
if let Some(value) = services
.users
.get_local_profile_field(&body.user_id, field.to_owned())
.await
{
response.set(value.field_name().as_str().to_owned(), value.value().into_owned());
}
response
} else {
get_local_profile(&services, &body.user_id)
services
.users
.get_local_profile(&body.user_id)
.await
.into_iter()
.collect()
+16 -82
View File
@@ -1,5 +1,5 @@
use std::{
collections::{BTreeMap, HashMap, HashSet},
collections::{BTreeMap, HashMap},
net::IpAddr,
time::{Duration, Instant},
};
@@ -9,7 +9,6 @@
use conduwuit::{
Err, Error, Result, debug, debug_error, debug_warn, err, error,
result::LogErr,
state_res::lexicographical_topological_sort,
trace,
utils::{
IterStream, ReadyExt, millis_since_unix_epoch,
@@ -25,8 +24,7 @@
use http::StatusCode;
use itertools::Itertools;
use ruma::{
CanonicalJsonObject, EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId,
OwnedUserId, RoomId, ServerName, UInt, UserId,
CanonicalJsonObject, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, ServerName, UserId,
api::{
error::{ErrorKind, LimitExceededErrorData},
federation::transactions::{
@@ -39,12 +37,12 @@
},
},
events::receipt::{ReceiptEvent, ReceiptEventContent, ReceiptType},
int,
serde::Raw,
to_device::DeviceIdOrAllDevices,
};
use service::transactions::{
FederationTxnState, TransactionError, TxnKey, WrappedTransactionResponse,
use service::{
rooms::event_handler::build_local_dag,
transactions::{FederationTxnState, TransactionError, TxnKey, WrappedTransactionResponse},
};
use tokio::sync::watch::{Receiver, Sender};
use tracing::instrument;
@@ -276,74 +274,6 @@ async fn handle(
Ok(results)
}
/// Attempts to build a localised directed acyclic graph out of the given PDUs,
/// returning them in a topologically sorted order.
///
/// This is used to attempt to process PDUs in an order that respects their
/// dependencies, however it is ultimately the sender's responsibility to send
/// them in a processable order, so this is just a best effort attempt. It does
/// not account for power levels or other tie breaks.
async fn build_local_dag(
pdu_map: &HashMap<OwnedEventId, CanonicalJsonObject>,
) -> Result<Vec<OwnedEventId>> {
debug_assert!(pdu_map.len() >= 2, "needless call to build_local_dag with less than 2 PDUs");
let mut dag: HashMap<OwnedEventId, HashSet<OwnedEventId>> =
HashMap::with_capacity(pdu_map.len());
let mut id_origin_ts: HashMap<OwnedEventId, _> = HashMap::with_capacity(pdu_map.len());
for (event_id, value) in pdu_map {
// We already checked that these properties are correct in parse_incoming_pdu,
// so it's safe to unwrap here.
// We also filter to remove any prev_events that are not in this pdu_map, as we
// need to have at least one event with zero out degrees for the lexico-topo
// sort below. If there are multiple events with omitted prevs, they will be
// ordered by timestamp, then event ID. At that point though, it's unlikely to
// matter.
let prev_events = value
.get("prev_events")
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|v| EventId::parse(v.as_str().unwrap()).unwrap())
.filter(|id| pdu_map.contains_key(id))
.collect();
dag.insert(event_id.clone(), prev_events);
let origin_server_ts = value
.get("origin_server_ts")
.and_then(ruma::CanonicalJsonValue::as_integer)
.unwrap_or_default();
id_origin_ts.insert(event_id.clone(), origin_server_ts);
}
debug!(count = dag.len(), "Sorting incoming events with partial graph");
lexicographical_topological_sort(&dag, &async |node_id| {
// Note: we don't bother fetching power levels because that would massively slow
// this function down. This is a best-effort attempt to order events correctly
// for processing, however ultimately that should be the sender's job.
let ts = id_origin_ts
.get(&node_id)
.copied()
.unwrap_or_else(|| int!(0))
.to_string()
.parse::<u64>()
.ok()
.and_then(UInt::new)
.unwrap_or_default();
Ok((int!(0), MilliSecondsSinceUnixEpoch(ts)))
})
.await
.inspect(|sorted| {
debug_assert_eq!(
sorted.len(),
pdu_map.len(),
"Sorted graph was not the same size as the input graph"
);
})
.map_err(|e| err!("failed to resolve local graph: {e}"))
}
async fn handle_room(
services: &Services,
_client: &IpAddr,
@@ -359,7 +289,7 @@ async fn handle_room(
.await;
let room_id = &room_id;
let pdu_map: HashMap<OwnedEventId, CanonicalJsonObject> = pdus
let mut pdu_map: HashMap<OwnedEventId, CanonicalJsonObject> = pdus
.into_iter()
.map(|(_, event_id, value)| (event_id, value))
.collect();
@@ -367,7 +297,11 @@ async fn handle_room(
// failure (e.g., cycles). This is best-effort; proper ordering is the sender's
// responsibility.
let sorted_event_ids = if pdu_map.len() >= 2 {
build_local_dag(&pdu_map).await.unwrap_or_else(|e| {
let refmap = pdu_map
.iter()
.map(|(event_id, obj)| (event_id.clone(), obj))
.collect();
build_local_dag(&refmap).await.unwrap_or_else(|e| {
debug_warn!("Failed to build local DAG for room {room_id}: {e}");
pdu_map.keys().cloned().collect()
})
@@ -377,9 +311,8 @@ async fn handle_room(
let mut results = Vec::with_capacity(sorted_event_ids.len());
for event_id in sorted_event_ids {
let value = pdu_map
.get(&event_id)
.expect("sorted event IDs must be from the original map")
.clone();
.remove(&event_id)
.expect("sorted event IDs must be from the original map");
services
.server
.check_running()
@@ -387,7 +320,7 @@ async fn handle_room(
let result = services
.rooms
.event_handler
.handle_incoming_pdu(origin, room_id, &event_id, value, true)
.handle_incoming_pdu(origin, room_id, &event_id, value.clone(), true)
.boxed()
.await
.map(|_| ());
@@ -687,8 +620,9 @@ async fn handle_edu_direct_to_device(
.broad_filter_map(|(target_user_id, map)| async move {
services
.users
.is_active_local(&target_user_id)
.status(&target_user_id)
.await
.is_active()
.then_some((target_user_id, map))
})
.for_each_concurrent(automatic_width(), |(target_user_id, map)| {
+2 -2
View File
@@ -11,8 +11,8 @@ pub(crate) async fn get_server_version_route(
) -> Result<get_server_version::v1::Response> {
Ok(assign!(get_server_version::v1::Response::new(), {
server: Some(assign!(get_server_version::v1::Server::new(), {
name: Some(conduwuit::version::name().into()),
version: Some(conduwuit::version::version().into()),
name: Some(conduwuit::BRANDING.into()),
version: Some(conduwuit::version().into()),
})),
}))
}
+2
View File
@@ -9,6 +9,7 @@ repository.workspace = true
version.workspace = true
[lib]
doctest = false
path = "mod.rs"
crate-type = [
"rlib",
@@ -117,6 +118,7 @@ url.workspace = true
parking_lot.workspace = true
lock_api.workspace = true
hyper-util.workspace = true
openidconnect.workspace = true
[target.'cfg(unix)'.dependencies]
nix.workspace = true
+9
View File
@@ -289,6 +289,15 @@ pub fn check(config: &Config) -> Result {
| _ => (),
}
if let Some(oidc) = &config.oauth.oidc
&& oidc.client_secret.is_none()
&& oidc.client_secret_file.is_none()
{
return Err!(
"Either `client_secret` or `client_secret_file` must be set if OIDC is configured."
);
}
Ok(())
}
+222 -12
View File
@@ -17,10 +17,12 @@
use figment::providers::{Env, Format, Toml};
pub use figment::{Figment, value::Value as FigmentValue};
use lettre::message::Mailbox;
use openidconnect::{ClientId, ClientSecret, Scope};
use regex::RegexSet;
use ruma::{
OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId,
api::client::{discovery::discover_support::ContactRole, rtc::RtcTransport},
profile::ProfileFieldName,
serde::Base64,
};
use serde::{Deserialize, Serialize, de::IgnoredAny};
@@ -655,19 +657,25 @@ pub struct Config {
/// even if `recaptcha_site_key` is set.
pub recaptcha_private_site_key: Option<String>,
/// Policy documents, such as terms and conditions or a privacy policy,
/// which users must agree to when registering an account.
///
/// Example:
/// ```ignore
/// [global.registration_terms.privacy_policy]
/// en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
/// es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
/// ```
///
/// default: {}
/// display: nested
#[serde(default)]
pub registration_terms: HashMap<String, HashMap<String, TermsDocument>>,
pub registration_terms: RegistrationTerms,
/// display: nested
#[serde(default)]
pub oauth: OauthConfig,
/// Controls whether users are allowed to deactivate their own accounts
/// through the account management panel or their Matrix clients. Server
/// admins can always deactivate users using the relevant admin commands.
///
/// Note that, in some jurisdictions, you may be legally required to honor
/// users who request to deactivate their accounts if you set this option
/// to `false`.
///
/// default: true
#[serde(default = "true_fn")]
pub allow_deactivation: bool,
/// Controls whether encrypted rooms and events are allowed.
#[serde(default = "true_fn")]
@@ -1661,6 +1669,11 @@ pub struct Config {
#[serde(default)]
pub send_messages_from_ignored_users_to_client: bool,
/// Send "org.matrix.dummy_event" events to the client. This is a debugging
/// option.
#[serde(default)]
pub send_dummy_events_to_clients: bool,
/// Vector list of IPv4 and IPv6 CIDR ranges / subnets *in quotes* that you
/// do not want continuwuity to send outbound requests to. Defaults to
/// RFC1918, unroutable, loopback, multicast, and testnet addresses for
@@ -2361,6 +2374,29 @@ pub struct SmtpConfig {
pub require_email_for_token_registration: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.registration_terms",
optional = "true"
)]
pub struct RegistrationTerms {
/// The language code to provide to clients along with the policy documents.
///
/// default: "en"
#[serde(default = "default_terms_language")]
pub language: String,
/// Policy documents, such as terms and conditions or a privacy policy,
/// which users must agree to when registering an account.
///
/// Example:
/// ```ignore
/// [global.registration_terms.documents]
/// privacy_policy = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
/// ```
pub documents: BTreeMap<String, TermsDocument>,
}
/// A policy document for use with a m.login.terms stage.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TermsDocument {
@@ -2368,6 +2404,172 @@ pub struct TermsDocument {
pub url: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.oauth",
optional = "true"
)]
pub struct OauthConfig {
/// The compatibility mode to use for OAuth.
///
/// - "disabled": OAuth will be unavailable. Users will only be able to log
/// in using legacy authentication.
/// - "hybrid": OAuth and legacy authentication will both be available. Some
/// clients may only use one or the other.
/// - "exclusive": Only OAuth will be available. Clients which require
/// legacy authentication will be unable to log in.
///
/// default: "hybrid"
compatibility_mode: OAuthMode,
/// display: hidden
pub oidc: Option<OidcConfig>,
}
impl OauthConfig {
#[must_use]
pub fn compatibility_mode(&self) -> OAuthMode {
if self.oidc.is_some() {
OAuthMode::Exclusive
} else {
self.compatibility_mode
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OAuthMode {
Disabled,
#[default]
Hybrid,
Exclusive,
}
impl OAuthMode {
#[must_use]
pub fn uiaa_available(&self) -> bool { matches!(self, Self::Disabled | Self::Hybrid) }
#[must_use]
pub fn oauth_available(&self) -> bool { matches!(self, Self::Hybrid | Self::Exclusive) }
}
#[derive(Clone, Debug, Deserialize)]
#[config_example_generator(
filename = "conduwuit-example.toml",
section = "global.oauth.oidc",
optional = "true",
subheader = "\
# Uncommenting this section will enable Continuwuity's support for
# authenticating users using an OpenID Connect-compatible identity provider.
# This is referred to as \"delegated authentication\".
#
# IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if
# the `global.oauth.compatibility_mode` setting is set to `exclusive`.
# Matrix clients which do not support OAuth login (also referred to as \"next-gen auth\") will \
NOT be able
# to log in while delegated authentication is active."
)]
pub struct OidcConfig {
/// The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to
/// automatically fetch the identity provider's metadata from this URL.
/// Generally you should set this to the base domain your identity provider
/// runs on.
pub discovery_url: Url,
/// The OAuth client ID for Continuwuity to use when communicating with the
/// identity provider.
pub client_id: ClientId,
/// The OAuth client secret for Continuwuity to use when communicating with
/// the identity provider.
pub client_secret: Option<ClientSecret>,
/// A path to a file which Continuwuity will read the client secret from.
/// If this option is set, it will override `client_secret`.
///
/// The server will fail to start if the file cannot be read.
pub client_secret_file: Option<PathBuf>,
/// Additional scopes Continuwuity should request from the IDP. This may be
/// necessary to access certain claims. Continuwuity always requests the
/// `openid` scope.
///
/// default: []
#[serde(default)]
pub additional_scopes: Vec<Scope>,
/// Whether the user should be prompted to choose a localpart
/// when signing in for the first time. If this is `false`, Continuwuity
/// will attempt to use the value of the `preferred_username_claim`
/// (see below) as the user's localpart. Authentication will
/// fail if this claim is missing or is not a valid localpart.
///
/// default: true
#[serde(default = "true_fn")]
pub prompt_for_localpart: bool,
/// The claim to use for the user's localpart, if `prompt_for_localpart` is
/// false.
///
/// default: "preferred_username"
#[serde(default = "default_preferred_username_claim")]
pub preferred_username_claim: String,
/// The claim which will be used to set the user's email address,
/// either on initial registration or on every login depending on
/// the value of `profile_key_import_mode`. Continuwuity assumes that
/// the IDP has taken care of verifying that the user controls the email
/// address it provides.
///
/// This option does nothing if SMTP is not configured.
///
/// If this option is set, and `profile_key_import_mode` is `on_login`,
/// users will not be able to change their email addresses themselves.
///
/// default: "email"
pub email_claim: Option<String>,
/// Defines how claims returned from the IDP should be mapped to a user's
/// profile data. The profile field named in each key will be set from the
/// claim named in the corresponding value when the user first registers,
/// and possibly on subsequent logins as well, depending on the value of
/// `profile_key_import_mode` (see below).
///
/// Per-room overrides to the user's display name or avatar will be
/// preserved by the import process.
///
/// SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will
/// perform a HTTP GET to the URL in the mapped claim and use the returned
/// file as the user's profile picture. Make sure your users are not able
/// to set the value of the mapped claim to an arbitrary URL.
///
/// default: { displayname = "name" }
#[serde(default = "default_profile_key_map")]
pub profile_key_map: HashMap<String, String>,
/// When profile keys should be imported from the IDP's claims.
///
/// - "on_registration": Listed keys will be imported once, when the user
/// logs in for the first time and their shadow account is created.
/// - "on_login": Listed keys will be imported every time the user logs in.
/// Additionally, users will not be able to manually edit any listed keys
/// through their Matrix client.
///
/// default: "on_registration"
#[serde(default)]
pub profile_key_import_mode: OidcProfileKeyImportMode,
}
#[derive(Clone, Debug, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OidcProfileKeyImportMode {
#[default]
OnRegistration,
OnLogin,
}
const DEPRECATED_KEYS: &[&str] = &[
"cache_capacity",
"conduit_cache_capacity_modifier",
@@ -2750,3 +2952,11 @@ fn default_client_response_timeout() -> u64 { 120 }
fn default_client_shutdown_timeout() -> u64 { 15 }
fn default_sender_shutdown_timeout() -> u64 { 5 }
fn default_terms_language() -> String { "en".to_owned() }
fn default_preferred_username_claim() -> String { "preferred_username".to_owned() }
fn default_profile_key_map() -> HashMap<String, String> {
HashMap::from_iter([(ProfileFieldName::DisplayName.to_string(), "name".to_owned())])
}
+1
View File
@@ -161,6 +161,7 @@ pub fn message(&self) -> String {
match self {
| Self::Federation(origin, error) => format!("Answer from {origin}: {error}"),
| Self::Ruma(error) => response::ruma_error_message(error),
| Self::Request(_, message, _) => message.clone().into_owned(),
| _ => format!("{self}"),
}
}
+1 -4
View File
@@ -73,11 +73,8 @@ pub(super) fn bad_request_code(kind: &ErrorKind) -> StatusCode {
// 413
| TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
// 405
| Unrecognized => StatusCode::METHOD_NOT_ALLOWED,
// 404
| NotFound => StatusCode::NOT_FOUND,
| Unrecognized | NotFound => StatusCode::NOT_FOUND,
// 403
| GuestAccessForbidden
+6 -9
View File
@@ -7,19 +7,16 @@
use std::sync::OnceLock;
static BRANDING: &str = "continuwuity";
static WEBSITE: &str = "https://continuwuity.org";
static SEMANTIC: &str = env!("CARGO_PKG_VERSION");
pub const BRANDING: &str = "continuwuity";
pub const ROUTE_PREFIX: &str = "/_continuwuity";
pub const WEBSITE: &str = "https://continuwuity.org";
pub const SEMANTIC: &str = env!("CARGO_PKG_VERSION");
static VERSION: OnceLock<String> = OnceLock::new();
static VERSION_UA: OnceLock<String> = OnceLock::new();
static USER_AGENT: OnceLock<String> = OnceLock::new();
static USER_AGENT_MEDIA: OnceLock<String> = OnceLock::new();
#[inline]
#[must_use]
pub fn name() -> &'static str { BRANDING }
#[inline]
pub fn version() -> &'static str { VERSION.get_or_init(init_version) }
@@ -32,10 +29,10 @@ pub fn user_agent() -> &'static str { USER_AGENT.get_or_init(init_user_agent) }
#[inline]
pub fn user_agent_media() -> &'static str { USER_AGENT_MEDIA.get_or_init(init_user_agent_media) }
fn init_user_agent() -> String { format!("{}/{} (bot; +{WEBSITE})", name(), version_ua()) }
fn init_user_agent() -> String { format!("{BRANDING}/{} (bot; +{WEBSITE})", version_ua()) }
fn init_user_agent_media() -> String {
format!("{}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", name(), version_ua())
format!("{BRANDING}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", version_ua())
}
fn init_version_ua() -> String {
+5 -2
View File
@@ -5,7 +5,10 @@
events::{MessageLikeEventContent, StateEventContent, TimelineEventType},
};
use serde::Deserialize;
use serde_json::value::{RawValue as RawJsonValue, to_raw_value};
use serde_json::{
json,
value::{RawValue as RawJsonValue, to_raw_value},
};
use super::StateKey;
@@ -62,7 +65,7 @@ impl Default for PartialPdu {
fn default() -> Self {
Self {
event_type: "m.room.message".into(),
content: to_raw_value("{}").unwrap(),
content: to_raw_value(&json!({})).unwrap(),
unsigned: None,
state_key: None,
redacts: None,
+13 -17
View File
@@ -21,29 +21,25 @@ pub fn versions() -> Vec<String> {
"v1.12".to_owned(),
"v1.13".to_owned(),
"v1.14".to_owned(),
"v1.15".to_owned(),
"v1.16".to_owned(),
"v1.17".to_owned(),
"v1.18".to_owned(),
]
}
#[must_use]
pub fn unstable_features() -> BTreeMap<String, bool> {
BTreeMap::from_iter([
("org.matrix.e2e_cross_signing".to_owned(), true),
("org.matrix.msc2285.stable".to_owned(), true), /* private read receipts (https://github.com/matrix-org/matrix-spec-proposals/pull/2285) */
("uk.half-shot.msc2666.query_mutual_rooms".to_owned(), true), /* query mutual rooms (https://github.com/matrix-org/matrix-spec-proposals/pull/2666) */
("org.matrix.msc2836".to_owned(), true), /* threading/threads (https://github.com/matrix-org/matrix-spec-proposals/pull/2836) */
("org.matrix.msc2946".to_owned(), true), /* spaces/hierarchy summaries (https://github.com/matrix-org/matrix-spec-proposals/pull/2946) */
("org.matrix.msc3026.busy_presence".to_owned(), true), /* busy presence status (https://github.com/matrix-org/matrix-spec-proposals/pull/3026) */
("org.matrix.msc3827".to_owned(), true), /* filtering of /publicRooms by room type (https://github.com/matrix-org/matrix-spec-proposals/pull/3827) */
("org.matrix.msc3952_intentional_mentions".to_owned(), true), /* intentional mentions (https://github.com/matrix-org/matrix-spec-proposals/pull/3952) */
("org.matrix.msc3916.stable".to_owned(), true), /* authenticated media (https://github.com/matrix-org/matrix-spec-proposals/pull/3916) */
("org.matrix.msc4180".to_owned(), true), /* stable flag for 3916 (https://github.com/matrix-org/matrix-spec-proposals/pull/4180) */
("uk.tcpip.msc4133".to_owned(), true), /* Extending User Profile API with Key:Value Pairs (https://github.com/matrix-org/matrix-spec-proposals/pull/4133) */
("us.cloke.msc4175".to_owned(), true), /* Profile field for user time zone (https://github.com/matrix-org/matrix-spec-proposals/pull/4175) */
("org.matrix.simplified_msc3575".to_owned(), true), /* Simplified Sliding sync (https://github.com/matrix-org/matrix-spec-proposals/pull/4186) */
("uk.timedout.msc4323".to_owned(), true), /* agnostic suspend (https://github.com/matrix-org/matrix-spec-proposals/pull/4323) */
("org.matrix.msc4155".to_owned(), true), /* invite filtering (https://github.com/matrix-org/matrix-spec-proposals/pull/4155) */
("computer.gingershaped.msc4466".to_owned(), true), /* profile change propagation (https://github.com/matrix-org/matrix-spec-proposals/pull/4466) */
("org.matrix.msc4380.stable".to_owned(), true),
// 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.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),
// invite filtering (https://github.com/matrix-org/matrix-spec-proposals/pull/4155)
("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),
])
}
+1 -4
View File
@@ -34,10 +34,7 @@ macro_rules! mod_dtor {
pub use conduwuit_build_metadata as build_metadata;
pub use config::Config;
pub use error::Error;
pub use info::{
version,
version::{name, version},
};
pub use info::version::*;
pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res};
pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
pub use server::Server;
+19 -10
View File
@@ -1,7 +1,10 @@
pub mod exponential_backoff;
pub mod jitter;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub use jitter::jitter;
use crate::{Result, err};
#[inline]
@@ -61,17 +64,23 @@ pub fn format(ts: SystemTime, str: &str) -> String {
pub fn pretty(d: Duration) -> String {
use Unit::*;
let fmt = |w, f, u| format!("{w}.{f} {u}");
let gen64 = |w, f, u| fmt(w, (f * 100.0) as u32, u);
let gen128 = |w, f, u| gen64(u64::try_from(w).expect("u128 to u64"), f, u);
let fmt = |w, u| {
if w == 1 {
format!("{w} {u}")
} else {
format!("{w} {u}s")
}
};
let gen64 = |w, u| fmt(w, u);
let gen128 = |w, u| gen64(u64::try_from(w).expect("u128 to u64"), u);
match whole_and_frac(d) {
| (Days(whole), frac) => gen64(whole, frac, "days"),
| (Hours(whole), frac) => gen64(whole, frac, "hours"),
| (Mins(whole), frac) => gen64(whole, frac, "minutes"),
| (Secs(whole), frac) => gen64(whole, frac, "seconds"),
| (Millis(whole), frac) => gen128(whole, frac, "milliseconds"),
| (Micros(whole), frac) => gen128(whole, frac, "microseconds"),
| (Nanos(whole), frac) => gen128(whole, frac, "nanoseconds"),
| (Days(whole), _) => gen64(whole, "day"),
| (Hours(whole), _) => gen64(whole, "hour"),
| (Mins(whole), _) => gen64(whole, "minute"),
| (Secs(whole), _) => gen64(whole, "second"),
| (Millis(whole), _) => gen128(whole, "millisecond"),
| (Micros(whole), _) => gen128(whole, "microsecond"),
| (Nanos(whole), _) => gen128(whole, "nanosecond"),
}
}
+16
View File
@@ -0,0 +1,16 @@
use std::{ops::RangeInclusive, time::Duration};
/// Returns a `Duration` that is jittered by a random percentage of the base
/// duration. The jitter is applied as a random percentage in the range of
/// `-jitter_range` to `jitter_range`.
///
/// # Example
/// ```
/// use conduwuit_core::utils::time::jitter;
/// let sleep_duration = jitter(Duration::from_secs(1), -10..=10);
/// // Adds a jitter of between -10% and 10% to the duration.
/// ```
#[must_use]
pub fn jitter(base: Duration, jitter_range: RangeInclusive<f64>) -> Duration {
base.mul_f64(1.0 + (rand::random_range(jitter_range)) / 100.0)
}
+36
View File
@@ -49,6 +49,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "bannedroomids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "clientid_clientmetadata",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "disabledroomids",
..descriptor::RANDOM_SMALL
@@ -120,6 +124,14 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "onetimekeyid_onetimekeys",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "openidsubject_localpart",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "openidsubject_currentpictureurl",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "fallbackkeyid_fallbackkey",
..descriptor::RANDOM_SMALL
@@ -157,10 +169,18 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "referencedevents",
..descriptor::RANDOM
},
Descriptor {
name: "refreshtoken_refreshtokeninfo",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "registrationtoken_info",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "remoteuserid_remoteuser",
..descriptor::RANDOM
},
Descriptor {
name: "roomid_invitedcount",
..descriptor::RANDOM_SMALL
@@ -370,6 +390,14 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userdevicetxnid_response",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userdeviceid_oauthsessioninfo",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userdeviceid_tokenexpires",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userfilterid_filter",
..descriptor::RANDOM_SMALL
@@ -382,6 +410,10 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userid_blurhash",
..descriptor::DROPPED
},
Descriptor {
name: "userid_deactivated",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "userid_dehydrateddevice",
..descriptor::RANDOM_SMALL
@@ -474,4 +506,8 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "websessionid_session",
..descriptor::RANDOM_SMALL
},
];
+6
View File
@@ -81,6 +81,12 @@ fn generate_example(input: &ItemStruct, args: &[Meta], write: bool) -> Result<To
};
file.write_fmt(format_args!("{section_header}"))
.expect("written to config file");
if let Some(subheader) = settings.get("subheader") {
file.write_all(subheader.as_bytes())
.expect("written to config file");
file.write_all(b"\n\n").expect("written to config file");
}
}
let mut summary: Vec<TokenStream2> = Vec::new();
+1 -1
View File
@@ -15,7 +15,7 @@
#[clap(
about,
long_about = None,
name = conduwuit_core::name(),
name = conduwuit_core::BRANDING,
version = conduwuit_core::version(),
)]
pub struct Args {
+1 -1
View File
@@ -110,7 +110,7 @@ pub(crate) fn init(
.with_batch_exporter(exporter)
.build();
let tracer = provider.tracer(conduwuit_core::name());
let tracer = provider.tracer(conduwuit_core::BRANDING);
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
+1 -1
View File
@@ -47,7 +47,7 @@ fn options(config: &Config) -> ClientOptions {
traces_sample_rate: config.sentry_traces_sample_rate,
debug: cfg!(debug_assertions),
release: release_name(),
user_agent: conduwuit_core::version::user_agent().into(),
user_agent: conduwuit_core::user_agent().into(),
attach_stacktrace: config.sentry_attach_stacktrace,
before_send: Some(Arc::new(before_send)),
before_breadcrumb: Some(Arc::new(before_breadcrumb)),
+3 -1
View File
@@ -112,7 +112,9 @@ fn handle_result(method: &Method, uri: &Uri, result: Response) -> Result<Respons
}
if status == StatusCode::METHOD_NOT_ALLOWED {
return Ok(err!(Request(Unrecognized("Method Not Allowed"))).into_response());
return Ok(
err!(Request(Unrecognized("Method not allowed"), METHOD_NOT_ALLOWED)).into_response()
);
}
Ok(result)
+2 -2
View File
@@ -9,8 +9,8 @@
pub(crate) fn build(services: &Arc<Services>) -> (Router, Guard) {
let router = Router::<state::State>::new();
let (state, guard) = state::create(services.clone());
let router = conduwuit_api::router::build(router, &services.server)
.merge(conduwuit_web::build())
let router = conduwuit_api::router::build(router, state)
.merge(conduwuit_web::build(services))
.fallback(not_found)
.with_state(state);
+2
View File
@@ -119,6 +119,8 @@ recaptcha-verify = { version = "0.2.0", default-features = false }
reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated
yansi.workspace = true
lettre.workspace = true
serde_urlencoded.workspace = true
openidconnect.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true
+1 -1
View File
@@ -48,7 +48,7 @@ pub async fn create_admin_room(services: &Services) -> Result {
// Create a user for the server
let server_user = services.globals.server_user.as_ref();
services.users.create(server_user, None)?;
services.users.create_shadow_account(server_user).await?;
let mut create_content = if room_version_rules.authorization.use_room_create_sender {
RoomCreateEventContent::new_v1(server_user.into())
+53 -1
View File
@@ -18,6 +18,8 @@
use loole::{Receiver, Sender};
use ruma::{
OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UInt, UserId,
api::client::discovery::discover_support::{Contact, ContactRole},
assign,
events::{
Mentions,
room::message::{
@@ -28,7 +30,7 @@
use tokio::sync::RwLock;
use crate::{
Dep, account_data, globals,
Dep, account_data, config, globals,
media::{MXC_LENGTH, mxc::Mxc},
rooms::{self, state::RoomMutexGuard},
};
@@ -44,6 +46,7 @@ pub struct Service {
struct Services {
server: Arc<Server>,
config: Dep<config::Service>,
globals: Dep<globals::Service>,
alias: Dep<rooms::alias::Service>,
timeline: Dep<rooms::timeline::Service>,
@@ -115,6 +118,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
server: args.server.clone(),
config: args.depend::<config::Service>("config"),
globals: args.depend::<globals::Service>("globals"),
alias: args.depend::<rooms::alias::Service>("rooms::alias"),
timeline: args.depend::<rooms::timeline::Service>("rooms::timeline"),
@@ -623,4 +627,52 @@ pub(super) fn set_services(&self, services: Option<&Arc<crate::Services>>) {
let weak = services.map(Arc::downgrade);
*receiver = weak;
}
/// Get the server's configured support contacts.
pub async fn get_support_contacts(&self) -> Vec<Contact> {
let email_address = self.services.config.well_known.support_email.clone();
let matrix_id = self.services.config.well_known.support_mxid.clone();
let pgp_key = self.services.config.well_known.support_pgp_key.clone();
// TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![];
let role = self
.services
.config
.well_known
.support_role
.clone()
.unwrap_or(ContactRole::Admin);
// Add configured contact if at least one contact method is specified
let configured_contact = match (matrix_id, email_address) {
| (Some(matrix_id), email_address) =>
Some(assign!(Contact::with_matrix_id(role, matrix_id), { email_address })),
| (None, Some(email_address)) =>
Some(Contact::with_email_address(role, email_address)),
| (None, None) => None,
};
if let Some(mut configured_contact) = configured_contact {
configured_contact.pgp_key = pgp_key;
contacts.push(configured_contact);
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
let admin_users = self.get_admins().await;
for user_id in &admin_users {
if *user_id == self.services.globals.server_user {
continue;
}
contacts.push(Contact::with_matrix_id(ContactRole::Admin, user_id.to_owned()));
}
}
contacts
}
}
+6 -5
View File
@@ -18,7 +18,11 @@
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use conduwuit::{Result, Server, debug, error, utils::response::LimitReadExt, warn};
use conduwuit::{
Result, Server, debug, error,
utils::{response::LimitReadExt, time::jitter},
warn,
};
use database::{Deserialized, Map};
use ruma::events::{Mentions, room::message::RoomMessageEventContent};
use serde::Deserialize;
@@ -98,10 +102,7 @@ async fn worker(self: Arc<Self>) -> Result<()> {
.ok();
}
let first_check_jitter = {
let jitter_percent = rand::random_range(-50.0..=10.0);
self.interval.mul_f64(1.0 + jitter_percent / 100.0)
};
let first_check_jitter = jitter(self.interval, -50.0..=10.0);
let mut i = interval(self.interval);
i.set_missed_tick_behavior(MissedTickBehavior::Delay);
+9 -9
View File
@@ -67,7 +67,7 @@ async fn worker(self: Arc<Self>) -> Result {
for (id, registration) in appservices {
// During startup, resolve any token collisions in favour of appservices
// by logging out conflicting user devices
if let Ok((user_id, device_id)) = self
if let Some((user_id, device_id, _)) = self
.services
.users
.find_from_token(&registration.as_token)
@@ -108,17 +108,17 @@ async fn start_appservice(&self, id: String, registration: Registration) -> Resu
self.services.globals.server_name(),
)?;
if !self.services.users.exists(&appservice_user_id).await {
self.services.users.create(&appservice_user_id, None)?;
} else if self
if !self
.services
.users
.is_deactivated(&appservice_user_id)
.status(&appservice_user_id)
.await
.unwrap_or(false)
.is_found()
{
// Reactivate the appservice user if it was accidentally deactivated
self.services.users.set_password(&appservice_user_id, None);
self.services
.users
.create_shadow_account(&appservice_user_id)
.await?;
}
self.registration_info
@@ -155,7 +155,7 @@ pub async fn register_appservice(
.users
.find_from_token(&registration.as_token)
.await
.is_ok()
.is_some()
{
return Err(err!(Request(InvalidParam(
"Cannot register appservice: The provided token is already in use by a user \
+2 -2
View File
@@ -39,7 +39,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
let url_preview_user_agent = config
.url_preview_user_agent
.clone()
.unwrap_or_else(|| conduwuit::version::user_agent_media().to_owned());
.unwrap_or_else(|| conduwuit::user_agent_media().to_owned());
Ok(Arc::new(Self {
default: base(config)?
@@ -159,7 +159,7 @@ fn base(config: &Config) -> Result<reqwest::ClientBuilder> {
.timeout(Duration::from_secs(config.request_total_timeout))
.pool_idle_timeout(Duration::from_secs(config.request_idle_timeout))
.pool_max_idle_per_host(config.request_idle_per_host.into())
.user_agent(conduwuit::version::user_agent())
.user_agent(conduwuit::user_agent())
.redirect(redirect::Policy::limited(6))
.danger_accept_invalid_certs(config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure)
.connection_verbose(cfg!(debug_assertions));
+16 -9
View File
@@ -54,15 +54,22 @@ impl Service {
async fn set_emergency_access(&self) -> Result {
let server_user = &self.services.globals.server_user;
self.services.users.set_password(
server_user,
self.services
.config
.emergency_password
.as_deref()
.map(HashedPassword::new)
.transpose()?,
);
match &self.services.config.emergency_password {
| Some(emergency_password) => {
let emergency_password = HashedPassword::new(emergency_password)?;
self.services
.users
.convert_to_local_account(server_user, emergency_password)
.await?;
},
| None => {
self.services
.users
.convert_to_shadow_account(server_user)
.await?;
},
}
let (ruleset, pwd_set) = match self.services.config.emergency_password {
| Some(_) => (Ruleset::server_default(server_user), true),
+16 -11
View File
@@ -6,7 +6,7 @@
use askama::Template;
use async_trait::async_trait;
use conduwuit::{Result, info, utils::ReadyExt};
use futures::{FutureExt, StreamExt};
use futures::StreamExt;
use ruma::{UserId, events::room::message::RoomMessageEventContent};
use crate::{
@@ -87,7 +87,7 @@ async fn worker(self: Arc<Self>) -> Result {
// first run mode is inactive (already filled inner lock)
OnceLock::from(())
})
.expect("Service worker should only be called once");
.expect("service worker should only be called once");
Ok(())
}
@@ -98,7 +98,7 @@ impl Service {
pub fn is_first_run(&self) -> bool {
self.first_run_marker
.get()
.expect("First run mode should not be checked during server startup")
.expect("first run mode should not be checked during server startup")
.get()
.is_none()
}
@@ -110,7 +110,7 @@ pub fn is_first_run(&self) -> bool {
fn disable_first_run(&self) -> bool {
self.first_run_marker
.get()
.expect("First run mode should not be disabled during server startup")
.expect("first run mode should not be disabled during server startup")
.set(())
.is_ok()
}
@@ -118,9 +118,9 @@ fn disable_first_run(&self) -> bool {
/// If first-run mode is active, grant admin powers to the specified user
/// and disable first-run mode.
///
/// Returns Ok(true) if the specified user was the first user, and Ok(false)
/// Returns true if the specified user was the first user, and false
/// if they were not.
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
pub async fn empower_first_user(&self, user: &UserId) -> bool {
#[derive(Template)]
#[template(path = "welcome.md")]
struct WelcomeMessage<'a> {
@@ -130,10 +130,14 @@ struct WelcomeMessage<'a> {
// If first run mode isn't active, do nothing.
if !self.disable_first_run() {
return Ok(false);
return false;
}
self.services.admin.make_user_admin(user).boxed().await?;
self.services
.admin
.make_user_admin(user)
.await
.expect("should have been able to empower the first user");
// Send the welcome message
let welcome_message = WelcomeMessage {
@@ -146,11 +150,12 @@ struct WelcomeMessage<'a> {
self.services
.admin
.send_loud_message(RoomMessageEventContent::text_markdown(welcome_message))
.await?;
.await
.expect("should have been able to send welcome message");
info!("{user} has been invited to the admin room as the first user.");
Ok(true)
true
}
/// Get the single-use registration token which may be used to create the
@@ -181,7 +186,7 @@ pub fn print_first_run_banner(&self) {
eprintln!(
"Welcome to {} {}!",
"Continuwuity".bold().bright_magenta(),
conduwuit::version::version().bold()
conduwuit::version().bold()
);
eprintln!();
eprintln!(
+4 -2
View File
@@ -92,8 +92,8 @@ pub async fn send<Template: MessageTemplate>(
let message = MessageBuilder::new()
.from(self.sender.clone())
.to(recipient)
.subject(subject)
.to(recipient.clone())
.subject(subject.clone())
.date_now()
.header(ContentType::TEXT_PLAIN)
.body(body)
@@ -104,6 +104,8 @@ pub async fn send<Template: MessageTemplate>(
.await
.map_err(|err: TransportError| err!("Failed to send message: {err}"))?;
info!(recipient = recipient.to_string(), ?subject, "Email sent");
Ok(())
}
}
-6
View File
@@ -211,7 +211,6 @@ pub async fn download_audio(
Ok(preview_data)
}
#[cfg(feature = "url_preview")]
pub async fn download_media(&self, url: &str) -> Result<(OwnedMxcUri, usize)> {
use conduwuit::utils::random_string;
use http::header::CONTENT_TYPE;
@@ -268,11 +267,6 @@ pub async fn download_audio(
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
pub async fn download_media(&self, _url: &str) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(feature = "url_preview")]
async fn download_html(&self, url: &str) -> Result<UrlPreviewData> {
use webpage::HTML;
+57 -4
View File
@@ -41,7 +41,7 @@ pub(crate) async fn migrations(services: &Services) -> Result<()> {
// requires recreating the database from scratch.
if users_count > 0 {
let server_user = &services.globals.server_user;
if !services.users.exists(server_user).await {
if !services.users.status(server_user).await.is_found() {
error!("The {server_user} server user does not exist, and the database is not new.");
return Err!(Database(
"Cannot reuse an existing database after changing the server name, please \
@@ -228,6 +228,18 @@ async fn migrate(services: &Services) -> Result<()> {
.map_err(|e| err!("Failed to run 'fix_local_invite_state' migration': {e}"))?;
}
if services.globals.db.database_version().await < 18 {
services.globals.db.bump_database_version(18);
info!("Migration: Bumped database version to 18");
}
if db["global"].get(SPLIT_USERID_PASSWORD).await.is_not_found() {
info!("Running migration 'split_userid_password'");
split_userid_password(services)
.await
.map_err(|e| err!("Failed to run 'split_userid_password' migration': {e}"))?;
}
assert_eq!(
services.globals.db.database_version().await,
DATABASE_VERSION,
@@ -242,9 +254,9 @@ async fn migrate(services: &Services) -> Result<()> {
if !patterns.is_empty() {
services
.users
.stream()
.stream_local_users()
.filter_map(async |user_id| {
if services.users.is_active_local(&user_id).await {
if services.users.status(&user_id).await.is_found() {
Some(user_id)
} else {
None
@@ -774,7 +786,7 @@ async fn fix_local_invite_state(services: &Services) -> Result {
let db = &services.db;
let cork = db.cork_and_sync();
let userroomid_invitestate = services.db["userroomid_invitestate"].clone();
let userroomid_invitestate = db["userroomid_invitestate"].clone();
// for each user invited to a room
let fixed = userroomid_invitestate.stream()
@@ -818,3 +830,44 @@ async fn fix_local_invite_state(services: &Services) -> Result {
db.db.sort()?;
Ok(())
}
const SPLIT_USERID_PASSWORD: &str = "split_userid_password";
async fn split_userid_password(services: &Services) -> Result {
// Split remote and deactivated users out from the `userid_password` table
let db = &services.db;
let cork = db.cork_and_sync();
let userid_password = db["userid_password"].clone();
let remoteuserid_remoteuser = db["remoteuserid_remoteuser"].clone();
let userid_deactivated = db["userid_deactivated"].clone();
let remote_users = userid_password
.stream::<OwnedUserId, String>()
.ignore_err()
.fold(0_usize, async |mut remote_users, (user_id, hash)| {
if !services.globals.user_is_local(&user_id) {
assert!(hash.is_empty(), "non-empty hash {hash} for remote user {user_id}");
remoteuserid_remoteuser.insert(&user_id, "");
userid_password.remove(&user_id);
remote_users = remote_users.saturating_add(1);
} else if hash.is_empty() {
if !(services.appservice.is_exclusive_user_id(&user_id).await
|| user_id == services.globals.server_user)
{
info!("Marking {user_id} as deactivated");
userid_deactivated.insert(&user_id, "");
}
}
remote_users
})
.await;
drop(cork);
info!(?remote_users, "Split userid_password.");
db["global"].insert(FIXED_LOCAL_INVITE_STATE_MARKER, []);
db.db.sort()?;
Ok(())
}
+2 -1
View File
@@ -27,7 +27,8 @@
pub mod mailer;
pub mod media;
pub mod moderation;
pub mod password_reset;
pub mod oauth;
pub mod oidc;
pub mod presence;
pub mod pusher;
pub mod registration_tokens;
+196
View File
@@ -0,0 +1,196 @@
use std::{collections::BTreeSet, hash::Hash};
use itertools::Itertools;
use serde::{Deserialize, Deserializer, Serialize};
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ClientMetadata {
#[serde(default)]
pub application_type: ApplicationType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
pub client_uri: Url,
#[serde(default, deserialize_with = "btreeset_skip_err")]
pub grant_types: BTreeSet<GrantType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<Url>,
#[serde(default)]
pub redirect_uris: Vec<Url>,
#[serde(default, deserialize_with = "btreeset_skip_err")]
pub response_types: BTreeSet<ResponseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<Url>,
}
impl ClientMetadata {
pub(super) const ACCEPTABLE_LOCALHOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
pub(super) fn validate(&self) -> Result<(), &'static str> {
let Some(client_domain) = self.client_uri.domain() else {
return Err("Client URI must have a domain.");
};
if self.client_uri.scheme() != "https" {
return Err("Client URI must be HTTPS.");
}
if !self.client_uri.username().is_empty() || self.client_uri.password().is_some() {
return Err("Client URI must not include credentials.");
}
for uri in [&self.logo_uri, &self.policy_uri, &self.tos_uri]
.iter()
.filter_map(|uri| uri.as_ref())
{
if uri.scheme() != "https" {
return Err("All metadata URIs must be HTTPS.");
}
if !uri.username().is_empty() || uri.password().is_some() {
return Err("All metadata URIs must not include credentials.");
}
if !uri
.domain()
.is_some_and(|domain| is_subdomain(domain, client_domain))
{
return Err("All metadata URIs must be subdomains of the client URI.");
}
}
for uri in &self.redirect_uris {
match uri.scheme() {
| "https" => {
// HTTPS URIs are okay for native and web clients
if !uri.username().is_empty() || uri.password().is_some() {
return Err("HTTPS redirect URIs must not contain credentials.");
}
},
| "http" if self.application_type == ApplicationType::Native => {
if uri
.host_str()
.is_none_or(|host| !Self::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
return Err("HTTP redirect URIs for native applications must only \
refer to localhost.");
}
if uri.port().is_some() {
return Err("HTTP redirect URIs for native applications do not need to \
specify a port. All ports will be accepted during \
authorization.");
}
},
| private_scheme if self.application_type == ApplicationType::Native => {
let rdns_client_uri = client_domain.split('.').rev().join(".");
if !private_scheme.starts_with(&rdns_client_uri) {
return Err("Private-use scheme URIs for native applications must \
begin with the application's client URI domain in \
reverse-DNS notation.");
}
if uri.has_authority() {
return Err("Private-use scheme URIs for native applications must not \
have an authority.");
}
},
| _ =>
return Err("A redirect URI's scheme is not valid for this application type."),
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplicationType {
#[default]
Web,
Native,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantType {
AuthorizationCode,
RefreshToken,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseType {
Code,
}
/// Deserialize a BTreeSet from a sequence, skipping items which fail to
/// deserialize. This is used as a deserialize helper for ClientMetadata to
/// ignore unknown enum variants in a few fields.
fn btreeset_skip_err<'de, D, V>(de: D) -> Result<BTreeSet<V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de> + Hash + Eq + Ord,
{
use std::marker::PhantomData;
use serde::de::{SeqAccess, Visitor};
struct BTreeSetVisitor<V> {
item: PhantomData<V>,
}
impl<'de, V> Visitor<'de> for BTreeSetVisitor<V>
where
V: Deserialize<'de> + Hash + Eq + Ord,
{
type Value = BTreeSet<V>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "a sequence")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut set = BTreeSet::new();
while let Some(element) = seq.next_element().transpose() {
if let Ok(element) = element {
set.insert(element);
}
}
Ok(set)
}
}
de.deserialize_seq(BTreeSetVisitor { item: PhantomData })
}
fn is_subdomain(subdomain: &str, domain: &str) -> bool {
if subdomain == domain {
return true;
}
subdomain.ends_with(&format!(".{domain}"))
}
+211
View File
@@ -0,0 +1,211 @@
use std::{
borrow::Cow,
collections::BTreeSet,
error::Error,
fmt::{Debug, Display},
hash::Hash,
mem::discriminant,
};
use regex::Regex;
use ruma::OwnedDeviceId;
use serde::{Deserialize, Serialize};
use url::Url;
use super::client_metadata::ResponseType;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorizationCodeQuery {
pub response_type: ResponseType,
pub client_id: String,
pub redirect_uri: Url,
pub scope: RawScopes,
pub state: String,
#[serde(default)]
pub response_mode: ResponseMode,
pub code_challenge: String,
pub code_challenge_method: CodeChallengeMethod,
#[serde(default)]
pub prompt: Option<Prompt>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseMode {
#[default]
// default for `code` response type, see https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#:~:text=Client%2E-,For,encoding%2E,-See
Query,
Fragment,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
pub enum CodeChallengeMethod {
S256,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Prompt {
Create,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialOrd, Ord)]
pub enum Scope {
Device(OwnedDeviceId),
ClientApi,
}
impl PartialEq for Scope {
fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) }
}
impl Eq for Scope {}
impl Hash for Scope {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { discriminant(self).hash(state); }
}
impl Display for Scope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let urn = match self {
| Self::ClientApi => "urn:matrix:client:api:*".to_owned(),
| Self::Device(device_id) => format!("urn:matrix:client:device:{device_id}"),
};
f.write_str(&urn)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RawScopes(String);
impl RawScopes {
pub fn to_scopes(&self) -> Result<BTreeSet<Scope>, String> {
let client_api_token_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 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)
} else if let Some(captures) = device_token_regex.captures(token) {
scopes.insert(Scope::Device(captures.get(2).unwrap().as_str().into()))
} else if token == "openid" {
// TODO(unspecced): Element sets this scope but doesn't use it for anything
true
} else {
return Err(format!("Invalid scope: {token}"));
}
};
if !scope_was_new {
return Err("Scope was specified more than once".to_owned());
}
}
Ok(scopes)
}
}
#[derive(Serialize, Debug, Clone)]
pub struct OAuthError {
pub error: ErrorCode,
pub error_description: Cow<'static, str>,
}
impl OAuthError {
#[must_use]
pub const fn invalid_request(error_description: &'static str) -> Self {
Self {
error: ErrorCode::InvalidRequest,
error_description: Cow::Borrowed(error_description),
}
}
#[must_use]
pub const fn invalid_grant(error_description: &'static str) -> Self {
Self {
error: ErrorCode::InvalidGrant,
error_description: Cow::Borrowed(error_description),
}
}
}
impl Display for OAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "OAuth error {:?}: {}", self.error, self.error_description)
}
}
impl Error for OAuthError {}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
InvalidRequest,
AccessDenied,
InvalidScope,
InvalidGrant,
InvalidClientMetadata,
}
#[derive(Serialize, Deserialize)]
pub struct AuthorizationCodeResponse {
pub state: String,
pub code: String,
}
#[derive(Deserialize)]
#[serde(tag = "grant_type", rename_all = "snake_case")]
pub enum TokenRequest {
AuthorizationCode {
code: String,
redirect_uri: Url,
client_id: String,
code_verifier: String,
},
RefreshToken {
client_id: String,
refresh_token: String,
},
}
impl TokenRequest {
#[must_use]
pub fn client_id(&self) -> &str {
match self {
| Self::AuthorizationCode { client_id, .. }
| Self::RefreshToken { client_id, .. } => client_id,
}
}
}
#[derive(Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: TokenType,
pub expires_in: u64,
pub refresh_token: String,
pub scope: String,
}
#[derive(Serialize)]
pub enum TokenType {
Bearer,
}
#[derive(Deserialize)]
pub struct RevokeTokenRequest {
pub token: String,
}
+528
View File
@@ -0,0 +1,528 @@
use std::{
collections::{BTreeSet, HashMap},
sync::{Arc, Mutex},
time::{Duration, SystemTime},
};
use base64::Engine;
use conduwuit::{
Result, info,
utils::{self, hash::sha256},
};
use database::{Deserialized, Json, Map};
use itertools::Itertools;
use lru_cache::LruCache;
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
Dep,
oauth::{
client_metadata::{ApplicationType, ClientMetadata, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode,
OAuthError, ResponseMode, Scope, TokenRequest, TokenResponse, TokenType,
},
},
users,
};
pub mod client_metadata;
pub mod grant;
pub struct Service {
services: Services,
db: Data,
tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_code_grants: tokio::sync::Mutex<LruCache<String, PendingCodeGrant>>,
}
struct Data {
clientid_clientmetadata: Arc<Map>,
userdeviceid_oauthsessioninfo: Arc<Map>,
refreshtoken_refreshtokeninfo: Arc<Map>,
}
struct Services {
users: Dep<users::Service>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<Scope>,
current_refresh_token: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct RefreshTokenInfo {
client_id: String,
user_id: OwnedUserId,
device_id: OwnedDeviceId,
}
struct PendingCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
code_challenge: String,
requested_at: SystemTime,
}
impl PendingCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1);
const RANDOM_CODE_LENGTH: usize = 32;
#[must_use]
pub(crate) fn generate_code() -> String { utils::random_string(Self::RANDOM_CODE_LENGTH) }
#[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
let now = SystemTime::now();
self.expected_client_id == client_id
&& now
.duration_since(self.requested_at)
.is_ok_and(|age| age < Self::MAX_AGE)
}
}
/// A time-limited grant for a client to perform some sensitive action.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OAuthTicket {
CrossSigningReset,
}
impl OAuthTicket {
const MAX_AGE: Duration = Duration::from_mins(10);
#[must_use]
pub fn ticket_issue_path(&self) -> &'static str {
match self {
| Self::CrossSigningReset => "/account/cross_signing_reset",
}
}
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
users: args.depend::<users::Service>("users"),
},
db: Data {
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
userdeviceid_oauthsessioninfo: args.db["userdeviceid_oauthsessioninfo"].clone(),
refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(),
},
tickets: Mutex::default(),
pending_code_grants: tokio::sync::Mutex::new(LruCache::new(
Self::MAX_PENDING_CODE_GRANTS,
)),
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const ACCESS_TOKEN_MAX_AGE: Duration = Duration::from_hours(1);
// Maximum number of pending code grants which will be held in memory at once,
// to prevent unbounded memory use if someone decides to repeatedly reload the
// grant page.
const MAX_PENDING_CODE_GRANTS: usize = 100;
const RANDOM_TOKEN_LENGTH: usize = 32;
fn generate_token() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
pub async fn register_client(&self, metadata: &ClientMetadata) -> Result<String, OAuthError> {
metadata.validate().map_err(|error| OAuthError {
error: ErrorCode::InvalidClientMetadata,
error_description: error.into(),
})?;
let client_id = base64::prelude::BASE64_STANDARD
.encode(sha256::hash(serde_json::to_string(metadata).unwrap().as_bytes()));
if self
.db
.clientid_clientmetadata
.exists(&client_id)
.await
.is_err()
{
self.db
.clientid_clientmetadata
.raw_put(&client_id, Json(metadata.clone()));
}
Ok(client_id)
}
pub async fn get_client_metadata(&self, client_id: &str) -> Option<ClientMetadata> {
self.db
.clientid_clientmetadata
.get(client_id)
.await
.deserialized()
.ok()
}
pub async fn get_session_info_for_device(
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> Option<SessionInfo> {
self.db
.userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id))
.await
.deserialized::<SessionInfo>()
.ok()
}
pub async fn request_authorization_code(
&self,
authorizing_user: OwnedUserId,
query: AuthorizationCodeQuery,
) -> Result<String, String> {
let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else {
return Err("Invalid client ID".to_owned());
};
if !(client_metadata
.response_types
.contains(&query.response_type)
&& matches!(query.response_type, ResponseType::Code))
{
return Err("Invalid response type".to_owned());
}
if !matches!(query.code_challenge_method, CodeChallengeMethod::S256) {
return Err("Invalid code challenge type".to_owned());
}
{
let mut stripped_uri = query.redirect_uri.clone();
if client_metadata.application_type == ApplicationType::Native
&& query
.redirect_uri
.host_str()
.is_some_and(|host| ClientMetadata::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
// Remove the port from localhost redirect URIs for native applications when
// checking if it's valid
stripped_uri.set_port(None).unwrap();
}
if !client_metadata.redirect_uris.contains(&stripped_uri) {
return Err("Invalid redirect URI".to_owned());
}
}
let requested_scopes = query.scope.to_scopes()?;
let redirect_uri_query_separator = match query.response_mode {
| ResponseMode::Fragment => '#',
| ResponseMode::Query => '?',
};
let code = PendingCodeGrant::generate_code();
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
?authorizing_user,
"Issuing oauth authorization code"
);
let redirect_uri = format!(
"{}{}{}",
query.redirect_uri,
redirect_uri_query_separator,
serde_urlencoded::to_string(AuthorizationCodeResponse {
state: query.state,
code: code.clone(),
})
.unwrap(),
);
let pending_grant = PendingCodeGrant {
authorizing_user,
requested_scopes,
client_name: client_metadata.client_name,
expected_client_id: query.client_id,
expected_redirect_uri: query.redirect_uri,
code_challenge: query.code_challenge,
requested_at: SystemTime::now(),
};
self.pending_code_grants
.lock()
.await
.insert(code, pending_grant);
Ok(redirect_uri)
}
pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> {
match request {
| TokenRequest::AuthorizationCode {
code,
redirect_uri,
client_id,
code_verifier,
} => {
let mut pending_grants = self.pending_code_grants.lock().await;
let Some(pending_grant) = pending_grants
.remove(&code)
.filter(|grant| grant.is_valid_for(&client_id))
else {
return Err(OAuthError::invalid_grant("Invalid authorization code"));
};
if redirect_uri != pending_grant.expected_redirect_uri {
return Err(OAuthError::invalid_grant("Invalid redirect URI"));
}
let expected_code_challenge =
base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(sha256::hash(&code_verifier));
if expected_code_challenge != pending_grant.code_challenge {
return Err(OAuthError::invalid_grant("Invalid code challenge"));
}
self.create_session(
pending_grant.authorizing_user,
pending_grant.requested_scopes,
pending_grant.client_name,
client_id,
)
.await
},
| TokenRequest::RefreshToken { client_id, refresh_token } =>
self.refresh_session(client_id, refresh_token).await,
}
}
pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> {
let (user_id, device_id) = if let Ok(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&token)
.await
.deserialized::<RefreshTokenInfo>()
{
(refresh_token_info.user_id, refresh_token_info.device_id)
} else if let Some((user_id, device_id, _)) =
self.services.users.find_from_token(&token).await
{
(user_id, device_id)
} else {
return Err(OAuthError::invalid_grant("Invalid access or refersh token"));
};
// This will also call [`Self::remove_session`]
self.services
.users
.remove_device(&user_id, &device_id)
.await;
Ok(())
}
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
let access_token = Self::generate_token();
let refresh_token = Self::generate_token();
let device_id = requested_scopes
.iter()
.find_map(|scope| {
if let Scope::Device(device_id) = scope {
Some(device_id)
} else {
None
}
})
.ok_or_else(|| OAuthError::invalid_grant("No device ID scope supplied"))?;
if self
.services
.users
.get_device_metadata(&authorizing_user, device_id)
.await
.is_ok()
{
return Err(OAuthError {
error: ErrorCode::InvalidScope,
error_description: "A device with the supplied ID already exists for this user"
.into(),
});
}
self.services
.users
.create_device(
&authorizing_user,
device_id,
&access_token,
Some(Self::ACCESS_TOKEN_MAX_AGE),
client_name,
None,
)
.await
// This can only panic if the authorizing user suffered a spontaneous existence
// failure during authentication, which should(?) be impossible(?)
.expect("failed to create device");
self.db.userdeviceid_oauthsessioninfo.put(
(&authorizing_user, device_id),
Json(SessionInfo {
client_id: client_id.clone(),
current_refresh_token: refresh_token.clone(),
scopes: requested_scopes.clone(),
}),
);
self.db.refreshtoken_refreshtokeninfo.raw_put(
&refresh_token,
Json(RefreshTokenInfo {
client_id: client_id.clone(),
user_id: authorizing_user.clone(),
device_id: device_id.to_owned(),
}),
);
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
Ok(TokenResponse {
access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope: requested_scopes.iter().join(" "),
refresh_token,
})
}
async fn refresh_session(
&self,
client_id: String,
refresh_token: String,
) -> Result<TokenResponse, OAuthError> {
let Some(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&refresh_token)
.await
.deserialized::<RefreshTokenInfo>()
.ok()
else {
return Err(OAuthError::invalid_grant("Invalid refresh token"));
};
assert_eq!(&client_id, &refresh_token_info.client_id, "refresh token client id mismatch");
let mut session_info = self
.get_session_info_for_device(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
)
.await
.expect("session info should exist");
assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch");
let new_access_token = Self::generate_token();
let new_refresh_token = Self::generate_token();
let scope = session_info.scopes.iter().join(" ");
session_info
.current_refresh_token
.clone_from(&new_refresh_token);
self.services
.users
.set_token(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
&new_access_token,
Some(Self::ACCESS_TOKEN_MAX_AGE),
)
.await
.expect("should be able to set token");
self.db.userdeviceid_oauthsessioninfo.put(
(&refresh_token_info.user_id, &refresh_token_info.device_id),
Json(session_info),
);
self.db.refreshtoken_refreshtokeninfo.remove(&refresh_token);
drop(refresh_token);
self.db
.refreshtoken_refreshtokeninfo
.raw_put(&new_refresh_token, Json(refresh_token_info));
Ok(TokenResponse {
access_token: new_access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope,
refresh_token: new_refresh_token,
})
}
pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
let session_info = self.get_session_info_for_device(user_id, device_id).await;
if let Some(session_info) = session_info {
self.db
.refreshtoken_refreshtokeninfo
.remove(&session_info.current_refresh_token);
self.db
.userdeviceid_oauthsessioninfo
.del((user_id, device_id));
info!(?user_id, ?device_id, "Removed OAuth session");
}
}
/// Issue a ticket for `localpart` to perform some action.
pub fn issue_ticket(&self, localpart: String, ticket: OAuthTicket) {
self.tickets
.lock()
.unwrap()
.entry(localpart)
.or_default()
.insert(ticket, SystemTime::now());
}
/// Try to consume an unexpired ticket for `localpart`.
pub fn try_consume_ticket(&self, localpart: &str, ticket: OAuthTicket) -> bool {
let now = SystemTime::now();
self.tickets
.lock()
.unwrap()
.get_mut(localpart)
.and_then(|tickets| tickets.remove(&ticket))
.is_some_and(|issued| {
now.duration_since(issued)
.is_ok_and(|duration| duration < OAuthTicket::MAX_AGE)
})
}
}
+484
View File
@@ -0,0 +1,484 @@
use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
use async_trait::async_trait;
use conduwuit::{
Result,
config::{OidcConfig, OidcProfileKeyImportMode},
debug, err, error, info, warn,
};
use database::{Deserialized, Map};
use lettre::Address;
use openidconnect::{
AdditionalClaims, AuthorizationCode, ClientSecret, CsrfToken, EmptyExtraTokenFields,
EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenClaims, IdTokenFields, IssuerUrl,
Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, StandardErrorResponse,
StandardTokenResponse, TokenResponse,
core::{
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreErrorResponseType,
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreRevocableToken,
CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType,
},
reqwest,
};
use ruma::{
OwnedUserId, UserId,
api::client::profile::PropagateTo,
profile::{ProfileFieldName, ProfileFieldValue},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::{runtime, sync::SetOnce};
use url::Url;
use crate::{
Dep, config, globals, media,
oauth::grant::AuthorizationCodeResponse,
threepid,
users::{self, AccountStatus, ProfileFieldChange},
};
pub struct Service {
services: Services,
runtime: runtime::Handle,
db: Data,
client: Option<OidcClient>,
}
struct Data {
openidsubject_localpart: Arc<Map>,
openidsubject_currentpictureurl: Arc<Map>,
}
struct Services {
config: Dep<config::Service>,
globals: Dep<globals::Service>,
media: Dep<media::Service>,
threepid: Dep<threepid::Service>,
users: Dep<users::Service>,
}
struct OidcClient {
config: OidcConfig,
client_secret: ClientSecret,
machine: SetOnce<OidcClientMachine>,
client: reqwest::Client,
}
type OidcClientMachine = openidconnect::Client<
AllClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
StandardTokenResponse<
IdTokenFields<
AllClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
>,
CoreTokenType,
>,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>;
pub type Claims = IdTokenClaims<AllClaims, CoreGenderClaim>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AllClaims {
#[serde(flatten)]
pub claims: HashMap<String, Value>,
}
impl AdditionalClaims for AllClaims {}
#[derive(Debug, Deserialize, Serialize)]
pub struct PendingSession {
pkce_verifier: PkceCodeVerifier,
nonce: Nonce,
csrf_token: CsrfToken,
}
pub enum SessionCompletionStatus {
NeedsUserId,
Complete(OwnedUserId),
}
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
config: args.depend::<config::Service>("config"),
globals: args.depend::<globals::Service>("globals"),
media: args.depend::<media::Service>("media"),
threepid: args.depend::<threepid::Service>("threepid"),
users: args.depend::<users::Service>("users"),
},
runtime: args.server.runtime().clone(),
db: Data {
openidsubject_localpart: args.db["openidsubject_localpart"].clone(),
openidsubject_currentpictureurl: args.db["openidsubject_currentpictureurl"].clone(),
},
client: args.server.config.oauth.oidc.as_ref().map(|config| -> Result<OidcClient> {
Ok(OidcClient {
config: config.clone(),
client_secret: if let Some(client_secret_file) = &config.client_secret_file {
std::fs::read_to_string(client_secret_file)
.map(ClientSecret::new)
.map_err(|err| err!("Failed to read OIDC client secret file: {err}"))?
} else if let Some(client_secret) = &config.client_secret {
client_secret.clone()
} else {
// The config check function should cause an early exit before this happens
panic!("neither client secret or client secret file were set");
},
machine: SetOnce::new(),
// This isn't in the client service because it has to use the `reqwest` shipped by `openidconnect`
client: reqwest::ClientBuilder::new()
.connect_timeout(Duration::from_secs(args.server.config.request_conn_timeout))
.read_timeout(Duration::from_secs(args.server.config.request_timeout))
.timeout(Duration::from_secs(args.server.config.request_total_timeout))
.pool_idle_timeout(Duration::from_secs(args.server.config.request_idle_timeout))
.pool_max_idle_per_host(args.server.config.request_idle_per_host.into())
.user_agent(conduwuit::user_agent())
.redirect(reqwest::redirect::Policy::none())
.danger_accept_invalid_certs(args.server.config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure)
.build()
.expect("client should build")
})}
).transpose()?,
}))
}
async fn worker(self: Arc<Self>) -> Result {
if let Some(OidcClient { config, client_secret, machine, client }) = &self.client {
let redirect_url = self
.services
.config
.get_client_domain()
.join(&format!("{}/oidc/complete", conduwuit::ROUTE_PREFIX))
.expect("redirect url should be valid");
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::from_url(config.discovery_url.clone()),
client,
)
.await
.map_err(|err| err!("Failed to discover OIDC provider metadata: {err}"))?;
machine
.set(
OidcClientMachine::from_provider_metadata(
provider_metadata,
config.client_id.clone(),
Some(client_secret.clone()),
)
.set_redirect_uri(RedirectUrl::from_url(redirect_url)),
)
.expect("machine should be empty");
}
Ok(())
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const SERVER_MISCONFIGURED: &str =
"Identity server is misconfigured. Contact your homeserver's administrator.";
pub fn enabled(&self) -> bool { self.client.is_some() }
pub fn restricted_profile_fields(&self) -> Vec<ProfileFieldName> {
if let Some(config) = self.client.as_ref().map(|client| &client.config)
&& matches!(config.profile_key_import_mode, OidcProfileKeyImportMode::OnLogin)
{
config
.profile_key_map
.keys()
.map(|key| ProfileFieldName::from(key.as_str()))
.collect()
} else {
vec![]
}
}
pub async fn begin_session(&self, prompt: Option<CoreAuthPrompt>) -> (PendingSession, Url) {
let OidcClient { machine, config, .. } =
self.client.as_ref().expect("oidc should be configured");
let machine = machine.wait().await;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let mut auth_url = machine
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scopes(config.additional_scopes.iter().cloned())
.set_pkce_challenge(pkce_challenge);
if let Some(prompt) = prompt {
auth_url = auth_url.add_prompt(prompt);
}
let (auth_url, csrf_token, nonce) = auth_url.url();
(PendingSession { pkce_verifier, nonce, csrf_token }, auth_url)
}
pub async fn exchange_code(
&self,
session: PendingSession,
response: AuthorizationCodeResponse,
) -> Result<Claims, &'static str> {
let Some(OidcClient { machine, client, .. }) = self.client.as_ref() else {
return Err("Delegated authentication is not enabled on this server.");
};
let machine = machine.wait().await;
if session.csrf_token.into_secret() != response.state {
return Err("State mismatch.");
}
let token_response = machine
.exchange_code(AuthorizationCode::new(response.code))
.expect("machine should be configured correctly")
.set_pkce_verifier(session.pkce_verifier)
.request_async(client)
.await
.map_err(|err| {
error!("Failed to exchange OIDC authorization code: {err}");
"Code exchange failed."
})?;
let Some(id_token) = token_response.id_token() else {
error!("Identity server did not return an id token");
return Err(Self::SERVER_MISCONFIGURED);
};
let claims = id_token
.claims(&machine.id_token_verifier(), &session.nonce)
.map_err(|err| {
error!("Failed to verify id token claims: {err}");
Self::SERVER_MISCONFIGURED
})?
.to_owned();
info!(subject = claims.subject().as_str(), "Authenticated subject");
Ok(claims)
}
#[tracing::instrument(skip(self, claims), fields(subject = claims.subject().to_string()))]
pub async fn complete_session(
&self,
claims: &Claims,
supplied_user_id: Option<OwnedUserId>,
) -> Result<SessionCompletionStatus, &'static str> {
let Some(OidcClient { config, .. }) = self.client.as_ref() else {
return Err("Delegated authentication is not enabled on this server.");
};
// this is a truly awful hack but we really need all the claims in a map
let all_claims = serde_json::to_value(claims)
.expect("should be able to serialize claims")
.as_object()
.expect("claims should be an object")
.to_owned();
debug!(?all_claims);
let subject = claims.subject().as_str();
let user_id = if let Ok(localpart) = self
.db
.openidsubject_localpart
.get(subject)
.await
.deserialized::<String>()
{
UserId::parse(format!("@{localpart}:{}", self.services.globals.server_name()))
.expect("saved localpart should be valid")
} else if config.prompt_for_localpart {
if let Some(supplied_user_id) = supplied_user_id {
supplied_user_id
} else {
return Ok(SessionCompletionStatus::NeedsUserId);
}
} else if let Some(preferred_username) = all_claims
.get(&config.preferred_username_claim)
.and_then(|claim| claim.as_str())
{
self.services
.users
.determine_registration_user_id(Some(preferred_username.to_owned()), None, None)
.await
.map_err(|err| {
error!("Preferred username claim is not a valid localpart: {err}");
"Your preferred username is not a valid Matrix user ID localpart. Contact \
your homeserver's administrator."
})?
} else {
error!("Preferred username claim was not present or was not a string");
return Err(Self::SERVER_MISCONFIGURED);
};
info!(?subject, ?user_id, "User {user_id} successfully authorized with OIDC");
// Create a shadow account for the user if necessary
let new_account_registered = match self.services.users.status(&user_id).await {
| AccountStatus::Active => {
// Do nothing, an account already exists
false
},
| AccountStatus::NotFound => {
// Create a new shadow user
self.services
.users
.create_local_account(&user_id, None, None)
.await
.map_err(|err| {
error!("Failed to create a shadow user for {user_id}: {err}");
Self::SERVER_MISCONFIGURED
})?;
info!(?subject, ?user_id, "Shadow user created for {user_id}");
true
},
| AccountStatus::Deactivated => {
return Err("Your account has been deactivated.");
},
};
self.link_user(&user_id, subject);
// Import profile fields
if matches!(config.profile_key_import_mode, OidcProfileKeyImportMode::OnLogin)
|| (matches!(
config.profile_key_import_mode,
OidcProfileKeyImportMode::OnRegistration
) && new_account_registered)
{
if let Some(email_claim) = &config.email_claim {
if let Some(email) = claims.email().map(|email| email.as_str())
&& let Ok(address) = Address::from_str(email)
{
if let Err(err) = self
.services
.threepid
.associate_localpart_email(user_id.localpart(), &address)
.await
{
warn!(?email_claim, ?address, "Failed to associate email address: {err}");
}
} else {
warn!(
?email_claim,
"Email claim was not present or was not a valid email address"
);
}
}
let user_id = user_id.clone();
let subject = claims.subject().to_string();
let profile_key_map = config.profile_key_map.clone();
let openidsubject_currentpictureurl = self.db.openidsubject_currentpictureurl.clone();
let users = self.services.users.clone();
let media = self.services.media.clone();
let import_task = self.runtime.spawn(async move {
for (field, claim) in &profile_key_map {
let Some(value) = all_claims.get(claim).cloned() else {
warn!(?field, ?claim, "IDP provided no value for this mapped claim");
continue;
};
let value = if let Some(picture_url) = value.as_str()
&& field == ProfileFieldName::AvatarUrl.as_str()
&& openidsubject_currentpictureurl
.get(&subject)
.await
.deserialized::<String>()
.ok()
.is_none_or(|current_picture| current_picture != picture_url)
{
match media.download_media(picture_url).await {
| Ok((mxc, size)) => {
openidsubject_currentpictureurl.insert(&subject, picture_url);
info!(?picture_url, ?mxc, ?size, "Downloaded profile picture");
ProfileFieldValue::AvatarUrl(mxc)
},
| Err(err) => {
warn!(
?claim,
?picture_url,
"Failed to download profile picture: {err}"
);
continue;
},
}
} else {
match ProfileFieldValue::new(field, value.clone()) {
| Ok(value) => value,
| Err(err) => {
warn!(
?field,
?claim,
?value,
"Failed to parse claim value for profile field: {err}"
);
continue;
},
}
};
if let Err(err) = users
.set_profile_field(
&user_id,
ProfileFieldChange::Set(value),
PropagateTo::Unchanged,
)
.await
{
warn!(?field, ?claim, "Error while setting profile field: {err}");
}
}
info!("Profile import complete");
});
// Only wait for import to complete if this is a new account,
// so they see the correct profile information in the account panel
if new_account_registered {
let _ = import_task.await;
}
}
Ok(SessionCompletionStatus::Complete(user_id))
}
pub fn link_user(&self, user_id: &UserId, subject: &str) {
self.db
.openidsubject_localpart
.insert(subject, user_id.localpart());
}
pub fn unlink_user(&self, subject: &str) { self.db.openidsubject_localpart.remove(subject); }
}
-68
View File
@@ -1,68 +0,0 @@
use std::{
sync::Arc,
time::{Duration, SystemTime},
};
use conduwuit::utils::{ReadyExt, stream::TryExpect};
use database::{Database, Deserialized, Json, Map};
use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
pub(super) struct Data {
passwordresettoken_info: Arc<Map>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResetTokenInfo {
pub user: OwnedUserId,
pub issued_at: SystemTime,
}
impl ResetTokenInfo {
// one hour
const MAX_TOKEN_AGE: Duration = Duration::from_hours(1);
pub fn is_valid(&self) -> bool {
let now = SystemTime::now();
now.duration_since(self.issued_at)
.is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE)
}
}
impl Data {
pub(super) fn new(db: &Arc<Database>) -> Self {
Self {
passwordresettoken_info: db["passwordresettoken_info"].clone(),
}
}
/// Associate a reset token with its info in the database.
pub(super) fn save_token(&self, token: &str, info: &ResetTokenInfo) {
self.passwordresettoken_info.raw_put(token, Json(info));
}
/// Lookup the info for a reset token.
pub(super) async fn lookup_token_info(&self, token: &str) -> Option<ResetTokenInfo> {
self.passwordresettoken_info
.get(token)
.await
.deserialized()
.ok()
}
/// Find a user's existing reset token, if any.
pub(super) async fn find_token_for_user(
&self,
user: &UserId,
) -> Option<(String, ResetTokenInfo)> {
self.passwordresettoken_info
.stream::<'_, String, ResetTokenInfo>()
.expect_ok()
.ready_find(|(_, info)| info.user == user)
.await
}
/// Remove a reset token.
pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.remove(token); }
}
-111
View File
@@ -1,111 +0,0 @@
mod data;
use std::{sync::Arc, time::SystemTime};
use conduwuit::{Err, Result, utils};
use data::{Data, ResetTokenInfo};
use ruma::OwnedUserId;
use crate::{
Dep, globals,
users::{self, HashedPassword},
};
pub const PASSWORD_RESET_PATH: &str = "/_continuwuity/account/reset_password";
pub const RESET_TOKEN_QUERY_PARAM: &str = "token";
const RESET_TOKEN_LENGTH: usize = 32;
pub struct Service {
db: Data,
services: Services,
}
struct Services {
users: Dep<users::Service>,
globals: Dep<globals::Service>,
}
#[derive(Debug)]
pub struct ValidResetToken {
pub token: String,
pub info: ResetTokenInfo,
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
db: Data::new(args.db),
services: Services {
users: args.depend::<users::Service>("users"),
globals: args.depend::<globals::Service>("globals"),
},
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
/// Generate a random string suitable to be used as a password reset token.
#[must_use]
pub fn generate_token_string() -> String { utils::random_string(RESET_TOKEN_LENGTH) }
/// Issue a password reset token for `user`, who must be a local user with
/// the `password` origin.
pub async fn issue_token(&self, user_id: OwnedUserId) -> Result<ValidResetToken> {
if !self.services.globals.user_is_local(&user_id) {
return Err!("Cannot issue a password reset token for remote user {user_id}");
}
if user_id == self.services.globals.server_user {
return Err!("Cannot issue a password reset token for the server user");
}
if self.services.users.is_deactivated(&user_id).await? {
return Err!("Cannot issue a password reset token for deactivated user {user_id}");
}
if let Some((existing_token, _)) = self.db.find_token_for_user(&user_id).await {
self.db.remove_token(&existing_token);
}
let token = Self::generate_token_string();
let info = ResetTokenInfo {
user: user_id,
issued_at: SystemTime::now(),
};
self.db.save_token(&token, &info);
Ok(ValidResetToken { token, info })
}
/// Check if `token` represents a valid, non-expired password reset token.
pub async fn check_token(&self, token: &str) -> Option<ValidResetToken> {
self.db.lookup_token_info(token).await.and_then(|info| {
if info.is_valid() {
Some(ValidResetToken { token: token.to_owned(), info })
} else {
self.db.remove_token(token);
None
}
})
}
/// Consume the supplied valid token, using it to change its user's password
/// to `new_password`.
pub async fn consume_token(
&self,
ValidResetToken { token, info }: ValidResetToken,
new_password: &str,
) -> Result<()> {
if info.is_valid() {
self.db.remove_token(&token);
self.services
.users
.set_password(&info.user, Some(HashedPassword::new(new_password)?));
}
Ok(())
}
}
+3 -2
View File
@@ -10,6 +10,7 @@
stream::{iter, once},
};
use ruma::OwnedUserId;
use serde::{Deserialize, Serialize};
use crate::{Dep, config, firstrun};
@@ -27,7 +28,7 @@ struct Services {
}
/// A validated registration token which may be used to create an account.
#[derive(Debug)]
#[derive(Debug, Deserialize, Serialize)]
pub struct ValidToken {
pub token: String,
pub source: ValidTokenSource,
@@ -44,7 +45,7 @@ fn eq(&self, other: &str) -> bool { self.token == other }
}
/// The source of a valid database token.
#[derive(Debug)]
#[derive(Debug, Deserialize, Serialize)]
pub enum ValidTokenSource {
/// The static token set in the homeserver's config file.
Config,

Some files were not shown because too many files have changed in this diff Show More