Compare commits

...

21 Commits

Author SHA1 Message Date
Mathijs van Veluw 7cf0c5d67e Update web-vault and crates (#7121)
- Updated web-vault to v2026.3.1
  Added a new endpoint needed for the admin console to work
- Updated all crates including webpki CVE fixes - Closes #7115
- Updated GHA

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-22 14:29:35 +02:00
Mathijs van Veluw b04ed75f9f Update Rust, Crates, GHA and fix a DNS issue (#7108)
* Update Rust, Crates and GHA

- Updated Rust to v1.95.0
- Updated all the crates
- Update GitHub Actions

With the crate updates, hickory-resolver was updated which needed some changes.
During testing I found a bug with the fallback resolving from Tokio.
The resolver doesn't work if it receives only a `&str`, it needs a `port` too.
This fixed the resolving if Hickory failed to load.

Also, Hickory switched the resolving to prefer IPv6. While this is nice, it could break or slowdown resolving for IPv4 only environments.
Since we already have a flag to prefer IPv6, we check if this is set, else resolve IPv4 first and IPv6 afterwards.

Also, we returned just 1 IpAddr record, and ignored the rest. This could mean, a failed attempt to connect if the first IP endpoint has issues.
Same if the first records is IPv6 but the server doesn't support this, it never tried a possible returned IPv4 address.

We now return a full list of the resolved records unless one of the records matched a filtered address, than the whole resolving is ignored as was previously the case.

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

* Adjust resolver builder path

Changed the way the resolver is constructed.
This way the default is always selected no matter which part of the hickory build fails.

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-18 15:03:41 +02:00
Mathijs van Veluw 0ed8ab68f7 Fix invalid refresh token response (#7105)
If the refresh token is invalid or expired we need to return a specific JSON and HTTP Status, else the clients will not logout.

Fixes #7060
Closes #7080

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-16 18:42:13 +02:00
Mathijs van Veluw dfebee57ec Fix recovery-code not working (#7102)
This commit fixes an issue where the recovery code isn't working anymore.

Fixes #7096

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-15 20:49:58 +02:00
Timshel bfe420a018 Dummy org Master password policy auth fix (#7097)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-04-15 20:44:55 +02:00
Mathijs van Veluw e7e4b9a86d Fix 2FA for Android (#7093)
The `RecoveryCode` Type should not be sent as a valid type which can be used.
Fixes #7092

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-13 21:47:20 +02:00
Mathijs van Veluw bb549986e6 Fix MFA Remember (#7085)
Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-12 21:04:32 +02:00
Mathijs van Veluw 39954af96a Crate and GHA updates (#7081)
Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-11 20:27:07 +02:00
idontneedonetho a6b43651ca Fix windows build issues (#7065)
Need to set signals to UNIX only so we can build on windows.
2026-04-08 15:35:18 +02:00
qaz741wsd856 3f28b583db Fix logout push identifiers and send logout before clearing devices (#7047)
* Fix logout push identifiers and send logout before clearing devices

* Refactor logout function parameters

* Fix parameters in logout notification functions
2026-04-05 22:43:58 +02:00
Hex d4f67429d6 Do not display unavailable 2FA options (#7013)
* do not display unavailable 2FA options

* use existing function to check webauthn support

* clarity in 2fa skip code
2026-04-05 22:43:06 +02:00
Hex fc43737868 Handle SIGTERM and SIGQUIT shutdown signals. (#7008)
* handle more shutdown signals

* disable Rocket's built-in signal handlers
2026-04-05 22:41:14 +02:00
Aaron Brager 43df0fb7f4 Change SQLite backup to use VACUUM INTO query (#6989)
* Refactor SQLite backup to use VACUUM INTO query

Replaced manual file creation for SQLite backup with a VACUUM INTO query.

* Fix VACUUM INTO query error handling
2026-04-05 22:40:00 +02:00
Stefan Melmuk d29cd29f55 prevent managers from creating collections (#6890)
managers without the access_all flag should not be able to create
collections. the manage all collections permission actually consists of
three separate custom permissions that have not been implemented yet for
more fine-grain access control.
2026-04-05 22:39:33 +02:00
Mathijs van Veluw 2811df2953 Fix Send icons (#7051)
Send uses icons to display if it is protected by password or not.
Bitwarden has added a feature to use email with an OTP for newer versions.
Vaultwarden does not yet support this, but this commit adds an Enum with all 3 the options.

The email option currently needs a feature-flag and newer web-vault/clients.

For now, this will at least fix the display of icons.

Fixes #6976

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-05 22:35:21 +02:00
Daniel 8f0e99b875 Disable deployments for release env (#7033)
As according to the docs:
https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments#using-environments-without-deployments

This is useful when you want to use environments for:

Organizing secrets—group related secrets under an environment name without creating deployment records.
Access control—restrict which branches can use certain secrets via environment branch policies, without deployment tracking.
CI and testing jobs—reference an environment for its configuration without adding noise to the deployment history.
2026-04-01 23:04:34 +02:00
Mathijs van Veluw f07a91141a Fix empty string FolderId (#7048)
In newer versions of Bitwarden Clients instead of using `null` the folder_id will be an empty string.
This commit adds a special deserialize_with function to keep the same way of working code-wise.

Fixes #6962

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-01 23:04:10 +02:00
Mathijs van Veluw 787822854c Misc org fixes (#7032)
* Split vault org/personal purge endpoints

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

* Adjust several other call-sites

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

* Several other misc fixes

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

* Add some more validation for groups, collections and memberships

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-03-29 23:15:48 +02:00
Mathijs van Veluw f62a7a66c8 Rotate refresh-tokens on sstamp reset (#7031)
When a security-stamp gets reset/rotated we should also rotate all device refresh-tokens to invalidate them.
Else clients are still able to use old refresh tokens.

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-03-29 22:43:36 +02:00
Daniel 3a1378f469 Switch to attest action (#7017)
From the `attest-build-provenance` changelog:
> As of version 4, actions/attest-build-provenance is simply a wrapper on top of actions/attest.

> Existing applications may continue to use the attest-build-provenance action, but new implementations should use actions/attest instead. Please see the actions/attest repository for usage information.
2026-03-29 22:22:27 +02:00
Mathijs van Veluw dde63e209e Misc Updates (#7027)
- Update Rust to v1.94.1
- Updated all crates
- Update GHA
- Update global domains and ensure a newline is always present

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-03-29 22:21:39 +02:00
44 changed files with 1252 additions and 748 deletions
-1
View File
@@ -1,3 +1,2 @@
# Ignore vendored scripts in GitHub stats
src/static/scripts/* linguist-vendored
+19 -15
View File
@@ -38,7 +38,9 @@ jobs:
docker-build:
name: Build Vaultwarden containers
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
environment: release
environment:
name: release
deployment: false
permissions:
packages: write # Needed to upload packages and artifacts
contents: read
@@ -104,7 +106,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -119,7 +121,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -135,7 +137,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -183,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@82490499d2e5613fcead7e128237ef0b0ea210f7 # v7.0.0
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -220,7 +222,7 @@ jobs:
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: ${{ runner.temp }}/digests/*
@@ -235,12 +237,12 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
- name: Upload binaries as artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -249,7 +251,9 @@ jobs:
name: Merge manifests
runs-on: ubuntu-latest
needs: docker-build
environment: release
environment:
name: release
deployment: false
permissions:
packages: write # Needed to upload packages and artifacts
attestations: write # Needed to generate an artifact attestation for a build
@@ -268,7 +272,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -283,7 +287,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -299,7 +303,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -361,7 +365,7 @@ jobs:
# Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -369,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -377,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
persist-credentials: false
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
sarif_file: 'trivy-results.sarif'
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
# 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@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
+55 -53
View File
@@ -1,58 +1,60 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
hooks:
- id: check-yaml
- id: check-json
- id: check-toml
- id: mixed-line-ending
args: ["--fix=no"]
- id: end-of-file-fixer
exclude: "(.*js$|.*css$)"
- id: check-case-conflict
- id: check-merge-conflict
- id: detect-private-key
- id: check-symlinks
- id: forbid-submodules
- repo: local
- id: check-yaml
- id: check-json
- id: check-toml
- id: mixed-line-ending
args: [ "--fix=no" ]
- id: end-of-file-fixer
exclude: "(.*js$|.*css$)"
- id: check-case-conflict
- id: check-merge-conflict
- id: detect-private-key
- id: check-symlinks
- id: forbid-submodules
# 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: cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
hooks:
- id: fmt
name: fmt
description: Format files with cargo fmt.
entry: cargo fmt
language: system
always_run: true
pass_filenames: false
args: ["--", "--check"]
- id: cargo-test
name: cargo test
description: Test the package for errors.
entry: cargo test
language: system
args: ["--features", "sqlite,mysql,postgresql", "--"]
types_or: [rust, file]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
description: Lint Rust sources
entry: cargo clippy
language: system
args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"]
types_or: [rust, file]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: check-docker-templates
name: check docker templates
description: Check if the Docker templates are updated
language: system
entry: sh
args:
- "-c"
- "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: 631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
hooks:
- id: typos
- id: typos
- repo: local
hooks:
- id: fmt
name: fmt
description: Format files with cargo fmt.
entry: cargo fmt
language: system
always_run: true
pass_filenames: false
args: [ "--", "--check" ]
- id: cargo-test
name: cargo test
description: Test the package for errors.
entry: cargo test
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
description: Lint Rust sources
entry: cargo clippy
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: check-docker-templates
name: check docker templates
description: Check if the Docker templates are updated
language: system
entry: sh
args:
- "-c"
- "cd docker && make"
Generated
+481 -344
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,6 +1,6 @@
[workspace.package]
edition = "2021"
rust-version = "1.92.0"
rust-version = "1.93.0"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@@ -79,7 +79,7 @@ dashmap = "6.1.0"
# Async futures
futures = "0.3.32"
tokio = { version = "1.50.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
tokio-util = { version = "0.7.18", features = ["compat"]}
# A generic serialization/deserialization framework
@@ -98,12 +98,12 @@ diesel-derive-newtype = "2.1.2"
libsqlite3-sys = { version = "0.36.0", features = ["bundled"], optional = true }
# Crypto-related libraries
rand = "0.10.0"
rand = "0.10.1"
ring = "0.17.14"
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.22.0", features = ["v4"] }
uuid = { version = "1.23.1", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
@@ -136,7 +136,7 @@ webauthn-rs-core = "0.5.4"
url = "2.5.8"
# Email libraries
lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9"
@@ -145,7 +145,7 @@ handlebars = { version = "6.4.0", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
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.26.0"
# Favicon extraction libraries
html5gum = "0.8.3"
@@ -162,7 +162,7 @@ cookie = "0.18.1"
cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL
openssl = "0.10.76"
openssl = "0.10.78"
# CLI argument parsing
pico-args = "0.5.0"
@@ -176,11 +176,11 @@ openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] }
moka = { version = "0.12.15", features = ["future"] }
# Check client versions for specific features.
semver = "1.0.27"
semver = "1.0.28"
# Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true }
mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true }
which = "8.0.2"
@@ -198,9 +198,9 @@ opendal = { version = "0.55.0", features = ["services-fs"], default-features = f
# For retrieving AWS credentials, including temporary SSO credentials
anyhow = { version = "1.0.102", optional = true }
aws-config = { version = "1.8.15", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
aws-credential-types = { version = "1.2.14", optional = true }
aws-smithy-runtime-api = { version = "1.11.6", optional = true }
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
http = { version = "1.4.0", optional = true }
reqsign = { version = "0.16.5", optional = true }
+1 -1
View File
@@ -2,4 +2,4 @@
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/db/schema.rs"
file = "src/db/schema.rs"
+3 -3
View File
@@ -1,11 +1,11 @@
---
vault_version: "v2026.2.0"
vault_image_digest: "sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447"
vault_version: "v2026.3.1"
vault_image_digest: "sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767"
# 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
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.94.0 # Rust version to be used
rust_version: 1.95.0 # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.23" # Alpine version to be used
# For which platforms/architectures will we try to build images
+10 -10
View File
@@ -19,23 +19,23 @@
# - 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.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0
# [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447
# [docker.io/vaultwarden/web-vault:v2026.2.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767
# [docker.io/vaultwarden/web-vault:v2026.3.1]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault
########################## ALPINE BUILD IMAGES ##########################
## 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
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.94.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.94.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.94.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.94.0 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
+7 -7
View File
@@ -19,15 +19,15 @@
# - 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.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0
# [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447
# [docker.io/vaultwarden/web-vault:v2026.2.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767
# [docker.io/vaultwarden/web-vault:v2026.3.1]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.94.0-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
COPY --from=xx / /
ARG TARGETARCH
ARG TARGETVARIANT
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.94.0"
channel = "1.95.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+15 -5
View File
@@ -30,6 +30,7 @@ use crate::{
error::{Error, MapResult},
http_client::make_http_request,
mail,
sso::FAKE_SSO_IDENTIFIER,
util::{
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
@@ -315,7 +316,11 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if CONFIG.mail_enabled() {
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
let org_id: OrganizationId = if CONFIG.sso_enabled() {
FAKE_SSO_IDENTIFIER.into()
} else {
FAKE_ADMIN_UUID.into()
};
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else {
@@ -472,7 +477,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
}
Device::delete_all_by_user(&user.uuid, &conn).await?;
user.reset_security_stamp();
user.reset_security_stamp(&conn).await?;
user.save(&conn).await
}
@@ -480,14 +485,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
#[post("/users/<user_id>/disable", format = "application/json")]
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?;
Device::delete_all_by_user(&user.uuid, &conn).await?;
user.reset_security_stamp();
user.reset_security_stamp(&conn).await?;
user.enabled = false;
let save_result = user.save(&conn).await;
nt.send_logout(&user, None, &conn).await;
Device::delete_all_by_user(&user.uuid, &conn).await?;
save_result
}
@@ -517,7 +523,11 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
}
if CONFIG.mail_enabled() {
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
let org_id: OrganizationId = if CONFIG.sso_enabled() {
FAKE_SSO_IDENTIFIER.into()
} else {
FAKE_ADMIN_UUID.into()
};
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else {
+30 -24
View File
@@ -22,7 +22,7 @@ use crate::{
DbConn,
},
mail,
util::{format_date, NumberOrString},
util::{deser_opt_nonempty_str, format_date, NumberOrString},
CONFIG,
};
@@ -106,7 +106,6 @@ pub struct RegisterData {
name: Option<String>,
#[allow(dead_code)]
organization_user_id: Option<MembershipId>,
// Used only from the register/finish endpoint
@@ -296,7 +295,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
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, &conn).await?;
user.password_hint = password_hint;
// Add extra fields if present
@@ -364,7 +363,9 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
Some(data.key),
false,
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
);
&conn,
)
.await?;
user.password_hint = password_hint;
if let Some(keys) = data.keys {
@@ -373,15 +374,13 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
}
if let Some(identifier) = data.org_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 {
None => err!("Failed to retrieve the associated organization"),
Some(org) => org,
if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
err!("Failed to retrieve the associated organization")
};
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await {
None => err!("Failed to retrieve the invitation"),
Some(org) => org,
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
err!("Failed to retrieve the invitation")
};
accept_org_invite(&user, membership, None, &conn).await?;
@@ -532,14 +531,16 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
String::from("get_public_keys"),
String::from("get_api_webauthn"),
]),
);
&conn,
)
.await?;
let save_result = user.save(&conn).await;
// Prevent logging out the client where the user requested this endpoint from.
// If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this.
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
nt.send_logout(&user, Some(&headers.device), &conn).await;
save_result
}
@@ -579,7 +580,6 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
Ok(())
}
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthenticationData {
@@ -588,7 +588,6 @@ struct AuthenticationData {
master_password_authentication_hash: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UnlockData {
@@ -597,11 +596,12 @@ struct UnlockData {
master_key_wrapped_user_key: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangeKdfData {
#[allow(dead_code)]
new_master_password_hash: String,
#[allow(dead_code)]
key: String,
authentication_data: AuthenticationData,
unlock_data: UnlockData,
@@ -633,10 +633,12 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
Some(data.unlock_data.master_key_wrapped_user_key),
true,
None,
);
&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), &conn).await;
save_result
}
@@ -647,6 +649,7 @@ struct UpdateFolderData {
// There is a bug in 2024.3.x which adds a `null` item.
// To bypass this we allow a Option here, but skip it during the updates
// See: https://github.com/bitwarden/clients/issues/8453
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
id: Option<FolderId>,
name: String,
}
@@ -900,14 +903,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
true,
None,
);
&conn,
)
.await?;
let save_result = user.save(&conn).await;
// Prevent logging out the client where the user requested this endpoint from.
// If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this.
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
nt.send_logout(&user, Some(&headers.device), &conn).await;
save_result
}
@@ -919,12 +924,13 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
data.validate(&user, true, &conn).await?;
Device::delete_all_by_user(&user.uuid, &conn).await?;
user.reset_security_stamp();
user.reset_security_stamp(&conn).await?;
let save_result = user.save(&conn).await;
nt.send_logout(&user, None, &conn).await;
Device::delete_all_by_user(&user.uuid, &conn).await?;
save_result
}
@@ -1042,7 +1048,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
user.email_new = None;
user.email_new_token = None;
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
let save_result = user.save(&conn).await;
@@ -1254,7 +1260,7 @@ struct SecretVerificationRequest {
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
if user.password_iterations < CONFIG.password_iterations() {
user.password_iterations = CONFIG.password_iterations();
user.set_password(pwd_hash, None, false, None);
user.set_password(pwd_hash, None, false, None, conn).await?;
if let Err(e) = user.save(conn).await {
error!("Error updating user: {e:#?}");
+68 -53
View File
@@ -11,10 +11,10 @@ use rocket::{
use serde_json::Value;
use crate::auth::ClientVersion;
use crate::util::{save_temp_file, NumberOrString};
use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
use crate::{
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
auth::Headers,
auth::{Headers, OrgIdGuard, OwnerHeaders},
config::PathType,
crypto,
db::{
@@ -86,7 +86,8 @@ pub fn routes() -> Vec<Route> {
restore_cipher_put_admin,
restore_cipher_selected,
restore_cipher_selected_admin,
delete_all,
purge_org_vault,
purge_personal_vault,
move_cipher_selected,
move_cipher_selected_put,
put_collections2_update,
@@ -247,6 +248,7 @@ pub struct CipherData {
// Id is optional as it is included only in bulk share
pub id: Option<CipherId>,
// Folder id is not included in import
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
pub folder_id: Option<FolderId>,
// TODO: Some of these might appear all the time, no need for Option
#[serde(alias = "organizationID")]
@@ -296,6 +298,7 @@ pub struct CipherData {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
folder_id: Option<FolderId>,
favorite: bool,
}
@@ -425,7 +428,7 @@ pub async fn update_cipher_from_data(
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
if let Some(org_id) = data.organization_id {
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
None => err!("You don't have permission to add item to organization"),
Some(member) => {
if shared_to_collections.is_some()
@@ -1568,6 +1571,7 @@ async fn restore_cipher_selected(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MoveCipherData {
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
folder_id: Option<FolderId>,
ids: Vec<CipherId>,
}
@@ -1642,9 +1646,51 @@ struct OrganizationIdData {
org_id: OrganizationId,
}
// Use the OrgIdGuard here, to ensure there an organization id present.
// If there is no organization id present, it should be forwarded to purge_personal_vault.
// This guard needs to be the first argument, else OwnerHeaders will be triggered which will logout the user.
#[post("/ciphers/purge?<organization..>", data = "<data>")]
async fn delete_all(
organization: Option<OrganizationIdData>,
async fn purge_org_vault(
_org_id_guard: OrgIdGuard,
organization: OrganizationIdData,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
if organization.org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await {
Some(member) if member.atype == MembershipType::Owner => {
Cipher::delete_all_by_organization(&organization.org_id, &conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
log_event(
EventType::OrganizationPurgedVault as i32,
&organization.org_id,
&organization.org_id,
&user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(())
}
_ => err!("You don't have permission to purge the organization vault"),
}
}
#[post("/ciphers/purge", data = "<data>")]
async fn purge_personal_vault(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
@@ -1655,52 +1701,18 @@ async fn delete_all(
data.validate(&user, true, &conn).await?;
match organization {
Some(org_data) => {
// Organization ID in query params, purging organization vault
match Membership::find_by_user_and_org(&user.uuid, &org_data.org_id, &conn).await {
None => err!("You don't have permission to purge the organization vault"),
Some(member) => {
if member.atype == MembershipType::Owner {
Cipher::delete_all_by_organization(&org_data.org_id, &conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
log_event(
EventType::OrganizationPurgedVault as i32,
&org_data.org_id,
&org_data.org_id,
&user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(())
} else {
err!("You don't have permission to purge the organization vault");
}
}
}
}
None => {
// No organization ID in query params, purging user vault
// Delete ciphers and their attachments
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
cipher.delete(&conn).await?;
}
// Delete folders
for f in Folder::find_by_user(&user.uuid, &conn).await {
f.delete(&conn).await?;
}
user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
Ok(())
}
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
cipher.delete(&conn).await?;
}
for f in Folder::find_by_user(&user.uuid, &conn).await {
f.delete(&conn).await?;
}
user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
Ok(())
}
#[derive(PartialEq)]
@@ -1980,8 +1992,11 @@ impl CipherSyncData {
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> =
Membership::find_by_user(user_id, conn).await.into_iter().map(|m| (m.org_uuid.clone(), m)).collect();
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
+1 -1
View File
@@ -653,7 +653,7 @@ async fn password_emergency_access(
};
// change grantor_user password
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None);
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins
+1 -1
View File
@@ -240,7 +240,7 @@ async fn _log_user_event(
ip: &IpAddr,
conn: &DbConn,
) {
let memberships = Membership::find_by_user(user_id, conn).await;
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
// Upstream saves the event also without any org_id.
+2
View File
@@ -8,6 +8,7 @@ use crate::{
models::{Folder, FolderId},
DbConn,
},
util::deser_opt_nonempty_str,
};
pub fn routes() -> Vec<rocket::Route> {
@@ -38,6 +39,7 @@ async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> Json
#[serde(rename_all = "camelCase")]
pub struct FolderData {
pub name: String,
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
pub id: Option<FolderId>,
}
+165 -72
View File
@@ -20,7 +20,8 @@ use crate::{
DbConn,
},
mail,
util::{convert_json_key_lcase_first, get_uuid, NumberOrString},
sso::FAKE_SSO_IDENTIFIER,
util::{convert_json_key_lcase_first, NumberOrString},
CONFIG,
};
@@ -64,6 +65,7 @@ pub fn routes() -> Vec<Route> {
post_org_import,
list_policies,
list_policies_token,
get_dummy_master_password_policy,
get_master_password_policy,
get_policy,
put_policy,
@@ -99,6 +101,7 @@ pub fn routes() -> Vec<Route> {
get_billing_metadata,
get_billing_warnings,
get_auto_enroll_status,
get_self_host_billing_metadata,
]
}
@@ -131,6 +134,24 @@ struct FullCollectionData {
external_id: Option<String>,
}
impl FullCollectionData {
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
}
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
}
Ok(())
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CollectionGroupData {
@@ -233,30 +254,30 @@ async fn post_delete_organization(
}
#[post("/organizations/<org_id>/leave")]
async fn leave_organization(org_id: OrganizationId, headers: Headers, conn: DbConn) -> EmptyResult {
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
None => err!("User not part of organization"),
Some(member) => {
if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
{
err!("The last owner can't leave")
}
log_event(
EventType::OrganizationUserLeft as i32,
&member.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
member.delete(&conn).await
}
async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
if headers.membership.status != MembershipStatus::Confirmed as i32 {
err!("You need to be a Member of the Organization to call this endpoint")
}
let membership = headers.membership;
if membership.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
{
err!("The last owner can't leave")
}
log_event(
EventType::OrganizationUserLeft as i32,
&membership.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
membership.delete(&conn).await
}
#[get("/organizations/<org_id>")]
@@ -335,7 +356,7 @@ async fn get_user_collections(headers: Headers, conn: DbConn) -> Json<Value> {
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
#[get("/organizations/<identifier>/auto-enroll-status")]
async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult {
let org = if identifier == crate::sso::FAKE_IDENTIFIER {
let org = if identifier == FAKE_SSO_IDENTIFIER {
match Membership::find_main_user_org(&headers.user.uuid, &conn).await {
Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await,
None => None,
@@ -345,7 +366,7 @@ async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn
};
let (id, identifier, rp_auto_enroll) = match org {
None => (get_uuid(), identifier.to_string(), false),
None => (identifier.to_string(), identifier.to_string(), false),
Some(org) => (
org.uuid.to_string(),
org.uuid.to_string(),
@@ -480,12 +501,13 @@ async fn post_organization_collections(
err!("Organization not found", "Organization id's do not match");
}
let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?;
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
err!("Can't find organization details")
};
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
err!("You don't have permission to create collections")
}
let collection = Collection::new(org.uuid, data.name, data.external_id);
let collection = Collection::new(org_id.clone(), data.name, data.external_id);
collection.save(&conn).await?;
log_event(
@@ -501,7 +523,7 @@ async fn post_organization_collections(
for group in data.groups {
CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
.save(&conn)
.save(&org_id, &conn)
.await?;
}
@@ -525,10 +547,6 @@ async fn post_organization_collections(
.await?;
}
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, false, &conn).await?;
}
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
}
@@ -579,10 +597,10 @@ async fn post_bulk_access_collections(
)
.await;
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?;
CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?;
for group in &data.groups {
CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage)
.save(&conn)
.save(&org_id, &conn)
.await?;
}
@@ -627,6 +645,7 @@ async fn post_organization_collection_update(
err!("Organization not found", "Organization id's do not match");
}
let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?;
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
err!("Can't find organization details")
@@ -655,11 +674,11 @@ async fn post_organization_collection_update(
)
.await;
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?;
CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?;
for group in data.groups {
CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
.save(&conn)
.save(&org_id, &conn)
.await?;
}
@@ -908,7 +927,7 @@ async fn get_org_domain_sso_verified(data: Json<OrgDomainDetails>, conn: DbConn)
.collect::<Vec<(String, String)>>()
{
v if !v.is_empty() => v,
_ => vec![(crate::sso::FAKE_IDENTIFIER.to_string(), crate::sso::FAKE_IDENTIFIER.to_string())],
_ => vec![(FAKE_SSO_IDENTIFIER.to_string(), FAKE_SSO_IDENTIFIER.to_string())],
};
Ok(Json(json!({
@@ -1003,6 +1022,24 @@ struct InviteData {
permissions: HashMap<String, Value>,
}
impl InviteData {
async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
}
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
}
Ok(())
}
}
#[post("/organizations/<org_id>/users/invite", data = "<data>")]
async fn send_invite(
org_id: OrganizationId,
@@ -1014,6 +1051,7 @@ async fn send_invite(
err!("Organization not found", "Organization id's do not match");
}
let data: InviteData = data.into_inner();
data.validate(&org_id, &conn).await?;
// 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
@@ -1273,20 +1311,20 @@ async fn accept_invite(
// skip invitation logic when we were invited via the /admin panel
if **member_id != FAKE_ADMIN_UUID {
let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else {
let Some(mut membership) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else {
err!("Error accepting the invitation")
};
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &conn).await {
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&membership.org_uuid, &conn).await {
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
true => data.reset_password_key,
false => None,
};
// In case the user was invited before the mail was saved in db.
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email);
membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email);
accept_org_invite(&headers.user, member, reset_password_key, &conn).await?;
accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?;
} else if CONFIG.mail_enabled() {
// User was invited from /admin, so they are automatically confirmed
let org_name = CONFIG.invitation_org_name();
@@ -1520,9 +1558,8 @@ async fn edit_member(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut member_to_edit = match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await {
Some(member) => member,
None => err!("The specified user isn't member of the organization"),
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization")
};
if new_type != member_to_edit.atype
@@ -1839,7 +1876,6 @@ async fn post_org_import(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct BulkCollectionsData {
organization_id: OrganizationId,
cipher_ids: Vec<CipherId>,
@@ -1853,6 +1889,10 @@ struct BulkCollectionsData {
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data: BulkCollectionsData = data.into_inner();
if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() {
err!("You need to be a Member of the Organization to call this endpoint")
}
// Get all the collection available to the user in one query
// Also filter based upon the provided collections
let user_collections: HashMap<CollectionId, Collection> =
@@ -1868,7 +1908,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
})
.collect();
// Verify if all the collections requested exists and are writeable for the user, else abort
// Verify if all the collections requested exists and are writable for the user, else abort
for collection_uuid in &data.collection_ids {
match user_collections.get(collection_uuid) {
Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (),
@@ -1938,10 +1978,20 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn)
})))
}
// Called during the SSO enrollment.
// Return the org policy if it exists, otherwise use the default one.
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, conn: DbConn) -> JsonResult {
// Called during the SSO enrollment return the default policy
#[get("/organizations/vaultwarden-dummy-oidc-identifier/policies/master-password", rank = 1)]
fn get_dummy_master_password_policy() -> JsonResult {
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
_ => (false, "null".to_string()),
};
let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data);
Ok(Json(policy.to_json()))
}
// Called during the SSO enrollment return the org policy if it exists
#[get("/organizations/<org_id>/policies/master-password", rank = 2)]
async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
let policy =
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| {
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
@@ -1955,7 +2005,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, c
Ok(Json(policy.to_json()))
}
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 2)]
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 3)]
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -2149,13 +2199,13 @@ fn get_plans() -> Json<Value> {
}
#[get("/organizations/<_org_id>/billing/metadata")]
fn get_billing_metadata(_org_id: OrganizationId, _headers: Headers) -> Json<Value> {
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(_empty_data_json())
}
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json<Value> {
fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
Json(json!({
"freeTrial":null,
"inactiveSubscription":null,
@@ -2164,6 +2214,15 @@ fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json<Valu
}))
}
#[get("/organizations/<_org_id>/billing/vnext/self-host/metadata")]
fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(json!({
"isOnSecretsManagerStandalone": false, // Secrets Manager is not supported by Vaultwarden
"organizationOccupiedSeats": 0 // Vaultwarden does not count seats
}))
}
fn _empty_data_json() -> Value {
json!({
"object": "list",
@@ -2427,6 +2486,23 @@ impl GroupRequest {
group
}
/// Validate if all the collections and members belong to the provided organization
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
}
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
Ok(())
}
}
#[derive(Deserialize, Serialize)]
@@ -2470,6 +2546,8 @@ async fn post_groups(
}
let group_request = data.into_inner();
group_request.validate(&org_id, &conn).await?;
let group = group_request.to_group(&org_id);
log_event(
@@ -2506,10 +2584,12 @@ async fn put_group(
};
let group_request = data.into_inner();
group_request.validate(&org_id, &conn).await?;
let updated_group = group_request.update_group(group);
CollectionGroup::delete_all_by_group(&group_id, &conn).await?;
GroupUser::delete_all_by_group(&group_id, &conn).await?;
CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?;
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
log_event(
EventType::GroupUpdated as i32,
@@ -2537,7 +2617,7 @@ async fn add_update_group(
for col_selection in collections {
let mut collection_group = col_selection.to_collection_group(group.uuid.clone());
collection_group.save(conn).await?;
collection_group.save(&org_id, conn).await?;
}
for assigned_member in members {
@@ -2630,7 +2710,7 @@ async fn _delete_group(
)
.await;
group.delete(conn).await
group.delete(org_id, conn).await
}
#[delete("/organizations/<org_id>/groups", data = "<data>")]
@@ -2689,7 +2769,7 @@ async fn get_group_members(
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
};
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &conn)
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &org_id, &conn)
.await
.iter()
.map(|entry| entry.users_organizations_uuid.clone())
@@ -2717,9 +2797,15 @@ async fn put_group_members(
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
};
GroupUser::delete_all_by_group(&group_id, &conn).await?;
let assigned_members = data.into_inner();
let org_memberships = Membership::find_by_org(&org_id, &conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for assigned_member in assigned_members {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
user_entry.save(&conn).await?;
@@ -2858,7 +2944,8 @@ async fn put_reset_password(
let reset_request = data.into_inner();
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None);
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
@@ -2950,15 +3037,20 @@ async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn)
Ok(())
}
#[put("/organizations/<org_id>/users/<member_id>/reset-password-enrollment", data = "<data>")]
#[put("/organizations/<org_id>/users/<user_id>/reset-password-enrollment", data = "<data>")]
async fn put_reset_password_enrollment(
org_id: OrganizationId,
member_id: MembershipId,
headers: Headers,
user_id: UserId,
headers: OrgMemberHeaders,
data: Json<OrganizationUserResetPasswordEnrollmentRequest>,
conn: DbConn,
) -> EmptyResult {
let Some(mut member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else {
if user_id != headers.user.uuid {
err!("User to enroll isn't member of required organization", "The user_id and acting user do not match");
}
let Some(mut membership) = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, &conn).await
else {
err!("User to enroll isn't member of required organization")
};
@@ -2985,16 +3077,17 @@ async fn put_reset_password_enrollment(
.await?;
}
member.reset_password_key = reset_password_key;
member.save(&conn).await?;
membership.reset_password_key = reset_password_key;
membership.save(&conn).await?;
let log_id = if member.reset_password_key.is_some() {
let event_type = if membership.reset_password_key.is_some() {
EventType::OrganizationUserResetPasswordEnroll as i32
} else {
EventType::OrganizationUserResetPasswordWithdraw as i32
};
log_event(log_id, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
Ok(())
}
+1 -1
View File
@@ -156,7 +156,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
}
};
GroupUser::delete_all_by_group(&group_uuid, &conn).await?;
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
for ext_id in &group_data.member_external_ids {
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
+47 -2
View File
@@ -1,7 +1,9 @@
use chrono::{TimeDelta, Utc};
use data_encoding::BASE32;
use num_traits::FromPrimitive;
use rocket::serde::json::Json;
use rocket::Route;
use serde::Deserialize;
use serde_json::Value;
use crate::{
@@ -14,7 +16,7 @@ use crate::{
db::{
models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, User, UserId,
TwoFactorIncomplete, TwoFactorType, User, UserId,
},
DbConn, DbPool,
},
@@ -31,6 +33,43 @@ pub mod protected_actions;
pub mod webauthn;
pub mod yubikey;
fn has_global_duo_credentials() -> bool {
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
}
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool {
#[derive(Deserialize)]
struct DuoProviderData {
host: String,
ik: String,
sk: String,
}
match provider_type {
TwoFactorType::Authenticator => true,
TwoFactorType::Email => CONFIG._enable_email_2fa(),
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
provider_data
.and_then(|raw| serde_json::from_str::<DuoProviderData>(raw).ok())
.is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty())
|| has_global_duo_credentials()
}
TwoFactorType::YubiKey => {
CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some()
}
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
TwoFactorType::RecoveryCode => true,
TwoFactorType::U2f
| TwoFactorType::U2fRegisterChallenge
| TwoFactorType::U2fLoginChallenge
| TwoFactorType::EmailVerificationChallenge
| TwoFactorType::WebauthnRegisterChallenge
| TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::ProtectedActions => false,
}
}
pub fn routes() -> Vec<Route> {
let mut routes = routes![
get_twofactor,
@@ -53,7 +92,13 @@ pub fn routes() -> Vec<Route> {
#[get("/two-factor")]
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
let twofactors_json: Vec<Value> = twofactors
.iter()
.filter_map(|tf| {
let provider_type = TwoFactorType::from_i32(tf.atype)?;
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
})
.collect();
Json(json!({
"data": twofactors_json,
+2 -2
View File
@@ -108,8 +108,8 @@ impl WebauthnRegistration {
#[post("/two-factor/get-webauthn", data = "<data>")]
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
if !CONFIG.is_webauthn_2fa_supported() {
err!("Configured `DOMAIN` is not compatible with Webauthn")
}
let data: PasswordOrOtpData = data.into_inner();
+37 -11
View File
@@ -2,7 +2,6 @@ use chrono::Utc;
use num_traits::FromPrimitive;
use rocket::{
form::{Form, FromForm},
http::Status,
response::Redirect,
serde::json::Json,
Route,
@@ -12,9 +11,12 @@ use serde_json::Value;
use crate::{
api::{
core::{
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
log_user_event,
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
yubikey,
},
},
master_password_policy,
push::register_push_device,
@@ -128,12 +130,14 @@ async fn login(
login_result
}
// Return Status::Unauthorized to trigger logout
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Extract token
let refresh_token = match data.refresh_token {
Some(token) => token,
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
// See the link below for details
// https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797
let Some(refresh_token) = data.refresh_token else {
err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token")
};
// ---
@@ -144,7 +148,10 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
Err(err) => {
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
err_json!(
json!({"error": "invalid_grant"}),
format!("Unable to refresh login credentials: {}", err.message())
)
}
Ok((mut device, auth_tokens)) => {
// Save to update `device.updated_at` to track usage and toggle new status
@@ -739,8 +746,27 @@ async fn twofactor_auth(
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?;
let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect();
let twofactor_ids: Vec<_> = twofactors
.iter()
.filter_map(|tf| {
let provider_type = TwoFactorType::from_i32(tf.atype)?;
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
})
.collect();
if twofactor_ids.is_empty() {
err!("No enabled and usable two factor providers are available for this account")
}
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
// Ignore Remember and RecoveryCode Types during this check, these are special
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
&& !twofactor_ids.contains(&selected_id)
{
err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"Invalid two factor provider"
)
}
let twofactor_code = match data.two_factor_token {
Some(ref code) => code,
@@ -871,7 +897,7 @@ async fn _json_err_twofactor(
match TwoFactorType::from_i32(*provider) {
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => {
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
result["TwoFactorProviders2"][provider.to_string()] = request.0;
}
+4 -3
View File
@@ -358,15 +358,16 @@ impl WebSocketUsers {
}
}
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
// Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED {
return;
}
let acting_device_id = acting_device.map(|d| d.uuid.clone());
let data = create_update(
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
UpdateType::LogOut,
acting_device_id.clone(),
acting_device_id,
);
if CONFIG.enable_websocket() {
@@ -374,7 +375,7 @@ impl WebSocketUsers {
}
if CONFIG.push_enabled() {
push_logout(user, acting_device_id.clone(), conn).await;
push_logout(user, acting_device, conn).await;
}
}
+4 -6
View File
@@ -13,7 +13,7 @@ use tokio::sync::RwLock;
use crate::{
api::{ApiResult, EmptyResult, UpdateType},
db::{
models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId},
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
DbConn,
},
http_client::make_http_request,
@@ -188,15 +188,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
}
}
pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
"organizationId": (),
"deviceId": acting_device_id,
"identifier": acting_device_id,
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
"identifier": acting_device.map(|d| &d.uuid),
"type": UpdateType::LogOut as i32,
"payload": {
"userId": user.uuid,
+35 -16
View File
@@ -704,10 +704,9 @@ pub struct OrgHeaders {
impl OrgHeaders {
fn is_member(&self) -> bool {
// NOTE: we don't care about MembershipStatus at the moment because this is only used
// where an invited, accepted or confirmed user is expected if this ever changes or
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly
self.membership_type >= MembershipType::User
// Only allow not revoked members, we can not use the Confirmed status here
// as some endpoints can be triggered by invited users during joining
self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User
}
fn is_confirmed_and_admin(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
@@ -720,6 +719,36 @@ impl OrgHeaders {
}
}
// org_id is usually the second path param ("/organizations/<org_id>"),
// but there are cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values.
fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id)
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
Some(org_id)
} else {
None
}
}
// Special Guard to ensure that there is an organization id present
// If there is no org id trigger the Outcome::Forward.
// This is useful for endpoints which work for both organization and personal vaults, like purge.
pub struct OrgIdGuard;
#[rocket::async_trait]
impl<'r> FromRequest<'r> for OrgIdGuard {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match get_org_id(request) {
Some(_) => Outcome::Success(OrgIdGuard),
None => Outcome::Forward(rocket::http::Status::NotFound),
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for OrgHeaders {
type Error = &'static str;
@@ -727,18 +756,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(Headers::from_request(request).await);
// org_id is usually the second path param ("/organizations/<org_id>"),
// but there are cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values.
let url_org_id: Option<OrganizationId> = {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id)
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
Some(org_id)
} else {
None
}
};
// Extract the org_id from the request
let url_org_id = get_org_id(request);
match url_org_id {
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
+7 -11
View File
@@ -387,7 +387,6 @@ pub mod models;
#[cfg(sqlite)]
pub fn backup_sqlite() -> Result<String, Error> {
use diesel::Connection;
use std::{fs::File, io::Write};
let db_url = CONFIG.database_url();
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
@@ -401,16 +400,13 @@ pub fn backup_sqlite() -> Result<String, Error> {
.to_string_lossy()
.into_owned();
match File::create(backup_file.clone()) {
Ok(mut f) => {
let serialized_db = conn.serialize_database_to_buffer();
f.write_all(serialized_db.as_slice()).expect("Error writing SQLite backup");
Ok(backup_file)
}
Err(e) => {
err_silent!(format!("Unable to save SQLite backup: {e:?}"))
}
}
diesel::sql_query("VACUUM INTO ?")
.bind::<diesel::sql_types::Text, _>(&backup_file)
.execute(&mut conn)
.map(|_| ())
.map_res("VACUUM INTO failed")?;
Ok(backup_file)
} else {
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
}
+30 -21
View File
@@ -559,7 +559,7 @@ impl Cipher {
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
return cached_member.has_full_access();
}
} else if let Some(member) = Membership::find_by_user_and_org(user_uuid, org_uuid, conn).await {
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
return member.has_full_access();
}
}
@@ -668,10 +668,12 @@ impl Cipher {
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.inner_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
))
.inner_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid))))
.and(users_collections::user_uuid.eq(user_uuid))
))
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn)
.expect("Error getting user access restrictions")
@@ -697,6 +699,9 @@ impl Cipher {
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)
@@ -795,28 +800,28 @@ impl Cipher {
let mut query = ciphers::table
.left_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
))
))
.left_join(users_organizations::table.on(
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
))
))
.left_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
// Ensure that users_collections::user_uuid is NULL for unconfirmed users.
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))
))
))
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
// Ensure that group and membership belong to the same org
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
collections_groups::groups_uuid.eq(groups::uuid)
)
))
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))
))
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
@@ -986,7 +991,9 @@ impl Cipher {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))
@@ -1047,7 +1054,9 @@ impl Cipher {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))
@@ -1115,8 +1124,8 @@ impl Cipher {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
+11 -11
View File
@@ -191,7 +191,7 @@ impl Collection {
self.update_users_revision(conn).await;
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?;
CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?;
db_run! { conn: {
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
@@ -239,8 +239,8 @@ impl Collection {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
@@ -355,8 +355,8 @@ impl Collection {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
@@ -422,8 +422,8 @@ impl Collection {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
@@ -484,8 +484,8 @@ impl Collection {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
@@ -531,8 +531,8 @@ impl Collection {
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
+17 -1
View File
@@ -49,11 +49,16 @@ impl Device {
push_uuid: Some(PushId(get_uuid())),
push_token: None,
refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL),
refresh_token: Device::generate_refresh_token(),
twofactor_remember: None,
}
}
#[inline(always)]
pub fn generate_refresh_token() -> String {
crypto::encode_random_bytes::<64>(&BASE64URL)
}
pub fn to_json(&self) -> Value {
json!({
"id": self.uuid,
@@ -260,6 +265,17 @@ impl Device {
.unwrap_or(0) != 0
}}
}
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
// Generate a new token per device.
// We cannot do a single UPDATE with one value because each device needs a unique token.
let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices {
device.refresh_token = Device::generate_refresh_token();
device.save(false, conn).await?;
}
Ok(())
}
}
#[derive(Display)]
+62 -20
View File
@@ -1,6 +1,6 @@
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
use crate::api::EmptyResult;
use crate::db::schema::{collections_groups, groups, groups_users, users_organizations};
use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations};
use crate::db::DbConn;
use crate::error::MapResult;
use chrono::{NaiveDateTime, Utc};
@@ -81,7 +81,7 @@ impl Group {
// If both read_only and hide_passwords are false, then manage should be true
// You can't have an entry with read_only and manage, or hide_passwords and manage
// Or an entry with everything to false
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn)
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn)
.await
.iter()
.map(|entry| {
@@ -191,7 +191,7 @@ impl Group {
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
for group in Self::find_by_organization(org_uuid, conn).await {
group.delete(conn).await?;
group.delete(org_uuid, conn).await?;
}
Ok(())
}
@@ -246,8 +246,8 @@ impl Group {
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.inner_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::access_all.eq(true))
@@ -276,9 +276,9 @@ impl Group {
}}
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
CollectionGroup::delete_all_by_group(&self.uuid, conn).await?;
GroupUser::delete_all_by_group(&self.uuid, conn).await?;
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
db_run! { conn: {
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
@@ -306,8 +306,8 @@ impl Group {
}
impl CollectionGroup {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
@@ -365,10 +365,19 @@ impl CollectionGroup {
}
}
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> {
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
collections_groups::table
.inner_join(groups::table.on(
groups::uuid.eq(collections_groups::groups_uuid)
))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
))
.filter(collections_groups::groups_uuid.eq(group_uuid))
.filter(collections::org_uuid.eq(org_uuid))
.select(collections_groups::all_columns)
.load::<Self>(conn)
.expect("Error loading collection groups")
}}
@@ -383,6 +392,13 @@ impl CollectionGroup {
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.select(collections_groups::all_columns)
.load::<Self>(conn)
@@ -394,14 +410,20 @@ impl CollectionGroup {
db_run! { conn: {
collections_groups::table
.filter(collections_groups::collections_uuid.eq(collection_uuid))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
))
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(collections::org_uuid))
))
.select(collections_groups::all_columns)
.load::<Self>(conn)
.expect("Error loading collection groups")
}}
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
@@ -415,8 +437,8 @@ impl CollectionGroup {
}}
}
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
@@ -429,10 +451,14 @@ impl CollectionGroup {
}}
}
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult {
pub async fn delete_all_by_collection(
collection_uuid: &CollectionId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> EmptyResult {
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
for collection_assigned_to_group in collection_assigned_to_groups {
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await;
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
@@ -494,10 +520,19 @@ impl GroupUser {
}
}
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> {
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
groups_users::table
.inner_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))
))
.filter(groups_users::groups_uuid.eq(group_uuid))
.filter(groups::organizations_uuid.eq(org_uuid))
.select(groups_users::all_columns)
.load::<Self>(conn)
.expect("Error loading group users")
}}
@@ -522,6 +557,13 @@ impl GroupUser {
.inner_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
))
.inner_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
))
.filter(collections_groups::collections_uuid.eq(collection_uuid))
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
.count()
@@ -575,8 +617,8 @@ impl GroupUser {
}}
}
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
+1 -1
View File
@@ -332,7 +332,7 @@ impl OrgPolicy {
for policy in
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
{
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
if user.atype < MembershipType::Admin {
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
Ok(opts) => {
+5 -2
View File
@@ -514,7 +514,8 @@ impl Membership {
"familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null,
"accessSecretsManager": false,
"limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations
// limit collection creation to managers with access_all permission to prevent issues
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
"limitCollectionDeletion": true,
"limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true,
@@ -1073,7 +1074,9 @@ impl Membership {
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(ciphers_collections::table.on(
ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))
+11
View File
@@ -46,6 +46,16 @@ pub enum SendType {
File = 1,
}
enum SendAuthType {
#[allow(dead_code)]
// Send requires email OTP verification
Email = 0, // Not yet supported by Vaultwarden
// Send requires a password
Password = 1,
// Send requires no auth
None = 2,
}
impl Send {
pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self {
let now = Utc::now().naive_utc();
@@ -145,6 +155,7 @@ impl Send {
"maxAccessCount": self.max_access_count,
"accessCount": self.access_count,
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
"authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 },
"disabled": self.disabled,
"hideEmail": self.hide_email,
-1
View File
@@ -20,7 +20,6 @@ pub struct TwoFactor {
pub last_used: i64,
}
#[allow(dead_code)]
#[derive(num_derive::FromPrimitive)]
pub enum TwoFactorType {
Authenticator = 0,
+8 -4
View File
@@ -185,13 +185,14 @@ impl User {
/// These routes are able to use the previous stamp id for the next 2 minutes.
/// After these 2 minutes this stamp will expire.
///
pub fn set_password(
pub async fn set_password(
&mut self,
password: &str,
new_key: Option<String>,
reset_security_stamp: bool,
allow_next_route: Option<Vec<String>>,
) {
conn: &DbConn,
) -> EmptyResult {
self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32);
if let Some(route) = allow_next_route {
@@ -203,12 +204,15 @@ impl User {
}
if reset_security_stamp {
self.reset_security_stamp()
self.reset_security_stamp(conn).await?;
}
Ok(())
}
pub fn reset_security_stamp(&mut self) {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid();
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
Ok(())
}
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
+24 -21
View File
@@ -6,7 +6,7 @@ use std::{
time::Duration,
};
use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver};
use hickory_resolver::{net::runtime::TokioRuntimeProvider, TokioResolver};
use regex::Regex;
use reqwest::{
dns::{Name, Resolve, Resolving},
@@ -184,35 +184,35 @@ impl CustomDnsResolver {
}
fn new() -> Arc<Self> {
match TokioResolver::builder(TokioConnectionProvider::default()) {
Ok(mut builder) => {
if CONFIG.dns_prefer_ipv6() {
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4;
TokioResolver::builder(TokioRuntimeProvider::default())
.and_then(|mut builder| {
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
// This might cause issues on IPv4 only systems or containers
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
if !CONFIG.dns_prefer_ipv6() {
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
}
let resolver = builder.build();
Arc::new(Self::Hickory(Arc::new(resolver)))
}
Err(e) => {
warn!("Error creating Hickory resolver, falling back to default: {e:?}");
Arc::new(Self::Default())
}
}
builder.build()
})
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
.map(|resolver| Arc::new(Self::Hickory(Arc::new(resolver))))
.unwrap_or_else(|_| Arc::new(Self::Default()))
}
// Note that we get an iterator of addresses, but we only grab the first one for convenience
async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> {
async fn resolve_domain(&self, name: &str) -> Result<Vec<SocketAddr>, BoxError> {
pre_resolve(name)?;
let result = match self {
Self::Default() => tokio::net::lookup_host(name).await?.next(),
Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)),
let results: Vec<SocketAddr> = match self {
Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(),
Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(),
};
if let Some(addr) = &result {
for addr in &results {
post_resolve(name, addr.ip())?;
}
Ok(result)
Ok(results)
}
}
@@ -242,8 +242,11 @@ impl Resolve for CustomDnsResolver {
let this = self.clone();
Box::pin(async move {
let name = name.as_str();
let result = this.resolve_domain(name).await?;
Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter()))
let results = this.resolve_domain(name).await?;
if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address");
}
Ok::<reqwest::dns::Addrs, _>(Box::new(results.into_iter()))
})
}
}
+36 -5
View File
@@ -558,6 +558,12 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
let basepath = &CONFIG.domain_path();
let mut config = rocket::Config::from(rocket::Config::figment());
// We install our own signal handlers below; disable Rocket's built-in handlers
config.shutdown.ctrlc = false;
#[cfg(unix)]
config.shutdown.signals.clear();
config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into();
config.cli_colors = false; // Make sure Rocket does not color any values for logging.
config.limits = Limits::new()
@@ -589,11 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
CONFIG.set_rocket_shutdown_handle(instance.shutdown());
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
info!("Exiting Vaultwarden!");
CONFIG.shutdown();
});
spawn_shutdown_signal_handler();
#[cfg(all(unix, sqlite))]
{
@@ -621,6 +623,35 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
Ok(())
}
#[cfg(unix)]
fn spawn_shutdown_signal_handler() {
tokio::spawn(async move {
use tokio::signal::unix::signal;
let mut sigint = signal(SignalKind::interrupt()).expect("Error setting SIGINT handler");
let mut sigterm = signal(SignalKind::terminate()).expect("Error setting SIGTERM handler");
let mut sigquit = signal(SignalKind::quit()).expect("Error setting SIGQUIT handler");
let signal_name = tokio::select! {
_ = sigint.recv() => "SIGINT",
_ = sigterm.recv() => "SIGTERM",
_ = sigquit.recv() => "SIGQUIT",
};
info!("Received {signal_name}, initiating graceful shutdown");
CONFIG.shutdown();
});
}
#[cfg(not(unix))]
fn spawn_shutdown_signal_handler() {
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
info!("Received Ctrl-C, initiating graceful shutdown");
CONFIG.shutdown();
});
}
fn schedule_jobs(pool: db::DbPool) {
if CONFIG.job_poll_interval_ms() == 0 {
info!("Job scheduler disabled.");
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::{
CONFIG,
};
pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC";
pub static FAKE_SSO_IDENTIFIER: &str = "vaultwarden-dummy-oidc-identifier";
static SSO_JWT_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin()));
+11 -2
View File
@@ -111,7 +111,8 @@
"microsoftstore.com",
"xbox.com",
"azure.com",
"windowsazure.com"
"windowsazure.com",
"cloud.microsoft"
],
"excluded": false
},
@@ -971,5 +972,13 @@
"pinterest.se"
],
"excluded": false
},
{
"type": 91,
"domains": [
"twitter.com",
"x.com"
],
"excluded": false
}
]
]
@@ -137,6 +137,14 @@ bit-nav-logo bit-nav-item .bwi-shield {
app-user-layout app-danger-zone button:nth-child(1) {
@extend %vw-hide;
}
/* Hide unsupported Forwarding email alias options */
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) {
@extend %vw-hide;
}
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) {
@extend %vw-hide;
}
/**** END Static Vaultwarden Changes ****/
/**** START Dynamic Vaultwarden Changes ****/
{{#if signup_disabled}}
+15
View File
@@ -634,6 +634,21 @@ fn _process_key(key: &str) -> String {
}
}
pub fn deser_opt_nonempty_str<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: From<String>,
{
use serde::Deserialize;
Ok(Option::<String>::deserialize(deserializer)?.and_then(|s| {
if s.is_empty() {
None
} else {
Some(T::from(s))
}
}))
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum NumberOrString {
+1
View File
@@ -79,3 +79,4 @@ for name, domain_list in domain_lists.items():
# Write out the global domains JSON file.
with open(file=OUTPUT_FILE, mode='w', encoding='utf-8') as f:
json.dump(global_domains, f, indent=2)
f.write("\n")