Compare commits

...

37 Commits

Author SHA1 Message Date
Daniel García a4907f3539 Add wrapped named variants to UserDecryptionOptions (#6598) 2025-12-27 23:35:04 +01:00
Daniel 8801b47d80 Remove unnecessary output sharing between jobs (#6555)
Split step into 2 parts, since only 1 part is needed in the build job
2025-12-23 16:27:53 +01:00
Daniel 1ae9dc4119 Simplify binary extraction (#6554) 2025-12-23 16:26:28 +01:00
Mathijs van Veluw 02377eeac8 Update crates (#6585)
Signed-off-by: BlackDex <black.dex@gmail.com>
2025-12-23 16:25:56 +01:00
Mathijs van Veluw d9c75508c2 Fix posting cipher with readonly collections (#6578)
* Fix posting cipher with readonly collections

This fix will check if a collection is writeable for the user, and if not error out early instead of creating the cipher first and leaving it.
It will also save some database transactions.

Fixes #6562

Signed-off-by: BlackDex <black.dex@gmail.com>

* Adjust code to delete on error

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-12-21 18:51:58 +01:00
Mathijs van Veluw 0ab7784b06 Update web-vault to v2025.12.0 (#6577)
Updated web-vault
Updated one crate

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-12-21 00:01:30 +01:00
Daniel García 5c91058ba0 Add UserDecryptionOptions on /sync too (#6574) 2025-12-20 00:37:46 +01:00
Mathijs van Veluw 229b58fe4e Update crates and Rust (#6551)
* Update crates and Rust

- Updated all the crates
- Updated Rust to v1.92.0
- Updated to Alpine v3.23
- Adjusted some nightly clippy lints

Signed-off-by: BlackDex <black.dex@gmail.com>

* Add new updates

Signed-off-by: BlackDex <black.dex@gmail.com>

* Updated more crates and fix mariadb

Updated more crates
Also removed older MariaDB library since Diesel has fixed this in the v2.3.5 version.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix icon-fetch error

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update GHA workflows

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-12-19 17:38:13 +01:00
Daniel García 061d320c7f Add new accountKeys and masterPasswordUnlock fields (#6572)
* Add new accountKeys and masterPasswordUnlock fields

* Fmt
2025-12-19 13:34:43 +01:00
Stefan Melmuk 2c73c6c2f2 support UriMatchDefaults policy (#6570) 2025-12-19 12:07:58 +01:00
Daniel b920caf285 Revert to gzip compression (#6566)
- zstd support has been added in Docker v23
- Debian Bookworm/Bullseye ships with Docker v20.10
- Revert for now to maintain compatibility with older releases
2025-12-19 12:07:05 +01:00
Stefan Melmuk 57bdab1550 add empty /api/tasks endpoint (#6557) 2025-12-14 15:32:21 +01:00
Daniel b77c01b8bb Further fixes for the release workflow (#6533) 2025-12-07 16:07:07 +01:00
Mathijs van Veluw 9cca120fb3 Fix release workflow (#6532) 2025-12-07 13:12:05 +01:00
Stefan Melmuk 4ad8baf7be fix email as 2fa for sso (#6495)
* fix email as 2fa for sso

* allow saving device without updating `updated_at`

* check if email is some

* allow device to be saved in postgresql

* use twofactor_incomplete table

* no need to update device.updated_at
2025-12-06 22:22:33 +01:00
Timshel 8f689d8795 Improve sso auth flow (#6205)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2025-12-06 22:20:04 +01:00
Timshel 2d91a9460b Fix admin invite with SSO (#6498)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2025-12-06 22:14:20 +01:00
Timshel e81e6a5060 Android want response property in camelCase (#6513)
Co-authored-by: Timshel <timshel@480s>
2025-12-06 22:13:51 +01:00
Timshel 76d0856bbe Org.put_policy type not in body anymore (#6514)
Co-authored-by: Timshel <timshel@480s>
2025-12-06 22:12:46 +01:00
Timshel f0e79fd391 Iterate over tags on release (#6518)
Co-authored-by: Timshel <timshel@480s>
2025-12-06 22:12:25 +01:00
k725 5981705375 fix: typo (#6528) 2025-12-06 22:11:58 +01:00
Mathijs van Veluw 07569a06da Update crates and workflows and some fixes (#6508)
- Updated all the crates except for Diesel.
  Diesel is pinned at v2.3.3 since newer versions break MySQL/MariaDB.
- Updated all the GHA workflows
- Fixed an issue with a migration breaking on an empty MySQL/MariaDB database.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-30 15:16:23 +01:00
Mathijs van Veluw cb2f5741ac Some small admin js/css updates (#6501)
* Some small admin js/css updates

- Updated JS libraries
- Fixed some eslint errors
- Small update on the theme icon's to be a bit smaller and better sized.
  Used OXVG via OXVGUI to shrink and optimze them.

Probably Fixes #6493

Signed-off-by: BlackDex <black.dex@gmail.com>

* Adjust the size of the moon to be more inline with the other icons

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-29 22:57:57 +01:00
Mathijs van Veluw c9d527d84f Add option to prefer IPv6 resolving (#6494)
This PR adds an option to prefer IPv6 resolving before IPv4.
On IPv6 only systems this could be very useful, but will not solve IPv4 only domains of course.
For that you need a DNS64 + NAT64 solution

Fixes #6301

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-26 01:26:10 +01:00
Mathijs van Veluw 7c7f4f5d4f Update crates and Rust version (#6485)
* Update crates and Rust version

- Update all crates (where possible)
  Adjusted code where needed
- Fixed some nightly clippy lints

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix some issues/comments

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update some crates

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2025-11-23 22:03:30 +01:00
Stefan Melmuk aad1f19b45 fix email as 2fa provider (#6473) 2025-11-23 21:55:20 +01:00
Timshel 35e1a306f3 Fix around singleorg policy (#6247)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2025-11-23 21:54:37 +01:00
Mathijs van Veluw 7f7b412220 Fix icon redirect caching (#6487)
As reported in #6477, redirection of favicon's didn't allowed caching.
This commit fixes this by adding the `Cached` wrapper around the response.
It will use the same TTL's used for downloading icon's locally.

Also removed `_` as valid domain character, these should not be used in FQDN's at all.
Those only serve as special chars used in domain labels, mostly used in SRV or TXT records.

Fixes #6477

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-23 21:50:31 +01:00
Daniel bb41f64c0a Switch to multiple runners per arch (#6472)
- now uses arm64 native runners for faster compilation
2025-11-23 21:48:23 +01:00
Ephemera42 319d982113 Add pm-25373-windows-biometrics-v2 feature flag (#6468) 2025-11-14 18:46:50 +01:00
Stefan Melmuk 95a0c667e4 remove invalid emergency access dummy value (#6463) 2025-11-14 18:46:42 +01:00
Joep Duin b519832086 Fix: admin theme emoji alignment (#6459)
* Fix: admin theme dropdown emoji alignment

* Sprites
2025-11-14 18:46:31 +01:00
Mathijs van Veluw 2ee40d6105 Fix KDF Change with new web-vault (#6458)
The newer web-vault's use a different json to update the KDF settings.
This commit fixes this by updating the struct and adjust the validation settings.

Fixes #6457

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-11 19:37:32 +01:00
Timshel 0182567a62 Playwright against abitrary web-vault (#6380)
* Playwright improvements

* Playwright fix for the extension setup

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2025-11-11 19:23:35 +01:00
Mathijs van Veluw f9751a0a1d Use an older version of mariadb to prevent a panic (#6453)
* Use an older version of mariadb to prevent a panic

The Debian builds use a newer version of libmariadb which causes Diesel to panic on certain queries.
This commit prevents this by using an older version of libmariadb which doesn't cause this panic.

The Alpine based versions use a patched version which reverts the commit in the libmariadb library which causes this panic.
In the future this might be fixed in Diesel it self (https://github.com/dani-garcia/vaultwarden/issues/6416#issuecomment-3508822097), but until then, we use an older version of the library.

Fixes #6416

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update GHA versions

Signed-off-by: BlackDex <black.dex@gmail.com>

* Resolve docker build check issue

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-10 18:03:45 +01:00
Mathijs van Veluw 9017ca265a Optimizations and build speedup (#6339)
* Optimizations and build speedup

With this commit I have changed several components to be more efficient.
This can be less llvm-lines generated or less `clone()` calls.

 ### Config
- Re-ordered the `make_config` macro to be more efficient
- Created a custom Deserializer for `ConfigBuilder` less code and more efficient
- Use struct's for the `prepare_json` function instead of generating a custom JSON object.
  This generates less code and is more efficient.
- Updated the `get_support_string` function to handle the masking differently.
  This generates less code and also was able to remove some sub-macro-calls

 ### Error
- Added an extra new call to prevent duplicate Strings in generated macro code.
  This generated less llvm-lines and seems to be more efficient.
- Created a custom Serializer for `ApiError` and `CompactApiError`
  This makes that struct smaller in size, so better for memory, but also less llvm-lines.

 ### General
- Removed `once_lock` and replace it all with Rust's std LazyLock
- Added and fixed some Clippy lints which reduced `clone()` calls for example.
- Updated build profiles for more efficiency
  Also added a new profile specifically for CI, which should decrease the build check
- Updated several GitHub Workflows for better security and use the new `ci` build profile
- Updated to Rust v1.90.0 which uses a new linker `rust-lld` which should help in faster building
- Updated the Cargo.toml for all crates to better use the `workspace` variables
- Added a `typos` Workflow and Pre-Commit, which should help in detecting spell error's.
  Also fixed a few found by it.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix release profile

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update typos and remove mimalloc check from pre-commit checks

Signed-off-by: BlackDex <black.dex@gmail.com>

* Misc fixes and updated typos

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update crates and workflows

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix formating and pre-commit

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update to Rust v1.91 and update crates

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update web-vault to v2025.10.1 and xx to v1.8.0

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-01 22:21:04 +01:00
Mathijs van Veluw 8d30285160 Fix issue with key-rotation and emergency-access (#6421)
When a user has an unconfirmed emergency-access user, and tries to do a key-rotation, the validation fails.
The reason is that Bitwarden only returns new keys for confirmed users, not for invited or accepted.

This commit fixes this by only requesting confirmed or higher status emergency-access users.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-01 22:20:38 +01:00
88 changed files with 2887 additions and 2158 deletions
+9 -3
View File
@@ -183,9 +183,9 @@
## Defaults to every minute. Set blank to disable this job. ## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
# #
## Cron schedule of the job that cleans sso nonce from incomplete flow ## Cron schedule of the job that cleans sso auth from incomplete flow
## Defaults to daily (20 minutes after midnight). Set blank to disable this job. ## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *" # PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *"
######################## ########################
### General settings ### ### General settings ###
@@ -348,7 +348,7 @@
## Default: 2592000 (30 days) ## Default: 2592000 (30 days)
# ICON_CACHE_TTL=2592000 # ICON_CACHE_TTL=2592000
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever") ## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
## Default: 2592000 (3 days) ## Default: 259200 (3 days)
# ICON_CACHE_NEGTTL=259200 # ICON_CACHE_NEGTTL=259200
## Icon download timeout ## Icon download timeout
@@ -376,6 +376,7 @@
## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension. ## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension.
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0) ## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0) ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Needs desktop >= 2025.11.0)
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0) ## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0) ## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0) ## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
@@ -471,6 +472,11 @@
## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. ## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
# ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false # ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false
## Prefer IPv6 (AAAA) resolving
## This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
## This could be useful in IPv6 only environments.
# DNS_PREFER_IPV6=false
##################################### #####################################
### SSO settings (OpenID Connect) ### ### SSO settings (OpenID Connect) ###
##################################### #####################################
+30 -15
View File
@@ -14,6 +14,7 @@ on:
- "diesel.toml" - "diesel.toml"
- "docker/Dockerfile.j2" - "docker/Dockerfile.j2"
- "docker/DockerSettings.yaml" - "docker/DockerSettings.yaml"
- "macros/**"
pull_request: pull_request:
paths: paths:
@@ -27,13 +28,11 @@ on:
- "diesel.toml" - "diesel.toml"
- "docker/Dockerfile.j2" - "docker/Dockerfile.j2"
- "docker/DockerSettings.yaml" - "docker/DockerSettings.yaml"
- "macros/**"
jobs: jobs:
build: build:
name: Build and Test ${{ matrix.channel }} name: Build and Test ${{ matrix.channel }}
permissions:
actions: write
contents: read
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 120 timeout-minutes: 120
# Make warnings errors, this is to prevent warnings slipping through. # Make warnings errors, this is to prevent warnings slipping through.
@@ -55,7 +54,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
@@ -81,7 +80,7 @@ jobs:
# Only install the clippy and rustfmt components on the default rust-toolchain # Only install the clippy and rustfmt components on the default rust-toolchain
- name: "Install rust-toolchain version" - name: "Install rust-toolchain version"
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master @ Aug 23, 2025, 3:20 AM GMT+2 uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master @ Dec 16, 2025, 6:11 PM GMT+1
if: ${{ matrix.channel == 'rust-toolchain' }} if: ${{ matrix.channel == 'rust-toolchain' }}
with: with:
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
@@ -91,7 +90,7 @@ jobs:
# Install the any other channel to be used for which we do not execute clippy and rustfmt # Install the any other channel to be used for which we do not execute clippy and rustfmt
- name: "Install MSRV version" - name: "Install MSRV version"
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master @ Aug 23, 2025, 3:20 AM GMT+2 uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master @ Dec 16, 2025, 6:11 PM GMT+1
if: ${{ matrix.channel != 'rust-toolchain' }} if: ${{ matrix.channel != 'rust-toolchain' }}
with: with:
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
@@ -116,48 +115,60 @@ jobs:
# Enable Rust Caching # Enable Rust Caching
- name: Rust Caching - name: Rust Caching
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with: with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example. # Like changing the build host from Ubuntu 20.04 to 22.04 for example.
# Only update when really needed! Use a <year>.<month>[.<inc>] format. # Only update when really needed! Use a <year>.<month>[.<inc>] format.
prefix-key: "v2023.07-rust" prefix-key: "v2025.09-rust"
# End Enable Rust Caching # End Enable Rust Caching
# Run cargo tests # Run cargo tests
# First test all features together, afterwards test them separately. # First test all features together, afterwards test them separately.
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3"
id: test_sqlite_mysql_postgresql_mimalloc_s3
if: ${{ !cancelled() }}
run: |
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
id: test_sqlite_mysql_postgresql_mimalloc
if: ${{ !cancelled() }}
run: |
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc
- name: "test features: sqlite,mysql,postgresql" - name: "test features: sqlite,mysql,postgresql"
id: test_sqlite_mysql_postgresql id: test_sqlite_mysql_postgresql
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
run: | run: |
cargo test --features sqlite,mysql,postgresql cargo test --profile ci --features sqlite,mysql,postgresql
- name: "test features: sqlite" - name: "test features: sqlite"
id: test_sqlite id: test_sqlite
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
run: | run: |
cargo test --features sqlite cargo test --profile ci --features sqlite
- name: "test features: mysql" - name: "test features: mysql"
id: test_mysql id: test_mysql
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
run: | run: |
cargo test --features mysql cargo test --profile ci --features mysql
- name: "test features: postgresql" - name: "test features: postgresql"
id: test_postgresql id: test_postgresql
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
run: | run: |
cargo test --features postgresql cargo test --profile ci --features postgresql
# End Run cargo tests # End Run cargo tests
# Run cargo clippy, and fail on warnings # Run cargo clippy, and fail on warnings
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc" - name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3"
id: clippy id: clippy
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
run: | run: |
cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
# End Run cargo clippy # End Run cargo clippy
@@ -175,6 +186,8 @@ jobs:
- name: "Some checks failed" - name: "Some checks failed"
if: ${{ failure() }} if: ${{ failure() }}
env: env:
TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }}
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }} TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
TEST_SQLITE: ${{ steps.test_sqlite.outcome }} TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
TEST_MYSQL: ${{ steps.test_mysql.outcome }} TEST_MYSQL: ${{ steps.test_mysql.outcome }}
@@ -186,11 +199,13 @@ jobs:
echo "" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}" echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}" echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}" echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}" echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
echo "" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}" echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
+1 -3
View File
@@ -6,15 +6,13 @@ on: [ push, pull_request ]
jobs: jobs:
docker-templates: docker-templates:
name: Validate docker templates name: Validate docker templates
permissions:
contents: read
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
+5 -6
View File
@@ -1,20 +1,19 @@
name: Hadolint name: Hadolint
permissions: {}
on: [ push, pull_request ] on: [ push, pull_request ]
permissions: {}
jobs: jobs:
hadolint: hadolint:
name: Validate Dockerfile syntax name: Validate Dockerfile syntax
permissions:
contents: read
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -31,11 +30,11 @@ jobs:
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \ sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
sudo chmod +x /usr/local/bin/hadolint sudo chmod +x /usr/local/bin/hadolint
env: env:
HADOLINT_VERSION: 2.13.1 HADOLINT_VERSION: 2.14.0
# End Download hadolint # End Download hadolint
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
+293 -215
View File
@@ -16,50 +16,51 @@ concurrency:
# Don't cancel other runs when creating a tag # Don't cancel other runs when creating a tag
cancel-in-progress: ${{ github.ref_type == 'branch' }} cancel-in-progress: ${{ github.ref_type == 'branch' }}
defaults:
run:
shell: bash
env:
# The *_REPO variables need to be configured as repository variables
# Append `/settings/variables/actions` to your repo url
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>'
# Check for Docker hub credentials in secrets
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>'
# Check for Github credentials in secrets
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }}
# QUAY_REPO needs to be 'quay.io/<user>/<repo>'
# Check for Quay.io credentials in secrets
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }}
jobs: jobs:
docker-build: docker-build:
name: Build Vaultwarden containers name: Build Vaultwarden containers
if: ${{ github.repository == 'dani-garcia/vaultwarden' }} if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
permissions: permissions:
packages: write packages: write # Needed to upload packages and artifacts
contents: read contents: read
attestations: write attestations: write # Needed to generate an artifact attestation for a build
id-token: write id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
runs-on: ubuntu-24.04 runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 120 timeout-minutes: 120
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
services:
registry:
image: registry@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 # v3.0.0
ports:
- 5000:5000
env: env:
SOURCE_COMMIT: ${{ github.sha }} SOURCE_COMMIT: ${{ github.sha }}
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}" SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
# The *_REPO variables need to be configured as repository variables
# Append `/settings/variables/actions` to your repo url
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>'
# Check for Docker hub credentials in secrets
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>'
# Check for Github credentials in secrets
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }}
# QUAY_REPO needs to be 'quay.io/<user>/<repo>'
# Check for Quay.io credentials in secrets
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }}
strategy: strategy:
matrix: matrix:
arch: ["amd64", "arm64", "arm/v7", "arm/v6"]
base_image: ["debian","alpine"] base_image: ["debian","alpine"]
steps: steps:
- name: Initialize QEMU binfmt support - name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
with: with:
platforms: "arm64,arm" platforms: "arm64,arm"
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -72,15 +73,245 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
# We need fetch-depth of 0 so we also get all the tag metadata # We need fetch-depth of 0 so we also get all the tag metadata
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
# Determine Base Tags and Source Version # Normalize the architecture string for use in paths and cache keys
- name: Determine Base Tags and Source Version - name: Normalize architecture string
shell: bash env:
MATRIX_ARCH: ${{ matrix.arch }}
run: |
# Replace slashes with nothing to create a safe string for paths/cache keys
NORMALIZED_ARCH="${MATRIX_ARCH//\/}"
echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
# Determine Source Version
- name: Determine Source Version
run: |
# Get the Source Version for this release
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
if [[ -n "${GIT_EXACT_TAG}" ]]; then
echo "SOURCE_VERSION=${GIT_EXACT_TAG}" | tee -a "${GITHUB_ENV}"
else
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
fi
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
- name: Add registry for DockerHub
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
env:
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
- name: Add registry for ghcr.io
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
env:
GHCR_REPO: ${{ vars.GHCR_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
- name: Add registry for Quay.io
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
env:
QUAY_REPO: ${{ vars.QUAY_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
- name: Configure build cache from/to
env:
GHCR_REPO: ${{ vars.GHCR_REPO }}
BASE_IMAGE: ${{ matrix.base_image }}
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
run: |
#
# Check if there is a GitHub Container Registry Login and use it for caching
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
else
echo "BAKE_CACHE_FROM="
echo "BAKE_CACHE_TO="
fi
#
- name: Generate tags
id: tags
env:
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
run: |
# Convert comma-separated list to newline-separated set commands
TAGS=$(echo "${CONTAINER_REGISTRIES}" | tr ',' '\n' | sed "s|.*|*.tags=&|")
# Output for use in next step
{
echo "TAGS<<EOF"
echo "$TAGS"
echo "EOF"
} >> "$GITHUB_ENV"
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
with:
pull: true
source: .
files: docker/docker-bake.hcl
targets: "${{ matrix.base_image }}-multi"
set: |
*.cache-from=${{ env.BAKE_CACHE_FROM }}
*.cache-to=${{ env.BAKE_CACHE_TO }}
*.platform=linux/${{ matrix.arch }}
${{ env.TAGS }}
*.output=type=local,dest=./output
*.output=type=image,push-by-digest=true,name-canonical=true,push=true
- name: Extract digest SHA
env:
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
BASE_IMAGE: ${{ matrix.base_image }}
run: |
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
- name: Export digest
env:
DIGEST_SHA: ${{ env.DIGEST_SHA }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
mkdir -p "${RUNNER_TEMP}"/digests
digest="${DIGEST_SHA}"
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
- name: Rename binaries to match target platform
env:
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
run: |
mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}"
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
- name: Upload binaries as artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
merge-manifests:
name: Merge manifests
runs-on: ubuntu-latest
needs: docker-build
permissions:
packages: write # Needed to upload packages and artifacts
attestations: write # Needed to generate an artifact attestation for a build
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
strategy:
matrix:
base_image: ["debian","alpine"]
steps:
- name: Download digests
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: ${{ runner.temp }}/digests
pattern: digests-*-${{ matrix.base_image }}
merge-multiple: true
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
- name: Add registry for DockerHub
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
env:
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
- name: Add registry for ghcr.io
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
env:
GHCR_REPO: ${{ vars.GHCR_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
- name: Add registry for Quay.io
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
env:
QUAY_REPO: ${{ vars.QUAY_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
# Determine Base Tags
- name: Determine Base Tags
env: env:
REF_TYPE: ${{ github.ref_type }} REF_TYPE: ${{ github.ref_type }}
run: | run: |
@@ -91,215 +322,62 @@ jobs:
echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}" echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}"
fi fi
# Get the Source Version for this release - name: Create manifest list, push it and extract digest SHA
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)" working-directory: ${{ runner.temp }}/digests
if [[ -n "${GIT_EXACT_TAG}" ]]; then
echo "SOURCE_VERSION=${GIT_EXACT_TAG}" | tee -a "${GITHUB_ENV}"
else
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
fi
# End Determine Base Tags
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
- name: Add registry for DockerHub
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
shell: bash
env:
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
- name: Add registry for ghcr.io
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
shell: bash
env:
GHCR_REPO: ${{ vars.GHCR_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
- name: Add registry for Quay.io
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
shell: bash
env:
QUAY_REPO: ${{ vars.QUAY_REPO }}
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
- name: Configure build cache from/to
shell: bash
env:
GHCR_REPO: ${{ vars.GHCR_REPO }}
BASE_IMAGE: ${{ matrix.base_image }}
run: |
#
# Check if there is a GitHub Container Registry Login and use it for caching
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}" | tee -a "${GITHUB_ENV}"
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
else
echo "BAKE_CACHE_FROM="
echo "BAKE_CACHE_TO="
fi
#
- name: Add localhost registry
shell: bash
run: |
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}"
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@3acf805d94d93a86cce4ca44798a76464a75b88c # v6.9.0
env: env:
BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}"
BASE_TAGS: "${{ env.BASE_TAGS }}" BASE_TAGS: "${{ env.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}" CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
with:
pull: true
push: true
source: .
files: docker/docker-bake.hcl
targets: "${{ matrix.base_image }}-multi"
set: |
*.cache-from=${{ env.BAKE_CACHE_FROM }}
*.cache-to=${{ env.BAKE_CACHE_TO }}
- name: Extract digest SHA
shell: bash
env:
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
BASE_IMAGE: ${{ matrix.base_image }}
run: | run: |
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")" set +e
IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}"
IFS=',' read -ra TAGS <<< "${BASE_TAGS}"
for img in "${IMAGES[@]}"; do
for tag in "${TAGS[@]}"; do
echo "Creating manifest for ${img}:${tag}${BASE_IMAGE_TAG}"
OUTPUT=$(docker buildx imagetools create \
-t "${img}:${tag}${BASE_IMAGE_TAG}" \
$(printf "${img}@sha256:%s " *) 2>&1)
STATUS=$?
if [ ${STATUS} -ne 0 ]; then
echo "Manifest creation failed for ${img}:${tag}${BASE_IMAGE_TAG}"
echo "${OUTPUT}"
exit ${STATUS}
fi
echo "Manifest created for ${img}:${tag}${BASE_IMAGE_TAG}"
echo "${OUTPUT}"
done
done
set -e
# Extract digest SHA for subsequent steps
GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}" echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
# Attest container images # Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }} - name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}} if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
push-to-registry: true push-to-registry: true
- name: Attest - ghcr.io - ${{ matrix.base_image }} - name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}} if: ${{ env.HAVE_GHCR_LOGIN == 'true' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
push-to-registry: true push-to-registry: true
- name: Attest - quay.io - ${{ matrix.base_image }} - name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}} if: ${{ env.HAVE_QUAY_LOGIN == 'true' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
push-to-registry: true push-to-registry: true
# Extract the Alpine binaries from the containers
- name: Extract binaries
shell: bash
env:
REF_TYPE: ${{ github.ref_type }}
BASE_IMAGE: ${{ matrix.base_image }}
run: |
# Check which main tag we are going to build determined by ref_type
if [[ "${REF_TYPE}" == "tag" ]]; then
EXTRACT_TAG="latest"
elif [[ "${REF_TYPE}" == "branch" ]]; then
EXTRACT_TAG="testing"
fi
# Check which base_image was used and append -alpine if needed
if [[ "${BASE_IMAGE}" == "alpine" ]]; then
EXTRACT_TAG="${EXTRACT_TAG}-alpine"
fi
# After each extraction the image is removed.
# This is needed because using different platforms doesn't trigger a new pull/download
# Extract amd64 binary
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp amd64:/vaultwarden vaultwarden-amd64-${BASE_IMAGE}
docker rm --force amd64
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Extract arm64 binary
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp arm64:/vaultwarden vaultwarden-arm64-${BASE_IMAGE}
docker rm --force arm64
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Extract armv7 binary
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp armv7:/vaultwarden vaultwarden-armv7-${BASE_IMAGE}
docker rm --force armv7
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Extract armv6 binary
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp armv6:/vaultwarden vaultwarden-armv6-${BASE_IMAGE}
docker rm --force armv6
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Upload artifacts to Github Actions and Attest the binaries
- name: "Upload amd64 artifact ${{ matrix.base_image }}"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64-${{ matrix.base_image }}
path: vaultwarden-amd64-${{ matrix.base_image }}
- name: "Upload arm64 artifact ${{ matrix.base_image }}"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64-${{ matrix.base_image }}
path: vaultwarden-arm64-${{ matrix.base_image }}
- name: "Upload armv7 artifact ${{ matrix.base_image }}"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7-${{ matrix.base_image }}
path: vaultwarden-armv7-${{ matrix.base_image }}
- name: "Upload armv6 artifact ${{ matrix.base_image }}"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6-${{ matrix.base_image }}
path: vaultwarden-armv6-${{ matrix.base_image }}
- name: "Attest artifacts ${{ matrix.base_image }}"
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-path: vaultwarden-*
# End Upload artifacts to Github Actions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
releasecache-cleanup: releasecache-cleanup:
name: Releasecache Cleanup name: Releasecache Cleanup
permissions: permissions:
packages: write packages: write # To be able to cleanup old caches
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
continue-on-error: true continue-on-error: true
timeout-minutes: 30 timeout-minutes: 30
+3 -5
View File
@@ -23,15 +23,13 @@ jobs:
if: ${{ github.repository == 'dani-garcia/vaultwarden' }} if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
name: Trivy Scan name: Trivy Scan
permissions: permissions:
contents: read security-events: write # To write the security report
actions: read
security-events: write
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -48,6 +46,6 @@ jobs:
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab - name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'
+22
View File
@@ -0,0 +1,22 @@
name: Code Spell Checking
on: [ push, pull_request ]
permissions: {}
jobs:
typos:
name: Run typos spell checking
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with:
persist-credentials: false
# End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
+3 -3
View File
@@ -13,15 +13,15 @@ jobs:
name: Run zizmor name: Run zizmor
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
security-events: write security-events: write # To write the security report
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0 uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.
+10 -4
View File
@@ -1,7 +1,7 @@
--- ---
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: check-json - id: check-json
@@ -22,14 +22,15 @@ repos:
description: Format files with cargo fmt. description: Format files with cargo fmt.
entry: cargo fmt entry: cargo fmt
language: system language: system
types: [rust] always_run: true
pass_filenames: false
args: ["--", "--check"] args: ["--", "--check"]
- id: cargo-test - id: cargo-test
name: cargo test name: cargo test
description: Test the package for errors. description: Test the package for errors.
entry: cargo test entry: cargo test
language: system language: system
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--"] args: ["--features", "sqlite,mysql,postgresql", "--"]
types_or: [rust, file] types_or: [rust, file]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false pass_filenames: false
@@ -38,7 +39,7 @@ repos:
description: Lint Rust sources description: Lint Rust sources
entry: cargo clippy entry: cargo clippy
language: system language: system
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--", "-D", "warnings"] args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"]
types_or: [rust, file] types_or: [rust, file]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false pass_filenames: false
@@ -50,3 +51,8 @@ repos:
args: args:
- "-c" - "-c"
- "cd docker && make" - "cd docker && make"
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: 2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
hooks:
- id: typos
+26
View File
@@ -0,0 +1,26 @@
[files]
extend-exclude = [
".git/",
"playwright/",
"*.js", # Ignore all JavaScript files
"!admin*.js", # Except our own JavaScript files
]
ignore-hidden = false
[default]
extend-ignore-re = [
# We use this in place of the reserved type identifier at some places
"typ",
# In SMTP it's called HELO, so ignore it
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSEKey",
"COSEAlgorithm",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
"AuthRequestResponseRecieved",
]
Generated
+451 -387
View File
File diff suppressed because it is too large Load Diff
+74 -46
View File
@@ -1,3 +1,10 @@
[workspace.package]
edition = "2021"
rust-version = "1.90.0"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
[workspace] [workspace]
members = ["macros"] members = ["macros"]
@@ -5,15 +12,14 @@ members = ["macros"]
name = "vaultwarden" name = "vaultwarden"
version = "1.0.0" version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"] authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
edition = "2021"
rust-version = "1.87.0"
resolver = "2"
repository = "https://github.com/dani-garcia/vaultwarden"
readme = "README.md" readme = "README.md"
license = "AGPL-3.0-only"
publish = false
build = "build.rs" build = "build.rs"
resolver = "2"
repository.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[features] [features]
default = [ default = [
@@ -49,16 +55,13 @@ syslog = "7.0.0"
macros = { path = "./macros" } macros = { path = "./macros" }
# Logging # Logging
log = "0.4.28" log = "0.4.29"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
tracing = { version = "0.1.41", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
# A `dotenv` implementation for Rust # A `dotenv` implementation for Rust
dotenvy = { version = "0.15.7", default-features = false } dotenvy = { version = "0.15.7", default-features = false }
# Lazy initialization
once_cell = "1.21.3"
# Numerical libraries # Numerical libraries
num-traits = "0.2.19" num-traits = "0.2.19"
num-derive = "0.4.2" num-derive = "0.4.2"
@@ -77,17 +80,18 @@ dashmap = "6.1.0"
# Async futures # Async futures
futures = "0.3.31" futures = "0.3.31"
tokio = { version = "1.48.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } tokio = { version = "1.48.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
tokio-util = { version = "0.7.16", features = ["compat"]} tokio-util = { version = "0.7.17", features = ["compat"]}
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.3", features = ["chrono", "r2d2", "numeric"] } # Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility
diesel_migrations = "2.3.0" diesel = { version = "2.3.5", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.1"
derive_more = { version = "2.0.1", features = ["from", "into", "as_ref", "deref", "display"] } derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
diesel-derive-newtype = "2.1.2" diesel-derive-newtype = "2.1.2"
# Bundled/Static SQLite # Bundled/Static SQLite
@@ -99,7 +103,7 @@ ring = "0.17.14"
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.18.1", features = ["v4"] } uuid = { version = "1.19.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.42", features = ["clock", "serde"], default-features = false } chrono = { version = "0.4.42", features = ["clock", "serde"], default-features = false }
@@ -113,7 +117,7 @@ job_scheduler_ng = "2.4.0"
data-encoding = "2.9.0" data-encoding = "2.9.0"
# JWT library # JWT library
jsonwebtoken = "9.3.1" jsonwebtoken = { version = "10.2.0", features = ["use_pem", "rust_crypto"], default-features = false }
# TOTP library # TOTP library
totp-lite = "2.0.1" totp-lite = "2.0.1"
@@ -124,9 +128,9 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"
# WebAuthn libraries # WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db # danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration # danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.3", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.3" webauthn-rs-proto = "0.5.4"
webauthn-rs-core = "0.5.3" webauthn-rs-core = "0.5.4"
# Handling of URL's for WebAuthn and favicons # Handling of URL's for WebAuthn and favicons
url = "2.5.7" url = "2.5.7"
@@ -140,14 +144,14 @@ email_address = "0.2.9"
handlebars = { version = "6.3.2", features = ["dir_source"] } handlebars = { version = "6.3.2", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API) # HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.12.24", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false} reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.0" html5gum = "0.8.3"
regex = { version = "1.12.2", features = ["std", "perf", "unicode-perl"], default-features = false } regex = { version = "1.12.2", features = ["std", "perf", "unicode-perl"], default-features = false }
data-url = "0.3.2" data-url = "0.3.2"
bytes = "1.10.1" bytes = "1.11.0"
svg-hush = "0.9.5" svg-hush = "0.9.5"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
@@ -158,14 +162,14 @@ cookie = "0.18.1"
cookie_store = "0.22.0" cookie_store = "0.22.0"
# Used by U2F, JWT and PostgreSQL # Used by U2F, JWT and PostgreSQL
openssl = "0.10.74" openssl = "0.10.75"
# CLI argument parsing # CLI argument parsing
pico-args = "0.5.0" pico-args = "0.5.0"
# Macro ident concatenation # Macro ident concatenation
pastey = "0.1.1" pastey = "0.2.1"
governor = "0.10.1" governor = "0.10.4"
# OIDC for SSO # OIDC for SSO
openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] } openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] }
@@ -190,14 +194,14 @@ rpassword = "7.4.0"
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.54.1", features = ["services-fs"], default-features = false } opendal = { version = "0.55.0", features = ["services-fs"], default-features = false }
# For retrieving AWS credentials, including temporary SSO credentials # For retrieving AWS credentials, including temporary SSO credentials
anyhow = { version = "1.0.100", optional = true } anyhow = { version = "1.0.100", optional = true }
aws-config = { version = "1.8.8", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } aws-config = { version = "1.8.12", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
aws-credential-types = { version = "1.2.8", optional = true } aws-credential-types = { version = "1.2.11", optional = true }
aws-smithy-runtime-api = { version = "1.9.1", optional = true } aws-smithy-runtime-api = { version = "1.9.3", optional = true }
http = { version = "1.3.1", optional = true } http = { version = "1.4.0", optional = true }
reqsign = { version = "0.16.5", optional = true } reqsign = { version = "0.16.5", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
@@ -207,23 +211,13 @@ reqsign = { version = "0.16.5", optional = true }
strip = "debuginfo" strip = "debuginfo"
lto = "fat" lto = "fat"
codegen-units = 1 codegen-units = 1
debug = false
# A little bit of a speedup
[profile.dev]
split-debuginfo = "unpacked"
# Always build argon2 using opt-level 3
# This is a huge speed improvement during testing
[profile.dev.package.argon2]
opt-level = 3
# Optimize for size # Optimize for size
[profile.release-micro] [profile.release-micro]
inherits = "release" inherits = "release"
opt-level = "z"
strip = "symbols" strip = "symbols"
lto = "fat" opt-level = "z"
codegen-units = 1
panic = "abort" panic = "abort"
# Profile for systems with low resources # Profile for systems with low resources
@@ -234,6 +228,32 @@ strip = "symbols"
lto = "thin" lto = "thin"
codegen-units = 16 codegen-units = 16
# Used for profiling and debugging like valgrind or heaptrack
# Inherits release to be sure all optimizations have been done
[profile.dbg]
inherits = "release"
strip = "none"
split-debuginfo = "off"
debug = "full"
# A little bit of a speedup for generic building
[profile.dev]
split-debuginfo = "unpacked"
debug = "line-tables-only"
# Used for CI builds to improve compile time
[profile.ci]
inherits = "dev"
debug = false
debug-assertions = false
strip = "symbols"
panic = "abort"
# Always build argon2 using opt-level 3
# This is a huge speed improvement during testing
[profile.dev.package.argon2]
opt-level = 3
# Linting config # Linting config
# https://doc.rust-lang.org/rustc/lints/groups.html # https://doc.rust-lang.org/rustc/lints/groups.html
[workspace.lints.rust] [workspace.lints.rust]
@@ -243,15 +263,16 @@ non_ascii_idents = "forbid"
# Deny # Deny
deprecated_in_future = "deny" deprecated_in_future = "deny"
deprecated_safe = { level = "deny", priority = -1 }
future_incompatible = { level = "deny", priority = -1 } future_incompatible = { level = "deny", priority = -1 }
keyword_idents = { level = "deny", priority = -1 } keyword_idents = { level = "deny", priority = -1 }
let_underscore = { level = "deny", priority = -1 } let_underscore = { level = "deny", priority = -1 }
nonstandard_style = { level = "deny", priority = -1 }
noop_method_call = "deny" noop_method_call = "deny"
refining_impl_trait = { level = "deny", priority = -1 } refining_impl_trait = { level = "deny", priority = -1 }
rust_2018_idioms = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 }
rust_2021_compatibility = { level = "deny", priority = -1 } rust_2021_compatibility = { level = "deny", priority = -1 }
rust_2024_compatibility = { level = "deny", priority = -1 } rust_2024_compatibility = { level = "deny", priority = -1 }
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
single_use_lifetimes = "deny" single_use_lifetimes = "deny"
trivial_casts = "deny" trivial_casts = "deny"
trivial_numeric_casts = "deny" trivial_numeric_casts = "deny"
@@ -261,7 +282,8 @@ unused_lifetimes = "deny"
unused_qualifications = "deny" unused_qualifications = "deny"
variant_size_differences = "deny" variant_size_differences = "deny"
# Allow the following lints since these cause issues with Rust v1.84.0 or newer # Allow the following lints since these cause issues with Rust v1.84.0 or newer
# Building Vaultwarden with Rust v1.85.0 and edition 2024 also works without issues # Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
if_let_rescope = "allow" if_let_rescope = "allow"
tail_expr_drop_order = "allow" tail_expr_drop_order = "allow"
@@ -275,10 +297,12 @@ todo = "warn"
result_large_err = "allow" result_large_err = "allow"
# Deny # Deny
branches_sharing_code = "deny"
case_sensitive_file_extension_comparisons = "deny" case_sensitive_file_extension_comparisons = "deny"
cast_lossless = "deny" cast_lossless = "deny"
clone_on_ref_ptr = "deny" clone_on_ref_ptr = "deny"
equatable_if_let = "deny" equatable_if_let = "deny"
excessive_precision = "deny"
filter_map_next = "deny" filter_map_next = "deny"
float_cmp_const = "deny" float_cmp_const = "deny"
implicit_clone = "deny" implicit_clone = "deny"
@@ -292,15 +316,19 @@ manual_instant_elapsed = "deny"
manual_string_new = "deny" manual_string_new = "deny"
match_wildcard_for_single_variants = "deny" match_wildcard_for_single_variants = "deny"
mem_forget = "deny" mem_forget = "deny"
needless_borrow = "deny"
needless_collect = "deny"
needless_continue = "deny" needless_continue = "deny"
needless_lifetimes = "deny" needless_lifetimes = "deny"
option_option = "deny" option_option = "deny"
redundant_clone = "deny"
string_add_assign = "deny" string_add_assign = "deny"
unnecessary_join = "deny" unnecessary_join = "deny"
unnecessary_self_imports = "deny" unnecessary_self_imports = "deny"
unnested_or_patterns = "deny" unnested_or_patterns = "deny"
unused_async = "deny" unused_async = "deny"
unused_self = "deny" unused_self = "deny"
useless_let_if_seq = "deny"
verbose_file_reads = "deny" verbose_file_reads = "deny"
zero_sized_map_values = "deny" zero_sized_map_values = "deny"
+6 -7
View File
@@ -1,13 +1,13 @@
--- ---
vault_version: "v2025.9.1" vault_version: "v2025.12.0"
vault_image_digest: "sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4" vault_image_digest: "sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613"
# Cross Compile Docker Helper Scripts v1.6.1 # Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894" xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.89.0 # Rust version to be used rust_version: 1.92.0 # Rust version to be used
debian_version: trixie # Debian release name to be used debian_version: trixie # Debian release name to be used
alpine_version: "3.22" # Alpine version to be used alpine_version: "3.23" # Alpine version to be used
# For which platforms/architectures will we try to build images # For which platforms/architectures will we try to build images
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
# Determine the build images per OS/Arch # Determine the build images per OS/Arch
@@ -17,7 +17,6 @@ build_stage_image:
platform: "$BUILDPLATFORM" platform: "$BUILDPLATFORM"
alpine: alpine:
image: "build_${TARGETARCH}${TARGETVARIANT}" image: "build_${TARGETARCH}${TARGETVARIANT}"
platform: "linux/amd64" # The Alpine build images only have linux/amd64 images
arch_image: arch_image:
amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}" amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}"
arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}" arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}"
+13 -13
View File
@@ -19,27 +19,27 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2025.9.1 # $ docker pull docker.io/vaultwarden/web-vault:v2025.12.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.9.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.12.0
# [docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4] # [docker.io/vaultwarden/web-vault@sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4 # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613
# [docker.io/vaultwarden/web-vault:v2025.9.1] # [docker.io/vaultwarden/web-vault:v2025.12.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4 AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613 AS vault
########################## ALPINE BUILD IMAGES ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used ## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.89.0 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.92.0 AS build_amd64
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.89.0 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.92.0 AS build_arm64
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.89.0 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.92.0 AS build_armv7
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.89.0 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.92.0 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} AS build FROM --platform=$BUILDPLATFORM build_${TARGETARCH}${TARGETVARIANT} AS build
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
ARG TARGETPLATFORM ARG TARGETPLATFORM
@@ -127,7 +127,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.22 FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \
ROCKET_ADDRESS=0.0.0.0 \ ROCKET_ADDRESS=0.0.0.0 \
+9 -10
View File
@@ -19,24 +19,24 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2025.9.1 # $ docker pull docker.io/vaultwarden/web-vault:v2025.12.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.9.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.12.0
# [docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4] # [docker.io/vaultwarden/web-vault@sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4 # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613
# [docker.io/vaultwarden/web-vault:v2025.9.1] # [docker.io/vaultwarden/web-vault:v2025.12.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4 AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:bb7303efafdb7e2b41bee2c772e14f67676ae2c9047bd7bba80d3544d4162613 AS vault
########################## Cross Compile Docker Helper Scripts ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
## And these bash scripts do not have any significant difference if at all ## And these bash scripts do not have any significant difference if at all
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894 AS xx FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.89.0-slim-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.92.0-slim-trixie AS build
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@@ -51,7 +51,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-256color \ TERM=xterm-256color \
CARGO_HOME="/root/.cargo" \ CARGO_HOME="/root/.cargo" \
USER="root" USER="root"
# Install clang to get `xx-cargo` working # Install clang to get `xx-cargo` working
# Install pkg-config to allow amd64 builds to find all libraries # Install pkg-config to allow amd64 builds to find all libraries
# Install git so build.rs can determine the correct version # Install git so build.rs can determine the correct version
@@ -175,7 +174,7 @@ RUN mkdir /data && \
--no-install-recommends \ --no-install-recommends \
ca-certificates \ ca-certificates \
curl \ curl \
libmariadb-dev \ libmariadb3 \
libpq5 \ libpq5 \
openssl && \ openssl && \
apt-get clean && \ apt-get clean && \
+4 -5
View File
@@ -36,16 +36,16 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_diges
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
{% elif base == "alpine" %} {% elif base == "alpine" %}
########################## ALPINE BUILD IMAGES ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used ## And for Alpine we define all build images here, they will only be loaded when actually used
{% for arch in build_stage_image[base].arch_image %} {% for arch in build_stage_image[base].arch_image %}
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }} FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} AS build FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
{% if base == "debian" %} {% if base == "debian" %}
COPY --from=xx / / COPY --from=xx / /
{% endif %} {% endif %}
@@ -69,7 +69,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
{% endif %} {% endif %}
{% if base == "debian" %} {% if base == "debian" %}
# Install clang to get `xx-cargo` working # Install clang to get `xx-cargo` working
# Install pkg-config to allow amd64 builds to find all libraries # Install pkg-config to allow amd64 builds to find all libraries
# Install git so build.rs can determine the correct version # Install git so build.rs can determine the correct version
@@ -212,7 +211,7 @@ RUN mkdir /data && \
--no-install-recommends \ --no-install-recommends \
ca-certificates \ ca-certificates \
curl \ curl \
libmariadb-dev \ libmariadb3 \
libpq5 \ libpq5 \
openssl && \ openssl && \
apt-get clean && \ apt-get clean && \
+2 -2
View File
@@ -116,7 +116,7 @@ docker/bake.sh
``` ```
You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.<br> You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.<br>
This will also append those values to the tag so you can see the builded container when running `docker images`. This will also append those values to the tag so you can see the built container when running `docker images`.
You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use. You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use.
```bash ```bash
@@ -162,7 +162,7 @@ You can append extra arguments after the target if you want. This can be useful
For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.<br> For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.<br>
### Testing podman builded images ### Testing podman built images
The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that. The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that.
+7 -3
View File
@@ -1,7 +1,11 @@
[package] [package]
name = "macros" name = "macros"
version = "0.1.0" version = "0.1.0"
edition = "2021" repository.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[lib] [lib]
name = "macros" name = "macros"
@@ -9,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
quote = "1.0.41" quote = "1.0.42"
syn = "2.0.108" syn = "2.0.111"
[lints] [lints]
workspace = true workspace = true
@@ -1,2 +1,15 @@
ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`; -- Dynamically create DROP FOREIGN KEY
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
-- This checks if the key exists, and if so, will drop it.
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
CONSTRAINT_SCHEMA = DATABASE() AND
TABLE_NAME = 'sso_users' AND
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
'SELECT 1');
PREPARE stmt FROM @drop_sso_fk;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
@@ -0,0 +1,9 @@
DROP TABLE IF EXISTS sso_auth;
CREATE TABLE sso_nonce (
state VARCHAR(512) NOT NULL PRIMARY KEY,
nonce TEXT NOT NULL,
verifier TEXT,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,12 @@
DROP TABLE IF EXISTS sso_nonce;
CREATE TABLE sso_auth (
state VARCHAR(512) NOT NULL PRIMARY KEY,
client_challenge TEXT NOT NULL,
nonce TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_response TEXT,
auth_response TEXT,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
DROP TABLE IF EXISTS sso_auth;
CREATE TABLE sso_nonce (
state TEXT NOT NULL PRIMARY KEY,
nonce TEXT NOT NULL,
verifier TEXT,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,12 @@
DROP TABLE IF EXISTS sso_nonce;
CREATE TABLE sso_auth (
state TEXT NOT NULL PRIMARY KEY,
client_challenge TEXT NOT NULL,
nonce TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_response TEXT,
auth_response TEXT,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
DROP TABLE IF EXISTS sso_auth;
CREATE TABLE sso_nonce (
state TEXT NOT NULL PRIMARY KEY,
nonce TEXT NOT NULL,
verifier TEXT,
redirect_uri TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,12 @@
DROP TABLE IF EXISTS sso_nonce;
CREATE TABLE sso_auth (
state TEXT NOT NULL PRIMARY KEY,
client_challenge TEXT NOT NULL,
nonce TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_response TEXT,
auth_response TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+4 -2
View File
@@ -97,9 +97,9 @@ npx playwright codegen "http://127.0.0.1:8003"
## Override web-vault ## Override web-vault
It is possible to change the `web-vault` used by referencing a different `bw_web_builds` commit. It is possible to change the `web-vault` used by referencing a different `vw_web_builds` commit.
Simplest is to set and uncomment `PW_WV_REPO_URL` and `PW_WV_COMMIT_HASH` in the `test.env`. Simplest is to set and uncomment `PW_VW_REPO_URL` and `PW_VW_COMMIT_HASH` in the `test.env`.
Ensure that the image is built with: Ensure that the image is built with:
```bash ```bash
@@ -112,6 +112,8 @@ You can check the result running:
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
``` ```
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
# OpenID Connect test setup # OpenID Connect test setup
Additionally this `docker-compose` template allows to run locally Vaultwarden, Additionally this `docker-compose` template allows to run locally Vaultwarden,
+8 -7
View File
@@ -6,18 +6,19 @@ echo $COMMIT_HASH
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
rm -rf /web-vault rm -rf /web-vault
mkdir bw_web_builds; mkdir -p vw_web_builds;
cd bw_web_builds; cd vw_web_builds;
git -c init.defaultBranch=main init git -c init.defaultBranch=main init
git remote add origin "$REPO_URL" git remote add origin "$REPO_URL"
git fetch --depth 1 origin "$COMMIT_HASH" git fetch --depth 1 origin "$COMMIT_HASH"
git -c advice.detachedHead=false checkout FETCH_HEAD git -c advice.detachedHead=false checkout FETCH_HEAD
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2) npm ci --ignore-scripts
./scripts/checkout_web_vault.sh
./scripts/build_web_vault.sh
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json
mv ./web-vault/apps/web/build /web-vault cd apps/web
npm run dist:oss:selfhost
printf '{"version":"%s"}' "$COMMIT_HASH" > build/vw-version.json
mv build /web-vault
fi fi
+3 -2
View File
@@ -18,10 +18,11 @@ services:
context: compose/warden context: compose/warden
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
REPO_URL: ${PW_WV_REPO_URL:-} REPO_URL: ${PW_VW_REPO_URL:-}
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-} COMMIT_HASH: ${PW_VW_COMMIT_HASH:-}
env_file: ${DC_ENV_FILE:-.env} env_file: ${DC_ENV_FILE:-.env}
environment: environment:
- ADMIN_TOKEN
- DATABASE_URL - DATABASE_URL
- I_REALLY_WANT_VOLATILE_STORAGE - I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL - LOG_LEVEL
+19 -3
View File
@@ -221,9 +221,13 @@ export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB:
} }
export async function checkNotification(page: Page, hasText: string) { export async function checkNotification(page: Page, hasText: string) {
await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible(); await expect(page.locator('bit-toast', { hasText })).toBeVisible();
await page.locator('bit-toast').filter({ hasText }).getByRole('button').click(); try {
await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0); await page.locator('bit-toast', { hasText }).getByRole('button', { name: 'Close' }).click({force: true, timeout: 10_000});
} catch (error) {
console.log(`Closing notification failed but it should now be invisible (${error})`);
}
await expect(page.locator('bit-toast', { hasText })).toHaveCount(0);
} }
export async function cleanLanding(page: Page) { export async function cleanLanding(page: Page) {
@@ -244,3 +248,15 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
}); });
} }
export async function ignoreExtension(page: Page) {
await page.waitForLoadState('domcontentloaded');
try {
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
await page.getByRole('link', { name: 'Skip to web app' }).click();
} catch (error) {
console.log('Extension setup not visible. Continuing');
}
}
+126 -202
View File
@@ -9,15 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"mysql2": "3.15.0", "mysql2": "3.15.3",
"otpauth": "9.4.1", "otpauth": "9.4.1",
"pg": "8.16.3" "pg": "8.16.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.55.1", "@playwright/test": "1.56.1",
"dotenv": "17.2.2", "dotenv": "17.2.3",
"dotenv-expand": "12.0.3", "dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@^3.2.3" "maildev": "npm:@timshel_npm/maildev@3.2.5"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -34,16 +34,16 @@
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "6.5.6", "version": "6.7.3",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.6.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz",
"integrity": "sha512-Mj3Hu9ymlsERd7WOsUKNUZnJYL4IZ/I9wVVYgtvOsWYiEFbkQ4G7VRIh2USxTVW4BBDIsLG+gBUgqOqf2Kvqow==", "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9", "@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3", "bidi-js": "^1.0.3",
"css-tree": "^3.1.0", "css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.1" "lru-cache": "^11.2.2"
} }
}, },
"node_modules/@asamuzakjp/nwsapi": { "node_modules/@asamuzakjp/nwsapi": {
@@ -144,9 +144,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.14", "version": "1.0.15",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz",
"integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -160,9 +160,6 @@
], ],
"engines": { "engines": {
"node": ">=18" "node": ">=18"
},
"peerDependencies": {
"postcss": "^8.4"
} }
}, },
"node_modules/@csstools/css-tokenizer": { "node_modules/@csstools/css-tokenizer": {
@@ -196,12 +193,12 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.55.1", "version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright": "1.55.1" "playwright": "1.56.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -249,12 +246,12 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.5.2", "version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~7.12.0" "undici-types": "~7.10.0"
} }
}, },
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
@@ -363,9 +360,9 @@
} }
}, },
"node_modules/body-parser/node_modules/debug": { "node_modules/body-parser/node_modules/debug": {
"version": "4.4.3", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -637,9 +634,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.2.7", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"dev": true, "dev": true,
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -660,9 +657,9 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.2.2", "version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -952,9 +949,9 @@
} }
}, },
"node_modules/express/node_modules/debug": { "node_modules/express/node_modules/debug": {
"version": "4.4.3", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -992,9 +989,9 @@
} }
}, },
"node_modules/finalhandler/node_modules/debug": { "node_modules/finalhandler/node_modules/debug": {
"version": "4.4.3", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -1340,20 +1337,20 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "27.0.0", "version": "27.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@asamuzakjp/dom-selector": "^6.5.4", "@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.0", "cssstyle": "^5.3.1",
"data-urls": "^6.0.0", "data-urls": "^6.0.0",
"decimal.js": "^10.5.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^4.0.0", "html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2", "http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"parse5": "^7.3.0", "parse5": "^8.0.0",
"rrweb-cssom": "^0.8.0", "rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "symbol-tree": "^3.2.4",
@@ -1362,8 +1359,8 @@
"webidl-conversions": "^8.0.0", "webidl-conversions": "^8.0.0",
"whatwg-encoding": "^3.1.1", "whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0", "whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.0.0", "whatwg-url": "^15.1.0",
"ws": "^8.18.2", "ws": "^8.18.3",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
@@ -1426,9 +1423,9 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.2.1", "version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": "20 || >=22" "node": "20 || >=22"
@@ -1450,9 +1447,9 @@
}, },
"node_modules/maildev": { "node_modules/maildev": {
"name": "@timshel_npm/maildev", "name": "@timshel_npm/maildev",
"version": "3.2.3", "version": "3.2.5",
"resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.5.tgz",
"integrity": "sha512-CNxMz4Obw7nL8MZbx4y1YUFeqqAQk+Qwm51tcBV5lRBotMlXKeYhuEcayb1v66nUwq832bUfKF4hyQpJixFZrw==", "integrity": "sha512-suWQu2s2kmO+MXtNJYW9peklznhd+aorIUb4tSNrfaKoEJjDa3vLXTvWf+3cb67o4Yv4Z6nPeKdMTCDZVn/Nyw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/mailparser": "3.4.6", "@types/mailparser": "3.4.6",
@@ -1461,13 +1458,13 @@
"commander": "14.0.1", "commander": "14.0.1",
"compression": "1.8.1", "compression": "1.8.1",
"cors": "2.8.5", "cors": "2.8.5",
"dompurify": "3.2.7", "dompurify": "3.3.0",
"express": "5.1.0", "express": "5.1.0",
"jsdom": "27.0.0", "jsdom": "27.0.1",
"mailparser": "3.7.4", "mailparser": "3.7.5",
"mime": "4.1.0", "mime": "4.1.0",
"nodemailer": "7.0.6", "nodemailer": "7.0.9",
"smtp-server": "3.14.0", "smtp-server": "3.15.0",
"socket.io": "4.8.1", "socket.io": "4.8.1",
"wildstring": "1.0.9" "wildstring": "1.0.9"
}, },
@@ -1479,36 +1476,44 @@
} }
}, },
"node_modules/mailparser": { "node_modules/mailparser": {
"version": "3.7.4", "version": "3.7.5",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz", "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz",
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==", "integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"encoding-japanese": "2.2.0", "encoding-japanese": "2.2.0",
"he": "1.2.0", "he": "1.2.0",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"iconv-lite": "0.6.3", "iconv-lite": "0.7.0",
"libmime": "5.3.7", "libmime": "5.3.7",
"linkify-it": "5.0.0", "linkify-it": "5.0.0",
"mailsplit": "5.4.5", "mailsplit": "5.4.6",
"nodemailer": "7.0.4", "nodemailer": "7.0.9",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"tlds": "1.259.0" "tlds": "1.260.0"
} }
}, },
"node_modules/mailparser/node_modules/nodemailer": { "node_modules/mailparser/node_modules/iconv-lite": {
"version": "7.0.4", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dev": true, "dev": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/mailsplit": { "node_modules/mailsplit": {
"version": "5.4.5", "version": "5.4.6",
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz", "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz",
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==", "integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==",
"deprecated": "This package has been renamed to @zone-eu/mailsplit. Please update your dependencies.",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"libbase64": "1.3.0", "libbase64": "1.3.0",
@@ -1595,9 +1600,9 @@
"dev": true "dev": true
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.15.0", "version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.0.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"dependencies": { "dependencies": {
"aws-ssl-profiles": "^1.1.1", "aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0", "denque": "^2.1.0",
@@ -1647,25 +1652,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
@@ -1676,9 +1662,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.6", "version": "7.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -1747,9 +1733,9 @@
} }
}, },
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"entities": "^6.0.0" "entities": "^6.0.0"
@@ -1793,13 +1779,12 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"dev": true, "dev": true,
"funding": { "engines": {
"type": "opencollective", "node": ">=16"
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/peberminta": { "node_modules/peberminta": {
@@ -1892,20 +1877,13 @@
"split2": "^4.1.0" "split2": "^4.1.0"
} }
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"peer": true
},
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.55.1", "version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.55.1" "playwright-core": "1.56.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -1918,9 +1896,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.55.1", "version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true, "dev": true,
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@@ -1929,35 +1907,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -2049,34 +1998,18 @@
} }
}, },
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "3.0.1", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "3.1.2",
"http-errors": "2.0.0", "http-errors": "2.0.0",
"iconv-lite": "0.7.0", "iconv-lite": "0.6.3",
"unpipe": "1.0.0" "unpipe": "1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dev": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/require-from-string": { "node_modules/require-from-string": {
@@ -2105,9 +2038,9 @@
} }
}, },
"node_modules/router/node_modules/debug": { "node_modules/router/node_modules/debug": {
"version": "4.4.3", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2205,9 +2138,9 @@
} }
}, },
"node_modules/send/node_modules/debug": { "node_modules/send/node_modules/debug": {
"version": "4.4.3", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2326,29 +2259,20 @@
} }
}, },
"node_modules/smtp-server": { "node_modules/smtp-server": {
"version": "3.14.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.14.0.tgz", "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.15.0.tgz",
"integrity": "sha512-cEw/hdIY+xw1pkbQbQ23hvnm9kNABAsgYB+jJYGkzAynZxJ2VB9aqC6JhB1vpdDnqan7C7AL3qHYRGwz5eD6BQ==", "integrity": "sha512-yv945vk0/xcukSKAoIhGz6GOlcXoCyGQH2w9IlLrTKk3SJiOBH9bcO6tD0ILTZYJsMqRa6OTRZAyqeuLXkv59Q==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"base32.js": "0.1.0", "base32.js": "0.1.0",
"ipv6-normalize": "1.0.1", "ipv6-normalize": "1.0.1",
"nodemailer": "7.0.3", "nodemailer": "7.0.9",
"punycode.js": "2.3.1" "punycode.js": "2.3.1"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/smtp-server/node_modules/nodemailer": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
"integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/socket.io": { "node_modules/socket.io": {
"version": "4.8.1", "version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
@@ -2564,30 +2488,30 @@
"dev": true "dev": true
}, },
"node_modules/tlds": { "node_modules/tlds": {
"version": "1.259.0", "version": "1.260.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz",
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==",
"dev": true, "dev": true,
"bin": { "bin": {
"tlds": "bin.js" "tlds": "bin.js"
} }
}, },
"node_modules/tldts": { "node_modules/tldts": {
"version": "7.0.16", "version": "7.0.17",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
"integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"tldts-core": "^7.0.16" "tldts-core": "^7.0.17"
}, },
"bin": { "bin": {
"tldts": "bin/cli.js" "tldts": "bin/cli.js"
} }
}, },
"node_modules/tldts-core": { "node_modules/tldts-core": {
"version": "7.0.16", "version": "7.0.17",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz",
"integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
"dev": true "dev": true
}, },
"node_modules/toidentifier": { "node_modules/toidentifier": {
@@ -2644,9 +2568,9 @@
"dev": true "dev": true
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.12.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true "dev": true
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
+4 -4
View File
@@ -8,13 +8,13 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.55.1", "@playwright/test": "1.56.1",
"dotenv": "17.2.2", "dotenv": "17.2.3",
"dotenv-expand": "12.0.3", "dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@^3.2.3" "maildev": "npm:@timshel_npm/maildev@3.2.5"
}, },
"dependencies": { "dependencies": {
"mysql2": "3.15.0", "mysql2": "3.15.3",
"otpauth": "9.4.1", "otpauth": "9.4.1",
"pg": "8.16.3" "pg": "8.16.3"
} }
+3 -2
View File
@@ -55,6 +55,7 @@ ROCKET_PORT=8003
DOMAIN=http://localhost:${ROCKET_PORT} DOMAIN=http://localhost:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100 LOGIN_RATELIMIT_MAX_BURST=100
ADMIN_TOKEN=admin
SMTP_SECURITY=off SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT} SMTP_PORT=${MAILDEV_SMTP_PORT}
@@ -67,8 +68,8 @@ SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SSO_DEBUG_TOKENS=true SSO_DEBUG_TOKENS=true
# Custom web-vault build # Custom web-vault build
# PW_WV_REPO_URL=https://github.com/dani-garcia/bw_web_builds.git # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git
# PW_WV_COMMIT_HASH=a5f5390895516bce2f48b7baadb6dc399e5fe75a # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3
########################### ###########################
# Docker MariaDb container# # Docker MariaDb container#
+3
View File
@@ -91,6 +91,9 @@ test('2fa', async ({ page }) => {
await page.getByLabel(/Verification code/).fill(code); await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Add it later' }).click();
await page.getByRole('link', { name: 'Skip to web app' }).click();
await expect(page).toHaveTitle(/Vaults/); await expect(page).toHaveTitle(/Vaults/);
}) })
+9 -5
View File
@@ -57,15 +57,17 @@ test('invited with new account', async ({ page }) => {
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
//await page.getByLabel('Name').fill(users.user2.name); //await page.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm new master password (').fill(users.user2.password); await page.getByLabel('Confirm master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created'); await utils.checkNotification(page, 'Your new account has been created');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// Redirected to the vault // Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
await utils.checkNotification(page, 'You have been logged in!'); // await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Invitation accepted');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
@@ -91,9 +93,11 @@ test('invited with existing account', async ({ page }) => {
await page.getByLabel('Master password').fill(users.user3.password); await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in with master password' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Invitation accepted');
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
+1 -1
View File
@@ -48,7 +48,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
await page.getByRole('menuitem', { name: 'Account settings' }).click(); await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').fill(user.password); await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send email' }).click(); await page.getByRole('button', { name: 'Send email' }).click();
+9 -5
View File
@@ -33,19 +33,21 @@ export async function logNewUser(
await test.step('Create Vault account', async () => { await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm new master password (').fill(user.password); await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
}); });
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
}); });
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
if( options.mailBuffer ){ if( options.mailBuffer ){
let mailBuffer = options.mailBuffer; let mailBuffer = options.mailBuffer;
await test.step('Check emails', async () => { await test.step('Check emails', async () => {
@@ -115,6 +117,8 @@ export async function logUser(
await page.getByRole('button', { name: 'Unlock' }).click(); await page.getByRole('button', { name: 'Unlock' }).click();
}); });
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
+6 -3
View File
@@ -17,15 +17,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation // Vault finish Creation
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm new master password (').fill(user.password); await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created') await utils.checkNotification(page, 'Your new account has been created')
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
await utils.checkNotification(page, 'You have been logged in!'); // await utils.checkNotification(page, 'You have been logged in!');
if( mailBuffer ){ if( mailBuffer ){
await mailBuffer.expect((m) => m.subject === "Welcome"); await mailBuffer.expect((m) => m.subject === "Welcome");
@@ -45,6 +46,8 @@ export async function logUser(test, page: Page, user: { email: string, password:
await page.getByLabel('Master password').fill(user.password); await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in with master password' }).click();
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
@@ -67,16 +67,17 @@ test('invited with new account', async ({ page }) => {
await test.step('Create Vault account', async () => { await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm new master password (').fill(users.user2.password); await page.getByLabel('Confirm master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
}); });
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
@@ -107,11 +108,13 @@ test('invited with existing account', async ({ page }) => {
await expect(page).toHaveTitle('Vaultwarden Web'); await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password); await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Unlock' }).click(); await page.getByRole('button', { name: 'Unlock' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
}); });
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Invitation accepted');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.89.0" channel = "1.92.0"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"
+25 -41
View File
@@ -1,17 +1,16 @@
use once_cell::sync::Lazy; use std::{env, sync::LazyLock};
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::env;
use rocket::serde::json::Json; use reqwest::Method;
use rocket::{ use rocket::{
form::Form, form::Form,
http::{Cookie, CookieJar, MediaType, SameSite, Status}, http::{Cookie, CookieJar, MediaType, SameSite, Status},
request::{FromRequest, Outcome, Request}, request::{FromRequest, Outcome, Request},
response::{content::RawHtml as Html, Redirect}, response::{content::RawHtml as Html, Redirect},
serde::json::Json,
Catcher, Route, Catcher, Route,
}; };
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::{ use crate::{
api::{ api::{
@@ -24,7 +23,7 @@ use crate::{
backup_sqlite, get_sql_server_version, backup_sqlite, get_sql_server_version,
models::{ models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, OrgPolicyErr, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
}, },
DbConn, DbConnType, ACTIVE_DB_TYPE, DbConn, DbConnType, ACTIVE_DB_TYPE,
}, },
@@ -82,7 +81,7 @@ pub fn catchers() -> Vec<Catcher> {
} }
} }
static DB_TYPE: Lazy<&str> = Lazy::new(|| match ACTIVE_DB_TYPE.get() { static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
#[cfg(mysql)] #[cfg(mysql)]
Some(DbConnType::Mysql) => "MySQL", Some(DbConnType::Mysql) => "MySQL",
#[cfg(postgresql)] #[cfg(postgresql)]
@@ -93,9 +92,10 @@ static DB_TYPE: Lazy<&str> = Lazy::new(|| match ACTIVE_DB_TYPE.get() {
}); });
#[cfg(sqlite)] #[cfg(sqlite)]
static CAN_BACKUP: Lazy<bool> = Lazy::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false)); static CAN_BACKUP: LazyLock<bool> =
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
#[cfg(not(sqlite))] #[cfg(not(sqlite))]
static CAN_BACKUP: Lazy<bool> = Lazy::new(|| false); static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
#[get("/")] #[get("/")]
fn admin_disabled() -> &'static str { fn admin_disabled() -> &'static str {
@@ -157,10 +157,10 @@ fn admin_login(request: &Request<'_>) -> ApiResult<Html<String>> {
err_code!("Authorization failed.", Status::Unauthorized.code); err_code!("Authorization failed.", Status::Unauthorized.code);
} }
let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string(); let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
render_admin_login(None, Some(redirect)) render_admin_login(None, Some(&redirect))
} }
fn render_admin_login(msg: Option<&str>, redirect: Option<String>) -> ApiResult<Html<String>> { fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Html<String>> {
// If there is an error, show it // If there is an error, show it
let msg = msg.map(|msg| format!("Error: {msg}")); let msg = msg.map(|msg| format!("Error: {msg}"));
let json = json!({ let json = json!({
@@ -194,14 +194,17 @@ fn post_admin_login(
if crate::ratelimit::check_limit_admin(&ip.ip).is_err() { if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
return Err(AdminResponse::TooManyRequests(render_admin_login( return Err(AdminResponse::TooManyRequests(render_admin_login(
Some("Too many requests, try again later."), Some("Too many requests, try again later."),
redirect, redirect.as_deref(),
))); )));
} }
// If the token is invalid, redirect to login page // If the token is invalid, redirect to login page
if !_validate_token(&data.token) { if !_validate_token(&data.token) {
error!("Invalid admin token. IP: {}", ip.ip); error!("Invalid admin token. IP: {}", ip.ip);
Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect))) Err(AdminResponse::Unauthorized(render_admin_login(
Some("Invalid admin token, please try again."),
redirect.as_deref(),
)))
} else { } else {
// If the token received is valid, generate JWT and save it as a cookie // If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims(); let claims = generate_admin_claims();
@@ -308,7 +311,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
err_code!("User already exists", Status::Conflict.code) err_code!("User already exists", Status::Conflict.code)
} }
let mut user = User::new(data.email, None); let mut user = User::new(&data.email, None);
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult { async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
@@ -553,23 +556,9 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
} }
} }
member_to_edit.atype = new_type;
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
// It returns different error messages per function. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
if new_type < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member_to_edit.user_uuid, &member_to_edit.org_uuid, true, &conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if CONFIG.email_2fa_auto_fallback() {
two_factor::email::find_and_activate_email_2fa(&member_to_edit.user_uuid, &conn).await?;
} else {
err!("You cannot modify this user to this type because they have not setup 2FA");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot modify this user to this type because it is a member of an organization which forbids it");
}
}
}
log_event( log_event(
EventType::OrganizationUserUpdated as i32, EventType::OrganizationUserUpdated as i32,
@@ -582,7 +571,6 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
) )
.await; .await;
member_to_edit.atype = new_type;
member_to_edit.save(&conn).await member_to_edit.save(&conn).await
} }
@@ -825,11 +813,7 @@ impl<'r> FromRequest<'r> for AdminToken {
_ => err_handler!("Error getting Client IP"), _ => err_handler!("Error getting Client IP"),
}; };
if CONFIG.disable_admin_token() { if !CONFIG.disable_admin_token() {
Outcome::Success(Self {
ip,
})
} else {
let cookies = request.cookies(); let cookies = request.cookies();
let access_token = match cookies.get(COOKIE_NAME) { let access_token = match cookies.get(COOKIE_NAME) {
@@ -853,10 +837,10 @@ impl<'r> FromRequest<'r> for AdminToken {
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
return Outcome::Error((Status::Unauthorized, "Session expired")); return Outcome::Error((Status::Unauthorized, "Session expired"));
} }
Outcome::Success(Self {
ip,
})
} }
Outcome::Success(Self {
ip,
})
} }
} }
+75 -29
View File
@@ -66,6 +66,7 @@ pub fn routes() -> Vec<rocket::Route> {
put_device_token, put_device_token,
put_clear_device_token, put_clear_device_token,
post_clear_device_token, post_clear_device_token,
get_tasks,
post_auth_request, post_auth_request,
get_auth_request, get_auth_request,
put_auth_request, put_auth_request,
@@ -75,12 +76,16 @@ pub fn routes() -> Vec<rocket::Route> {
] ]
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct KDFData { pub struct KDFData {
#[serde(alias = "kdfType")]
kdf: i32, kdf: i32,
#[serde(alias = "iterations")]
kdf_iterations: i32, kdf_iterations: i32,
#[serde(alias = "memory")]
kdf_memory: Option<i32>, kdf_memory: Option<i32>,
#[serde(alias = "parallelism")]
kdf_parallelism: Option<i32>, kdf_parallelism: Option<i32>,
} }
@@ -285,7 +290,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|| CONFIG.is_signup_allowed(&email) || CONFIG.is_signup_allowed(&email)
|| pending_emergency_access.is_some() || pending_emergency_access.is_some()
{ {
User::new(email.clone(), None) User::new(&email, None)
} else { } else {
err!("Registration not allowed or user already exists") err!("Registration not allowed or user already exists")
} }
@@ -295,7 +300,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
// Make sure we don't leave a lingering invitation. // Make sure we don't leave a lingering invitation.
Invitation::take(&email, &conn).await; Invitation::take(&email, &conn).await;
set_kdf_data(&mut user, data.kdf)?; set_kdf_data(&mut user, &data.kdf)?;
user.set_password(&data.master_password_hash, Some(data.key), true, None); user.set_password(&data.master_password_hash, Some(data.key), true, None);
user.password_hint = password_hint; user.password_hint = password_hint;
@@ -358,7 +363,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
let password_hint = clean_password_hint(&data.master_password_hint); let password_hint = clean_password_hint(&data.master_password_hint);
enforce_password_hint_setting(&password_hint)?; enforce_password_hint_setting(&password_hint)?;
set_kdf_data(&mut user, data.kdf)?; set_kdf_data(&mut user, &data.kdf)?;
user.set_password( user.set_password(
&data.master_password_hash, &data.master_password_hash,
@@ -374,7 +379,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
} }
if let Some(identifier) = data.org_identifier { if let Some(identifier) = data.org_identifier {
if identifier != crate::sso::FAKE_IDENTIFIER { if identifier != crate::sso::FAKE_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
let org = match Organization::find_by_uuid(&identifier.into(), &conn).await { let org = match Organization::find_by_uuid(&identifier.into(), &conn).await {
None => err!("Failed to retrieve the associated organization"), None => err!("Failed to retrieve the associated organization"),
Some(org) => org, Some(org) => org,
@@ -401,8 +406,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
user.save(&conn).await?; user.save(&conn).await?;
Ok(Json(json!({ Ok(Json(json!({
"Object": "set-password", "object": "set-password",
"CaptchaBypassToken": "", "captchaBypassToken": "",
}))) })))
} }
@@ -545,18 +550,7 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
save_result save_result
} }
#[derive(Deserialize)] fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
#[serde(rename_all = "camelCase")]
struct ChangeKdfData {
#[serde(flatten)]
kdf: KDFData,
master_password_hash: String,
new_master_password_hash: String,
key: String,
}
fn set_kdf_data(user: &mut User, data: KDFData) -> EmptyResult {
if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 { if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 {
err!("PBKDF2 KDF iterations must be at least 100000.") err!("PBKDF2 KDF iterations must be at least 100000.")
} }
@@ -591,18 +585,61 @@ fn set_kdf_data(user: &mut User, data: KDFData) -> EmptyResult {
Ok(()) Ok(())
} }
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthenticationData {
salt: String,
kdf: KDFData,
master_password_authentication_hash: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UnlockData {
salt: String,
kdf: KDFData,
master_key_wrapped_user_key: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangeKdfData {
new_master_password_hash: String,
key: String,
authentication_data: AuthenticationData,
unlock_data: UnlockData,
master_password_hash: String,
}
#[post("/accounts/kdf", data = "<data>")] #[post("/accounts/kdf", data = "<data>")]
async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: ChangeKdfData = data.into_inner(); let data: ChangeKdfData = data.into_inner();
let mut user = headers.user;
if !user.check_valid_password(&data.master_password_hash) { if !headers.user.check_valid_password(&data.master_password_hash) {
err!("Invalid password") err!("Invalid password")
} }
set_kdf_data(&mut user, data.kdf)?; if data.authentication_data.kdf != data.unlock_data.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
user.set_password(&data.new_master_password_hash, Some(data.key), true, None); if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt {
err!("Invalid master password salt")
}
let mut user = headers.user;
set_kdf_data(&mut user, &data.unlock_data.kdf)?;
user.set_password(
&data.authentication_data.master_password_authentication_hash,
Some(data.unlock_data.master_key_wrapped_user_key),
true,
None,
);
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
@@ -780,7 +817,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await; let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
let mut existing_folders = Folder::find_by_user(user_id, &conn).await; let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
let mut existing_emergency_access = EmergencyAccess::find_all_by_grantor_uuid(user_id, &conn).await; let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await;
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await; let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
// We only rotate the reset password key if it is set. // We only rotate the reset password key if it is set.
existing_memberships.retain(|m| m.reset_password_key.is_some()); existing_memberships.retain(|m| m.reset_password_key.is_some());
@@ -1279,10 +1316,11 @@ async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: D
#[get("/devices/knowndevice")] #[get("/devices/knowndevice")]
async fn get_known_device(device: KnownDevice, conn: DbConn) -> JsonResult { async fn get_known_device(device: KnownDevice, conn: DbConn) -> JsonResult {
let mut result = false; let result = if let Some(user) = User::find_by_mail(&device.email, &conn).await {
if let Some(user) = User::find_by_mail(&device.email, &conn).await { Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &conn).await.is_some()
result = Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &conn).await.is_some(); } else {
} false
};
Ok(Json(json!(result))) Ok(Json(json!(result)))
} }
@@ -1372,7 +1410,7 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
} }
device.push_token = Some(token); device.push_token = Some(token);
if let Err(e) = device.save(&conn).await { if let Err(e) = device.save(true, &conn).await {
err!(format!("An error occurred while trying to save the device push token: {e}")); err!(format!("An error occurred while trying to save the device push token: {e}"));
} }
@@ -1408,6 +1446,14 @@ async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResu
put_clear_device_token(device_id, conn).await put_clear_device_token(device_id, conn).await
} }
#[get("/tasks")]
fn get_tasks(_client_headers: ClientHeaders) -> JsonResult {
Ok(Json(json!({
"data": [],
"object": "list"
})))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AuthRequestRequest { struct AuthRequestRequest {
+31 -9
View File
@@ -159,7 +159,28 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let domains_json = if data.exclude_domains { let domains_json = if data.exclude_domains {
Value::Null Value::Null
} else { } else {
api::core::_get_eq_domains(headers, true).into_inner() api::core::_get_eq_domains(&headers, true).into_inner()
};
// This is very similar to the the userDecryptionOptions sent in connect/token,
// but as of 2025-12-19 they're both using different casing conventions.
let has_master_password = !headers.user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
json!({
"kdf": {
"kdfType": headers.user.client_kdf_type,
"iterations": headers.user.client_kdf_iter,
"memory": headers.user.client_kdf_memory,
"parallelism": headers.user.client_kdf_parallelism
},
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email
})
} else {
Value::Null
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -170,6 +191,9 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json, "ciphers": ciphers_json,
"domains": domains_json, "domains": domains_json,
"sends": sends_json, "sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
},
"object": "sync" "object": "sync"
}))) })))
} }
@@ -301,12 +325,6 @@ async fn post_ciphers_create(
) -> JsonResult { ) -> JsonResult {
let mut data: ShareCipherData = data.into_inner(); let mut data: ShareCipherData = data.into_inner();
// Check if there are one more more collections selected when this cipher is part of an organization.
// err if this is not the case before creating an empty cipher.
if data.cipher.organization_id.is_some() && data.collection_ids.is_empty() {
err!("You must select at least one collection.");
}
// This check is usually only needed in update_cipher_from_data(), but we // This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to // need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below. // cipher.save() below.
@@ -324,7 +342,11 @@ async fn post_ciphers_create(
// or otherwise), we can just ignore this field entirely. // or otherwise), we can just ignore this field entirely.
data.cipher.last_known_revision_date = None; data.cipher.last_known_revision_date = None;
share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await;
if res.is_err() {
cipher.delete(&conn).await?;
}
res
} }
/// Called when creating a new user-owned cipher. /// Called when creating a new user-owned cipher.
@@ -1269,7 +1291,7 @@ async fn save_attachment(
attachment.save(&conn).await.expect("Error saving attachment"); attachment.save(&conn).await.expect("Error saving attachment");
} }
save_temp_file(PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?; save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
nt.send_cipher_update( nt.send_cipher_update(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
+6 -19
View File
@@ -47,24 +47,11 @@ pub fn routes() -> Vec<Route> {
#[get("/emergency-access/trusted")] #[get("/emergency-access/trusted")]
async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> { async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
if !CONFIG.emergency_access_allowed() { let emergency_access_list = if CONFIG.emergency_access_allowed() {
return Json(json!({ EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await
"data": [{ } else {
"id": "", Vec::new()
"status": 2, };
"type": 0,
"waitTimeDays": 0,
"granteeId": "",
"email": "",
"name": "NOTE: Emergency Access is disabled!",
"object": "emergencyAccessGranteeDetails",
}],
"object": "list",
"continuationToken": null
}));
}
let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await;
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
for ea in emergency_access_list { for ea in emergency_access_list {
if let Some(grantee) = ea.to_json_grantee_details(&conn).await { if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
@@ -245,7 +232,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
invitation.save(&conn).await?; invitation.save(&conn).await?;
} }
let mut user = User::new(email.clone(), None); let mut user = User::new(&email, None);
user.save(&conn).await?; user.save(&conn).await?;
(user, true) (user, true)
} }
+9 -23
View File
@@ -53,7 +53,7 @@ use crate::{
api::{EmptyResult, JsonResult, Notify, UpdateType}, api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers, auth::Headers,
db::{ db::{
models::{Membership, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyErr, Organization, User}, models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
DbConn, DbConn,
}, },
error::Error, error::Error,
@@ -74,11 +74,11 @@ const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
#[get("/settings/domains")] #[get("/settings/domains")]
fn get_eq_domains(headers: Headers) -> Json<Value> { fn get_eq_domains(headers: Headers) -> Json<Value> {
_get_eq_domains(headers, false) _get_eq_domains(&headers, false)
} }
fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json<Value> { fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
let user = headers.user; let user = &headers.user;
use serde_json::from_str; use serde_json::from_str;
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap(); let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
@@ -217,7 +217,8 @@ fn config() -> Json<Value> {
// We should make sure that we keep this updated when we support the new server features // We should make sure that we keep this updated when we support the new server features
// Version history: // Version history:
// - Individual cipher key encryption: 2024.2.0 // - Individual cipher key encryption: 2024.2.0
"version": "2025.6.0", // - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2025.12.0",
"gitHash": option_env!("GIT_REV"), "gitHash": option_env!("GIT_REV"),
"server": { "server": {
"name": "Vaultwarden", "name": "Vaultwarden",
@@ -269,27 +270,12 @@ async fn accept_org_invite(
err!("User already accepted the invitation"); err!("User already accepted the invitation");
} }
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// It returns different error messages per function.
if member.atype < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if crate::CONFIG.email_2fa_auto_fallback() {
two_factor::email::activate_email_2fa(user, conn).await?;
} else {
err!("You cannot join this organization until you enable two-step login on your user account");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot join this organization because you are a member of an organization which forbids it");
}
}
}
member.status = MembershipStatus::Accepted as i32; member.status = MembershipStatus::Accepted as i32;
member.reset_password_key = reset_password_key; member.reset_password_key = reset_password_key;
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
OrgPolicy::check_user_allowed(&member, "join", conn).await?;
member.save(conn).await?; member.save(conn).await?;
if crate::CONFIG.mail_enabled() { if crate::CONFIG.mail_enabled() {
+23 -69
View File
@@ -15,7 +15,7 @@ use crate::{
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyErr, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
}, },
DbConn, DbConn,
}, },
@@ -195,14 +195,13 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, conn: DbConn
} }
let data: OrgData = data.into_inner(); let data: OrgData = data.into_inner();
let (private_key, public_key) = if data.keys.is_some() { let (private_key, public_key) = if let Some(keys) = data.keys {
let keys: OrgKeyData = data.keys.unwrap();
(Some(keys.encrypted_private_key), Some(keys.public_key)) (Some(keys.encrypted_private_key), Some(keys.public_key))
} else { } else {
(None, None) (None, None)
}; };
let org = Organization::new(data.name, data.billing_email, private_key, public_key); let org = Organization::new(data.name, &data.billing_email, private_key, public_key);
let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None); let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None);
let collection = Collection::new(org.uuid.clone(), data.collection_name, None); let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
@@ -370,9 +369,9 @@ async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn
}; };
Ok(Json(json!({ Ok(Json(json!({
"Id": id, "id": id,
"Identifier": identifier, "identifier": identifier,
"ResetPasswordEnabled": rp_auto_enroll, "resetPasswordEnabled": rp_auto_enroll,
}))) })))
} }
@@ -1124,7 +1123,7 @@ async fn send_invite(
Invitation::new(email).save(&conn).await?; Invitation::new(email).save(&conn).await?;
} }
let mut new_user = User::new(email.clone(), None); let mut new_user = User::new(email, None);
new_user.save(&conn).await?; new_user.save(&conn).await?;
user_created = true; user_created = true;
new_user new_user
@@ -1463,27 +1462,12 @@ async fn _confirm_invite(
err!("User in invalid state") err!("User in invalid state")
} }
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// It returns different error messages per function.
if member_to_confirm.atype < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member_to_confirm.user_uuid, org_id, true, conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if CONFIG.email_2fa_auto_fallback() {
two_factor::email::find_and_activate_email_2fa(&member_to_confirm.user_uuid, conn).await?;
} else {
err!("You cannot confirm this user because they have not setup 2FA");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot confirm this user because they are a member of an organization which forbids it");
}
}
}
member_to_confirm.status = MembershipStatus::Confirmed as i32; member_to_confirm.status = MembershipStatus::Confirmed as i32;
member_to_confirm.akey = key.to_string(); member_to_confirm.akey = key.to_string();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
log_event( log_event(
EventType::OrganizationUserConfirmed as i32, EventType::OrganizationUserConfirmed as i32,
&member_to_confirm.uuid, &member_to_confirm.uuid,
@@ -1591,7 +1575,7 @@ async fn edit_member(
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type // The from_str() will convert the custom role type into a manager role type
let raw_type = &data.r#type.into_string(); let raw_type = &data.r#type.into_string();
// MembershipTyp::from_str will convert custom (4) to manager (3) // MembershipType::from_str will convert custom (4) to manager (3)
let Some(new_type) = MembershipType::from_str(raw_type) else { let Some(new_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type") err!("Invalid type")
}; };
@@ -1631,27 +1615,13 @@ async fn edit_member(
} }
} }
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// It returns different error messages per function.
if new_type < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member_to_edit.user_uuid, &org_id, true, &conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if CONFIG.email_2fa_auto_fallback() {
two_factor::email::find_and_activate_email_2fa(&member_to_edit.user_uuid, &conn).await?;
} else {
err!("You cannot modify this user to this type because they have not setup 2FA");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot modify this user to this type because they are a member of an organization which forbids it");
}
}
}
member_to_edit.access_all = access_all; member_to_edit.access_all = access_all;
member_to_edit.atype = new_type as i32; member_to_edit.atype = new_type as i32;
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// We need to perform the check after changing the type since `admin` is exempt.
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
// Delete all the odd collections // Delete all the odd collections
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await {
c.delete(&conn).await?; c.delete(&conn).await?;
@@ -2086,8 +2056,6 @@ async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders
#[derive(Deserialize)] #[derive(Deserialize)]
struct PolicyData { struct PolicyData {
enabled: bool, enabled: bool,
#[serde(rename = "type")]
_type: i32,
data: Option<Value>, data: Option<Value>,
} }
@@ -2154,14 +2122,14 @@ async fn put_policy(
// When enabling the SingleOrg policy, remove this org's members that are members of other orgs // When enabling the SingleOrg policy, remove this org's members that are members of other orgs
if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled {
for member in Membership::find_by_org(&org_id, &conn).await.into_iter() { for mut member in Membership::find_by_org(&org_id, &conn).await.into_iter() {
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org // Policy only applies to non-Owner/non-Admin members who have accepted joining the org
// Exclude invited and revoked users when checking for this policy. // Exclude invited and revoked users when checking for this policy.
// Those users will not be allowed to accept or be activated because of the policy checks done there. // Those users will not be allowed to accept or be activated because of the policy checks done there.
// We check if the count is larger then 1, because it includes this organization also.
if member.atype < MembershipType::Admin if member.atype < MembershipType::Admin
&& member.status != MembershipStatus::Invited as i32 && member.status != MembershipStatus::Invited as i32
&& Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &conn).await > 1 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await
> 0
{ {
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap(); let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap();
@@ -2181,7 +2149,8 @@ async fn put_policy(
) )
.await; .await;
member.delete(&conn).await?; member.revoke();
member.save(&conn).await?;
} }
} }
} }
@@ -2628,25 +2597,10 @@ async fn _restore_member(
err!("Only owners can restore other owners") err!("Only owners can restore other owners")
} }
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// It returns different error messages per function.
if member.atype < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member.user_uuid, org_id, false, conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if CONFIG.email_2fa_auto_fallback() {
two_factor::email::find_and_activate_email_2fa(&member.user_uuid, conn).await?;
} else {
err!("You cannot restore this user because they have not setup 2FA");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot restore this user because they are a member of an organization which forbids it");
}
}
}
member.restore(); member.restore();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// This check need to be done after restoring to work with the correct status
OrgPolicy::check_user_allowed(&member, "restore", conn).await?;
member.save(conn).await?; member.save(conn).await?;
log_event( log_event(
+1 -1
View File
@@ -94,7 +94,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
Some(user) => user, // exists in vaultwarden Some(user) => user, // exists in vaultwarden
None => { None => {
// User does not exist yet // User does not exist yet
let mut new_user = User::new(user_data.email.clone(), None); let mut new_user = User::new(&user_data.email, None);
new_user.save(&conn).await?; new_user.save(&conn).await?;
if !CONFIG.mail_enabled() { if !CONFIG.mail_enabled() {
+11 -12
View File
@@ -1,13 +1,12 @@
use std::path::Path; use std::{path::Path, sync::LazyLock, time::Duration};
use std::time::Duration;
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use num_traits::ToPrimitive; use num_traits::ToPrimitive;
use once_cell::sync::Lazy; use rocket::{
use rocket::form::Form; form::Form,
use rocket::fs::NamedFile; fs::{NamedFile, TempFile},
use rocket::fs::TempFile; serde::json::Json,
use rocket::serde::json::Json; };
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
@@ -23,7 +22,7 @@ use crate::{
}; };
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: Lazy<Device> = Lazy::new(|| { static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
Device { Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(), uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
@@ -274,7 +273,7 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbCo
let file_id = crate::crypto::generate_send_file_id(); let file_id = crate::crypto::generate_send_file_id();
save_temp_file(PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?; save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
let mut data_value: Value = serde_json::from_str(&send.data)?; let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() { if let Some(o) = data_value.as_object_mut() {
@@ -426,7 +425,7 @@ async fn post_send_file_v2_data(
let file_path = format!("{send_id}/{file_id}"); let file_path = format!("{send_id}/{file_id}");
save_temp_file(PathType::Sends, &file_path, data.data, false).await?; save_temp_file(&PathType::Sends, &file_path, data.data, false).await?;
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendCreate, UpdateType::SyncSendCreate,
@@ -567,9 +566,9 @@ async fn post_access_file(
} }
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> { async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
if operator.info().scheme() == opendal::Scheme::Fs { if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
let token_claims = crate::auth::generate_send_claims(send_id, file_id); let token_claims = crate::auth::generate_send_claims(send_id, file_id);
let token = crate::auth::encode_jwt(&token_claims); let token = crate::auth::encode_jwt(&token_claims);
+1 -1
View File
@@ -31,7 +31,7 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
let (enabled, key) = match twofactor { let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data), Some(tf) => (true, tf.data),
_ => (false, crypto::encode_random_bytes::<20>(BASE32)), _ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
}; };
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
+29 -13
View File
@@ -26,12 +26,8 @@ pub fn routes() -> Vec<Route> {
struct SendEmailLoginData { struct SendEmailLoginData {
#[serde(alias = "DeviceIdentifier")] #[serde(alias = "DeviceIdentifier")]
device_identifier: DeviceId, device_identifier: DeviceId,
#[allow(unused)]
#[serde(alias = "Email")] #[serde(alias = "Email")]
email: Option<String>, email: Option<String>,
#[allow(unused)]
#[serde(alias = "MasterPasswordHash")] #[serde(alias = "MasterPasswordHash")]
master_password_hash: Option<String>, master_password_hash: Option<String>,
} }
@@ -42,20 +38,40 @@ struct SendEmailLoginData {
async fn send_email_login(data: Json<SendEmailLoginData>, conn: DbConn) -> EmptyResult { async fn send_email_login(data: Json<SendEmailLoginData>, conn: DbConn) -> EmptyResult {
let data: SendEmailLoginData = data.into_inner(); let data: SendEmailLoginData = data.into_inner();
use crate::db::models::User;
// Get the user
let Some(user) = User::find_by_device_id(&data.device_identifier, &conn).await else {
err!("Cannot find user. Try again.")
};
if !CONFIG._enable_email_2fa() { if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled") err!("Email 2FA is disabled")
} }
send_token(&user.uuid, &conn).await?; // Get the user
let email = match &data.email {
Some(email) if !email.is_empty() => Some(email),
_ => None,
};
let user = if let Some(email) = email {
let Some(master_password_hash) = &data.master_password_hash else {
err!("No password hash has been submitted.")
};
Ok(()) let Some(user) = User::find_by_mail(email, &conn).await else {
err!("Username or password is incorrect. Try again.")
};
// Check password
if !user.check_valid_password(master_password_hash) {
err!("Username or password is incorrect. Try again.")
}
user
} else {
// SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.")
};
user
};
send_token(&user.uuid, &conn).await
} }
/// Generate the token, save the data for later verification and send email to user /// Generate the token, save the data for later verification and send email to user
+1 -1
View File
@@ -126,7 +126,7 @@ async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, co
async fn _generate_recover_code(user: &mut User, conn: &DbConn) { async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
if user.totp_recover.is_none() { if user.totp_recover.is_none() {
let totp_recover = crypto::encode_random_bytes::<20>(BASE32); let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
user.totp_recover = Some(totp_recover); user.totp_recover = Some(totp_recover);
user.save(conn).await.ok(); user.save(conn).await.ok();
} }
+22 -20
View File
@@ -1,13 +1,13 @@
use std::{ use std::{
collections::HashMap, collections::HashMap,
net::IpAddr, net::IpAddr,
sync::Arc, sync::{Arc, LazyLock},
time::{Duration, SystemTime}, time::{Duration, SystemTime},
}; };
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
use futures::{stream::StreamExt, TryFutureExt}; use futures::{stream::StreamExt, TryFutureExt};
use once_cell::sync::Lazy; use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
use regex::Regex; use regex::Regex;
use reqwest::{ use reqwest::{
header::{self, HeaderMap, HeaderValue}, header::{self, HeaderMap, HeaderValue},
@@ -16,8 +16,6 @@ use reqwest::{
use rocket::{http::ContentType, response::Redirect, Route}; use rocket::{http::ContentType, response::Redirect, Route};
use svg_hush::{data_url_filter, Filter}; use svg_hush::{data_url_filter, Filter};
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
use crate::{ use crate::{
config::PathType, config::PathType,
error::Error, error::Error,
@@ -33,7 +31,7 @@ pub fn routes() -> Vec<Route> {
} }
} }
static CLIENT: Lazy<Client> = Lazy::new(|| { static CLIENT: LazyLock<Client> = LazyLock::new(|| {
// Generate the default headers // Generate the default headers
let mut default_headers = HeaderMap::new(); let mut default_headers = HeaderMap::new();
default_headers.insert( default_headers.insert(
@@ -78,25 +76,25 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
}); });
// Build Regex only once since this takes a lot of time. // Build Regex only once since this takes a lot of time.
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders` // The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
// It is used to prevent sending a specific header which breaks icon downloads. // It is used to prevent sending a specific header which breaks icon downloads.
// If this function needs to be renamed, also adjust the code in `util.rs` // If this function needs to be renamed, also adjust the code in `util.rs`
#[get("/<domain>/icon.png")] #[get("/<domain>/icon.png")]
fn icon_external(domain: &str) -> Option<Redirect> { fn icon_external(domain: &str) -> Cached<Option<Redirect>> {
if !is_valid_domain(domain) { if !is_valid_domain(domain) {
warn!("Invalid domain: {domain}"); warn!("Invalid domain: {domain}");
return None; return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
} }
if should_block_address(domain) { if should_block_address(domain) {
warn!("Blocked address: {domain}"); warn!("Blocked address: {domain}");
return None; return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
} }
let url = CONFIG._icon_service_url().replace("{}", domain); let url = CONFIG._icon_service_url().replace("{}", domain);
match CONFIG.icon_redirect_code() { let redir = match CONFIG.icon_redirect_code() {
301 => Some(Redirect::moved(url)), // legacy permanent redirect 301 => Some(Redirect::moved(url)), // legacy permanent redirect
302 => Some(Redirect::found(url)), // legacy temporary redirect 302 => Some(Redirect::found(url)), // legacy temporary redirect
307 => Some(Redirect::temporary(url)), 307 => Some(Redirect::temporary(url)),
@@ -105,7 +103,8 @@ fn icon_external(domain: &str) -> Option<Redirect> {
error!("Unexpected redirect code {}", CONFIG.icon_redirect_code()); error!("Unexpected redirect code {}", CONFIG.icon_redirect_code());
None None
} }
} };
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
} }
#[get("/<domain>/icon.png")] #[get("/<domain>/icon.png")]
@@ -143,7 +142,7 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
/// This does some manual checks and makes use of Url to do some basic checking. /// This does some manual checks and makes use of Url to do some basic checking.
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255. /// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
fn is_valid_domain(domain: &str) -> bool { fn is_valid_domain(domain: &str) -> bool {
const ALLOWED_CHARS: &str = "_-."; const ALLOWED_CHARS: &str = "-.";
// If parsing the domain fails using Url, it will not work with reqwest. // If parsing the domain fails using Url, it will not work with reqwest.
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) { if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
@@ -220,7 +219,7 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
} }
// Try to read the cached icon, and return it if it exists // Try to read the cached icon, and return it if it exists
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(PathType::IconCache) { if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
if let Ok(buf) = operator.read(path).await { if let Ok(buf) = operator.read(path).await {
return Some(buf.to_vec()); return Some(buf.to_vec());
} }
@@ -230,7 +229,7 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
} }
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> { async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
let operator = CONFIG.opendal_operator_for_path_type(PathType::IconCache)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
let meta = operator.stat(path).await?; let meta = operator.stat(path).await?;
let modified = let modified =
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
@@ -246,7 +245,7 @@ async fn icon_is_negcached(path: &str) -> bool {
match expired { match expired {
// No longer negatively cached, drop the marker // No longer negatively cached, drop the marker
Ok(true) => { Ok(true) => {
match CONFIG.opendal_operator_for_path_type(PathType::IconCache) { match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
Ok(operator) => { Ok(operator) => {
if let Err(e) = operator.delete(&miss_indicator).await { if let Err(e) = operator.delete(&miss_indicator).await {
error!("Could not remove negative cache indicator for icon {path:?}: {e:?}"); error!("Could not remove negative cache indicator for icon {path:?}: {e:?}");
@@ -462,8 +461,8 @@ async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Err
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", ""); /// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
/// ``` /// ```
fn get_icon_priority(href: &str, sizes: &str) -> u8 { fn get_icon_priority(href: &str, sizes: &str) -> u8 {
static PRIORITY_MAP: Lazy<HashMap<&'static str, u8>> = static PRIORITY_MAP: LazyLock<HashMap<&'static str, u8>> =
Lazy::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect()); LazyLock::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect());
// Check if there is a dimension set // Check if there is a dimension set
let (width, height) = parse_sizes(sizes); let (width, height) = parse_sizes(sizes);
@@ -597,7 +596,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
} }
async fn save_icon(path: &str, icon: Vec<u8>) { async fn save_icon(path: &str, icon: Vec<u8>) {
let operator = match CONFIG.opendal_operator_for_path_type(PathType::IconCache) { let operator = match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
Ok(operator) => operator, Ok(operator) => operator,
Err(e) => { Err(e) => {
warn!("Failed to get OpenDAL operator while saving icon: {e}"); warn!("Failed to get OpenDAL operator while saving icon: {e}");
@@ -798,8 +797,11 @@ impl Emitter for FaviconEmitter {
fn emit_current_tag(&mut self) -> Option<html5gum::State> { fn emit_current_tag(&mut self) -> Option<html5gum::State> {
self.flush_current_attribute(true); self.flush_current_attribute(true);
self.last_start_tag.clear(); self.last_start_tag.clear();
if self.current_token.is_some() && !self.current_token.as_ref().unwrap().closing { match &self.current_token {
self.last_start_tag.extend(&*self.current_token.as_ref().unwrap().tag.name); Some(token) if !token.closing => {
self.last_start_tag.extend(&*token.tag.name);
}
_ => {}
} }
html5gum::naive_next_state(&self.last_start_tag) html5gum::naive_next_state(&self.last_start_tag)
} }
+100 -66
View File
@@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc}; use chrono::Utc;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::{ use rocket::{
form::{Form, FromForm}, form::{Form, FromForm},
@@ -24,14 +24,14 @@ use crate::{
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion}, auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
db::{ db::{
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OrganizationApiKey, OrganizationId, AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
SsoNonce, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
}, },
DbConn, DbConn,
}, },
error::MapResult, error::MapResult,
mail, sso, mail, sso,
sso::{OIDCCode, OIDCState}, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
util, CONFIG, util, CONFIG,
}; };
@@ -92,6 +92,7 @@ async fn login(
"authorization_code" if CONFIG.sso_enabled() => { "authorization_code" if CONFIG.sso_enabled() => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.code, "code cannot be blank")?; _check_is_some(&data.code, "code cannot be blank")?;
_check_is_some(&data.code_verifier, "code verifier cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?;
@@ -147,7 +148,7 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
} }
Ok((mut device, auth_tokens)) => { Ok((mut device, auth_tokens)) => {
// Save to update `device.updated_at` to track usage and toggle new status // Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?; device.save(true, conn).await?;
let result = json!({ let result = json!({
"refresh_token": auth_tokens.refresh_token(), "refresh_token": auth_tokens.refresh_token(),
@@ -175,17 +176,23 @@ async fn _sso_login(
// Ratelimit the login // Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?; crate::ratelimit::check_limit_login(&ip.ip)?;
let code = match data.code.as_ref() { let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
None => err!( (None, _) => err!(
"Got no code in OIDC data", "Got no code in OIDC data",
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn event: EventType::UserFailedLogIn
} }
), ),
Some(code) => code, (_, None) => err!(
"Got no code verifier in OIDC data",
ErrorEvent {
event: EventType::UserFailedLogIn
}
),
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
}; };
let user_infos = sso::exchange_code(code, conn).await?; let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?;
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
None => match SsoUser::find_by_mail(&user_infos.email, conn).await { None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
None => None, None => None,
@@ -248,7 +255,7 @@ async fn _sso_login(
_ => (), _ => (),
} }
let mut user = User::new(user_infos.email, user_infos.user_name); let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
user.verified_at = Some(now); user.verified_at = Some(now);
user.save(conn).await?; user.save(conn).await?;
@@ -267,13 +274,14 @@ async fn _sso_login(
} }
Some((mut user, sso_user)) => { Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?; let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
if user.private_key.is_none() { if user.private_key.is_none() {
// User was invited a stub was created // User was invited a stub was created
user.verified_at = Some(now); user.verified_at = Some(now);
if let Some(user_name) = user_infos.user_name { if let Some(ref user_name) = user_infos.user_name {
user.name = user_name; user.name = user_name.clone();
} }
user.save(conn).await?; user.save(conn).await?;
@@ -290,30 +298,13 @@ async fn _sso_login(
} }
}; };
// We passed 2FA get full user information
let auth_user = sso::redeem(&user_infos.state, conn).await?;
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: user_infos.identifier,
};
user_sso.save(conn).await?;
}
// Set the user_uuid here to be passed back used for event logging. // Set the user_uuid here to be passed back used for event logging.
*user_id = Some(user.uuid.clone()); *user_id = Some(user.uuid.clone());
let auth_tokens = sso::create_auth_tokens( // We passed 2FA get auth tokens
&device, let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
&user,
data.client_id,
auth_user.refresh_token,
auth_user.access_token,
auth_user.expires_in,
)?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
async fn _password_login( async fn _password_login(
@@ -435,7 +426,7 @@ async fn _password_login(
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
async fn authenticated_response( async fn authenticated_response(
@@ -443,12 +434,12 @@ async fn authenticated_response(
device: &mut Device, device: &mut Device,
auth_tokens: auth::AuthTokens, auth_tokens: auth::AuthTokens,
twofactor_token: Option<String>, twofactor_token: Option<String>,
now: &NaiveDateTime,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
) -> JsonResult { ) -> JsonResult {
if CONFIG.mail_enabled() && device.is_new() { if CONFIG.mail_enabled() && device.is_new() {
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await { let now = Utc::now().naive_utc();
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, device).await {
error!("Error sending new device email: {e:#?}"); error!("Error sending new device email: {e:#?}");
if CONFIG.require_device_email() { if CONFIG.require_device_email() {
@@ -468,10 +459,38 @@ async fn authenticated_response(
} }
// Save to update `device.updated_at` to track usage and toggle new status // Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?; device.save(true, conn).await?;
let master_password_policy = master_password_policy(user, conn).await; let master_password_policy = master_password_policy(user, conn).await;
let has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
json!({
"Kdf": {
"KdfType": user.client_kdf_type,
"Iterations": user.client_kdf_iter,
"Memory": user.client_kdf_memory,
"Parallelism": user.client_kdf_parallelism
},
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
})
} else {
Value::Null
};
let account_keys = json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
});
let mut result = json!({ let mut result = json!({
"access_token": auth_tokens.access_token(), "access_token": auth_tokens.access_token(),
"expires_in": auth_tokens.expires_in(), "expires_in": auth_tokens.expires_in(),
@@ -486,8 +505,10 @@ async fn authenticated_response(
"ForcePasswordReset": false, "ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy, "MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(), "scope": auth_tokens.scope(),
"AccountKeys": account_keys,
"UserDecryptionOptions": { "UserDecryptionOptions": {
"HasMasterPassword": !user.password_hash.is_empty(), "HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"Object": "userDecryptionOptions" "Object": "userDecryptionOptions"
}, },
}); });
@@ -585,7 +606,7 @@ async fn _user_api_key_login(
let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id); let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id);
// Save to update `device.updated_at` to track usage and toggle new status // Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?; device.save(true, conn).await?;
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
@@ -648,7 +669,12 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult
// Find device or create new // Find device or create new
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
Some(device) => Ok(device), Some(device) => Ok(device),
None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await, None => {
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
// save device without updating `device.updated_at`
device.save(false, conn).await?;
Ok(device)
}
} }
} }
@@ -997,9 +1023,12 @@ struct ConnectData {
two_factor_remember: Option<i32>, two_factor_remember: Option<i32>,
#[field(name = uncased("authrequest"))] #[field(name = uncased("authrequest"))]
auth_request: Option<AuthRequestId>, auth_request: Option<AuthRequestId>,
// Needed for authorization code // Needed for authorization code
#[field(name = uncased("code"))] #[field(name = uncased("code"))]
code: Option<String>, code: Option<OIDCState>,
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,
} }
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult { fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
if value.is_none() { if value.is_none() {
@@ -1021,14 +1050,13 @@ fn prevalidate() -> JsonResult {
} }
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)] #[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
async fn oidcsignin(code: OIDCCode, state: String, conn: DbConn) -> ApiResult<Redirect> { async fn oidcsignin(code: OIDCCode, state: String, mut conn: DbConn) -> ApiResult<Redirect> {
oidcsignin_redirect( _oidcsignin_redirect(
state, state,
|decoded_state| sso::OIDCCodeWrapper::Ok { OIDCCodeWrapper::Ok {
state: decoded_state,
code, code,
}, },
&conn, &mut conn,
) )
.await .await
} }
@@ -1040,42 +1068,44 @@ async fn oidcsignin_error(
state: String, state: String,
error: String, error: String,
error_description: Option<String>, error_description: Option<String>,
conn: DbConn, mut conn: DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
oidcsignin_redirect( _oidcsignin_redirect(
state, state,
|decoded_state| sso::OIDCCodeWrapper::Error { OIDCCodeWrapper::Error {
state: decoded_state,
error, error,
error_description, error_description,
}, },
&conn, &mut conn,
) )
.await .await
} }
// The state was encoded using Base64 to ensure no issue with providers. // The state was encoded using Base64 to ensure no issue with providers.
// iss and scope parameters are needed for redirection to work on IOS. // iss and scope parameters are needed for redirection to work on IOS.
async fn oidcsignin_redirect( // We pass the state as the code to get it back later on.
async fn _oidcsignin_redirect(
base64_state: String, base64_state: String,
wrapper: impl FnOnce(OIDCState) -> sso::OIDCCodeWrapper, code_response: OIDCCodeWrapper,
conn: &DbConn, conn: &mut DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
let state = sso::decode_state(base64_state)?; let state = sso::decode_state(&base64_state)?;
let code = sso::encode_code_claims(wrapper(state.clone()));
let nonce = match SsoNonce::find(&state, conn).await { let mut sso_auth = match SsoAuth::find(&state, conn).await {
Some(n) => n, None => err!(format!("Cannot retrieve sso_auth for {state}")),
None => err!(format!("Failed to retrieve redirect_uri with {state}")), Some(sso_auth) => sso_auth,
}; };
sso_auth.code_response = Some(code_response);
sso_auth.updated_at = Utc::now().naive_utc();
sso_auth.save(conn).await?;
let mut url = match url::Url::parse(&nonce.redirect_uri) { let mut url = match url::Url::parse(&sso_auth.redirect_uri) {
Ok(url) => url, Ok(url) => url,
Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", nonce.redirect_uri)), Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", sso_auth.redirect_uri)),
}; };
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("code", &code) .append_pair("code", &state)
.append_pair("state", &state) .append_pair("state", &state)
.append_pair("scope", &AuthMethod::Sso.scope()) .append_pair("scope", &AuthMethod::Sso.scope())
.append_pair("iss", &CONFIG.domain()); .append_pair("iss", &CONFIG.domain());
@@ -1098,10 +1128,8 @@ struct AuthorizeData {
#[allow(unused)] #[allow(unused)]
scope: Option<String>, scope: Option<String>,
state: OIDCState, state: OIDCState,
#[allow(unused)] code_challenge: OIDCCodeChallenge,
code_challenge: Option<String>, code_challenge_method: String,
#[allow(unused)]
code_challenge_method: Option<String>,
#[allow(unused)] #[allow(unused)]
response_mode: Option<String>, response_mode: Option<String>,
#[allow(unused)] #[allow(unused)]
@@ -1118,10 +1146,16 @@ async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
client_id, client_id,
redirect_uri, redirect_uri,
state, state,
code_challenge,
code_challenge_method,
.. ..
} = data; } = data;
let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?; if code_challenge_method != "S256" {
err!("Unsupported code challenge method");
}
let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?;
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }
+23 -21
View File
@@ -1,11 +1,14 @@
use std::{net::IpAddr, sync::Arc, time::Duration}; use std::{
net::IpAddr,
sync::{Arc, LazyLock},
time::Duration,
};
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use rmpv::Value; use rmpv::Value;
use rocket::{futures::StreamExt, Route}; use rocket::{futures::StreamExt, Route};
use tokio::sync::mpsc::Sender;
use rocket_ws::{Message, WebSocket}; use rocket_ws::{Message, WebSocket};
use tokio::sync::mpsc::Sender;
use crate::{ use crate::{
auth::{ClientIp, WsAccessTokenHeader}, auth::{ClientIp, WsAccessTokenHeader},
@@ -16,15 +19,13 @@ use crate::{
Error, CONFIG, Error, CONFIG,
}; };
use once_cell::sync::Lazy; pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
pub static WS_USERS: Lazy<Arc<WebSocketUsers>> = Lazy::new(|| {
Arc::new(WebSocketUsers { Arc::new(WebSocketUsers {
map: Arc::new(dashmap::DashMap::new()), map: Arc::new(dashmap::DashMap::new()),
}) })
}); });
pub static WS_ANONYMOUS_SUBSCRIPTIONS: Lazy<Arc<AnonymousWebSocketSubscriptions>> = Lazy::new(|| { pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
Arc::new(AnonymousWebSocketSubscriptions { Arc::new(AnonymousWebSocketSubscriptions {
map: Arc::new(dashmap::DashMap::new()), map: Arc::new(dashmap::DashMap::new()),
}) })
@@ -35,7 +36,7 @@ use super::{
push_send_update, push_user_update, push_send_update, push_user_update,
}; };
static NOTIFICATIONS_DISABLED: Lazy<bool> = Lazy::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
@@ -109,8 +110,7 @@ fn websockets_hub<'r>(
ip: ClientIp, ip: ClientIp,
header_token: WsAccessTokenHeader, header_token: WsAccessTokenHeader,
) -> Result<rocket_ws::Stream!['r], Error> { ) -> Result<rocket_ws::Stream!['r], Error> {
let addr = ip.ip; info!("Accepting Rocket WS connection from {}", ip.ip);
info!("Accepting Rocket WS connection from {addr}");
let token = if let Some(token) = data.access_token { let token = if let Some(token) = data.access_token {
token token
@@ -133,7 +133,7 @@ fn websockets_hub<'r>(
users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx)); users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx));
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
(rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, addr)) (rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, ip.ip))
}; };
Ok({ Ok({
@@ -189,8 +189,7 @@ fn websockets_hub<'r>(
#[allow(tail_expr_drop_order)] #[allow(tail_expr_drop_order)]
#[get("/anonymous-hub?<token..>")] #[get("/anonymous-hub?<token..>")]
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> { fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
let addr = ip.ip; info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
info!("Accepting Anonymous Rocket WS connection from {addr}");
let (mut rx, guard) = { let (mut rx, guard) = {
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
@@ -200,7 +199,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
subscriptions.map.insert(token.clone(), tx); subscriptions.map.insert(token.clone(), tx);
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, addr)) (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
}; };
Ok({ Ok({
@@ -257,11 +256,11 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
// Websockets server // Websockets server
// //
fn serialize(val: Value) -> Vec<u8> { fn serialize(val: &Value) -> Vec<u8> {
use rmpv::encode::write_value; use rmpv::encode::write_value;
let mut buf = Vec::new(); let mut buf = Vec::new();
write_value(&mut buf, &val).expect("Error encoding MsgPack"); write_value(&mut buf, val).expect("Error encoding MsgPack");
// Add size bytes at the start // Add size bytes at the start
// Extracted from BinaryMessageFormat.js // Extracted from BinaryMessageFormat.js
@@ -552,7 +551,7 @@ impl AnonymousWebSocketSubscriptions {
let data = create_anonymous_update( let data = create_anonymous_update(
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
UpdateType::AuthRequestResponse, UpdateType::AuthRequestResponse,
user_id.clone(), user_id,
); );
self.send_update(auth_request_id, &data).await; self.send_update(auth_request_id, &data).await;
} }
@@ -588,16 +587,19 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
])]), ])]),
]); ]);
serialize(value) serialize(&value)
} }
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: UserId) -> Vec<u8> { fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: &UserId) -> Vec<u8> {
use rmpv::Value as V; use rmpv::Value as V;
let value = V::Array(vec![ let value = V::Array(vec![
1.into(), 1.into(),
V::Map(vec![]), V::Map(vec![]),
V::Nil, V::Nil,
// This word is misspelled, but upstream has this too
// https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
// https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
"AuthRequestResponseRecieved".into(), "AuthRequestResponseRecieved".into(),
V::Array(vec![V::Map(vec![ V::Array(vec![V::Map(vec![
("Type".into(), (ut as i32).into()), ("Type".into(), (ut as i32).into()),
@@ -606,11 +608,11 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
])]), ])]),
]); ]);
serialize(value) serialize(&value)
} }
fn create_ping() -> Vec<u8> { fn create_ping() -> Vec<u8> {
serialize(Value::Array(vec![6.into()])) serialize(&Value::Array(vec![6.into()]))
} }
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
+7 -5
View File
@@ -1,3 +1,8 @@
use std::{
sync::LazyLock,
time::{Duration, Instant},
};
use reqwest::{ use reqwest::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
Method, Method,
@@ -16,9 +21,6 @@ use crate::{
CONFIG, CONFIG,
}; };
use once_cell::sync::Lazy;
use std::time::{Duration, Instant};
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthPushToken { struct AuthPushToken {
access_token: String, access_token: String,
@@ -32,7 +34,7 @@ struct LocalAuthPushToken {
} }
async fn get_auth_api_token() -> ApiResult<String> { async fn get_auth_api_token() -> ApiResult<String> {
static API_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| { static API_TOKEN: LazyLock<RwLock<LocalAuthPushToken>> = LazyLock::new(|| {
RwLock::new(LocalAuthPushToken { RwLock::new(LocalAuthPushToken {
access_token: String::new(), access_token: String::new(),
valid_until: Instant::now(), valid_until: Instant::now(),
@@ -126,7 +128,7 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe
err!(format!("An error occurred while proceeding registration of a device: {e}")); err!(format!("An error occurred while proceeding registration of a device: {e}"));
} }
if let Err(e) = device.save(conn).await { if let Err(e) = device.save(true, conn).await {
err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}")); err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}"));
} }
+32 -26
View File
@@ -1,12 +1,15 @@
// JWT Handling use std::{
env,
net::IpAddr,
sync::{LazyLock, OnceLock},
};
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use once_cell::sync::{Lazy, OnceCell};
use openssl::rsa::Rsa; use openssl::rsa::Rsa;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::ser::Serialize; use serde::ser::Serialize;
use std::{env, net::IpAddr};
use crate::{ use crate::{
api::ApiResult, api::ApiResult,
@@ -22,27 +25,30 @@ use crate::{
const JWT_ALGORITHM: Algorithm = Algorithm::RS256; const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
// Limit when BitWarden consider the token as expired // Limit when BitWarden consider the token as expired
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap()); pub static BW_EXPIRATION: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_minutes(5).unwrap());
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap()); pub static DEFAULT_REFRESH_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_days(30).unwrap());
pub static MOBILE_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(90).unwrap()); pub static MOBILE_REFRESH_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_days(90).unwrap());
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); pub static DEFAULT_ACCESS_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_hours(2).unwrap());
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM)); static JWT_HEADER: LazyLock<Header> = LazyLock::new(|| Header::new(JWT_ALGORITHM));
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); pub static JWT_LOGIN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|login", CONFIG.domain_origin()));
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_INVITE_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|invite", CONFIG.domain_origin()));
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> = static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: LazyLock<String> =
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); LazyLock::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|delete", CONFIG.domain_origin()));
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|admin", CONFIG.domain_origin()));
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin())); static JWT_SEND_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|send", CONFIG.domain_origin()));
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin())); static JWT_ORG_API_KEY_ISSUER: LazyLock<String> =
static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin())); LazyLock::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin())); static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin()));
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new(); static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new(); static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
pub async fn initialize_keys() -> Result<(), Error> { pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error; use std::io::Error;
@@ -54,7 +60,7 @@ pub async fn initialize_keys() -> Result<(), Error> {
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))? .ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
.to_string(); .to_string();
let operator = CONFIG.opendal_operator_for_path_type(PathType::RsaKey).map_err(Error::other)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?;
let priv_key_buffer = match operator.read(&rsa_key_filename).await { let priv_key_buffer = match operator.read(&rsa_key_filename).await {
Ok(buffer) => Some(buffer), Ok(buffer) => Some(buffer),
@@ -457,7 +463,7 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
} }
} }
pub fn generate_verify_email_claims(user_id: UserId) -> BasicJwtClaims { pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
BasicJwtClaims { BasicJwtClaims {
@@ -696,9 +702,9 @@ impl<'r> FromRequest<'r> for OrgHeaders {
// First check the path, if this is not a valid uuid, try the query values. // First check the path, if this is not a valid uuid, try the query values.
let url_org_id: Option<OrganizationId> = { let url_org_id: Option<OrganizationId> = {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) { if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id.clone()) Some(org_id)
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") { } else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
Some(org_id.clone()) Some(org_id)
} else { } else {
None None
} }
@@ -1217,7 +1223,7 @@ pub async fn refresh_tokens(
}; };
// Save to update `updated_at`. // Save to update `updated_at`.
device.save(conn).await?; device.save(true, conn).await?;
let user = match User::find_by_uuid(&device.user_uuid, conn).await { let user = match User::find_by_uuid(&device.user_uuid, conn).await {
None => err!("Impossible to find user"), None => err!("Impossible to find user"),
+217 -108
View File
@@ -1,5 +1,6 @@
use std::{ use std::{
env::consts::EXE_SUFFIX, env::consts::EXE_SUFFIX,
fmt,
process::exit, process::exit,
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -8,15 +9,15 @@ use std::{
}; };
use job_scheduler_ng::Schedule; use job_scheduler_ng::Schedule;
use once_cell::sync::Lazy;
use reqwest::Url; use reqwest::Url;
use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor};
use crate::{ use crate::{
error::Error, error::Error,
util::{get_env, get_env_bool, get_web_vault_version, is_valid_email, parse_experimental_client_feature_flags}, util::{get_env, get_env_bool, get_web_vault_version, is_valid_email, parse_experimental_client_feature_flags},
}; };
static CONFIG_FILE: Lazy<String> = Lazy::new(|| { static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data")); let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json")) get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
}); });
@@ -33,7 +34,7 @@ static CONFIG_FILENAME: LazyLock<String> = LazyLock::new(|| {
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false); pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
pub static CONFIG: Lazy<Config> = Lazy::new(|| { pub static CONFIG: LazyLock<Config> = LazyLock::new(|| {
std::thread::spawn(|| { std::thread::spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap_or_else(|e| { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap_or_else(|e| {
println!("Error loading config:\n {e:?}\n"); println!("Error loading config:\n {e:?}\n");
@@ -55,6 +56,41 @@ pub static CONFIG: Lazy<Config> = Lazy::new(|| {
pub type Pass = String; pub type Pass = String;
macro_rules! make_config { macro_rules! make_config {
// Support string print
( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option<String> with "***"
( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***"
( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value(&$value).unwrap() }; // Optional other or string, we convert to json
( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { $value.as_str().into() }; // Required string value, we convert to json
( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to json
// Group or empty string
( @show ) => { "" };
( @show $lit:literal ) => { $lit };
// Wrap the optionals in an Option type
( @type $ty:ty, option) => { Option<$ty> };
( @type $ty:ty, $id:ident) => { $ty };
// Generate the values depending on none_action
( @build $value:expr, $config:expr, option, ) => { $value };
( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) };
( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{
match $value {
Some(v) => v,
None => {
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
f($config)
}
}
}};
( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
f($config)
}};
( @getenv $name:expr, bool ) => { get_env_bool($name) };
( @getenv $name:expr, $ty:ident ) => { get_env($name) };
($( ($(
$(#[doc = $groupdoc:literal])? $(#[doc = $groupdoc:literal])?
$group:ident $(: $group_enabled:ident)? { $group:ident $(: $group_enabled:ident)? {
@@ -74,10 +110,103 @@ macro_rules! make_config {
_env: ConfigBuilder, _env: ConfigBuilder,
_usr: ConfigBuilder, _usr: ConfigBuilder,
_overrides: Vec<String>, _overrides: Vec<&'static str>,
} }
#[derive(Clone, Default, Deserialize, Serialize)] // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
// This deserialize doesn't care if there are keys missing, or if there are duplicate keys
// In case of duplicate keys (which should never be possible unless manually edited), the last value is used!
// Main reason for this is removing the `visit_seq` function, which causes a lot of code generation not needed or used for this struct.
impl<'de> Deserialize<'de> for ConfigBuilder {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
const FIELDS: &[&str] = &[
$($(
stringify!($name),
)+)+
];
#[allow(non_camel_case_types)]
enum Field {
$($(
$name,
)+)+
__ignore,
}
impl<'de> Deserialize<'de> for Field {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct FieldVisitor;
impl Visitor<'_> for FieldVisitor {
type Value = Field;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ConfigBuilder field identifier")
}
#[inline]
fn visit_str<E>(self, value: &str) -> Result<Field, E>
where
E: de::Error,
{
match value {
$($(
stringify!($name) => Ok(Field::$name),
)+)+
_ => Ok(Field::__ignore),
}
}
}
deserializer.deserialize_identifier(FieldVisitor)
}
}
struct ConfigBuilderVisitor;
impl<'de> Visitor<'de> for ConfigBuilderVisitor {
type Value = ConfigBuilder;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("struct ConfigBuilder")
}
#[inline]
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut builder = ConfigBuilder::default();
while let Some(key) = map.next_key()? {
match key {
$($(
Field::$name => {
if builder.$name.is_some() {
return Err(de::Error::duplicate_field(stringify!($name)));
}
builder.$name = map.next_value()?;
}
)+)+
Field::__ignore => {
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
}
Ok(builder)
}
}
deserializer.deserialize_struct("ConfigBuilder", FIELDS, ConfigBuilderVisitor)
}
}
#[derive(Clone, Default, Serialize)]
pub struct ConfigBuilder { pub struct ConfigBuilder {
$($( $($(
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -86,7 +215,6 @@ macro_rules! make_config {
} }
impl ConfigBuilder { impl ConfigBuilder {
#[allow(clippy::field_reassign_with_default)]
fn from_env() -> Self { fn from_env() -> Self {
let env_file = get_env("ENV_FILE").unwrap_or_else(|| String::from(".env")); let env_file = get_env("ENV_FILE").unwrap_or_else(|| String::from(".env"));
match dotenvy::from_path(&env_file) { match dotenvy::from_path(&env_file) {
@@ -148,14 +276,14 @@ macro_rules! make_config {
/// Merges the values of both builders into a new builder. /// Merges the values of both builders into a new builder.
/// If both have the same element, `other` wins. /// If both have the same element, `other` wins.
fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<String>) -> Self { fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<&str>) -> Self {
let mut builder = self.clone(); let mut builder = self.clone();
$($( $($(
if let v @Some(_) = &other.$name { if let v @Some(_) = &other.$name {
builder.$name = v.clone(); builder.$name = v.clone();
if self.$name.is_some() { if self.$name.is_some() {
overrides.push(pastey::paste!(stringify!([<$name:upper>])).into()); overrides.push(pastey::paste!(stringify!([<$name:upper>])));
} }
} }
)+)+ )+)+
@@ -196,6 +324,32 @@ macro_rules! make_config {
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct ConfigItems { $($( $name: make_config! {@type $ty, $none_action}, )+)+ } struct ConfigItems { $($( $name: make_config! {@type $ty, $none_action}, )+)+ }
#[derive(Serialize)]
struct ElementDoc {
name: &'static str,
description: &'static str,
}
#[derive(Serialize)]
struct ElementData {
editable: bool,
name: &'static str,
value: serde_json::Value,
default: serde_json::Value,
#[serde(rename = "type")]
r#type: &'static str,
doc: ElementDoc,
overridden: bool,
}
#[derive(Serialize)]
pub struct GroupData {
group: &'static str,
grouptoggle: &'static str,
groupdoc: &'static str,
elements: Vec<ElementData>,
}
#[allow(unused)] #[allow(unused)]
impl Config { impl Config {
$($( $($(
@@ -207,11 +361,12 @@ macro_rules! make_config {
pub fn prepare_json(&self) -> serde_json::Value { pub fn prepare_json(&self) -> serde_json::Value {
let (def, cfg, overridden) = { let (def, cfg, overridden) = {
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
let inner = &self.inner.read().unwrap(); let inner = &self.inner.read().unwrap();
(inner._env.build(), inner.config.clone(), inner._overrides.clone()) (inner._env.build(), inner.config.clone(), inner._overrides.clone())
}; };
fn _get_form_type(rust_type: &str) -> &'static str { fn _get_form_type(rust_type: &'static str) -> &'static str {
match rust_type { match rust_type {
"Pass" => "password", "Pass" => "password",
"String" => "text", "String" => "text",
@@ -220,48 +375,36 @@ macro_rules! make_config {
} }
} }
fn _get_doc(doc: &str) -> serde_json::Value { fn _get_doc(doc_str: &'static str) -> ElementDoc {
let mut split = doc.split("|>").map(str::trim); let mut split = doc_str.split("|>").map(str::trim);
ElementDoc {
// We do not use the json!() macro here since that causes a lot of macro recursion. name: split.next().unwrap_or_default(),
// This slows down compile time and it also causes issues with rust-analyzer description: split.next().unwrap_or_default(),
serde_json::Value::Object({ }
let mut doc_json = serde_json::Map::new();
doc_json.insert("name".into(), serde_json::to_value(split.next()).unwrap());
doc_json.insert("description".into(), serde_json::to_value(split.next()).unwrap());
doc_json
})
} }
// We do not use the json!() macro here since that causes a lot of macro recursion. let data: Vec<GroupData> = vec![
// This slows down compile time and it also causes issues with rust-analyzer $( // This repetition is for each group
serde_json::Value::Array(<[_]>::into_vec(Box::new([ GroupData {
$( group: stringify!($group),
serde_json::Value::Object({ grouptoggle: stringify!($($group_enabled)?),
let mut group = serde_json::Map::new(); groupdoc: (make_config! { @show $($groupdoc)? }),
group.insert("group".into(), (stringify!($group)).into());
group.insert("grouptoggle".into(), (stringify!($($group_enabled)?)).into());
group.insert("groupdoc".into(), (make_config! { @show $($groupdoc)? }).into());
group.insert("elements".into(), serde_json::Value::Array(<[_]>::into_vec(Box::new([ elements: vec![
$( $( // This repetition is for each element within a group
serde_json::Value::Object({ ElementData {
let mut element = serde_json::Map::new(); editable: $editable,
element.insert("editable".into(), ($editable).into()); name: stringify!($name),
element.insert("name".into(), (stringify!($name)).into()); value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
element.insert("value".into(), serde_json::to_value(cfg.$name).unwrap()); default: serde_json::to_value(&def.$name).unwrap_or_default(),
element.insert("default".into(), serde_json::to_value(def.$name).unwrap()); r#type: _get_form_type(stringify!($ty)),
element.insert("type".into(), (_get_form_type(stringify!($ty))).into()); doc: _get_doc(concat!($($doc),+)),
element.insert("doc".into(), (_get_doc(concat!($($doc),+))).into()); overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
element.insert("overridden".into(), (overridden.contains(&pastey::paste!(stringify!([<$name:upper>])).into())).into()); },
element )+], // End of elements repetition
}), },
)+ )+]; // End of groups repetition
])))); serde_json::to_value(data).unwrap()
group
}),
)+
])))
} }
pub fn get_support_json(&self) -> serde_json::Value { pub fn get_support_json(&self) -> serde_json::Value {
@@ -269,8 +412,8 @@ macro_rules! make_config {
// Pass types will always be masked and no need to put them in the list. // Pass types will always be masked and no need to put them in the list.
// Besides Pass, only String types will be masked via _privacy_mask. // Besides Pass, only String types will be masked via _privacy_mask.
const PRIVACY_CONFIG: &[&str] = &[ const PRIVACY_CONFIG: &[&str] = &[
"allowed_iframe_ancestors",
"allowed_connect_src", "allowed_connect_src",
"allowed_iframe_ancestors",
"database_url", "database_url",
"domain_origin", "domain_origin",
"domain_path", "domain_path",
@@ -278,16 +421,18 @@ macro_rules! make_config {
"helo_name", "helo_name",
"org_creation_users", "org_creation_users",
"signups_domains_whitelist", "signups_domains_whitelist",
"_smtp_img_src",
"smtp_from_name",
"smtp_from", "smtp_from",
"smtp_host", "smtp_host",
"smtp_username", "smtp_username",
"_smtp_img_src",
"sso_client_id",
"sso_authority", "sso_authority",
"sso_callback_path", "sso_callback_path",
"sso_client_id",
]; ];
let cfg = { let cfg = {
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
let inner = &self.inner.read().unwrap(); let inner = &self.inner.read().unwrap();
inner.config.clone() inner.config.clone()
}; };
@@ -317,13 +462,21 @@ macro_rules! make_config {
serde_json::Value::Object({ serde_json::Value::Object({
let mut json = serde_json::Map::new(); let mut json = serde_json::Map::new();
$($( $($(
json.insert(stringify!($name).into(), make_config! { @supportstr $name, cfg.$name, $ty, $none_action }); json.insert(String::from(stringify!($name)), make_config! { @supportstr $name, cfg.$name, $ty, $none_action });
)+)+; )+)+;
// Loop through all privacy sensitive keys and mask them
for mask_key in PRIVACY_CONFIG {
if let Some(value) = json.get_mut(*mask_key) {
if let Some(s) = value.as_str() {
*value = _privacy_mask(s).into();
}
}
}
json json
}) })
} }
pub fn get_overrides(&self) -> Vec<String> { pub fn get_overrides(&self) -> Vec<&'static str> {
let overrides = { let overrides = {
let inner = &self.inner.read().unwrap(); let inner = &self.inner.read().unwrap();
inner._overrides.clone() inner._overrides.clone()
@@ -332,55 +485,6 @@ macro_rules! make_config {
} }
} }
}; };
// Support string print
( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value($value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option<String> with "***"
( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***"
( @supportstr $name:ident, $value:expr, String, option ) => { // Optional other value, we return as is or convert to string to apply the privacy config
if PRIVACY_CONFIG.contains(&stringify!($name)) {
serde_json::to_value($value.as_ref().map(|x| _privacy_mask(x) )).unwrap()
} else {
serde_json::to_value($value).unwrap()
}
};
( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { // Required other value, we return as is or convert to string to apply the privacy config
if PRIVACY_CONFIG.contains(&stringify!($name)) {
_privacy_mask(&$value).into()
} else {
($value).into()
}
};
( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value($value).unwrap() }; // Optional other value, we return as is or convert to string to apply the privacy config
( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to string to apply the privacy config
// Group or empty string
( @show ) => { "" };
( @show $lit:literal ) => { $lit };
// Wrap the optionals in an Option type
( @type $ty:ty, option) => { Option<$ty> };
( @type $ty:ty, $id:ident) => { $ty };
// Generate the values depending on none_action
( @build $value:expr, $config:expr, option, ) => { $value };
( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) };
( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{
match $value {
Some(v) => v,
None => {
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
f($config)
}
}
}};
( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
f($config)
}};
( @getenv $name:expr, bool ) => { get_env_bool($name) };
( @getenv $name:expr, $ty:ident ) => { get_env($name) };
} }
//STRUCTURE: //STRUCTURE:
@@ -460,9 +564,9 @@ make_config! {
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
/// Defaults to once every minute. Set blank to disable this job. /// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string(); duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
/// Purge incomplete SSO nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete SSO login. /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
/// Defaults to daily. Set blank to disable this job. /// Defaults to daily. Set blank to disable this job.
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string(); purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string();
}, },
/// General settings /// General settings
@@ -685,6 +789,10 @@ make_config! {
/// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available. /// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
/// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. /// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
enforce_single_org_with_reset_pw_policy: bool, false, def, false; enforce_single_org_with_reset_pw_policy: bool, false, def, false;
/// Prefer IPv6 (AAAA) resolving |> This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
/// This could be useful in IPv6 only environments.
dns_prefer_ipv6: bool, true, def, false;
}, },
/// OpenID Connect SSO settings /// OpenID Connect SSO settings
@@ -931,6 +1039,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
"ssh-agent", "ssh-agent",
// Key Management Team // Key Management Team
"ssh-key-vault-item", "ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
// Tools // Tools
"export-attachments", "export-attachments",
// Mobile Team // Mobile Team
@@ -1518,7 +1627,7 @@ impl Config {
if let Some(akey) = self._duo_akey() { if let Some(akey) = self._duo_akey() {
akey akey
} else { } else {
let akey_s = crate::crypto::encode_random_bytes::<64>(data_encoding::BASE64); let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64);
// Save the new value // Save the new value
let builder = ConfigBuilder { let builder = ConfigBuilder {
@@ -1542,7 +1651,7 @@ impl Config {
token.is_some() && !token.unwrap().trim().is_empty() token.is_some() && !token.unwrap().trim().is_empty()
} }
pub fn opendal_operator_for_path_type(&self, path_type: PathType) -> Result<opendal::Operator, Error> { pub fn opendal_operator_for_path_type(&self, path_type: &PathType) -> Result<opendal::Operator, Error> {
let path = match path_type { let path = match path_type {
PathType::Data => self.data_folder(), PathType::Data => self.data_folder(),
PathType::IconCache => self.icon_cache_folder(), PathType::IconCache => self.icon_cache_folder(),
@@ -1735,7 +1844,7 @@ fn to_json<'reg, 'rc>(
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then. // Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
// The default is based upon the version since this feature is added. // The default is based upon the version since this feature is added.
static WEB_VAULT_VERSION: Lazy<semver::Version> = Lazy::new(|| { static WEB_VAULT_VERSION: LazyLock<semver::Version> = LazyLock::new(|| {
let vault_version = get_web_vault_version(); let vault_version = get_web_vault_version();
// Use a single regex capture to extract version components // Use a single regex capture to extract version components
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap(); let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
@@ -1751,7 +1860,7 @@ static WEB_VAULT_VERSION: Lazy<semver::Version> = Lazy::new(|| {
// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then. // Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then.
// The default is based upon the version since this feature is added. // The default is based upon the version since this feature is added.
static VW_VERSION: Lazy<semver::Version> = Lazy::new(|| { static VW_VERSION: LazyLock<semver::Version> = LazyLock::new(|| {
let vw_version = crate::VERSION.unwrap_or("1.32.5"); let vw_version = crate::VERSION.unwrap_or("1.32.5");
// Use a single regex capture to extract version components // Use a single regex capture to extract version components
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap(); let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
+2 -2
View File
@@ -48,7 +48,7 @@ pub fn get_random_bytes<const N: usize>() -> [u8; N] {
} }
/// Encode random bytes using the provided function. /// Encode random bytes using the provided function.
pub fn encode_random_bytes<const N: usize>(e: Encoding) -> String { pub fn encode_random_bytes<const N: usize>(e: &Encoding) -> String {
e.encode(&get_random_bytes::<N>()) e.encode(&get_random_bytes::<N>())
} }
@@ -81,7 +81,7 @@ pub fn get_random_string_alphanum(num_chars: usize) -> String {
} }
pub fn generate_id<const N: usize>() -> String { pub fn generate_id<const N: usize>() -> String {
encode_random_bytes::<N>(HEXLOWER) encode_random_bytes::<N>(&HEXLOWER)
} }
pub fn generate_send_file_id() -> String { pub fn generate_send_file_id() -> String {
+40
View File
@@ -337,6 +337,46 @@ macro_rules! db_run {
}; };
} }
// Write all ToSql<Text, DB> and FromSql<Text, DB> given a serializable/deserializable type.
#[macro_export]
macro_rules! impl_FromToSqlText {
($name:ty) => {
#[cfg(mysql)]
impl ToSql<Text, diesel::mysql::Mysql> for $name {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result {
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
}
}
#[cfg(postgresql)]
impl ToSql<Text, diesel::pg::Pg> for $name {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
}
}
#[cfg(sqlite)]
impl ToSql<Text, diesel::sqlite::Sqlite> for $name {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {
serde_json::to_string(self).map_err(Into::into).map(|str| {
out.set_value(str);
diesel::serialize::IsNull::No
})
}
}
impl<DB: diesel::backend::Backend> FromSql<Text, DB> for $name
where
String: FromSql<Text, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
<String as FromSql<Text, DB>>::from_sql(bytes)
.and_then(|str| serde_json::from_str(&str).map_err(Into::into))
}
}
};
}
pub mod schema; pub mod schema;
// Reexport the models, needs to be after the macros are defined so it can access them // Reexport the models, needs to be after the macros are defined so it can access them
+3 -3
View File
@@ -44,9 +44,9 @@ impl Attachment {
} }
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> { pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
let operator = CONFIG.opendal_operator_for_path_type(PathType::Attachments)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
if operator.info().scheme() == opendal::Scheme::Fs { if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)) Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
} else { } else {
@@ -117,7 +117,7 @@ impl Attachment {
.map_res("Error deleting attachment") .map_res("Error deleting attachment")
}}?; }}?;
let operator = CONFIG.opendal_operator_for_path_type(PathType::Attachments)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
let file_path = self.get_file_path(); let file_path = self.get_file_path();
if let Err(e) = operator.delete(&file_path).await { if let Err(e) = operator.delete(&file_path).await {
+28 -32
View File
@@ -35,6 +35,25 @@ pub struct Device {
/// Local methods /// Local methods
impl Device { impl Device {
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid,
created_at: now,
updated_at: now,
user_uuid,
name,
atype,
push_uuid: Some(PushId(get_uuid())),
push_token: None,
refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL),
twofactor_remember: None,
}
}
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
@@ -48,7 +67,7 @@ impl Device {
} }
pub fn refresh_twofactor_remember(&mut self) -> String { pub fn refresh_twofactor_remember(&mut self) -> String {
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); let twofactor_remember = crypto::encode_random_bytes::<180>(&BASE64);
self.twofactor_remember = Some(twofactor_remember.clone()); self.twofactor_remember = Some(twofactor_remember.clone());
twofactor_remember twofactor_remember
@@ -110,38 +129,21 @@ impl DeviceWithAuthRequest {
} }
use crate::db::DbConn; use crate::db::DbConn;
use crate::api::{ApiResult, EmptyResult}; use crate::api::EmptyResult;
use crate::error::MapResult; use crate::error::MapResult;
/// Database methods /// Database methods
impl Device { impl Device {
pub async fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32, conn: &DbConn) -> ApiResult<Device> { pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult {
let now = Utc::now().naive_utc(); if update_time {
self.updated_at = Utc::now().naive_utc();
}
let device = Self {
uuid,
created_at: now,
updated_at: now,
user_uuid,
name,
atype,
push_uuid: Some(PushId(get_uuid())),
push_token: None,
refresh_token: crypto::encode_random_bytes::<64>(BASE64URL),
twofactor_remember: None,
};
device.inner_save(conn).await.map(|()| device)
}
async fn inner_save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: db_run! { conn:
sqlite, mysql { sqlite, mysql {
crate::util::retry(|| crate::util::retry(||
diesel::replace_into(devices::table) diesel::replace_into(devices::table)
.values(self) .values(&*self)
.execute(conn), .execute(conn),
10, 10,
).map_res("Error saving device") ).map_res("Error saving device")
@@ -149,10 +151,10 @@ impl Device {
postgresql { postgresql {
crate::util::retry(|| crate::util::retry(||
diesel::insert_into(devices::table) diesel::insert_into(devices::table)
.values(self) .values(&*self)
.on_conflict((devices::uuid, devices::user_uuid)) .on_conflict((devices::uuid, devices::user_uuid))
.do_update() .do_update()
.set(self) .set(&*self)
.execute(conn), .execute(conn),
10, 10,
).map_res("Error saving device") ).map_res("Error saving device")
@@ -160,12 +162,6 @@ impl Device {
} }
} }
// Should only be called after user has passed authentication
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
self.inner_save(conn).await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
db_run! { conn: { db_run! { conn: {
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
+10
View File
@@ -340,6 +340,16 @@ impl EmergencyAccess {
}} }}
} }
pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
.filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32))
.load::<Self>(conn)
.expect("Error loading emergency_access")
}}
}
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult { pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email { if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email {
err!("User email does not match invite."); err!("User email does not match invite.");
+3 -3
View File
@@ -11,7 +11,7 @@ mod group;
mod org_policy; mod org_policy;
mod organization; mod organization;
mod send; mod send;
mod sso_nonce; mod sso_auth;
mod two_factor; mod two_factor;
mod two_factor_duo_context; mod two_factor_duo_context;
mod two_factor_incomplete; mod two_factor_incomplete;
@@ -27,7 +27,7 @@ pub use self::event::{Event, EventType};
pub use self::favorite::Favorite; pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::organization::{ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId, OrganizationId,
@@ -36,7 +36,7 @@ pub use self::send::{
id::{SendFileId, SendId}, id::{SendFileId, SendId},
Send, SendType, Send, SendType,
}; };
pub use self::sso_nonce::SsoNonce; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::two_factor_incomplete::TwoFactorIncomplete;
+33 -31
View File
@@ -2,10 +2,12 @@ use derive_more::{AsRef, From};
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use crate::api::core::two_factor;
use crate::api::EmptyResult; use crate::api::EmptyResult;
use crate::db::schema::{org_policies, users_organizations}; use crate::db::schema::{org_policies, users_organizations};
use crate::db::DbConn; use crate::db::DbConn;
use crate::error::MapResult; use crate::error::MapResult;
use crate::CONFIG;
use diesel::prelude::*; use diesel::prelude::*;
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
@@ -40,6 +42,10 @@ pub enum OrgPolicyType {
// FreeFamiliesSponsorshipPolicy = 13, // FreeFamiliesSponsorshipPolicy = 13,
RemoveUnlockWithPin = 14, RemoveUnlockWithPin = 14,
RestrictedItemTypes = 15, RestrictedItemTypes = 15,
UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
@@ -58,14 +64,6 @@ pub struct ResetPasswordDataModel {
pub auto_enroll_enabled: bool, pub auto_enroll_enabled: bool,
} }
pub type OrgPolicyResult = Result<(), OrgPolicyErr>;
#[derive(Debug)]
pub enum OrgPolicyErr {
TwoFactorMissing,
SingleOrgEnforced,
}
/// Local methods /// Local methods
impl OrgPolicy { impl OrgPolicy {
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
@@ -280,31 +278,35 @@ impl OrgPolicy {
false false
} }
pub async fn is_user_allowed( pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult {
user_uuid: &UserId, if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) {
org_uuid: &OrganizationId, // Enforce TwoFactor/TwoStep login
exclude_current_org: bool, if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await
conn: &DbConn, {
) -> OrgPolicyResult { if p.enabled && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() {
// Enforce TwoFactor/TwoStep login if CONFIG.email_2fa_auto_fallback() {
if TwoFactor::find_by_user(user_uuid, conn).await.is_empty() { two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?;
match Self::find_by_org_and_type(org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await { } else {
Some(p) if p.enabled => { err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid));
return Err(OrgPolicyErr::TwoFactorMissing); }
} }
_ => {} }
};
}
// Enforce Single Organization Policy of other organizations user is a member of // Check if the user is part of another Organization with SingleOrg activated
// This check here needs to exclude this current org-id, else an accepted user can not be confirmed. if Self::is_applicable_to_user(&m.user_uuid, OrgPolicyType::SingleOrg, Some(&m.org_uuid), conn).await {
let exclude_org = if exclude_current_org { err!(format!(
Some(org_uuid) "Cannot {} because another organization policy forbids it (membership {})",
} else { action, m.uuid
None ));
}; }
if Self::is_applicable_to_user(user_uuid, OrgPolicyType::SingleOrg, exclude_org, conn).await {
return Err(OrgPolicyErr::SingleOrgEnforced); if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await {
if p.enabled
&& Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0
{
err!(format!("Cannot {} because the organization policy forbids being part of other organization (membership {})", action, m.uuid));
}
}
} }
Ok(()) Ok(())
+7 -2
View File
@@ -172,7 +172,7 @@ impl PartialOrd<MembershipType> for i32 {
/// Local methods /// Local methods
impl Organization { impl Organization {
pub fn new(name: String, billing_email: String, private_key: Option<String>, public_key: Option<String>) -> Self { pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
let billing_email = billing_email.to_lowercase(); let billing_email = billing_email.to_lowercase();
Self { Self {
uuid: OrganizationId(crate::util::get_uuid()), uuid: OrganizationId(crate::util::get_uuid()),
@@ -883,10 +883,15 @@ impl Membership {
}} }}
} }
pub async fn count_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { pub async fn count_accepted_and_confirmed_by_user(
user_uuid: &UserId,
excluded_org: &OrganizationId,
conn: &DbConn,
) -> i64 {
db_run! { conn: { db_run! { conn: {
users_organizations::table users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.ne(excluded_org))
.filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))) .filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)))
.count() .count()
.first::<i64>(conn) .first::<i64>(conn)
+1 -1
View File
@@ -225,7 +225,7 @@ impl Send {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;
if self.atype == SendType::File as i32 { if self.atype == SendType::File as i32 {
let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
operator.remove_all(&self.uuid).await.ok(); operator.remove_all(&self.uuid).await.ok();
} }
+134
View File
@@ -0,0 +1,134 @@
use chrono::{NaiveDateTime, Utc};
use std::time::Duration;
use crate::api::EmptyResult;
use crate::db::schema::sso_auth;
use crate::db::{DbConn, DbPool};
use crate::error::MapResult;
use crate::sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION};
use diesel::deserialize::FromSql;
use diesel::expression::AsExpression;
use diesel::prelude::*;
use diesel::serialize::{Output, ToSql};
use diesel::sql_types::Text;
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = Text)]
pub enum OIDCCodeWrapper {
Ok {
code: OIDCCode,
},
Error {
error: String,
error_description: Option<String>,
},
}
impl_FromToSqlText!(OIDCCodeWrapper);
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = Text)]
pub struct OIDCAuthenticatedUser {
pub refresh_token: Option<String>,
pub access_token: String,
pub expires_in: Option<Duration>,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
impl_FromToSqlText!(OIDCAuthenticatedUser);
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = sso_auth)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(state))]
pub struct SsoAuth {
pub state: OIDCState,
pub client_challenge: OIDCCodeChallenge,
pub nonce: String,
pub redirect_uri: String,
pub code_response: Option<OIDCCodeWrapper>,
pub auth_response: Option<OIDCAuthenticatedUser>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// Local methods
impl SsoAuth {
pub fn new(state: OIDCState, client_challenge: OIDCCodeChallenge, nonce: String, redirect_uri: String) -> Self {
let now = Utc::now().naive_utc();
SsoAuth {
state,
client_challenge,
nonce,
redirect_uri,
created_at: now,
updated_at: now,
code_response: None,
auth_response: None,
}
}
}
/// Database methods
impl SsoAuth {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(sso_auth::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving SSO auth")
}
postgresql, sqlite {
diesel::insert_into(sso_auth::table)
.values(self)
.on_conflict(sso_auth::state)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving SSO auth")
}
}
}
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
db_run! { conn: {
sso_auth::table
.filter(sso_auth::state.eq(state))
.filter(sso_auth::created_at.ge(oldest))
.first::<Self>(conn)
.ok()
}}
}
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
db_run! {conn: {
diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state)))
.execute(conn)
.map_res("Error deleting sso_auth")
}}
}
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
debug!("Purging expired sso_auth");
if let Ok(conn) = pool.get().await {
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
db_run! { conn: {
diesel::delete(sso_auth::table.filter(sso_auth::created_at.lt(oldest)))
.execute(conn)
.map_res("Error deleting expired SSO nonce")
}}
} else {
err!("Failed to get DB connection while purging expired sso_auth")
}
}
}
-87
View File
@@ -1,87 +0,0 @@
use chrono::{NaiveDateTime, Utc};
use crate::api::EmptyResult;
use crate::db::schema::sso_nonce;
use crate::db::{DbConn, DbPool};
use crate::error::MapResult;
use crate::sso::{OIDCState, NONCE_EXPIRATION};
use diesel::prelude::*;
#[derive(Identifiable, Queryable, Insertable)]
#[diesel(table_name = sso_nonce)]
#[diesel(primary_key(state))]
pub struct SsoNonce {
pub state: OIDCState,
pub nonce: String,
pub verifier: Option<String>,
pub redirect_uri: String,
pub created_at: NaiveDateTime,
}
/// Local methods
impl SsoNonce {
pub fn new(state: OIDCState, nonce: String, verifier: Option<String>, redirect_uri: String) -> Self {
let now = Utc::now().naive_utc();
SsoNonce {
state,
nonce,
verifier,
redirect_uri,
created_at: now,
}
}
}
/// Database methods
impl SsoNonce {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(sso_nonce::table)
.values(self)
.execute(conn)
.map_res("Error saving SSO nonce")
}
postgresql {
diesel::insert_into(sso_nonce::table)
.values(self)
.execute(conn)
.map_res("Error saving SSO nonce")
}
}
}
pub async fn delete(state: &OIDCState, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state)))
.execute(conn)
.map_res("Error deleting SSO nonce")
}}
}
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
db_run! { conn: {
sso_nonce::table
.filter(sso_nonce::state.eq(state))
.filter(sso_nonce::created_at.ge(oldest))
.first::<Self>(conn)
.ok()
}}
}
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
debug!("Purging expired sso_nonce");
if let Ok(conn) = pool.get().await {
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
db_run! { conn: {
diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest)))
.execute(conn)
.map_res("Error deleting expired SSO nonce")
}}
} else {
err!("Failed to get DB connection while purging expired sso_nonce")
}
}
}
+14 -12
View File
@@ -1,4 +1,4 @@
use crate::db::schema::{devices, invitations, sso_users, users}; use crate::db::schema::{invitations, sso_users, twofactor_incomplete, users};
use chrono::{NaiveDateTime, TimeDelta, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
@@ -10,8 +10,7 @@ use super::{
use crate::{ use crate::{
api::EmptyResult, api::EmptyResult,
crypto, crypto,
db::models::DeviceId, db::{models::DeviceId, DbConn},
db::DbConn,
error::MapResult, error::MapResult,
sso::OIDCIdentifier, sso::OIDCIdentifier,
util::{format_date, get_uuid, retry}, util::{format_date, get_uuid, retry},
@@ -106,7 +105,7 @@ impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
pub fn new(email: String, name: Option<String>) -> Self { pub fn new(email: &str, name: Option<String>) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let email = email.to_lowercase(); let email = email.to_lowercase();
@@ -387,15 +386,18 @@ impl User {
}} }}
} }
pub async fn find_by_device_id(device_uuid: &DeviceId, conn: &DbConn) -> Option<Self> { pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
db_run! { conn: { if let Some(user_uuid) = db_run! ( conn: {
users::table twofactor_incomplete::table
.inner_join(devices::table.on(devices::user_uuid.eq(users::uuid))) .filter(twofactor_incomplete::device_uuid.eq(device_uuid))
.filter(devices::uuid.eq(device_uuid)) .order_by(twofactor_incomplete::login_time.desc())
.select(users::all_columns) .select(twofactor_incomplete::user_uuid)
.first::<Self>(conn) .first::<UserId>(conn)
.ok() .ok()
}} }) {
return Self::find_by_uuid(&user_uuid, conn).await;
}
None
} }
pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option<SsoUser>)> { pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option<SsoUser>)> {
+5 -2
View File
@@ -256,12 +256,15 @@ table! {
} }
table! { table! {
sso_nonce (state) { sso_auth (state) {
state -> Text, state -> Text,
client_challenge -> Text,
nonce -> Text, nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text, redirect_uri -> Text,
code_response -> Nullable<Text>,
auth_response -> Nullable<Text>,
created_at -> Timestamp, created_at -> Timestamp,
updated_at -> Timestamp,
} }
} }
+123 -47
View File
@@ -3,6 +3,7 @@
// //
use crate::db::models::EventType; use crate::db::models::EventType;
use crate::http_client::CustomHttpClientError; use crate::http_client::CustomHttpClientError;
use serde::ser::{Serialize, SerializeStruct, Serializer};
use std::error::Error as StdError; use std::error::Error as StdError;
macro_rules! make_error { macro_rules! make_error {
@@ -73,7 +74,7 @@ make_error! {
Empty(Empty): _no_source, _serialize, Empty(Empty): _no_source, _serialize,
// Used to represent err! calls // Used to represent err! calls
Simple(String): _no_source, _api_error, Simple(String): _no_source, _api_error,
Compact(Compact): _no_source, _api_error_small, Compact(Compact): _no_source, _compact_api_error,
// Used in our custom http client to handle non-global IPs and blocked domains // Used in our custom http client to handle non-global IPs and blocked domains
CustomHttpClient(CustomHttpClientError): _has_source, _api_error, CustomHttpClient(CustomHttpClientError): _has_source, _api_error,
@@ -130,6 +131,10 @@ impl Error {
(usr_msg, log_msg.into()).into() (usr_msg, log_msg.into()).into()
} }
pub fn new_msg<M: Into<String> + Clone>(usr_msg: M) -> Self {
(usr_msg.clone(), usr_msg.into()).into()
}
pub fn empty() -> Self { pub fn empty() -> Self {
Empty {}.into() Empty {}.into()
} }
@@ -196,38 +201,97 @@ fn _no_source<T, S>(_: T) -> Option<S> {
None None
} }
fn _serialize(e: &impl serde::Serialize, _msg: &str) -> String { fn _serialize(e: &impl Serialize, _msg: &str) -> String {
serde_json::to_string(e).unwrap() serde_json::to_string(e).unwrap()
} }
fn _api_error(_: &impl std::any::Any, msg: &str) -> String { /// This will serialize the default ApiErrorResponse
let json = json!({ /// It will add the needed fields which are mostly empty or have multiple copies of the message
"message": msg, /// This is more efficient than having a larger struct and use the Serialize derive
"error": "", /// It also prevents using `json!()` calls to create the final output
"error_description": "", impl Serialize for ApiErrorResponse<'_> {
"validationErrors": {"": [ msg ]}, fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
"errorModel": { where
"message": msg, S: Serializer,
"object": "error" {
}, #[derive(serde::Serialize)]
"exceptionMessage": null, struct ErrorModel<'a> {
"exceptionStackTrace": null, message: &'a str,
"innerExceptionMessage": null, object: &'static str,
"object": "error" }
});
_serialize(&json, "") let mut state = serializer.serialize_struct("ApiErrorResponse", 9)?;
state.serialize_field("message", self.0.message)?;
let mut validation_errors = std::collections::HashMap::with_capacity(1);
validation_errors.insert("", vec![self.0.message]);
state.serialize_field("validationErrors", &validation_errors)?;
let error_model = ErrorModel {
message: self.0.message,
object: "error",
};
state.serialize_field("errorModel", &error_model)?;
state.serialize_field("error", "")?;
state.serialize_field("error_description", "")?;
state.serialize_field("exceptionMessage", &None::<()>)?;
state.serialize_field("exceptionStackTrace", &None::<()>)?;
state.serialize_field("innerExceptionMessage", &None::<()>)?;
state.serialize_field("object", "error")?;
state.end()
}
} }
fn _api_error_small(_: &impl std::any::Any, msg: &str) -> String { /// This will serialize the smaller CompactApiErrorResponse
let json = json!({ /// It will add the needed fields which are mostly empty
"message": msg, /// This is more efficient than having a larger struct and use the Serialize derive
"validationErrors": null, /// It also prevents using `json!()` calls to create the final output
"exceptionMessage": null, impl Serialize for CompactApiErrorResponse<'_> {
"exceptionStackTrace": null, fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
"innerExceptionMessage": null, where
"object": "error" S: Serializer,
}); {
_serialize(&json, "") let mut state = serializer.serialize_struct("CompactApiErrorResponse", 6)?;
state.serialize_field("message", self.0.message)?;
state.serialize_field("validationErrors", &None::<()>)?;
state.serialize_field("exceptionMessage", &None::<()>)?;
state.serialize_field("exceptionStackTrace", &None::<()>)?;
state.serialize_field("innerExceptionMessage", &None::<()>)?;
state.serialize_field("object", "error")?;
state.end()
}
}
/// Main API Error struct template
/// This struct which we can be used by both ApiErrorResponse and CompactApiErrorResponse
/// is small and doesn't contain unneeded empty fields. This is more memory efficient, but also less code to compile
struct ApiErrorMsg<'a> {
message: &'a str,
}
/// Default API Error response struct
/// The custom serialization adds all other needed fields
struct ApiErrorResponse<'a>(ApiErrorMsg<'a>);
/// Compact API Error response struct used for some newer error responses
/// The custom serialization adds all other needed fields
struct CompactApiErrorResponse<'a>(ApiErrorMsg<'a>);
fn _api_error(_: &impl std::any::Any, msg: &str) -> String {
let response = ApiErrorMsg {
message: msg,
};
serde_json::to_string(&ApiErrorResponse(response)).unwrap()
}
fn _compact_api_error(_: &impl std::any::Any, msg: &str) -> String {
let response = ApiErrorMsg {
message: msg,
};
serde_json::to_string(&CompactApiErrorResponse(response)).unwrap()
} }
// //
@@ -258,34 +322,41 @@ impl Responder<'_, 'static> for Error {
#[macro_export] #[macro_export]
macro_rules! err { macro_rules! err {
($kind:ident, $msg:expr) => {{ ($kind:ident, $msg:expr) => {{
error!("{}", $msg); let msg = $msg;
return Err($crate::error::Error::new($msg, $msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {}))); error!("{msg}");
return Err($crate::error::Error::new_msg(msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {})));
}}; }};
($msg:expr) => {{ ($msg:expr) => {{
error!("{}", $msg); let msg = $msg;
return Err($crate::error::Error::new($msg, $msg)); error!("{msg}");
return Err($crate::error::Error::new_msg(msg));
}}; }};
($msg:expr, ErrorEvent $err_event:tt) => {{ ($msg:expr, ErrorEvent $err_event:tt) => {{
error!("{}", $msg); let msg = $msg;
return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event)); error!("{msg}");
return Err($crate::error::Error::new_msg(msg).with_event($crate::error::ErrorEvent $err_event));
}}; }};
($usr_msg:expr, $log_value:expr) => {{ ($usr_msg:expr, $log_value:expr) => {{
error!("{}. {}", $usr_msg, $log_value); let usr_msg = $usr_msg;
return Err($crate::error::Error::new($usr_msg, $log_value)); let log_value = $log_value;
error!("{usr_msg}. {log_value}");
return Err($crate::error::Error::new(usr_msg, log_value));
}}; }};
($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{
error!("{}. {}", $usr_msg, $log_value); let usr_msg = $usr_msg;
return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event)); let log_value = $log_value;
error!("{usr_msg}. {log_value}");
return Err($crate::error::Error::new(usr_msg, log_value).with_event($crate::error::ErrorEvent $err_event));
}}; }};
} }
#[macro_export] #[macro_export]
macro_rules! err_silent { macro_rules! err_silent {
($msg:expr) => {{ ($msg:expr) => {{
return Err($crate::error::Error::new($msg, $msg)); return Err($crate::error::Error::new_msg($msg));
}}; }};
($msg:expr, ErrorEvent $err_event:tt) => {{ ($msg:expr, ErrorEvent $err_event:tt) => {{
return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event)); return Err($crate::error::Error::new_msg($msg).with_event($crate::error::ErrorEvent $err_event));
}}; }};
($usr_msg:expr, $log_value:expr) => {{ ($usr_msg:expr, $log_value:expr) => {{
return Err($crate::error::Error::new($usr_msg, $log_value)); return Err($crate::error::Error::new($usr_msg, $log_value));
@@ -298,12 +369,15 @@ macro_rules! err_silent {
#[macro_export] #[macro_export]
macro_rules! err_code { macro_rules! err_code {
($msg:expr, $err_code:expr) => {{ ($msg:expr, $err_code:expr) => {{
error!("{}", $msg); let msg = $msg;
return Err($crate::error::Error::new($msg, $msg).with_code($err_code)); error!("{msg}");
return Err($crate::error::Error::new_msg(msg).with_code($err_code));
}}; }};
($usr_msg:expr, $log_value:expr, $err_code:expr) => {{ ($usr_msg:expr, $log_value:expr, $err_code:expr) => {{
error!("{}. {}", $usr_msg, $log_value); let usr_msg = $usr_msg;
return Err($crate::error::Error::new($usr_msg, $log_value).with_code($err_code)); let log_value = $log_value;
error!("{usr_msg}. {log_value}");
return Err($crate::error::Error::new(usr_msg, log_value).with_code($err_code));
}}; }};
} }
@@ -311,7 +385,7 @@ macro_rules! err_code {
macro_rules! err_discard { macro_rules! err_discard {
($msg:expr, $data:expr) => {{ ($msg:expr, $data:expr) => {{
std::io::copy(&mut $data.open(), &mut std::io::sink()).ok(); std::io::copy(&mut $data.open(), &mut std::io::sink()).ok();
return Err($crate::error::Error::new($msg, $msg)); return Err($crate::error::Error::new_msg($msg));
}}; }};
($usr_msg:expr, $log_value:expr, $data:expr) => {{ ($usr_msg:expr, $log_value:expr, $data:expr) => {{
std::io::copy(&mut $data.open(), &mut std::io::sink()).ok(); std::io::copy(&mut $data.open(), &mut std::io::sink()).ok();
@@ -336,7 +410,9 @@ macro_rules! err_handler {
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr)); return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
}}; }};
($usr_msg:expr, $log_value:expr) => {{ ($usr_msg:expr, $log_value:expr) => {{
error!(target: "auth", "Unauthorized Error: {}. {}", $usr_msg, $log_value); let usr_msg = $usr_msg;
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $usr_msg)); let log_value = $log_value;
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg));
}}; }};
} }
+14 -11
View File
@@ -2,12 +2,11 @@ use std::{
fmt, fmt,
net::{IpAddr, SocketAddr}, net::{IpAddr, SocketAddr},
str::FromStr, str::FromStr,
sync::{Arc, Mutex}, sync::{Arc, LazyLock, Mutex},
time::Duration, time::Duration,
}; };
use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver}; use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver};
use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use reqwest::{ use reqwest::{
dns::{Name, Resolve, Resolving}, dns::{Name, Resolve, Resolving},
@@ -25,9 +24,10 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::
err!("Invalid host"); err!("Invalid host");
}; };
should_block_host(host)?; should_block_host(&host)?;
static INSTANCE: Lazy<Client> = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); static INSTANCE: LazyLock<Client> =
LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client"));
Ok(INSTANCE.request(method, url)) Ok(INSTANCE.request(method, url))
} }
@@ -45,7 +45,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder {
return attempt.error("Invalid host"); return attempt.error("Invalid host");
}; };
if let Err(e) = should_block_host(host) { if let Err(e) = should_block_host(&host) {
return attempt.error(e); return attempt.error(e);
} }
@@ -100,11 +100,11 @@ fn should_block_address_regex(domain_or_ip: &str) -> bool {
is_match is_match
} }
fn should_block_host(host: Host<&str>) -> Result<(), CustomHttpClientError> { fn should_block_host(host: &Host<&str>) -> Result<(), CustomHttpClientError> {
let (ip, host_str): (Option<IpAddr>, String) = match host { let (ip, host_str): (Option<IpAddr>, String) = match host {
Host::Ipv4(ip) => (Some(ip.into()), ip.to_string()), Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()),
Host::Ipv6(ip) => (Some(ip.into()), ip.to_string()), Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()),
Host::Domain(d) => (None, d.to_string()), Host::Domain(d) => (None, (*d).to_string()),
}; };
if let Some(ip) = ip { if let Some(ip) = ip {
@@ -179,13 +179,16 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
impl CustomDnsResolver { impl CustomDnsResolver {
fn instance() -> Arc<Self> { fn instance() -> Arc<Self> {
static INSTANCE: Lazy<Arc<CustomDnsResolver>> = Lazy::new(CustomDnsResolver::new); static INSTANCE: LazyLock<Arc<CustomDnsResolver>> = LazyLock::new(CustomDnsResolver::new);
Arc::clone(&*INSTANCE) Arc::clone(&*INSTANCE)
} }
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
match TokioResolver::builder(TokioConnectionProvider::default()) { match TokioResolver::builder(TokioConnectionProvider::default()) {
Ok(builder) => { Ok(mut builder) => {
if CONFIG.dns_prefer_ipv6() {
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4;
}
let resolver = builder.build(); let resolver = builder.build();
Arc::new(Self::Hickory(Arc::new(resolver))) Arc::new(Self::Hickory(Arc::new(resolver)))
} }
+5 -5
View File
@@ -184,7 +184,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
} }
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id.clone()); let claims = generate_verify_email_claims(user_id);
let verify_email_token = encode_jwt(&claims); let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -235,7 +235,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
} }
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id.clone()); let claims = generate_verify_email_claims(user_id);
let verify_email_token = encode_jwt(&claims); let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -705,7 +705,7 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult {
} }
async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult {
let smtp_from = &CONFIG.smtp_from(); let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
let body = if CONFIG.smtp_embed_images() { let body = if CONFIG.smtp_embed_images() {
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec()); let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
@@ -727,9 +727,9 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text:
}; };
let email = Message::builder() let email = Message::builder()
.message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.split('@').collect::<Vec<&str>>()[1]))) .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.domain())))
.to(Mailbox::new(None, Address::from_str(address)?)) .to(Mailbox::new(None, Address::from_str(address)?))
.from(Mailbox::new(Some(CONFIG.smtp_from_name()), Address::from_str(smtp_from)?)) .from(Mailbox::new(Some(CONFIG.smtp_from_name()), smtp_from))
.subject(subject) .subject(subject)
.multipart(body)?; .multipart(body)?;
+7 -7
View File
@@ -246,8 +246,8 @@ fn init_logging() -> Result<log::LevelFilter, Error> {
.split(',') .split(',')
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
.into_iter() .into_iter()
.flat_map(|s| match s.split('=').collect::<Vec<&str>>()[..] { .flat_map(|s| match s.split_once('=') {
[log, lvl_str] => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)), Some((log, lvl_str)) => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)),
_ => None, _ => None,
}) })
.collect() .collect()
@@ -448,7 +448,7 @@ async fn check_data_folder() {
if data_folder.starts_with("s3://") { if data_folder.starts_with("s3://") {
if let Err(e) = CONFIG if let Err(e) = CONFIG
.opendal_operator_for_path_type(PathType::Data) .opendal_operator_for_path_type(&PathType::Data)
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
error!("Failed to create S3 operator for data folder '{data_folder}': {e:?}"); error!("Failed to create S3 operator for data folder '{data_folder}': {e:?}");
exit(1); exit(1);
@@ -699,10 +699,10 @@ fn schedule_jobs(pool: db::DbPool) {
})); }));
} }
// Purge sso nonce from incomplete flow (default to daily at 00h20). // Purge sso auth from incomplete flow (default to daily at 00h20).
if !CONFIG.purge_incomplete_sso_nonce().is_empty() { if !CONFIG.purge_incomplete_sso_auth().is_empty() {
sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || { sched.add(Job::new(CONFIG.purge_incomplete_sso_auth().parse().unwrap(), || {
runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone())); runtime.spawn(db::models::SsoAuth::delete_expired(pool.clone()));
})); }));
} }
+3 -4
View File
@@ -1,5 +1,4 @@
use once_cell::sync::Lazy; use std::{net::IpAddr, num::NonZeroU32, sync::LazyLock, time::Duration};
use std::{net::IpAddr, num::NonZeroU32, time::Duration};
use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter}; use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter};
@@ -7,13 +6,13 @@ use crate::{Error, CONFIG};
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>; type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
static LIMITER_LOGIN: Lazy<Limiter> = Lazy::new(|| { static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_ADMIN: Lazy<Limiter> = Lazy::new(|| { static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
+149 -138
View File
@@ -1,18 +1,16 @@
use chrono::Utc; use std::{sync::LazyLock, time::Duration};
use derive_more::{AsRef, Deref, Display, From};
use regex::Regex;
use std::time::Duration;
use url::Url;
use mini_moka::sync::Cache; use chrono::Utc;
use once_cell::sync::Lazy; use derive_more::{AsRef, Deref, Display, From, Into};
use regex::Regex;
use url::Url;
use crate::{ use crate::{
api::ApiResult, api::ApiResult,
auth, auth,
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
db::{ db::{
models::{Device, SsoNonce, User}, models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User},
DbConn, DbConn,
}, },
sso_client::Client, sso_client::Client,
@@ -21,12 +19,10 @@ use crate::{
pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC"; pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC";
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> = static SSO_JWT_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin()));
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin())); pub static SSO_AUTH_EXPIRATION: LazyLock<chrono::Duration> =
LazyLock::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
#[derive( #[derive(
Clone, Clone,
@@ -48,6 +44,47 @@ pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeD
#[from(forward)] #[from(forward)]
pub struct OIDCCode(String); pub struct OIDCCode(String);
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
Into,
)]
#[deref(forward)]
#[into(owned)]
pub struct OIDCCodeChallenge(String);
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
Into,
)]
#[deref(forward)]
#[into(owned)]
pub struct OIDCCodeVerifier(String);
#[derive( #[derive(
Clone, Clone,
Debug, Debug,
@@ -92,40 +129,6 @@ pub fn encode_ssotoken_claims() -> String {
auth::encode_jwt(&claims) auth::encode_jwt(&claims)
} }
#[derive(Debug, Serialize, Deserialize)]
pub enum OIDCCodeWrapper {
Ok {
state: OIDCState,
code: OIDCCode,
},
Error {
state: OIDCState,
error: String,
error_description: Option<String>,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct OIDCCodeClaims {
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
pub code: OIDCCodeWrapper,
}
pub fn encode_code_claims(code: OIDCCodeWrapper) -> String {
let time_now = Utc::now();
let claims = OIDCCodeClaims {
exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(),
iss: SSO_JWT_ISSUER.to_string(),
code,
};
auth::encode_jwt(&claims)
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
struct BasicTokenClaims { struct BasicTokenClaims {
iat: Option<i64>, iat: Option<i64>,
@@ -133,6 +136,12 @@ struct BasicTokenClaims {
exp: i64, exp: i64,
} }
#[derive(Deserialize)]
struct BasicTokenClaimsValidation {
exp: u64,
iss: String,
}
impl BasicTokenClaims { impl BasicTokenClaims {
fn nbf(&self) -> i64 { fn nbf(&self) -> i64 {
self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp())
@@ -140,18 +149,28 @@ impl BasicTokenClaims {
} }
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> { fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
let mut validation = jsonwebtoken::Validation::default(); // We need to manually validate this token, since `insecure_decode` does not do this
validation.set_issuer(&[CONFIG.sso_authority()]); match jsonwebtoken::dangerous::insecure_decode::<BasicTokenClaimsValidation>(token) {
validation.insecure_disable_signature_validation(); Ok(btcv) => {
validation.validate_aud = false; let now = jsonwebtoken::get_current_timestamp();
let validate_claim = btcv.claims;
// Validate the exp in the claim with a leeway of 60 seconds, same as jsonwebtoken does
if validate_claim.exp < now - 60 {
err_silent!(format!("Expired Signature for base token claim from {token_name}"))
}
if validate_claim.iss.ne(&CONFIG.sso_authority()) {
err_silent!(format!("Invalid Issuer for base token claim from {token_name}"))
}
match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { // All is validated and ok, lets decode again using the wanted struct
Ok(btc) => Ok(btc.claims), let btc = jsonwebtoken::dangerous::insecure_decode::<BasicTokenClaims>(token).unwrap();
Ok(btc.claims)
}
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")),
} }
} }
pub fn decode_state(base64_state: String) -> ApiResult<OIDCState> { pub fn decode_state(base64_state: &str) -> ApiResult<OIDCState> {
let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) {
Ok(vec) => match String::from_utf8(vec) { Ok(vec) => match String::from_utf8(vec) {
Ok(valid) => OIDCState(valid), Ok(valid) => OIDCState(valid),
@@ -163,9 +182,14 @@ pub fn decode_state(base64_state: String) -> ApiResult<OIDCState> {
Ok(state) Ok(state)
} }
// The `nonce` allow to protect against replay attacks
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs // redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
pub async fn authorize_url(state: OIDCState, client_id: &str, raw_redirect_uri: &str, conn: DbConn) -> ApiResult<Url> { pub async fn authorize_url(
state: OIDCState,
client_challenge: OIDCCodeChallenge,
client_id: &str,
raw_redirect_uri: &str,
conn: DbConn,
) -> ApiResult<Url> {
let redirect_uri = match client_id { let redirect_uri = match client_id {
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(), "desktop" | "mobile" => "bitwarden://sso-callback".to_string(),
@@ -179,8 +203,8 @@ pub async fn authorize_url(state: OIDCState, client_id: &str, raw_redirect_uri:
_ => err!(format!("Unsupported client {client_id}")), _ => err!(format!("Unsupported client {client_id}")),
}; };
let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?; let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri).await?;
nonce.save(&conn).await?; sso_auth.save(&conn).await?;
Ok(auth_url) Ok(auth_url)
} }
@@ -210,78 +234,45 @@ impl OIDCIdentifier {
} }
} }
#[derive(Clone, Debug)]
pub struct AuthenticatedUser {
pub refresh_token: Option<String>,
pub access_token: String,
pub expires_in: Option<Duration>,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
#[derive(Clone, Debug)]
pub struct UserInformation {
pub state: OIDCState,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
async fn decode_code_claims(code: &str, conn: &DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) {
Ok(code_claims) => match code_claims.code {
OIDCCodeWrapper::Ok {
state,
code,
} => Ok((code, state)),
OIDCCodeWrapper::Error {
state,
error,
error_description,
} => {
if let Err(err) = SsoNonce::delete(&state, conn).await {
error!("Failed to delete database sso_nonce using {state}: {err}")
}
err!(format!(
"SSO authorization failed: {error}, {}",
error_description.as_ref().unwrap_or(&String::new())
))
}
},
Err(err) => err!(format!("Failed to decode code wrapper: {err}")),
}
}
// During the 2FA flow we will // During the 2FA flow we will
// - retrieve the user information and then only discover he needs 2FA. // - retrieve the user information and then only discover he needs 2FA.
// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged. // - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged.
// The `nonce` will ensure that the user is authorized only once. // The `SsoAuth` will ensure that the user is authorized only once.
// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`. pub async fn exchange_code(
pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserInformation> { state: &OIDCState,
client_verifier: OIDCCodeVerifier,
conn: &DbConn,
) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> {
use openidconnect::OAuth2TokenResponse; use openidconnect::OAuth2TokenResponse;
let (code, state) = decode_code_claims(wrapped_code, conn).await?; let mut sso_auth = match SsoAuth::find(state, conn).await {
None => err!(format!("Invalid state cannot retrieve sso auth")),
Some(sso_auth) => sso_auth,
};
if let Some(authenticated_user) = AC_CACHE.get(&state) { if let Some(authenticated_user) = sso_auth.auth_response.clone() {
return Ok(UserInformation { return Ok((sso_auth, authenticated_user));
state,
identifier: authenticated_user.identifier,
email: authenticated_user.email,
email_verified: authenticated_user.email_verified,
user_name: authenticated_user.user_name,
});
} }
let nonce = match SsoNonce::find(&state, conn).await { let code = match sso_auth.code_response.clone() {
None => err!(format!("Invalid state cannot retrieve nonce")), Some(OIDCCodeWrapper::Ok {
Some(nonce) => nonce, code,
}) => code.clone(),
Some(OIDCCodeWrapper::Error {
error,
error_description,
}) => {
sso_auth.delete(conn).await?;
err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new())))
}
None => {
sso_auth.delete(conn).await?;
err!("Missing authorization provider return");
}
}; };
let client = Client::cached().await?; let client = Client::cached().await?;
let (token_response, id_claims) = client.exchange_code(code, nonce).await?; let (token_response, id_claims) = client.exchange_code(code, client_verifier, &sso_auth).await?;
let user_info = client.user_info(token_response.access_token().to_owned()).await?; let user_info = client.user_info(token_response.access_token().to_owned()).await?;
@@ -301,7 +292,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject());
let authenticated_user = AuthenticatedUser { let authenticated_user = OIDCAuthenticatedUser {
refresh_token: refresh_token.cloned(), refresh_token: refresh_token.cloned(),
access_token: token_response.access_token().secret().clone(), access_token: token_response.access_token().secret().clone(),
expires_in: token_response.expires_in(), expires_in: token_response.expires_in(),
@@ -312,29 +303,49 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
}; };
debug!("Authenticated user {authenticated_user:?}"); debug!("Authenticated user {authenticated_user:?}");
sso_auth.auth_response = Some(authenticated_user.clone());
sso_auth.updated_at = Utc::now().naive_utc();
sso_auth.save(conn).await?;
AC_CACHE.insert(state.clone(), authenticated_user); Ok((sso_auth, authenticated_user))
Ok(UserInformation {
state,
identifier,
email,
email_verified,
user_name,
})
} }
// User has passed 2FA flow we can delete `nonce` and clear the cache. // User has passed 2FA flow we can delete auth info from database
pub async fn redeem(state: &OIDCState, conn: &DbConn) -> ApiResult<AuthenticatedUser> { pub async fn redeem(
if let Err(err) = SsoNonce::delete(state, conn).await { device: &Device,
error!("Failed to delete database sso_nonce using {state}: {err}") user: &User,
client_id: Option<String>,
sso_user: Option<SsoUser>,
sso_auth: SsoAuth,
auth_user: OIDCAuthenticatedUser,
conn: &DbConn,
) -> ApiResult<AuthTokens> {
sso_auth.delete(conn).await?;
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: auth_user.identifier.clone(),
};
user_sso.save(conn).await?;
} }
if let Some(au) = AC_CACHE.get(state) { if !CONFIG.sso_auth_only_not_session() {
AC_CACHE.invalidate(state); let now = Utc::now();
Ok(au)
let (ap_nbf, ap_exp) =
match (decode_token_claims("access_token", &auth_user.access_token), auth_user.expires_in) {
(Ok(ap), _) => (ap.nbf(), ap.exp),
(Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()),
_ => err!("Non jwt access_token and empty expires_in"),
};
let access_claims =
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now);
_create_auth_tokens(device, auth_user.refresh_token, access_claims, auth_user.access_token)
} else { } else {
err!("Failed to retrieve user info from sso cache") Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id))
} }
} }
+29 -26
View File
@@ -1,23 +1,19 @@
use regex::Regex; use std::{borrow::Cow, sync::LazyLock, time::Duration};
use std::borrow::Cow;
use std::time::Duration;
use url::Url;
use mini_moka::sync::Cache; use mini_moka::sync::Cache;
use once_cell::sync::Lazy; use openidconnect::{core::*, reqwest, *};
use openidconnect::core::*; use regex::Regex;
use openidconnect::reqwest; use url::Url;
use openidconnect::*;
use crate::{ use crate::{
api::{ApiResult, EmptyResult}, api::{ApiResult, EmptyResult},
db::models::SsoNonce, db::models::SsoAuth,
sso::{OIDCCode, OIDCState}, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
CONFIG, CONFIG,
}; };
static CLIENT_CACHE_KEY: Lazy<String> = Lazy::new(|| "sso-client".to_string()); static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_string());
static CLIENT_CACHE: Lazy<Cache<String, Client>> = Lazy::new(|| { static CLIENT_CACHE: LazyLock<Cache<String, Client>> = LazyLock::new(|| {
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build() Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build()
}); });
@@ -111,7 +107,11 @@ impl Client {
} }
// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). // The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
pub async fn authorize_url(state: OIDCState, redirect_uri: String) -> ApiResult<(Url, SsoNonce)> { pub async fn authorize_url(
state: OIDCState,
client_challenge: OIDCCodeChallenge,
redirect_uri: String,
) -> ApiResult<(Url, SsoAuth)> {
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
@@ -126,22 +126,21 @@ impl Client {
.add_scopes(scopes) .add_scopes(scopes)
.add_extra_params(CONFIG.sso_authorize_extra_params_vec()); .add_extra_params(CONFIG.sso_authorize_extra_params_vec());
let verifier = if CONFIG.sso_pkce() { if CONFIG.sso_pkce() {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); auth_req = auth_req
auth_req = auth_req.set_pkce_challenge(pkce_challenge); .add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into())
Some(pkce_verifier.into_secret()) .add_extra_param("code_challenge_method", "S256");
} else { }
None
};
let (auth_url, _, nonce) = auth_req.url(); let (auth_url, _, nonce) = auth_req.url();
Ok((auth_url, SsoNonce::new(state, nonce.secret().clone(), verifier, redirect_uri))) Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri)))
} }
pub async fn exchange_code( pub async fn exchange_code(
&self, &self,
code: OIDCCode, code: OIDCCode,
nonce: SsoNonce, client_verifier: OIDCCodeVerifier,
sso_auth: &SsoAuth,
) -> ApiResult<( ) -> ApiResult<(
StandardTokenResponse< StandardTokenResponse<
IdTokenFields< IdTokenFields<
@@ -159,17 +158,21 @@ impl Client {
let mut exchange = self.core_client.exchange_code(oidc_code); let mut exchange = self.core_client.exchange_code(oidc_code);
let verifier = PkceCodeVerifier::new(client_verifier.into());
if CONFIG.sso_pkce() { if CONFIG.sso_pkce() {
match nonce.verifier { exchange = exchange.set_pkce_verifier(verifier);
None => err!(format!("Missing verifier in the DB nonce table")), } else {
Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret.clone())), let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
err!(format!("PKCE client challenge failed"))
// Might need to notify admin ? how ?
} }
} }
match exchange.request_async(&self.http_client).await { match exchange.request_async(&self.http_client).await {
Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)), Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)),
Ok(token_response) => { Ok(token_response) => {
let oidc_nonce = Nonce::new(nonce.nonce); let oidc_nonce = Nonce::new(sso_auth.nonce.clone());
let id_token = match token_response.extra_fields().id_token() { let id_token = match token_response.extra_fields().id_token() {
None => err!("Token response did not contain an id_token"), None => err!("Token response did not contain an id_token"),
+17
View File
@@ -58,3 +58,20 @@ img {
.abbr-badge { .abbr-badge {
cursor: help; cursor: help;
} }
.theme-icon,
.theme-icon-active {
display: inline-flex;
flex: 0 0 1.75em;
justify-content: center;
}
.theme-icon svg,
.theme-icon-active svg {
width: 1.25em;
height: 1.25em;
min-width: 1.25em;
min-height: 1.25em;
display: block;
overflow: visible;
}
+12 -3
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
/* eslint-env es2017, browser */ /* eslint-env es2017, browser */
/* exported BASE_URL, _post */ /* exported BASE_URL, _post _delete */
function getBaseUrl() { function getBaseUrl() {
// If the base URL is `https://vaultwarden.example.com/base/path/admin/`, // If the base URL is `https://vaultwarden.example.com/base/path/admin/`,
@@ -106,7 +106,11 @@ const showActiveTheme = (theme, focus = false) => {
const themeSwitcherText = document.querySelector("#bd-theme-text"); const themeSwitcherText = document.querySelector("#bd-theme-text");
const activeThemeIcon = document.querySelector(".theme-icon-active use"); const activeThemeIcon = document.querySelector(".theme-icon-active use");
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`); const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
const svgOfActiveBtn = btnToActive.querySelector("span use").textContent; if (!btnToActive) {
return;
}
const btnIconUse = btnToActive ? btnToActive.querySelector("[data-theme-icon-use]") : null;
const iconHref = btnIconUse ? btnIconUse.getAttribute("href") || btnIconUse.getAttribute("xlink:href") : null;
document.querySelectorAll("[data-bs-theme-value]").forEach(element => { document.querySelectorAll("[data-bs-theme-value]").forEach(element => {
element.classList.remove("active"); element.classList.remove("active");
@@ -115,7 +119,12 @@ const showActiveTheme = (theme, focus = false) => {
btnToActive.classList.add("active"); btnToActive.classList.add("active");
btnToActive.setAttribute("aria-pressed", "true"); btnToActive.setAttribute("aria-pressed", "true");
activeThemeIcon.textContent = svgOfActiveBtn;
if (iconHref && activeThemeIcon) {
activeThemeIcon.setAttribute("href", iconHref);
activeThemeIcon.setAttribute("xlink:href", iconHref);
}
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`; const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel); themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
/* eslint-env es2017, browser, jquery */ /* eslint-env es2017, browser, jquery */
/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */ /* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */
function deleteUser(event) { function deleteUser(event) {
event.preventDefault(); event.preventDefault();
+2 -5
View File
@@ -1,5 +1,5 @@
/*! /*!
* Bootstrap v5.3.7 (https://getbootstrap.com/) * Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/ */
@@ -647,7 +647,7 @@
* Constants * Constants
*/ */
const VERSION = '5.3.7'; const VERSION = '5.3.8';
/** /**
* Class definition * Class definition
@@ -3690,9 +3690,6 @@
this._element.setAttribute('aria-expanded', 'false'); this._element.setAttribute('aria-expanded', 'false');
Manipulator.removeDataAttribute(this._menu, 'popper'); Manipulator.removeDataAttribute(this._menu, 'popper');
EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget); EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget);
// Explicitly return focus to the trigger element
this._element.focus();
} }
_getConfig(config) { _getConfig(config) {
config = super._getConfig(config); config = super._getConfig(config);
+6 -1
View File
@@ -1,6 +1,6 @@
@charset "UTF-8"; @charset "UTF-8";
/*! /*!
* Bootstrap v5.3.7 (https://getbootstrap.com/) * Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors * Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/ */
@@ -547,6 +547,10 @@ legend + * {
-webkit-appearance: textfield; -webkit-appearance: textfield;
outline-offset: -2px; outline-offset: -2px;
} }
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
/* rtl:raw: /* rtl:raw:
[type="tel"], [type="tel"],
@@ -6208,6 +6212,7 @@ textarea.form-control-lg {
.spinner-grow, .spinner-grow,
.spinner-border { .spinner-border {
display: inline-block; display: inline-block;
flex-shrink: 0;
width: var(--bs-spinner-width); width: var(--bs-spinner-width);
height: var(--bs-spinner-height); height: var(--bs-spinner-height);
vertical-align: var(--bs-spinner-vertical-align); vertical-align: var(--bs-spinner-vertical-align);
+9 -10
View File
@@ -4,20 +4,21 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-2.3.2 * https://datatables.net/download/#bs5/dt-2.3.5
* *
* Included libraries: * Included libraries:
* DataTables 2.3.2 * DataTables 2.3.5
*/ */
:root { :root {
--dt-row-selected: 13, 110, 253; --dt-row-selected: 13, 110, 253;
--dt-row-selected-text: 255, 255, 255; --dt-row-selected-text: 255, 255, 255;
--dt-row-selected-link: 9, 10, 11; --dt-row-selected-link: 228, 228, 228;
--dt-row-stripe: 0, 0, 0; --dt-row-stripe: 0, 0, 0;
--dt-row-hover: 0, 0, 0; --dt-row-hover: 0, 0, 0;
--dt-column-ordering: 0, 0, 0; --dt-column-ordering: 0, 0, 0;
--dt-header-align-items: center; --dt-header-align-items: center;
--dt-header-vertical-align: middle;
--dt-html-background: white; --dt-html-background: white;
} }
:root.dark { :root.dark {
@@ -112,7 +113,7 @@ table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order,
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order { table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order {
position: relative; position: relative;
width: 12px; width: 12px;
height: 20px; height: 24px;
} }
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
@@ -144,7 +145,8 @@ table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after { table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
opacity: 0.6; opacity: 0.6;
} }
table.dataTable thead > tr > th.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) span.dt-column-order:empty, table.dataTable thead > tr > th.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled span.dt-column-order:before,
table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) span.dt-column-order:empty,
table.dataTable thead > tr > td.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > td.sorting_desc_disabled span.dt-column-order:after,
table.dataTable thead > tr > td.sorting_asc_disabled span.dt-column-order:before { table.dataTable thead > tr > td.sorting_asc_disabled span.dt-column-order:before {
display: none; display: none;
@@ -340,6 +342,7 @@ table.dataTable thead td,
table.dataTable tfoot th, table.dataTable tfoot th,
table.dataTable tfoot td { table.dataTable tfoot td {
text-align: left; text-align: left;
vertical-align: var(--dt-header-vertical-align);
} }
table.dataTable thead th.dt-head-left, table.dataTable thead th.dt-head-left,
table.dataTable thead td.dt-head-left, table.dataTable thead td.dt-head-left,
@@ -422,10 +425,6 @@ table.dataTable tbody td.dt-body-nowrap {
white-space: nowrap; white-space: nowrap;
} }
:root {
--dt-header-align-items: flex-end;
}
/*! Bootstrap 5 integration for DataTables /*! Bootstrap 5 integration for DataTables
* *
* ©2020 SpryMedia Ltd, all rights reserved. * ©2020 SpryMedia Ltd, all rights reserved.
@@ -453,7 +452,7 @@ table.table.dataTable > tbody > tr.selected > * {
color: rgb(var(--dt-row-selected-text)); color: rgb(var(--dt-row-selected-text));
} }
table.table.dataTable > tbody > tr.selected a { table.table.dataTable > tbody > tr.selected a {
color: rgb(9, 10, 11); color: rgb(228, 228, 228);
color: rgb(var(--dt-row-selected-link)); color: rgb(var(--dt-row-selected-link));
} }
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * { table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
+333 -279
View File
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -11,6 +11,21 @@
<script src="{{urlpath}}/vw_static/admin.js"></script> <script src="{{urlpath}}/vw_static/admin.js"></script>
</head> </head>
<body> <body>
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="vw-icon-sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5" fill="currentColor"/>
<g stroke="currentColor" stroke-linecap="round" stroke-width="1.5">
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</g>
</symbol>
<symbol id="vw-icon-moon" viewBox="0 0 24 24">
<path fill="currentColor" stroke-width=".8" d="M18.4 17.8A9 8.6 0 0 1 13 2a10.5 10 0 1 0 9 14.4 9.4 9 0 0 1-3.6 1.4"/>
</symbol>
<symbol id="vw-icon-auto" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path fill="currentColor" d="M12 3a9 9 0 1 1 0 18Z"/>
</symbol>
</svg>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4 shadow fixed-top"> <nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4 shadow fixed-top">
<div class="container-xl"> <div class="container-xl">
<a class="navbar-brand" href="{{urlpath}}/admin"><img class="vaultwarden-icon" src="{{urlpath}}/vw_static/vaultwarden-icon.png" alt="V">aultwarden Admin</a> <a class="navbar-brand" href="{{urlpath}}/admin"><img class="vaultwarden-icon" src="{{urlpath}}/vw_static/vaultwarden-icon.png" alt="V">aultwarden Admin</a>
@@ -39,14 +54,16 @@
</li> </li>
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav mx-3">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<button <button
class="btn btn-link nav-link py-0 px-0 px-md-2 dropdown-toggle d-flex align-items-center" class="btn btn-link nav-link py-0 px-0 px-md-2 dropdown-toggle d-flex align-items-center"
id="bd-theme" type="button" aria-expanded="false" data-bs-toggle="dropdown" id="bd-theme" type="button" aria-expanded="false" data-bs-toggle="dropdown"
data-bs-display="static" aria-label="Toggle theme (auto)"> data-bs-display="static" aria-label="Toggle theme (auto)">
<span class="my-1 fs-4 theme-icon-active"> <span class="my-1 fs-4 theme-icon-active">
<use>&#9775;</use> <svg class="vw-theme-icon" focusable="false" aria-hidden="true">
<use data-theme-icon-use href="#vw-icon-auto"></use>
</svg>
</span> </span>
<span class="d-md-none ms-2" id="bd-theme-text">Toggle theme</span> <span class="d-md-none ms-2" id="bd-theme-text">Toggle theme</span>
</button> </button>
@@ -55,7 +72,9 @@
<button type="button" class="dropdown-item d-flex align-items-center" <button type="button" class="dropdown-item d-flex align-items-center"
data-bs-theme-value="light" aria-pressed="false"> data-bs-theme-value="light" aria-pressed="false">
<span class="me-2 fs-4 theme-icon"> <span class="me-2 fs-4 theme-icon">
<use>&#9728;</use> <svg class="vw-theme-icon" focusable="false" aria-hidden="true">
<use data-theme-icon-use href="#vw-icon-sun"></use>
</svg>
</span> </span>
Light Light
</button> </button>
@@ -64,7 +83,9 @@
<button type="button" class="dropdown-item d-flex align-items-center" <button type="button" class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark" aria-pressed="false"> data-bs-theme-value="dark" aria-pressed="false">
<span class="me-2 fs-4 theme-icon"> <span class="me-2 fs-4 theme-icon">
<use>&starf;</use> <svg class="vw-theme-icon" focusable="false" aria-hidden="true">
<use data-theme-icon-use href="#vw-icon-moon"></use>
</svg>
</span> </span>
Dark Dark
</button> </button>
@@ -73,7 +94,9 @@
<button type="button" class="dropdown-item d-flex align-items-center active" <button type="button" class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto" aria-pressed="true"> data-bs-theme-value="auto" aria-pressed="true">
<span class="me-2 fs-4 theme-icon"> <span class="me-2 fs-4 theme-icon">
<use>&#9775;</use> <svg class="vw-theme-icon" focusable="false" aria-hidden="true">
<use data-theme-icon-use href="#vw-icon-auto"></use>
</svg>
</span> </span>
Auto Auto
</button> </button>
@@ -1,4 +1,4 @@
You have been removed from {{{org_name}}} Your access to {{{org_name}}} has been revoked
<!----------------> <!---------------->
Your user account has been removed from the *{{org_name}}* organization because you are a part of another organization. The {{org_name}} organization has enabled a policy that prevents users from being a part of multiple organizations. Before you can re-join this organization you need to leave all other organizations or join with a different account. Your access to the *{{org_name}}* organization has been revoked because you are a part of another organization. The {{org_name}} organization has enabled a policy that prevents users from being a part of multiple organizations. Before your access can be restored you need to leave all other organizations or join with a different account.
{{> email/email_footer_text }} {{> email/email_footer_text }}
@@ -1,10 +1,10 @@
You have been removed from {{{org_name}}} Your access to {{{org_name}}} has been revoked
<!----------------> <!---------------->
{{> email/email_header }} {{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center"> <td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
Your user account has been removed from the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization because you are a part of another organization. The {{org_name}} organization has enabled a policy that prevents users from being a part of multiple organizations. Before you can re-join this organization you need to leave all other organizations or join with a different account. Your access to the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization has been revoked because you are a part of another organization. The {{org_name}} organization has enabled a policy that prevents users from being a part of multiple organizations. Before your access can be restored you need to leave all other organizations or join with a different account.
</td> </td>
</tr> </tr>
</table> </table>
+1 -1
View File
@@ -842,7 +842,7 @@ pub fn is_global(ip: std::net::IpAddr) -> bool {
/// Saves a Rocket temporary file to the OpenDAL Operator at the given path. /// Saves a Rocket temporary file to the OpenDAL Operator at the given path.
pub async fn save_temp_file( pub async fn save_temp_file(
path_type: PathType, path_type: &PathType,
path: &str, path: &str,
temp_file: rocket::fs::TempFile<'_>, temp_file: rocket::fs::TempFile<'_>,
overwrite: bool, overwrite: bool,