mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 12:18:37 +02:00
Compare commits
2
Commits
main
..
274ee69db3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
274ee69db3 | ||
|
|
46f4ec83df |
No files matched your search
+6
-29
@@ -78,7 +78,7 @@
|
||||
# DB_CONNECTION_RETRIES=15
|
||||
|
||||
## Database timeout
|
||||
## Timeout in seconds when acquiring database connection
|
||||
## Timeout when acquiring database connection
|
||||
# DATABASE_TIMEOUT=30
|
||||
|
||||
## Database idle timeout
|
||||
@@ -173,7 +173,7 @@
|
||||
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
||||
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
||||
## Number of days to retain events stored in the database.
|
||||
## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
|
||||
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
|
||||
# EVENTS_DAYS_RETAIN=
|
||||
##
|
||||
## Cron schedule of the job that cleans old auth requests from the auth request.
|
||||
@@ -233,7 +233,7 @@
|
||||
# USER_SEND_LIMIT=
|
||||
|
||||
## Number of days to wait before auto-deleting a trashed item.
|
||||
## If unset (the default) or 0, trashed items are not auto-deleted.
|
||||
## If unset (the default), trashed items are not auto-deleted.
|
||||
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
||||
# TRASH_AUTO_DELETE_DAYS=
|
||||
|
||||
@@ -386,34 +386,23 @@
|
||||
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
||||
|
||||
## Client Settings
|
||||
## Server-wide Fill Assist rules feed base directory URL, not a JSON file.
|
||||
## Defaults to https://fillassist.bitwarden.com.
|
||||
## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace.
|
||||
# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com
|
||||
|
||||
## Enable experimental feature flags for clients.
|
||||
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
||||
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||
##
|
||||
## The following flags are available:
|
||||
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
|
||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
|
||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
||||
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
||||
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
||||
## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0)
|
||||
## - "pm-30529-webauthn-related-origins":
|
||||
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
|
||||
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
|
||||
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
|
||||
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
||||
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
||||
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
|
||||
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
|
||||
## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1)
|
||||
## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0)
|
||||
## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one.
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||
|
||||
## Require new device emails. When a user logs in an email is required to be sent.
|
||||
@@ -474,7 +463,7 @@
|
||||
|
||||
## Allowed connect-src (Know the risks!)
|
||||
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
|
||||
## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
||||
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
||||
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
||||
@@ -500,15 +489,6 @@
|
||||
## KNOW WHAT YOU ARE DOING!
|
||||
# ORG_GROUPS_ENABLED=false
|
||||
|
||||
## EXPERIMENTAL FEATURE: Custom roles
|
||||
## Enables the Custom member role with granular permissions for organizations.
|
||||
## This is still being developed: only the permissions this version enforces can be granted.
|
||||
## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS.
|
||||
## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled.
|
||||
## Disabled by default because this is an experimental feature!
|
||||
## KNOW WHAT YOU ARE DOING!
|
||||
# EXPERIMENTAL_CUSTOM_ROLES=false
|
||||
|
||||
## Increase secure note size limit (Know the risks!)
|
||||
## Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||
@@ -626,9 +606,6 @@
|
||||
## Note that the checkbox would still be present, but ignored.
|
||||
# DISABLE_2FA_REMEMBER=false
|
||||
##
|
||||
## Number of days before a remembered 2FA login expires (min: 1, max: 90).
|
||||
# TWO_FACTOR_REMEMBER_DAYS=30
|
||||
##
|
||||
## Authenticator Settings
|
||||
## Disable authenticator time drifted codes to be valid.
|
||||
## TOTP codes of the previous and next 30 seconds will be invalid
|
||||
@@ -637,7 +614,7 @@
|
||||
## we allow by default the TOTP code which was valid one step back and one in the future.
|
||||
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
||||
## You can disable this, so that only the current TOTP Code is allowed.
|
||||
## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid.
|
||||
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
|
||||
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
||||
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
# Enable Rust Caching
|
||||
- name: Rust Caching
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Bake ${{ matrix.base_image }} containers
|
||||
id: bake_vw
|
||||
uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
|
||||
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||
env:
|
||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
- name: Upload binaries as artifacts
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
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 }}
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*-${{ matrix.base_image }}
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -303,7 +303,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
|
||||
@@ -50,6 +50,6 @@ jobs:
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
@@ -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@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
||||
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
# intentionally not scanning the entire repository,
|
||||
# since it contains integration tests.
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
|
||||
# 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: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
||||
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||
hooks:
|
||||
- id: typos
|
||||
always_run: true
|
||||
|
||||
Generated
+499
-790
File diff suppressed because it is too large.
Load diff
+16
-16
@@ -66,7 +66,7 @@ syslog = "7.0.0"
|
||||
macros = { path = "./macros" }
|
||||
|
||||
# Logging
|
||||
log = "0.4.35"
|
||||
log = "0.4.34"
|
||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||
tracing = { version = "0.1.44", features = ["log"] }
|
||||
@@ -80,7 +80,7 @@ num-derive = "0.5.1"
|
||||
bigdecimal = "0.4.11"
|
||||
|
||||
# Web framework
|
||||
rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] }
|
||||
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||
rocket_ws = { version = "0.1.1" }
|
||||
|
||||
# WebSockets libraries
|
||||
@@ -91,7 +91,7 @@ dashmap = "6.2.1"
|
||||
|
||||
# Async futures
|
||||
futures = "0.3.34"
|
||||
tokio = { version = "1.54.1", features = [
|
||||
tokio = { version = "1.53.2", features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"net",
|
||||
@@ -100,14 +100,14 @@ tokio = { version = "1.54.1", features = [
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tokio-util = { version = "0.7.20", features = ["compat"] }
|
||||
tokio-util = { version = "0.7.19", features = ["compat"] }
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
serde_json = "1.0.151"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
||||
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
||||
diesel_migrations = "2.3.2"
|
||||
|
||||
derive_more = { version = "2.1.1", features = [
|
||||
@@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true }
|
||||
# Crypto-related libraries
|
||||
rand = "0.10.3"
|
||||
ring = "0.17.14"
|
||||
rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false }
|
||||
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
|
||||
subtle = "2.6.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.29.0", features = ["v4"] }
|
||||
uuid = { version = "1.27.0", features = ["v4"] }
|
||||
|
||||
# Date and time libraries
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||
@@ -204,7 +204,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [
|
||||
hickory-resolver = "0.26.3"
|
||||
|
||||
# Favicon extraction libraries
|
||||
html5gum = "0.9.0"
|
||||
html5gum = "0.8.4"
|
||||
regex = { version = "1.13.1", default-features = false, features = [
|
||||
"perf",
|
||||
"std",
|
||||
@@ -215,7 +215,7 @@ bytes = "1.12.1"
|
||||
svg-hush = "0.9.7"
|
||||
|
||||
# Cache function results (Used for version check and favicon fetching)
|
||||
cached = { version = "4.1.0", features = ["async"] }
|
||||
cached = { version = "4.0.1", features = ["async"] }
|
||||
|
||||
# Used for custom short lived cookie jar during favicon extraction
|
||||
cookie = "0.18.2"
|
||||
@@ -235,7 +235,7 @@ governor = "0.10.4"
|
||||
ipnet = "2.12.2"
|
||||
|
||||
# OIDC for SSO
|
||||
openidconnect = { version = "4.1.0", default-features = false }
|
||||
openidconnect = { version = "4.0.1", default-features = false }
|
||||
moka = { version = "0.12.16", features = ["future"] }
|
||||
|
||||
# Check client versions for specific features.
|
||||
@@ -257,18 +257,18 @@ rpassword = "7.5.4"
|
||||
grass_compiler = { version = "0.13.4", default-features = false }
|
||||
|
||||
# File are accessed through Apache OpenDAL
|
||||
opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] }
|
||||
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
|
||||
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||
|
||||
# For retrieving AWS credentials, including temporary SSO credentials
|
||||
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
|
||||
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
||||
"behavior-version-latest",
|
||||
"credentials-process",
|
||||
"rt-tokio",
|
||||
"sso",
|
||||
] }
|
||||
aws-credential-types = { version = "1.3.1", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.19.2", optional = true }
|
||||
aws-credential-types = { version = "1.3.0", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
|
||||
http = { version = "1.5.0", optional = true }
|
||||
reqsign-aws-v4 = { version = "3.3.1", optional = true }
|
||||
reqsign-core = { version = "3.3.2", optional = true }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
vault_version: "v2026.10.1"
|
||||
vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
|
||||
# Cross Compile Docker Helper Scripts v1.10.0
|
||||
vault_version: "v2026.8.0"
|
||||
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
|
||||
# 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:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
|
||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
||||
# rust_version: x.yy.z # Rust version to be used
|
||||
debian_version: trixie # Debian release name to be used
|
||||
|
||||
+10
-10
@@ -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.10.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
||||
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
||||
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a 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.99.1 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
|
||||
@@ -19,24 +19,24 @@
|
||||
# - 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.10.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
||||
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
||||
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
||||
|
||||
########################## Cross Compile Docker Helper Scripts ##########################
|
||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||
## And these bash scripts do not have any significant difference if at all
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
|
||||
# hadolint ignore=DL3067
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -15,7 +15,6 @@ Some unsupported platforms for Debian based images. These are not built and test
|
||||
- 386 (linux/386)
|
||||
- ppc64le (linux/ppc64le)
|
||||
- s390x (linux/s390x)
|
||||
- riscv64 (linux/riscv64)
|
||||
|
||||
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
|
||||
This ensures the container build process can run binaries from other architectures.<br>
|
||||
|
||||
@@ -148,12 +148,6 @@ target "debian-s390x" {
|
||||
platforms = ["linux/s390x"]
|
||||
tags = generate_tags("", "-s390x")
|
||||
}
|
||||
|
||||
target "debian-riscv64" {
|
||||
inherits = ["debian"]
|
||||
platforms = ["linux/riscv64"]
|
||||
tags = generate_tags("", "-riscv64")
|
||||
}
|
||||
// ==== End of unsupported Debian architecture targets ===
|
||||
|
||||
// A Group to build all platforms individually for local testing
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.47"
|
||||
syn = "3.0.9"
|
||||
syn = "3.0.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
||||
-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything
|
||||
-- is dropped: change their role first, or restore the backup taken before the upgrade.
|
||||
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
|
||||
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
||||
DROP TEMPORARY TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations
|
||||
DROP COLUMN manage_users,
|
||||
DROP COLUMN manage_groups,
|
||||
DROP COLUMN manage_policies,
|
||||
DROP COLUMN create_new_collections,
|
||||
DROP COLUMN edit_any_collection,
|
||||
DROP COLUMN delete_any_collection,
|
||||
DROP COLUMN access_event_logs,
|
||||
DROP COLUMN access_import_export,
|
||||
DROP COLUMN access_reports;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
||||
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
||||
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
|
||||
ALTER TABLE users_organizations
|
||||
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
Whitespace-only changes.
@@ -1,4 +0,0 @@
|
||||
-- Key rotations before this kept the id of the old user key, so every new
|
||||
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||
-- all of them; clients record their current key's id again when it's empty.
|
||||
UPDATE users SET key_id = NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||
@@ -1,3 +1 @@
|
||||
-- TEXT has no size limit here, only MySQL needed the change.
|
||||
-- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op.
|
||||
SELECT 1;
|
||||
-- TEXT has no size limit here, only MySQL needed the change
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
||||
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
|
||||
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
||||
DROP TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations
|
||||
DROP COLUMN manage_users,
|
||||
DROP COLUMN manage_groups,
|
||||
DROP COLUMN manage_policies,
|
||||
DROP COLUMN create_new_collections,
|
||||
DROP COLUMN edit_any_collection,
|
||||
DROP COLUMN delete_any_collection,
|
||||
DROP COLUMN access_event_logs,
|
||||
DROP COLUMN access_import_export,
|
||||
DROP COLUMN access_reports;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
||||
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
||||
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
|
||||
ALTER TABLE users_organizations
|
||||
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
Whitespace-only changes.
@@ -1,4 +0,0 @@
|
||||
-- Key rotations before this kept the id of the old user key, so every new
|
||||
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||
-- all of them; clients record their current key's id again when it's empty.
|
||||
UPDATE users SET key_id = NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
||||
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
|
||||
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
||||
DROP TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_users;
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_groups;
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_policies;
|
||||
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
|
||||
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
|
||||
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;
|
||||
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
|
||||
ALTER TABLE users_organizations DROP COLUMN access_import_export;
|
||||
ALTER TABLE users_organizations DROP COLUMN access_reports;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
||||
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
Whitespace-only changes.
@@ -1,4 +0,0 @@
|
||||
-- Key rotations before this kept the id of the old user key, so every new
|
||||
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||
-- all of them; clients record their current key's id again when it's empty.
|
||||
UPDATE users SET key_id = NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.99.1"
|
||||
channel = "1.99.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
+11
-52
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashMap, env, sync::LazyLock};
|
||||
use std::{env, sync::LazyLock};
|
||||
|
||||
use reqwest::Method;
|
||||
use rocket::{
|
||||
@@ -17,7 +17,7 @@ use crate::{
|
||||
api::{
|
||||
ApiResult, EmptyResult, JsonResult, Notify,
|
||||
core::{log_event, two_factor},
|
||||
push_logout_to_relay, unregister_push_device,
|
||||
unregister_push_device,
|
||||
},
|
||||
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||
config::ConfigBuilder,
|
||||
@@ -26,7 +26,6 @@ use crate::{
|
||||
models::{
|
||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||
custom_roles_enabled,
|
||||
},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
@@ -202,15 +201,12 @@ fn post_admin_login(
|
||||
// If the token is invalid, redirect to login page
|
||||
if validate_token(&data.token) {
|
||||
// If the token received is valid, generate JWT and save it as a cookie
|
||||
let claims = match generate_admin_claims() {
|
||||
Ok(claims) => claims,
|
||||
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
|
||||
};
|
||||
let claims = generate_admin_claims();
|
||||
let jwt = encode_jwt(&claims);
|
||||
|
||||
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
||||
.path(admin_path())
|
||||
.max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
|
||||
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
||||
.same_site(SameSite::Strict)
|
||||
.http_only(true)
|
||||
.secure(secure.https);
|
||||
@@ -257,7 +253,6 @@ struct AdminTemplateData {
|
||||
logged_in: bool,
|
||||
urlpath: String,
|
||||
sso_enabled: bool,
|
||||
custom_roles_enabled: bool,
|
||||
}
|
||||
|
||||
impl AdminTemplateData {
|
||||
@@ -268,7 +263,6 @@ impl AdminTemplateData {
|
||||
logged_in: true,
|
||||
urlpath: CONFIG.domain_path(),
|
||||
sso_enabled: CONFIG.sso_enabled(),
|
||||
custom_roles_enabled: custom_roles_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,18 +387,6 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
|
||||
|
||||
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||
|
||||
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
|
||||
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|member| (member.org_uuid.to_string(), member.atype))
|
||||
.collect();
|
||||
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
|
||||
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
|
||||
org["type"] = json!(atype);
|
||||
}
|
||||
}
|
||||
|
||||
users_json.push(usr);
|
||||
}
|
||||
|
||||
@@ -434,21 +416,13 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso
|
||||
}
|
||||
|
||||
#[post("/users/<user_id>/delete", format = "application/json")]
|
||||
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
let user = get_user_or_404(&user_id, &conn).await?;
|
||||
|
||||
// Get the membership records and push devices before deleting the actual user
|
||||
// Get the membership records before deleting the actual user
|
||||
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await;
|
||||
let res = user.delete(&conn).await;
|
||||
|
||||
if res.is_ok() {
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
if has_push_device {
|
||||
push_logout_to_relay(&user, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
for membership in memberships {
|
||||
log_event(
|
||||
EventType::OrganizationUserDeleted,
|
||||
@@ -580,14 +554,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
err!("The specified user isn't member of the organization")
|
||||
};
|
||||
|
||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
||||
// Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
|
||||
// are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
|
||||
Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
|
||||
MembershipType::Manager
|
||||
}
|
||||
Some(new_type) => new_type,
|
||||
None => err!("Invalid type"),
|
||||
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid type")
|
||||
};
|
||||
|
||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||
@@ -597,14 +567,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
}
|
||||
}
|
||||
|
||||
// A Manager only becomes a Custom member if that changes nothing they may do
|
||||
if new_type == MembershipType::Custom
|
||||
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await
|
||||
{
|
||||
err!(loss)
|
||||
}
|
||||
|
||||
member_to_edit.set_type(new_type);
|
||||
member_to_edit.atype = new_type;
|
||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||
|
||||
@@ -816,8 +779,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
.into_keys()
|
||||
.collect();
|
||||
|
||||
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
|
||||
|
||||
let diagnostics_json = json!({
|
||||
"dns_resolved": dns_resolved,
|
||||
"current_release": VERSION,
|
||||
@@ -840,9 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
"db_version": get_sql_server_version(&conn).await,
|
||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||
"overrides": &CONFIG.get_overrides().join(", "),
|
||||
"config_file_status": &CONFIG.get_config_file_status(),
|
||||
"template_overrides": check_template_overrides().join(", "),
|
||||
"invalid_env_vars": invalid_env_vars,
|
||||
"invalid_feature_flags": invalid_feature_flags,
|
||||
"host_arch": env::consts::ARCH,
|
||||
"host_os": env::consts::OS,
|
||||
|
||||
+119
-599
@@ -1,7 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::Utc;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{
|
||||
http::Status,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
@@ -12,9 +11,9 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
AnonymousNotify, ApiResult, EmptyResult, JsonResult, LogOutReason, Notify, PasswordOrOtpData,
|
||||
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||
master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device,
|
||||
master_password_policy, register_push_device, unregister_push_device,
|
||||
},
|
||||
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||
crypto,
|
||||
@@ -32,7 +31,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
ciphers::{CipherData, rotate_cipher_data, validate_rotated_cipher},
|
||||
ciphers::{CipherData, update_cipher_from_data},
|
||||
sends::SendData,
|
||||
};
|
||||
|
||||
@@ -49,10 +48,8 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
post_password,
|
||||
post_set_password,
|
||||
post_kdf,
|
||||
get_key_rotation_data,
|
||||
post_rotatekey,
|
||||
post_user_key,
|
||||
post_rotate_user_keys,
|
||||
post_sstamp,
|
||||
post_email_token,
|
||||
post_email,
|
||||
@@ -339,21 +336,6 @@ impl AccountKeysData {
|
||||
}
|
||||
}
|
||||
|
||||
impl WrappedAccountCryptographicState {
|
||||
/// v2-only: the nested pair stands in for the top-level keys, so a partial state can't pass as v1.
|
||||
fn validate(self) -> ApiResult<ValidatedAccountKeys> {
|
||||
let key_pair = self.public_key_encryption_key_pair;
|
||||
AccountKeysData {
|
||||
user_key_encrypted_account_private_key: Some(key_pair.wrapped_private_key.clone()),
|
||||
account_public_key: Some(key_pair.public_key.clone()),
|
||||
public_key_encryption_key_pair: Some(key_pair),
|
||||
signature_key_pair: Some(self.signature_key_pair),
|
||||
security_state: Some(self.security_state),
|
||||
}
|
||||
.validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeysData> for ValidatedAccountKeys {
|
||||
fn from(keys: KeysData) -> Self {
|
||||
Self {
|
||||
@@ -385,9 +367,7 @@ impl ValidatedAccountKeys {
|
||||
self.v2.is_some()
|
||||
}
|
||||
|
||||
/// Sets the keys on the user. Save [`Self::save_signature_key_pair`] before the user: a v2 user without its key
|
||||
/// pair can't unlock, while a key pair saved for a user who isn't v2 yet is ignored until it is, and replaced on
|
||||
/// the next try. A rotation of a v2 account replaces its key pair, see `rotate_account`.
|
||||
/// Sets the keys on the user, which then needs saving before [`Self::save_signature_key_pair`].
|
||||
fn apply(&self, user: &mut User) -> EmptyResult {
|
||||
user.private_key = Some(self.private_key.clone());
|
||||
user.public_key = Some(self.public_key.clone());
|
||||
@@ -573,9 +553,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||
let account_keys = ValidatedAccountKeys::for_registration(data.account_keys, data.keys)?;
|
||||
|
||||
let existing_user = User::find_by_mail(&email, &conn).await;
|
||||
let is_new_user = existing_user.is_none();
|
||||
let mut user = match existing_user {
|
||||
let mut user = match User::find_by_mail(&email, &conn).await {
|
||||
Some(user) => {
|
||||
if !user.password_hash.is_empty() {
|
||||
err!("Registration not allowed or user already exists")
|
||||
@@ -655,15 +633,8 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
}
|
||||
}
|
||||
|
||||
// The key pair first, as in a rotation, but its foreign key needs the user: a new one is saved before without a
|
||||
// password or keys, like an invited user, so a failure in between leaves a registration that can be retried.
|
||||
if is_new_user && account_keys.is_v2() {
|
||||
let mut unregistered = User::new(&user.email, None);
|
||||
unregistered.uuid.clone_from(&user.uuid);
|
||||
unregistered.save(&conn).await?;
|
||||
}
|
||||
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||
user.save(&conn).await?;
|
||||
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||
|
||||
// accept any open emergency access invitations
|
||||
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
|
||||
@@ -790,11 +761,11 @@ async fn post_set_password(data: Json<Value>, headers: Headers, conn: DbConn) ->
|
||||
|
||||
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
// The key pair first, as in a rotation: if saving the user then fails, the password isn't set either
|
||||
user.save(&conn).await?;
|
||||
|
||||
if let Some(account_keys) = account_keys {
|
||||
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||
}
|
||||
user.save(&conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "set-password",
|
||||
@@ -952,9 +923,8 @@ async fn post_keys(data: Json<PostKeysData>, headers: Headers, conn: DbConn) ->
|
||||
};
|
||||
|
||||
account_keys.apply(&mut user)?;
|
||||
// The key pair first, as in a rotation: if saving the user then fails, the account stays without keys
|
||||
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||
user.save(&conn).await?;
|
||||
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"key": (!user.akey.is_empty()).then_some(&user.akey),
|
||||
@@ -995,8 +965,7 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
||||
err!("KDF settings must be equal for authentication and unlock")
|
||||
}
|
||||
|
||||
let salt = user.master_password_salt();
|
||||
if salt != authentication_data.salt || salt != unlock_data.salt {
|
||||
if user.email != authentication_data.salt || user.email != unlock_data.salt {
|
||||
err!("Invalid master password salt")
|
||||
}
|
||||
|
||||
@@ -1119,17 +1088,6 @@ fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A rotation's unlock data has to wrap the new user key. Neither id is sent by clients that predate them.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs#L49-L61>
|
||||
fn validate_contained_key_id(contained_key_id: Option<&KeyId>, new_user_key_id: Option<&KeyId>) -> EmptyResult {
|
||||
match (contained_key_id, new_user_key_id) {
|
||||
(None, None) => Ok(()),
|
||||
(Some(contained), Some(new)) if contained == new => Ok(()),
|
||||
_ => err!("Invalid user key sent in master-password unlock data."),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChangeKdfData {
|
||||
@@ -1184,72 +1142,50 @@ struct UpdateFolderData {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateEmergencyAccessData {
|
||||
id: EmergencyAccessId,
|
||||
key_encrypted: Option<String>,
|
||||
// Only validated, upstream doesn't store it either
|
||||
wait_time_days: Option<i32>,
|
||||
key_encrypted: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateResetPasswordData {
|
||||
organization_id: OrganizationId,
|
||||
// Absent in a v1 -> v2 upgrade, which keeps the key the organization already holds
|
||||
reset_password_key: Option<String>,
|
||||
reset_password_key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyData {
|
||||
account_unlock_data: RotateAccountUnlockData,
|
||||
account_keys: AccountKeysData,
|
||||
account_keys: RotateAccountKeys,
|
||||
account_data: RotateAccountData,
|
||||
old_master_key_authentication_hash: String,
|
||||
old_user_key_id: Option<KeyId>,
|
||||
new_user_key_id: Option<KeyId>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RotateAccountUnlockData {
|
||||
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
|
||||
master_password_unlock_data: MasterPasswordUnlockData,
|
||||
#[serde(flatten)]
|
||||
common: CommonUnlockData,
|
||||
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MasterPasswordUnlockData {
|
||||
#[serde(flatten)]
|
||||
kdf: KDFData,
|
||||
kdf_type: i32,
|
||||
kdf_iterations: i32,
|
||||
kdf_parallelism: Option<i32>,
|
||||
kdf_memory: Option<i32>,
|
||||
email: String,
|
||||
master_key_authentication_hash: String,
|
||||
master_key_encrypted_user_key: String,
|
||||
master_password_hint: Option<String>,
|
||||
master_password_salt: Option<String>,
|
||||
contained_key_id: Option<KeyId>,
|
||||
}
|
||||
|
||||
/// The unlock data of both rotation endpoints; passkey and device keys are ignored, as we support neither.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Models/Requests/CommonUnlockDataRequestModel.cs#L7-L14>
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CommonUnlockData {
|
||||
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
|
||||
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
|
||||
#[expect(dead_code, reason = "Required like upstream, but unused")]
|
||||
passkey_unlock_data: Vec<Value>,
|
||||
#[expect(dead_code, reason = "Required like upstream, but unused")]
|
||||
device_key_unlock_data: Vec<Value>,
|
||||
v2_upgrade_token: Option<V2UpgradeTokenData>,
|
||||
}
|
||||
|
||||
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct V2UpgradeTokenData {
|
||||
wrapped_user_key1: String,
|
||||
wrapped_user_key2: String,
|
||||
struct RotateAccountKeys {
|
||||
user_key_encrypted_account_private_key: String,
|
||||
account_public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1260,117 +1196,27 @@ struct RotateAccountData {
|
||||
sends: Vec<SendData>,
|
||||
}
|
||||
|
||||
/// Body of `rotate-user-keys`, which rotates the keys without touching the master password.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RotateUserKeysData {
|
||||
wrapped_account_cryptographic_state: WrappedAccountCryptographicState,
|
||||
unlock_data: CommonUnlockData,
|
||||
account_data: RotateAccountData,
|
||||
unlock_method_data: UnlockMethodData,
|
||||
old_user_key_id: Option<KeyId>,
|
||||
new_user_key_id: Option<KeyId>,
|
||||
}
|
||||
|
||||
/// The v2-only account cryptographic state, where all three parts are mandatory.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WrappedAccountCryptographicState {
|
||||
public_key_encryption_key_pair: PublicKeyEncryptionKeyPairData,
|
||||
signature_key_pair: SignatureKeyPairData,
|
||||
security_state: SecurityStateData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UnlockMethodData {
|
||||
unlock_method: i32,
|
||||
master_password_unlock_data: Option<UnlockData>,
|
||||
key_connector_key_wrapped_user_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Enums/UnlockMethod.cs#L3-L8>
|
||||
#[derive(num_derive::FromPrimitive)]
|
||||
enum UnlockMethod {
|
||||
Tde = 0,
|
||||
MasterPassword = 1,
|
||||
KeyConnector = 2,
|
||||
}
|
||||
|
||||
/// The rotation checks on the new keys: the public key never changes, nor the verifying key of a v2 account.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs#L192-L287>
|
||||
async fn validate_rotation_account_keys(keys: &ValidatedAccountKeys, user: &User, conn: &DbConn) -> EmptyResult {
|
||||
if user.public_key.as_ref() != Some(&keys.public_key) {
|
||||
err!("Changing the asymmetric keypair is not possible during key rotation")
|
||||
}
|
||||
|
||||
let Some(v2) = &keys.v2 else {
|
||||
if user.is_v2() {
|
||||
err!("Cannot downgrade an account from v2 to v1 encryption during key rotation")
|
||||
}
|
||||
// A v1 rotation: the private key stays wrapped by an AES-CBC-HMAC user key
|
||||
if enc_string_type(&keys.private_key) != Some(ENC_TYPE_AES_CBC_256_HMAC_SHA256) {
|
||||
err!("The provided account private key was not wrapped with AES-256-CBC-HMAC")
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Both a v2 rotation and the v1 -> v2 upgrade end up with a COSE user key wrapping the private keys
|
||||
if enc_string_type(&v2.signing_key) != Some(ENC_TYPE_COSE_ENCRYPT0) {
|
||||
err!("The provided signing key data is not wrapped with XChaCha20-Poly1305.")
|
||||
}
|
||||
if enc_string_type(&keys.private_key) != Some(ENC_TYPE_COSE_ENCRYPT0) {
|
||||
err!("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.")
|
||||
}
|
||||
if v2.verifying_key.is_empty() || v2.signed_public_key.is_empty() || v2.security_state.is_empty() {
|
||||
err!("The v2 account keys are missing the verifying key, signed public key or security state")
|
||||
}
|
||||
|
||||
if !user.is_v2() {
|
||||
// The v1 -> v2 upgrade, which is where the signature key pair comes from
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(key_pair) = UserSignatureKeyPair::find_by_user(&user.uuid, conn).await else {
|
||||
err!("The account is missing its signature key pair")
|
||||
};
|
||||
if key_pair.verifying_key != v2.verifying_key {
|
||||
err!("Changing the verifying key is not possible during key rotation")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `AesCbc256_HmacSha256_B64`, the v1 user key
|
||||
const ENC_TYPE_AES_CBC_256_HMAC_SHA256: &str = "2";
|
||||
/// `CoseEncrypt0B64`, the v2 user key
|
||||
const ENC_TYPE_COSE_ENCRYPT0: &str = "7";
|
||||
|
||||
/// The encryption type of an EncString, the number before the first `.`.
|
||||
fn enc_string_type(enc_string: &str) -> Option<&str> {
|
||||
enc_string.split_once('.').map(|(enc_type, _)| enc_type)
|
||||
}
|
||||
|
||||
impl KDFData {
|
||||
/// Whether these are the settings the user already has, which a key rotation can't change.
|
||||
fn is_unchanged_for(&self, user: &User) -> bool {
|
||||
user.client_kdf_type == self.kdf
|
||||
&& user.client_kdf_iter == self.kdf_iterations
|
||||
&& user.client_kdf_memory == self.kdf_memory
|
||||
&& user.client_kdf_parallelism == self.kdf_parallelism
|
||||
}
|
||||
}
|
||||
|
||||
/// Every existing item must be in the rotation, or it would be unreadable afterwards.
|
||||
fn validate_rotation_data(
|
||||
data: &RotateData,
|
||||
fn validate_keydata(
|
||||
data: &KeyData,
|
||||
existing_ciphers: &[Cipher],
|
||||
existing_folders: &[Folder],
|
||||
existing_emergency_access: &[EmergencyAccess],
|
||||
existing_memberships: &[Membership],
|
||||
existing_sends: &[Send],
|
||||
user: &User,
|
||||
) -> EmptyResult {
|
||||
if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type
|
||||
|| user.client_kdf_iter != data.account_unlock_data.master_password_unlock_data.kdf_iterations
|
||||
|| user.client_kdf_memory != data.account_unlock_data.master_password_unlock_data.kdf_memory
|
||||
|| user.client_kdf_parallelism != data.account_unlock_data.master_password_unlock_data.kdf_parallelism
|
||||
|| user.email != data.account_unlock_data.master_password_unlock_data.email
|
||||
{
|
||||
err!("Changing the kdf variant or email is not supported during key rotation");
|
||||
}
|
||||
if user.public_key.as_ref() != Some(&data.account_keys.account_public_key) {
|
||||
err!("Changing the asymmetric keypair is not possible during key rotation")
|
||||
}
|
||||
|
||||
// Check that we're correctly rotating all the user's ciphers
|
||||
let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::<HashSet<&CipherId>>();
|
||||
let provided_cipher_ids = data
|
||||
@@ -1395,8 +1241,12 @@ fn validate_rotation_data(
|
||||
// Check that we're correctly rotating all the user's emergency access keys
|
||||
let existing_emergency_access_ids =
|
||||
existing_emergency_access.iter().map(|ea| &ea.uuid).collect::<HashSet<&EmergencyAccessId>>();
|
||||
let provided_emergency_access_ids =
|
||||
data.unlock_data.emergency_access_unlock_data.iter().map(|ea| &ea.id).collect::<HashSet<&EmergencyAccessId>>();
|
||||
let provided_emergency_access_ids = data
|
||||
.account_unlock_data
|
||||
.emergency_access_unlock_data
|
||||
.iter()
|
||||
.map(|ea| &ea.id)
|
||||
.collect::<HashSet<&EmergencyAccessId>>();
|
||||
if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) {
|
||||
err!("All existing emergency access keys must be included in the rotation")
|
||||
}
|
||||
@@ -1405,7 +1255,7 @@ fn validate_rotation_data(
|
||||
let existing_reset_password_ids =
|
||||
existing_memberships.iter().map(|m| &m.org_uuid).collect::<HashSet<&OrganizationId>>();
|
||||
let provided_reset_password_ids = data
|
||||
.unlock_data
|
||||
.account_unlock_data
|
||||
.organization_account_recovery_unlock_data
|
||||
.iter()
|
||||
.map(|rp| &rp.organization_id)
|
||||
@@ -1424,441 +1274,137 @@ fn validate_rotation_data(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The keys a rotation re-shares the new user key with. The SDK needs all four lists, even when empty.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/UserKey/Queries/KeyRotationDataQuery.cs#L14-L122>
|
||||
#[get("/accounts/key-management/key-rotation-data")]
|
||||
async fn get_key_rotation_data(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let user_id = &headers.user.uuid;
|
||||
|
||||
let mut organization_data = Vec::new();
|
||||
for membership in Membership::find_by_user(user_id, &conn).await {
|
||||
// Only memberships actually enrolled in account recovery take part in the rotation.
|
||||
if membership.reset_password_key.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(org) = Organization::find_by_uuid(&membership.org_uuid, &conn).await else {
|
||||
continue;
|
||||
};
|
||||
let Some(public_key) = org.public_key else {
|
||||
continue;
|
||||
};
|
||||
organization_data.push(json!({
|
||||
"organizationId": org.uuid,
|
||||
"organizationName": org.name,
|
||||
"organizationPublicKey": public_key,
|
||||
"object": "organizationPasswordResetKeyData",
|
||||
}));
|
||||
}
|
||||
|
||||
let mut emergency_access_data = Vec::new();
|
||||
for emergency_access in EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await {
|
||||
// Without a stored key there is nothing to re-share.
|
||||
if emergency_access.key_encrypted.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(grantee_id) = emergency_access.grantee_uuid.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(grantee) = User::find_by_uuid(&grantee_id, &conn).await else {
|
||||
continue;
|
||||
};
|
||||
let Some(public_key) = grantee.public_key.clone() else {
|
||||
continue;
|
||||
};
|
||||
emergency_access_data.push(json!({
|
||||
"id": emergency_access.uuid,
|
||||
"granteeId": grantee_id,
|
||||
"granteeName": grantee.name,
|
||||
"granteeEmail": grantee.email,
|
||||
"publicKey": public_key,
|
||||
"object": "emergencyAccessKeyData",
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"organizationPasswordResetKeyData": organization_data,
|
||||
"emergencyAccessKeyData": emergency_access_data,
|
||||
"trustedDeviceKeyData": [],
|
||||
"passkeyKeyData": [],
|
||||
"object": "keyRotationData",
|
||||
})))
|
||||
}
|
||||
|
||||
/// Everything a rotation replaces, once each endpoint's wrapper has been peeled off.
|
||||
struct RotateData {
|
||||
account_keys: ValidatedAccountKeys,
|
||||
account_data: RotateAccountData,
|
||||
unlock_data: CommonUnlockData,
|
||||
/// The id of the key the client rotates from, `None` from clients that don't send it.
|
||||
old_user_key_id: Option<KeyId>,
|
||||
/// `None` from clients that predate key ids.
|
||||
new_user_key_id: Option<KeyId>,
|
||||
/// The new user key, wrapped by the master key.
|
||||
wrapped_user_key: String,
|
||||
/// Only for `rotate-user-account-keys`: the new password hash and hint.
|
||||
new_password: Option<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
/// Upstream's `[StringLength(max)]`, which counts UTF-16 code units.
|
||||
fn validate_max_length(value: &str, max: usize, field: &str) -> EmptyResult {
|
||||
if value.encode_utf16().count() > max {
|
||||
err!(format!("The field {field} must be a string with a maximum length of {max}."))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl KDFData {
|
||||
/// Upstream's check that the parameters fit the KDF type.
|
||||
fn validate_parameters(&self) -> EmptyResult {
|
||||
let has_argon2_parameters = (self.kdf_memory.is_some(), self.kdf_parallelism.is_some());
|
||||
if self.kdf == UserKdfType::Pbkdf2 as i32 {
|
||||
if has_argon2_parameters != (false, false) {
|
||||
err!("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256")
|
||||
}
|
||||
} else if self.kdf == UserKdfType::Argon2id as i32 {
|
||||
if has_argon2_parameters != (true, true) {
|
||||
err!("KdfMemory and KdfParallelism must have values for Argon2id")
|
||||
}
|
||||
} else {
|
||||
err!("Invalid KdfType")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MasterPasswordUnlockData {
|
||||
/// Upstream's model validation.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataAndAuthenticationModel.cs#L8-L48>
|
||||
fn validate(&self) -> EmptyResult {
|
||||
self.kdf.validate_parameters()?;
|
||||
if !crate::util::is_valid_email(&self.email) {
|
||||
err!("The Email field is not a supported e-mail address format.")
|
||||
}
|
||||
validate_max_length(&self.email, 256, "Email")?;
|
||||
required(Some(self.master_key_authentication_hash.clone()), "MasterKeyAuthenticationHash")?;
|
||||
validate_max_length(&self.master_key_authentication_hash, 300, "MasterKeyAuthenticationHash")?;
|
||||
required(Some(self.master_key_encrypted_user_key.clone()), "MasterKeyEncryptedUserKey")?;
|
||||
if let Some(hint) = &self.master_password_hint {
|
||||
validate_max_length(hint, 50, "MasterPasswordHint")?;
|
||||
}
|
||||
if self.master_password_salt.as_ref().is_some_and(|salt| salt.encode_utf16().count() > 256) {
|
||||
err!("The field MasterPasswordSalt must be a string or array type with a maximum length of '256'.")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonUnlockData {
|
||||
/// Upstream's model validation.
|
||||
fn validate(&self) -> EmptyResult {
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs#L32-L39>
|
||||
for emergency_access in &self.emergency_access_unlock_data {
|
||||
if !emergency_access.wait_time_days.is_some_and(|days| (1..=i32::from(i16::MAX)).contains(&days)) {
|
||||
err!("The field WaitTimeDays must be between 1 and 32767.")
|
||||
}
|
||||
}
|
||||
if let Some(token) = &self.v2_upgrade_token {
|
||||
required(Some(token.wrapped_user_key1.clone()), "WrappedUserKey1")?;
|
||||
required(Some(token.wrapped_user_key2.clone()), "WrappedUserKey2")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The model validation of the parts both rotation endpoints share, which upstream runs before anything else.
|
||||
fn validate_rotation_request(account_data: &RotateAccountData, unlock_data: &CommonUnlockData) -> EmptyResult {
|
||||
unlock_data.validate()?;
|
||||
|
||||
// Validate the import before continuing
|
||||
// Bitwarden does not process the import if there is one item invalid.
|
||||
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
|
||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||
Cipher::validate_cipher_data(&account_data.ciphers)?;
|
||||
|
||||
if account_data.ciphers.iter().any(|cipher| cipher.id.is_none()) {
|
||||
err!("The Id field is required.")
|
||||
}
|
||||
if account_data.sends.iter().any(|send| send.id.is_none()) {
|
||||
err!("The Id field is required.")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/accounts/key-management/rotate-user-account-keys", data = "<data>")]
|
||||
async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
// TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything.
|
||||
let data: KeyData = data.into_inner();
|
||||
|
||||
let unlock_data = data.account_unlock_data.master_password_unlock_data;
|
||||
validate_max_length(&data.old_master_key_authentication_hash, 300, "OldMasterKeyAuthenticationHash")?;
|
||||
unlock_data.validate()?;
|
||||
validate_rotation_request(&data.account_data, &data.account_unlock_data.common)?;
|
||||
|
||||
if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/UserKey/Models/Data/PasswordChangeAndRotateUserAccountKeysData.cs#L17-L32>
|
||||
let salt = unlock_data.master_password_salt.as_ref().unwrap_or(&unlock_data.email);
|
||||
if !unlock_data.kdf.is_unchanged_for(&headers.user) || *salt != headers.user.master_password_salt() {
|
||||
err!("The provided master password unlock data is not valid for this user.")
|
||||
// Rotating v2 keys, or upgrading to them, would leave an account that can't be unlocked here
|
||||
if headers.user.is_v2() {
|
||||
err!("Key rotation is not supported for v2 accounts")
|
||||
}
|
||||
validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?;
|
||||
let password_hint = clean_password_hint(unlock_data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||
|
||||
let mut common = data.account_unlock_data.common;
|
||||
// Like upstream, this endpoint logs every session out, so an upgrade token is never kept
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs#L94-L96>
|
||||
common.v2_upgrade_token = None;
|
||||
|
||||
rotate_account(
|
||||
RotateData {
|
||||
account_keys: data.account_keys.validate()?,
|
||||
account_data: data.account_data,
|
||||
unlock_data: common,
|
||||
old_user_key_id: data.old_user_key_id,
|
||||
new_user_key_id: data.new_user_key_id,
|
||||
wrapped_user_key: unlock_data.master_key_encrypted_user_key,
|
||||
new_password: Some((unlock_data.master_key_authentication_hash, password_hint)),
|
||||
},
|
||||
headers,
|
||||
conn,
|
||||
nt,
|
||||
)
|
||||
.await
|
||||
if !data.account_keys.user_key_encrypted_account_private_key.starts_with("2.") {
|
||||
err!("The provided account private key was not wrapped with AES-256-CBC-HMAC")
|
||||
}
|
||||
|
||||
/// Both rotation endpoints: every check first, then the re-encrypted data and the new keys are saved.
|
||||
async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
// Validate the import before continuing
|
||||
// Bitwarden does not process the import if there is one item invalid.
|
||||
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
|
||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||
Cipher::validate_cipher_data(&data.account_data.ciphers)?;
|
||||
|
||||
let user_id = &headers.user.uuid;
|
||||
|
||||
// TODO: Ideally we'd do everything after this point in a single transaction.
|
||||
|
||||
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
|
||||
let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
|
||||
let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await;
|
||||
// Like upstream, only the ones with a key take part
|
||||
existing_emergency_access.retain(|ea| ea.key_encrypted.is_some());
|
||||
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
|
||||
// We only rotate the reset password key if it is set.
|
||||
existing_memberships.retain(|m| m.reset_password_key.is_some());
|
||||
let mut existing_sends = Send::find_by_user(user_id, &conn).await;
|
||||
|
||||
validate_rotation_data(
|
||||
validate_keydata(
|
||||
&data,
|
||||
&existing_ciphers,
|
||||
&existing_folders,
|
||||
&existing_emergency_access,
|
||||
&existing_memberships,
|
||||
&existing_sends,
|
||||
&headers.user,
|
||||
)?;
|
||||
|
||||
// Like upstream, a client that rotates a user key that was already replaced has to sync first
|
||||
// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Core/KeyManagement/UserKey/Models/Data/BaseRotateUserAccountKeysData.cs#L34-L46>
|
||||
if let (Some(current), Some(old)) = (&headers.user.key_id, &data.old_user_key_id)
|
||||
&& current != old
|
||||
{
|
||||
err_code!("The user key being rotated is not the current user key. Sync and try again.", Status::Conflict.code)
|
||||
}
|
||||
|
||||
validate_rotation_account_keys(&data.account_keys, &headers.user, &conn).await?;
|
||||
|
||||
// An upgrade keeps the sessions; for an account already on v2 the token means nothing and is dropped
|
||||
let is_upgrade = data.unlock_data.v2_upgrade_token.is_some() && !headers.user.is_v2();
|
||||
let upgrade_token = match &data.unlock_data.v2_upgrade_token {
|
||||
Some(token) if is_upgrade => Some(serde_json::to_string(token)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// An upgrade can't re-wrap the recovery keys without a trust prompt, so they're kept and get the token
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs#L39-L64>
|
||||
for membership in &existing_memberships {
|
||||
let has_key = data
|
||||
.unlock_data
|
||||
.organization_account_recovery_unlock_data
|
||||
.iter()
|
||||
.find(|rp| rp.organization_id == membership.org_uuid)
|
||||
.and_then(|rp| rp.reset_password_key.as_deref())
|
||||
.is_some_and(|key| !key.trim().is_empty());
|
||||
if is_upgrade && has_key {
|
||||
err!("Account recovery keys cannot be rotated during a V1 to V2 upgrade rotation.")
|
||||
}
|
||||
if !is_upgrade && !has_key {
|
||||
err!("Account recovery keys cannot be null or empty during rotation.")
|
||||
}
|
||||
}
|
||||
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Validators/EmergencyAccessRotationValidator.cs#L23-L53>
|
||||
for emergency_access in &existing_emergency_access {
|
||||
if data
|
||||
.unlock_data
|
||||
.emergency_access_unlock_data
|
||||
.iter()
|
||||
.find(|ea| ea.id == emergency_access.uuid)
|
||||
.is_some_and(|ea| ea.key_encrypted.is_none())
|
||||
{
|
||||
err!("Emergency access keys cannot be set to null during rotation.")
|
||||
}
|
||||
}
|
||||
|
||||
// Saving's own checks, so that they can't fail halfway
|
||||
for cipher_data in data.account_data.ciphers.iter().filter(|c| c.organization_id.is_none()) {
|
||||
if let Some(cipher) = existing_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) {
|
||||
validate_rotated_cipher(cipher, cipher_data, &headers, &conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Ideally we'd do everything after this point in a single transaction.
|
||||
|
||||
// Update folder data, ignoring ids that aren't the user's, like upstream
|
||||
// Update folder data
|
||||
for folder_data in data.account_data.folders {
|
||||
// Skip `null` folder id entries.
|
||||
// See: https://github.com/bitwarden/clients/issues/8453
|
||||
if let Some(saved_folder) = folder_data.id.and_then(|id| existing_folders.iter_mut().find(|f| f.uuid == id)) {
|
||||
if let Some(folder_id) = folder_data.id {
|
||||
let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else {
|
||||
err!("Folder doesn't exist")
|
||||
};
|
||||
|
||||
saved_folder.name = folder_data.name;
|
||||
saved_folder.save(&conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Like upstream, the unlock data below comes from the first entry for each, as checked above
|
||||
for saved_emergency_access in &mut existing_emergency_access {
|
||||
if let Some(emergency_access_data) =
|
||||
data.unlock_data.emergency_access_unlock_data.iter().find(|ea| ea.id == saved_emergency_access.uuid)
|
||||
{
|
||||
saved_emergency_access.key_encrypted.clone_from(&emergency_access_data.key_encrypted);
|
||||
// Update emergency access data
|
||||
for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data {
|
||||
let Some(saved_emergency_access) =
|
||||
existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id)
|
||||
else {
|
||||
err!("Emergency access doesn't exist or is not owned by the user")
|
||||
};
|
||||
|
||||
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
||||
saved_emergency_access.save(&conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
for membership in &mut existing_memberships {
|
||||
if let Some(reset_password_data) = data
|
||||
.unlock_data
|
||||
.organization_account_recovery_unlock_data
|
||||
.iter()
|
||||
.find(|rp| rp.organization_id == membership.org_uuid)
|
||||
{
|
||||
if !is_upgrade {
|
||||
membership.reset_password_key.clone_from(&reset_password_data.reset_password_key);
|
||||
}
|
||||
membership.v2_upgrade_token.clone_from(&upgrade_token);
|
||||
// Update reset password data
|
||||
for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data {
|
||||
let Some(membership) =
|
||||
existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id)
|
||||
else {
|
||||
err!("Reset password doesn't exist")
|
||||
};
|
||||
|
||||
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
||||
membership.save(&conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Update send data
|
||||
for send_data in data.account_data.sends {
|
||||
if let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) {
|
||||
let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else {
|
||||
err!("Send doesn't exist")
|
||||
};
|
||||
|
||||
// Like upstream, only the key changes on rotation
|
||||
send.akey = send_data.key;
|
||||
send.save(&conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Update cipher data
|
||||
for cipher_data in data.account_data.ciphers {
|
||||
if cipher_data.organization_id.is_none()
|
||||
&& let Some(saved_cipher) =
|
||||
if cipher_data.organization_id.is_none() {
|
||||
let Some(saved_cipher) =
|
||||
cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id))
|
||||
{
|
||||
// No cipher pushes: the other sessions still hold the old user key
|
||||
rotate_cipher_data(saved_cipher, cipher_data, &headers, &conn, &nt).await?;
|
||||
else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
||||
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
||||
// We force the users to logout after the user has been saved to try and prevent these issues.
|
||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user data
|
||||
let mut user = headers.user;
|
||||
user.v2_upgrade_token = upgrade_token;
|
||||
|
||||
data.account_keys.apply(&mut user)?;
|
||||
// The old id names a key that no longer exists, so it is replaced even when the new key has none.
|
||||
user.key_id = data.new_user_key_id;
|
||||
user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key);
|
||||
user.set_password(
|
||||
&data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash,
|
||||
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
|
||||
true,
|
||||
None,
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
user.akey = data.wrapped_user_key;
|
||||
if let Some((new_password_hash, new_password_hint)) = data.new_password {
|
||||
// A password change ends every session, as `/accounts/password` does
|
||||
user.password_hint = new_password_hint;
|
||||
user.set_password(&new_password_hash, None, true, None, &conn).await?;
|
||||
} else if !is_upgrade {
|
||||
// An upgrade keeps the sessions alive, see `is_upgrade` above
|
||||
user.reset_security_stamp_after_key_rotation(&conn).await?;
|
||||
}
|
||||
|
||||
// The key pair first, so that a v2 account is never saved without one. The data above is already re-encrypted by
|
||||
// now, and for an account that was already v2 so is the signing key saved here, so a failure in either save
|
||||
// leaves them under a key that isn't stored (see the TODO before the first write).
|
||||
data.account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||
user.save(&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.
|
||||
// On an upgrade, the reason lets clients sync instead
|
||||
let reason = is_upgrade.then_some(LogOutReason::KeyRotation);
|
||||
nt.send_logout_with_reason(&user, Some(&headers.device), reason, &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rotates the keys without changing the master password; like upstream, the session is the only proof.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs#L174-L208>
|
||||
#[post("/accounts/key-management/rotate-user-keys", data = "<data>")]
|
||||
async fn post_rotate_user_keys(
|
||||
data: Json<RotateUserKeysData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
let data: RotateUserKeysData = data.into_inner();
|
||||
validate_rotation_request(&data.account_data, &data.unlock_data)?;
|
||||
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Models/Requests/UnlockMethodRequestModel.cs#L20-L46>
|
||||
let wrapped_user_key = match UnlockMethod::from_i32(data.unlock_method_data.unlock_method) {
|
||||
Some(UnlockMethod::MasterPassword) => {
|
||||
let (Some(unlock_data), None) = (
|
||||
data.unlock_method_data.master_password_unlock_data,
|
||||
data.unlock_method_data.key_connector_key_wrapped_user_key,
|
||||
) else {
|
||||
err!(
|
||||
"Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null"
|
||||
)
|
||||
};
|
||||
required(Some(unlock_data.master_key_wrapped_user_key.clone()), "MasterKeyWrappedUserKey")?;
|
||||
required(Some(unlock_data.salt.clone()), "Salt")?;
|
||||
validate_max_length(&unlock_data.salt, 256, "Salt")?;
|
||||
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/UserKey/Models/Data/MasterPasswordRotateUserAccountKeysData.cs#L12-L23>
|
||||
if headers.user.password_hash.is_empty() || headers.user.akey.is_empty() {
|
||||
err!("User is in an invalid state for master password key rotation.")
|
||||
}
|
||||
if unlock_data.salt != headers.user.master_password_salt() {
|
||||
err!("Invalid master password salt.")
|
||||
}
|
||||
if !unlock_data.kdf.is_unchanged_for(&headers.user) {
|
||||
err!("Invalid KDF settings.")
|
||||
}
|
||||
validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?;
|
||||
unlock_data.master_key_wrapped_user_key
|
||||
}
|
||||
Some(UnlockMethod::Tde) => err!("Trusted device encryption is not supported"),
|
||||
Some(UnlockMethod::KeyConnector) => err!("Key connector is not supported"),
|
||||
None => err!("Unrecognized unlock method"),
|
||||
};
|
||||
|
||||
rotate_account(
|
||||
RotateData {
|
||||
account_keys: data.wrapped_account_cryptographic_state.validate()?,
|
||||
account_data: data.account_data,
|
||||
unlock_data: data.unlock_data,
|
||||
old_user_key_id: data.old_user_key_id,
|
||||
new_user_key_id: data.new_user_key_id,
|
||||
wrapped_user_key,
|
||||
new_password: None,
|
||||
},
|
||||
headers,
|
||||
conn,
|
||||
nt,
|
||||
)
|
||||
.await
|
||||
save_result
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1946,11 +1492,7 @@ async fn post_email_token(data: Json<EmailTokenData>, headers: Headers, conn: Db
|
||||
error!("Error sending change-email email: {e:#?}");
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"Email change request for user ({}) to email ({}) with token ({token})",
|
||||
user.uuid,
|
||||
data.new_email.escape_debug()
|
||||
);
|
||||
debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email);
|
||||
}
|
||||
|
||||
user.email_new = Some(data.new_email);
|
||||
@@ -2103,7 +1645,7 @@ struct DeleteRecoverTokenData {
|
||||
}
|
||||
|
||||
#[post("/accounts/delete-recover-token", data = "<data>")]
|
||||
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn) -> EmptyResult {
|
||||
let data: DeleteRecoverTokenData = data.into_inner();
|
||||
|
||||
let Ok(claims) = decode_delete(&data.token) else {
|
||||
@@ -2117,44 +1659,22 @@ async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbC
|
||||
if claims.sub != *user.uuid {
|
||||
err!("Invalid claim");
|
||||
}
|
||||
|
||||
// The devices are deleted together with the user, so check for push devices beforehand
|
||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
|
||||
user.delete(&conn).await?;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
if has_push_device {
|
||||
push_logout_to_relay(&user, None, None);
|
||||
}
|
||||
Ok(())
|
||||
user.delete(&conn).await
|
||||
}
|
||||
|
||||
#[post("/accounts/delete", data = "<data>")]
|
||||
async fn post_delete_account(
|
||||
data: Json<PasswordOrOtpData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
delete_account(data, headers, conn, nt).await
|
||||
async fn post_delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
delete_account(data, headers, conn).await
|
||||
}
|
||||
|
||||
#[delete("/accounts", data = "<data>")]
|
||||
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
data.validate(&user, true, &conn).await?;
|
||||
|
||||
// The devices are deleted together with the user, so check for push devices beforehand
|
||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
|
||||
user.delete(&conn).await?;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
if has_push_device {
|
||||
push_logout_to_relay(&user, None, None);
|
||||
}
|
||||
Ok(())
|
||||
user.delete(&conn).await
|
||||
}
|
||||
|
||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||
@@ -2205,7 +1725,7 @@ async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn)
|
||||
mail::send_password_hint(email, hint).await?;
|
||||
Ok(())
|
||||
} else if let Some(hint) = hint {
|
||||
err_silent!(format!("Your password hint is: {hint}"));
|
||||
err!(format!("Your password hint is: {hint}"));
|
||||
} else {
|
||||
err!(NO_HINT);
|
||||
}
|
||||
|
||||
+72
-211
@@ -13,7 +13,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||
auth::ClientVersion,
|
||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||
config::PathType,
|
||||
@@ -190,7 +190,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||
"masterKeyEncryptedUserKey": headers.user.akey,
|
||||
"masterKeyWrappedUserKey": headers.user.akey,
|
||||
"salt": headers.user.master_password_salt(),
|
||||
"salt": headers.user.email,
|
||||
"containedKeyId": headers.user.key_id,
|
||||
})
|
||||
} else {
|
||||
@@ -202,9 +202,6 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
if let Some(key_id) = &headers.user.key_id {
|
||||
user_decryption["userKeyId"] = json!(key_id);
|
||||
}
|
||||
if let Some(v2_upgrade_token) = headers.user.v2_upgrade_token_json() {
|
||||
user_decryption["v2UpgradeToken"] = v2_upgrade_token;
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"profile": user_json,
|
||||
@@ -379,25 +376,6 @@ impl CipherData {
|
||||
self.data.as_deref().is_some_and(is_data_blob_encrypted)
|
||||
}
|
||||
|
||||
/// Saving's last checks: a known type, and its data unless it's a blob.
|
||||
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
|
||||
let type_data = match self.r#type {
|
||||
1 => &self.login,
|
||||
2 => &self.secure_note,
|
||||
3 => &self.card,
|
||||
4 => &self.identity,
|
||||
5 => &self.ssh_key,
|
||||
6 => &self.bank_account,
|
||||
7 => &self.drivers_license,
|
||||
8 => &self.passport,
|
||||
_ => err!("Invalid type"),
|
||||
};
|
||||
if !is_blob && type_data.is_none() {
|
||||
err!("Data missing")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
|
||||
@@ -535,7 +513,7 @@ async fn validate_cipher_update(
|
||||
{
|
||||
match NaiveDateTime::parse_from_str(dt, "%+") {
|
||||
// ISO 8601 format
|
||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
|
||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
||||
}
|
||||
@@ -566,40 +544,6 @@ pub async fn update_cipher_from_data(
|
||||
conn: &DbConn,
|
||||
nt: &Notify<'_>,
|
||||
ut: UpdateType,
|
||||
) -> EmptyResult {
|
||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||
save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await
|
||||
}
|
||||
|
||||
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
|
||||
pub async fn rotate_cipher_data(
|
||||
cipher: &mut Cipher,
|
||||
data: CipherData,
|
||||
headers: &Headers,
|
||||
conn: &DbConn,
|
||||
nt: &Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await
|
||||
}
|
||||
|
||||
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
|
||||
pub async fn validate_rotated_cipher(
|
||||
cipher: &Cipher,
|
||||
data: &CipherData,
|
||||
headers: &Headers,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await
|
||||
}
|
||||
|
||||
async fn save_cipher_data(
|
||||
cipher: &mut Cipher,
|
||||
data: CipherData,
|
||||
headers: &Headers,
|
||||
shared_to_collections: Option<Vec<CollectionId>>,
|
||||
conn: &DbConn,
|
||||
nt: &Notify<'_>,
|
||||
ut: UpdateType,
|
||||
) -> EmptyResult {
|
||||
// Cleanup cipher data, like removing the 'Response' key.
|
||||
// This key is somewhere generated during Javascript so no way for us this fix this.
|
||||
@@ -615,9 +559,10 @@ async fn save_cipher_data(
|
||||
json_data
|
||||
}
|
||||
|
||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||
|
||||
let is_blob = data.is_blob();
|
||||
data.validate_content(is_blob)?;
|
||||
data.validate_type_data(is_blob)?;
|
||||
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
|
||||
|
||||
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
||||
@@ -662,7 +607,7 @@ async fn save_cipher_data(
|
||||
// A missing attachment means it was removed via an other client.
|
||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||
// Bitwarden it self ignores these mismatches server side.
|
||||
warn!("Attachment {} doesn't exist", id.escape_debug());
|
||||
warn!("Attachment {id} doesn't exist");
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -731,7 +676,7 @@ async fn save_cipher_data(
|
||||
match data.archived_date {
|
||||
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||
Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()),
|
||||
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
||||
},
|
||||
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||
}
|
||||
@@ -817,9 +762,6 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||
cipher_data.folder_id = folder_id;
|
||||
// A personal import always creates personal ciphers, the organization ID is ignored instead of rejected
|
||||
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
|
||||
cipher_data.organization_id = None;
|
||||
|
||||
let mut cipher = Cipher::new(cipher_data.r#type);
|
||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||
@@ -841,11 +783,6 @@ async fn put_cipher_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
// Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's
|
||||
if data.organization_id.is_none() {
|
||||
err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code);
|
||||
}
|
||||
|
||||
put_cipher(cipher_id, data, headers, conn, nt).await
|
||||
}
|
||||
|
||||
@@ -857,7 +794,7 @@ async fn post_cipher_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
put_cipher_admin(cipher_id, data, headers, conn, nt).await
|
||||
post_cipher(cipher_id, data, headers, conn, nt).await
|
||||
}
|
||||
|
||||
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
||||
@@ -897,11 +834,6 @@ async fn put_cipher(
|
||||
// Against the cipher we hold rather than the organization the client claims, like upstream
|
||||
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
|
||||
|
||||
// Moving a cipher into an organization is only possible through the share endpoints
|
||||
if cipher.organization_uuid != data.organization_id {
|
||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||
}
|
||||
|
||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
||||
|
||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
||||
@@ -1198,53 +1130,14 @@ struct ShareSelectedCipherData {
|
||||
collection_ids: Vec<CollectionId>,
|
||||
}
|
||||
|
||||
// A bulk share returns cipherMini models, not the cipherDetails models used by
|
||||
// the single-share route. Keep the encrypted legacy fields and a future v2
|
||||
// `data` blob, but do not expose details-only fields in this response.
|
||||
fn cipher_mini_from_json(mut cipher: Value) -> Value {
|
||||
const FIELDS: &[&str] = &[
|
||||
"id",
|
||||
"organizationId",
|
||||
"type",
|
||||
"data",
|
||||
"partialData",
|
||||
"name",
|
||||
"notes",
|
||||
"login",
|
||||
"card",
|
||||
"identity",
|
||||
"secureNote",
|
||||
"sshKey",
|
||||
"bankAccount",
|
||||
"driversLicense",
|
||||
"passport",
|
||||
"fields",
|
||||
"passwordHistory",
|
||||
"attachments",
|
||||
"organizationUseTotp",
|
||||
"revisionDate",
|
||||
"creationDate",
|
||||
"deletedDate",
|
||||
"reprompt",
|
||||
"key",
|
||||
];
|
||||
|
||||
let Some(object) = cipher.as_object_mut() else {
|
||||
return json!({ "object": "cipherMini" });
|
||||
};
|
||||
object.retain(|field, _| FIELDS.contains(&field.as_str()));
|
||||
object.insert("object".into(), "cipherMini".into());
|
||||
cipher
|
||||
}
|
||||
|
||||
#[put("/ciphers/share", data = "<data>")]
|
||||
async fn put_cipher_share_selected(
|
||||
data: Json<ShareSelectedCipherData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let data: ShareSelectedCipherData = data.into_inner();
|
||||
) -> EmptyResult {
|
||||
let mut data: ShareSelectedCipherData = data.into_inner();
|
||||
|
||||
// Upstream's `PutShareMany` checks, before anything is written.
|
||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
|
||||
@@ -1262,9 +1155,7 @@ async fn put_cipher_share_selected(
|
||||
err!("You must select at least one collection.")
|
||||
}
|
||||
for cipher in &data.ciphers {
|
||||
let is_blob = cipher.is_blob();
|
||||
cipher.validate_content(is_blob)?;
|
||||
cipher.validate_type_data(is_blob)?;
|
||||
cipher.validate_content(cipher.is_blob())?;
|
||||
}
|
||||
for cipher in &data.ciphers {
|
||||
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
|
||||
@@ -1277,38 +1168,23 @@ async fn put_cipher_share_selected(
|
||||
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
|
||||
}
|
||||
|
||||
let mut responses = Vec::with_capacity(data.ciphers.len());
|
||||
for cipher in data.ciphers {
|
||||
while let Some(cipher) = data.ciphers.pop() {
|
||||
let mut shared_cipher_data = ShareCipherData {
|
||||
cipher,
|
||||
collection_ids: data.collection_ids.clone(),
|
||||
};
|
||||
|
||||
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||
let response =
|
||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?;
|
||||
|
||||
// Like upstream's VisibleToClient, withhold a cipher if sharing it
|
||||
// removed the user's access to it. Only then include its encrypted
|
||||
// mini response; the full single-share response stays internal.
|
||||
if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await
|
||||
&& shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||
{
|
||||
responses.push(cipher_mini_from_json(response.into_inner()));
|
||||
}
|
||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||
} else {
|
||||
err!("Request missing ids field")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
||||
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": responses,
|
||||
"object": "list",
|
||||
"continuationToken": null,
|
||||
})))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn share_cipher_by_uuid(
|
||||
@@ -1347,9 +1223,7 @@ async fn share_cipher_by_uuid(
|
||||
};
|
||||
|
||||
// For the same reason, the other checks of saving
|
||||
let is_blob = data.cipher.is_blob();
|
||||
data.cipher.validate_content(is_blob)?;
|
||||
data.cipher.validate_type_data(is_blob)?;
|
||||
data.cipher.validate_content(data.cipher.is_blob())?;
|
||||
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
|
||||
|
||||
let mut shared_to_collections = vec![];
|
||||
@@ -1417,40 +1291,6 @@ enum FileUploadType {
|
||||
// Azure = 1, // only used upstream
|
||||
}
|
||||
|
||||
/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited.
|
||||
/// `size_adjust` credits a v2 attachment whose record already counts as used.
|
||||
async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult<Option<i64>> {
|
||||
let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid {
|
||||
let Some(limit_kb) = CONFIG.user_attachment_limit() else {
|
||||
return Ok(None);
|
||||
};
|
||||
(limit_kb, Attachment::size_by_user(user_id, conn).await)
|
||||
} else if let Some(ref org_id) = cipher.organization_uuid {
|
||||
let Some(limit_kb) = CONFIG.org_attachment_limit() else {
|
||||
return Ok(None);
|
||||
};
|
||||
(limit_kb, Attachment::size_by_org(org_id, conn).await)
|
||||
} else {
|
||||
err!("Cipher is neither owned by a user nor an organization");
|
||||
};
|
||||
|
||||
if limit_kb == 0 {
|
||||
err!("Attachments are disabled")
|
||||
}
|
||||
|
||||
let Some(left) =
|
||||
limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust))
|
||||
else {
|
||||
err!("Attachment size overflow");
|
||||
};
|
||||
|
||||
if left <= 0 {
|
||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||
}
|
||||
|
||||
Ok(Some(left))
|
||||
}
|
||||
|
||||
/// v2 API for creating an attachment associated with a cipher.
|
||||
/// This redirects the client to the API it should use to upload the attachment.
|
||||
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
||||
@@ -1461,7 +1301,6 @@ async fn post_attachment_v2(
|
||||
data: Json<AttachmentRequestData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
@@ -1481,18 +1320,10 @@ async fn post_attachment_v2(
|
||||
if file_size > SIZE_525_MB {
|
||||
err!("Max file size is 525 MB.")
|
||||
}
|
||||
|
||||
if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await?
|
||||
&& file_size > size_limit
|
||||
{
|
||||
err!("Attachment storage limit exceeded with this file");
|
||||
}
|
||||
|
||||
let attachment_id = crypto::generate_attachment_id();
|
||||
let attachment =
|
||||
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
||||
attachment.save(&conn).await.expect("Error saving attachment");
|
||||
notify_attachment_created(&cipher, &headers, &conn, &nt).await;
|
||||
|
||||
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
||||
let response_key = match data.admin_request {
|
||||
@@ -1555,7 +1386,55 @@ async fn save_attachment(
|
||||
Some(a) => a.file_size, // v2 API
|
||||
};
|
||||
|
||||
if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await?
|
||||
let size_limit = if let Some(ref user_id) = cipher.user_uuid {
|
||||
match CONFIG.user_attachment_limit() {
|
||||
Some(0) => err!("Attachments are disabled"),
|
||||
Some(limit_kb) => {
|
||||
let already_used = Attachment::size_by_user(user_id, &conn).await;
|
||||
let left = limit_kb
|
||||
.checked_mul(1024)
|
||||
.and_then(|l| l.checked_sub(already_used))
|
||||
.and_then(|l| l.checked_add(size_adjust));
|
||||
|
||||
let Some(left) = left else {
|
||||
err!("Attachment size overflow");
|
||||
};
|
||||
|
||||
if left <= 0 {
|
||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||
}
|
||||
|
||||
Some(left)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else if let Some(ref org_id) = cipher.organization_uuid {
|
||||
match CONFIG.org_attachment_limit() {
|
||||
Some(0) => err!("Attachments are disabled"),
|
||||
Some(limit_kb) => {
|
||||
let already_used = Attachment::size_by_org(org_id, &conn).await;
|
||||
let left = limit_kb
|
||||
.checked_mul(1024)
|
||||
.and_then(|l| l.checked_sub(already_used))
|
||||
.and_then(|l| l.checked_add(size_adjust));
|
||||
|
||||
let Some(left) = left else {
|
||||
err!("Attachment size overflow");
|
||||
};
|
||||
|
||||
if left <= 0 {
|
||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||
}
|
||||
|
||||
Some(left)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
err!("Cipher is neither owned by a user nor an organization");
|
||||
};
|
||||
|
||||
if let Some(size_limit) = size_limit
|
||||
&& size > size_limit
|
||||
{
|
||||
err!("Attachment storage limit exceeded with this file");
|
||||
@@ -1612,23 +1491,13 @@ async fn save_attachment(
|
||||
|
||||
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
||||
|
||||
// With the v2 API, post_attachment_v2() already did this when it created the attachment record
|
||||
if attachment.is_none() {
|
||||
notify_attachment_created(&cipher, headers, &conn, &nt).await;
|
||||
}
|
||||
|
||||
Ok((cipher, conn))
|
||||
}
|
||||
|
||||
/// Sends the cipher update to the clients and logs the attachment event
|
||||
async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) {
|
||||
nt.send_cipher_update(
|
||||
UpdateType::SyncCipherUpdate,
|
||||
cipher,
|
||||
&cipher.update_users_revision(conn).await,
|
||||
&cipher,
|
||||
&cipher.update_users_revision(&conn).await,
|
||||
&headers.device,
|
||||
None,
|
||||
conn,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1640,10 +1509,12 @@ async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &Db
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
conn,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok((cipher, conn))
|
||||
}
|
||||
|
||||
/// v2 API for uploading the actual data content of an attachment.
|
||||
@@ -1919,22 +1790,12 @@ async fn move_cipher_selected(
|
||||
// Find all ciphers a user has access to, all others will be ignored
|
||||
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
||||
let accessible_ciphers_count = accessible_ciphers.len();
|
||||
let mut move_result = Ok(());
|
||||
for cipher in accessible_ciphers {
|
||||
move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await;
|
||||
if move_result.is_err() {
|
||||
break;
|
||||
}
|
||||
cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?;
|
||||
if cipher_count == 1 {
|
||||
single_cipher = Some(cipher);
|
||||
}
|
||||
}
|
||||
// Update the revision once for the whole batch. Also when a move failed halfway,
|
||||
// since the ciphers moved before it are already changed.
|
||||
if accessible_ciphers_count > 0 {
|
||||
User::update_uuid_revision(user_id, &conn).await;
|
||||
}
|
||||
move_result?;
|
||||
|
||||
if let Some(cipher) = single_cipher {
|
||||
nt.send_cipher_update(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::{
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
||||
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
@@ -136,21 +136,19 @@ async fn post_emergency_access(
|
||||
|
||||
let data: EmergencyAccessUpdateData = data.into_inner();
|
||||
|
||||
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
|
||||
err!("Emergency access wait time is out-of-bounds!")
|
||||
}
|
||||
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
emergency_access.atype = new_type as i32;
|
||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
emergency_access.atype = new_type;
|
||||
emergency_access.wait_time_days = data.wait_time_days;
|
||||
if data.key_encrypted.is_some() {
|
||||
emergency_access.key_encrypted = data.key_encrypted;
|
||||
@@ -212,11 +210,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
||||
let email = data.email.to_lowercase();
|
||||
let wait_time_days = data.wait_time_days;
|
||||
|
||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
|
||||
err!("Emergency access wait time is out-of-bounds!")
|
||||
}
|
||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||
|
||||
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
@@ -262,8 +260,6 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
||||
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
||||
}
|
||||
|
||||
let new_type = new_type as i32;
|
||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||
let mut new_emergency_access =
|
||||
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
||||
new_emergency_access.save(&conn).await?;
|
||||
@@ -296,7 +292,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
||||
err!("The grantee user is already accepted or confirmed for emergency access");
|
||||
err!("The grantee user is already accepted or confirmed to the organization");
|
||||
}
|
||||
|
||||
let Some(email) = emergency_access.email.clone() else {
|
||||
@@ -457,24 +453,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
||||
else {
|
||||
err!(
|
||||
"Emergency access not valid.",
|
||||
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
|
||||
)
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
||||
err!(
|
||||
"Emergency access not valid.",
|
||||
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
|
||||
)
|
||||
}
|
||||
|
||||
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
|
||||
err!(
|
||||
"Emergency access not valid.",
|
||||
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
|
||||
)
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||
@@ -507,17 +490,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||
else {
|
||||
err!(
|
||||
"Emergency access not valid.",
|
||||
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
|
||||
)
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
||||
err!(
|
||||
"Emergency access not valid.",
|
||||
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
|
||||
)
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
||||
@@ -770,72 +747,53 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(conn) = pool.get().await else {
|
||||
error!("Failed to get DB connection for emergency time-outs!");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(conn) = pool.get().await {
|
||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||
|
||||
if emergency_access_list.is_empty() {
|
||||
debug!("No emergency request timeout to approve");
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
for mut emer in emergency_access_list {
|
||||
match emer.is_recovery_allowed(&now) {
|
||||
Ok(true) => {} // Recovery is allowed
|
||||
Ok(false) => continue,
|
||||
Err(e) => {
|
||||
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get grantor user to send Accepted email
|
||||
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
||||
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
||||
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get grantee user to send Accepted email
|
||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
||||
let recovery_allowed_at =
|
||||
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
|
||||
if recovery_allowed_at.le(&now) {
|
||||
// Only update the access status
|
||||
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
||||
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
|
||||
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
|
||||
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
|
||||
continue;
|
||||
}
|
||||
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
|
||||
.await
|
||||
.expect("Unable to update emergency access status");
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
|
||||
// get grantor user to send Accepted email
|
||||
let grantor_user =
|
||||
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||
|
||||
// get grantee user to send Accepted email
|
||||
let grantee_user =
|
||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||
.await
|
||||
.expect("Grantee user not found");
|
||||
|
||||
mail::send_emergency_access_recovery_timed_out(
|
||||
&grantor_user.email,
|
||||
&grantee_user.name,
|
||||
emer.get_type_as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
|
||||
}
|
||||
.expect("Error on sending email");
|
||||
|
||||
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
|
||||
{
|
||||
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
|
||||
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)
|
||||
.await
|
||||
.expect("Error on sending email");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency request timed out");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
||||
@@ -844,85 +802,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(conn) = pool.get().await else {
|
||||
error!("Failed to get DB connection for emergency reminders!");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(conn) = pool.get().await {
|
||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||
|
||||
if emergency_access_list.is_empty() {
|
||||
debug!("No emergency request reminder notification to send");
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
for mut emer in emergency_access_list {
|
||||
let Ok(deadline_at) = emer.deadline_at() else {
|
||||
error!("Invalid deadline for emergency access '{}'", emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
|
||||
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Only send reminders during the last 24 hours, else continue
|
||||
if now < final_reminder_at || now >= deadline_at {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
|
||||
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
|
||||
// This is to prevent possible missed reminders, this might cause an extra reminder,
|
||||
// but that is better then no reminder at all.
|
||||
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
||||
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
|
||||
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
||||
// Calculate the day before the recovery will become active
|
||||
let final_recovery_reminder_at =
|
||||
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
|
||||
// Calculate if a day has passed since the previous notification, else no notification has been sent before
|
||||
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
||||
last_notification_at + TimeDelta::try_days(1).unwrap()
|
||||
} else {
|
||||
now
|
||||
};
|
||||
|
||||
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
|
||||
// Else we can skip the current emergency access this run.
|
||||
if next_reminder_at.gt(&now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get grantor user to send Reminder email
|
||||
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
||||
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
||||
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get grantee user to send Reminder email
|
||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
||||
continue;
|
||||
};
|
||||
|
||||
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
||||
// Only update the last notification date
|
||||
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
||||
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
|
||||
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
|
||||
continue;
|
||||
}
|
||||
emer.update_last_notification_date_and_save(&now, &conn)
|
||||
.await
|
||||
.expect("Unable to update emergency access notification date");
|
||||
|
||||
if CONFIG.mail_enabled()
|
||||
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
|
||||
if CONFIG.mail_enabled() {
|
||||
// get grantor user to send Accepted email
|
||||
let grantor_user =
|
||||
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||
|
||||
// get grantee user to send Accepted email
|
||||
let grantee_user =
|
||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||
.await
|
||||
.expect("Grantee user not found");
|
||||
|
||||
mail::send_emergency_access_recovery_reminder(
|
||||
&grantor_user.email,
|
||||
&grantee_user.name,
|
||||
emer.get_type_as_str(),
|
||||
"1", // This notification is only triggered one day before the activation
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
|
||||
.expect("Error on sending email");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||
}
|
||||
}
|
||||
+7
-10
@@ -214,8 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
.await;
|
||||
}
|
||||
}
|
||||
// Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
|
||||
1107..=1114 | 1117 | 1119..=1133 => {
|
||||
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
||||
1107..=1114 | 1117 | 1119..=1132 => {
|
||||
// The cipher determines the organization the event is logged to, so make sure the
|
||||
// user can actually access it instead of trusting the provided cipher uuid.
|
||||
if let Some(cipher_uuid) = &event.cipher_id
|
||||
@@ -347,17 +347,14 @@ async fn log_event_impl(
|
||||
|
||||
pub async fn event_cleanup_job(pool: DbPool) {
|
||||
debug!("Start events cleanup job");
|
||||
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
|
||||
debug!("events_days_retain is not configured or set to 0, skipping");
|
||||
if CONFIG.events_days_retain().is_none() {
|
||||
debug!("events_days_retain is not configured, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(conn) = pool.get().await else {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
Event::clean_events(&conn).await.ok();
|
||||
} else {
|
||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = Event::clean_events(&conn).await {
|
||||
error!("Failed to cleanup the events table: {e}");
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,6 @@ fn config() -> Json<Value> {
|
||||
"notifications": format!("{domain}/notifications"),
|
||||
"sso": "",
|
||||
"cloudRegion": null,
|
||||
"fillAssistRules": CONFIG.fill_assist_rules_url(),
|
||||
},
|
||||
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
||||
"push": {
|
||||
|
||||
+38
-135
@@ -15,10 +15,10 @@ use crate::{
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
|
||||
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
|
||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
|
||||
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
|
||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
||||
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
|
||||
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
|
||||
TwoFactorType, User, UserId,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
@@ -131,16 +131,13 @@ impl FullCollectionData {
|
||||
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.escape_debug(), org_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.escape_debug(), org_id)
|
||||
)
|
||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -555,7 +552,7 @@ async fn post_bulk_access_collections(
|
||||
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
||||
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
|
||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
|
||||
}
|
||||
|
||||
for col_id in data.collection_ids {
|
||||
@@ -978,16 +975,13 @@ impl InviteData {
|
||||
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.escape_debug(), org_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.escape_debug(), org_id))
|
||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1008,12 +1002,14 @@ async fn send_invite(
|
||||
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
|
||||
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
||||
// The from_str() will convert the custom role type into a manager role type
|
||||
let raw_type = &data.r#type.into_string();
|
||||
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
||||
// Membership::from_str will convert custom (4) to manager (3)
|
||||
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid type")
|
||||
};
|
||||
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
|
||||
|
||||
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
||||
err!("Only Owners can invite Managers, Admins or Owners")
|
||||
@@ -1023,19 +1019,11 @@ async fn send_invite(
|
||||
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
||||
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
||||
let access_all = new_type >= MembershipType::Admin
|
||||
|| (new_type == MembershipType::Manager
|
||||
&& raw_type.eq("4")
|
||||
|| (raw_type.eq("4")
|
||||
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
||||
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
||||
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
||||
|
||||
// Checked before anything is created, so a rejected permission leaves nothing behind
|
||||
let custom_permissions = if new_type == MembershipType::Custom {
|
||||
Some(CustomPermission::requested(&data.permissions)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut user_created: bool;
|
||||
for email in &data.emails {
|
||||
let mut member_status = MembershipStatus::Invited as i32;
|
||||
@@ -1077,11 +1065,8 @@ async fn send_invite(
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||
new_member.set_type(new_type);
|
||||
new_member.access_all = access_all;
|
||||
if let Some(permissions) = &custom_permissions {
|
||||
new_member.set_custom_permissions(permissions);
|
||||
}
|
||||
new_member.atype = new_type;
|
||||
new_member.status = member_status;
|
||||
new_member.save(&conn).await?;
|
||||
|
||||
@@ -1171,10 +1156,7 @@ async fn bulk_reinvite_members(
|
||||
for member_id in data.ids {
|
||||
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => {
|
||||
warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug());
|
||||
e.message().to_owned()
|
||||
}
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
|
||||
bulk_response.push(json!(
|
||||
@@ -1347,10 +1329,7 @@ async fn bulk_confirm_invite(
|
||||
let user_key = invite.key.unwrap_or_default();
|
||||
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => {
|
||||
warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug());
|
||||
e.message().to_owned()
|
||||
}
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
|
||||
bulk_response.push(json!(
|
||||
@@ -1517,32 +1496,24 @@ async fn edit_member(
|
||||
let data: EditUserData = data.into_inner();
|
||||
|
||||
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
||||
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
||||
// The from_str() will convert the custom role type into a manager role type
|
||||
let raw_type = &data.r#type.into_string();
|
||||
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
||||
// MembershipType::from_str will convert custom (4) to manager (3)
|
||||
let Some(new_type) = MembershipType::from_str(raw_type) else {
|
||||
err!("Invalid type")
|
||||
};
|
||||
|
||||
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")
|
||||
};
|
||||
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled());
|
||||
|
||||
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
|
||||
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
||||
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
||||
let access_all = new_type >= MembershipType::Admin
|
||||
|| (new_type == MembershipType::Manager
|
||||
&& raw_type.eq("4")
|
||||
|| (raw_type.eq("4")
|
||||
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
||||
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
||||
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
||||
|
||||
// While Custom roles are disabled, a Custom member keeps their stored permissions untouched
|
||||
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
|
||||
Some(CustomPermission::requested(&data.permissions)?)
|
||||
} else {
|
||||
None
|
||||
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
|
||||
@@ -1566,11 +1537,8 @@ async fn edit_member(
|
||||
}
|
||||
}
|
||||
|
||||
member_to_edit.set_type(new_type);
|
||||
member_to_edit.access_all = access_all;
|
||||
if let Some(permissions) = &custom_permissions {
|
||||
member_to_edit.set_custom_permissions(permissions);
|
||||
}
|
||||
member_to_edit.atype = new_type as i32;
|
||||
|
||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||
// We need to perform the check after changing the type since `admin` is exempt.
|
||||
@@ -1642,10 +1610,7 @@ async fn bulk_delete_member(
|
||||
for member_id in data.ids {
|
||||
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => {
|
||||
warn!("Unable to delete member {}: {e:?}", member_id.escape_debug());
|
||||
e.message().to_owned()
|
||||
}
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
|
||||
bulk_response.push(json!(
|
||||
@@ -2074,29 +2039,6 @@ async fn put_policy(
|
||||
err!("Invalid or unsupported policy type")
|
||||
};
|
||||
|
||||
let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await;
|
||||
let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled);
|
||||
|
||||
// Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the
|
||||
// policy changes state.
|
||||
if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled {
|
||||
let single_org_policy_enabled =
|
||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled);
|
||||
|
||||
if !single_org_policy_enabled {
|
||||
err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.")
|
||||
}
|
||||
}
|
||||
|
||||
if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled {
|
||||
let fill_assist_policy_enabled =
|
||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled);
|
||||
|
||||
if fill_assist_policy_enabled {
|
||||
err!("Turn off the Activate fill assist policy because it requires the Single organization policy.")
|
||||
}
|
||||
}
|
||||
|
||||
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
||||
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
||||
// Now that groups are available we can enforce this option when wanted.
|
||||
@@ -2129,37 +2071,6 @@ async fn put_policy(
|
||||
}
|
||||
}
|
||||
|
||||
if pol_type_enum == OrgPolicyType::FillAssist {
|
||||
let fill_assist_data = match data.data.as_ref() {
|
||||
None | Some(Value::Null) => FillAssistPolicyData {
|
||||
rules_url: None,
|
||||
},
|
||||
Some(data) => {
|
||||
let Ok(data) = serde_json::from_value::<FillAssistPolicyData>(data.clone()) else {
|
||||
err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.")
|
||||
};
|
||||
data
|
||||
}
|
||||
};
|
||||
|
||||
if data.enabled {
|
||||
let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else {
|
||||
err!("The RulesUrl field is required.")
|
||||
};
|
||||
let Ok(rules_url) = url::Url::parse(&rules_url) else {
|
||||
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
||||
};
|
||||
|
||||
if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") {
|
||||
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
||||
}
|
||||
|
||||
if rules_url.scheme() != "https" {
|
||||
err!("RulesUrl must use HTTPS.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
||||
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
||||
two_factor::enforce_2fa_policy_for_org(
|
||||
@@ -2191,7 +2102,7 @@ async fn put_policy(
|
||||
}
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
|
||||
EventType::OrganizationUserRemoved,
|
||||
&member.uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
@@ -2207,7 +2118,7 @@ async fn put_policy(
|
||||
}
|
||||
}
|
||||
|
||||
let mut policy = match current_policy {
|
||||
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
||||
Some(p) => p,
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
||||
};
|
||||
@@ -2311,10 +2222,7 @@ async fn bulk_revoke_members(
|
||||
for member_id in members {
|
||||
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => {
|
||||
warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug());
|
||||
e.message().to_owned()
|
||||
}
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
|
||||
bulk_response.push(json!(
|
||||
@@ -2417,10 +2325,7 @@ async fn bulk_restore_members(
|
||||
for member_id in data.ids {
|
||||
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => {
|
||||
warn!("Unable to restore member {}: {e:?}", member_id.escape_debug());
|
||||
e.message().to_owned()
|
||||
}
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
|
||||
bulk_response.push(json!(
|
||||
@@ -2573,16 +2478,13 @@ impl GroupRequest {
|
||||
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.escape_debug(), org_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.escape_debug(), org_id))
|
||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2973,11 +2875,12 @@ async fn recover_account(
|
||||
err!("Organization user must be confirmed for password reset functionality");
|
||||
}
|
||||
|
||||
let fallback_2fa_email = req.reset_two_factor
|
||||
&& CONFIG.mail_enabled()
|
||||
&& CONFIG.email_2fa_auto_fallback()
|
||||
&& user.verified()
|
||||
&& TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none();
|
||||
let fallback_2fa_email =
|
||||
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
|
||||
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Check the new password before the email below, so that a rejected request doesn't tell the user
|
||||
// their password was reset
|
||||
|
||||
@@ -268,12 +268,11 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
||||
let file_id = crate::crypto::generate_send_file_id();
|
||||
|
||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||
let Some(o) = data_value.as_object_mut() else {
|
||||
err!("Invalid Send file data")
|
||||
};
|
||||
if let Some(o) = data_value.as_object_mut() {
|
||||
o.insert(String::from("id"), Value::String(file_id.clone()));
|
||||
o.insert(String::from("size"), Value::Number(file_length.into()));
|
||||
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
||||
}
|
||||
send.data = serde_json::to_string(&data_value)?;
|
||||
send.save(&conn).await?;
|
||||
|
||||
|
||||
@@ -84,26 +84,18 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
||||
)
|
||||
}
|
||||
} else if let Some(auth_request_id) = auth_request_id {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||
err!("AuthRequest doesn't exist", "User not found")
|
||||
};
|
||||
let Some(code) = &data.auth_request_access_code else {
|
||||
err!("no auth request access code")
|
||||
};
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await
|
||||
else {
|
||||
err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user")
|
||||
};
|
||||
|
||||
let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5);
|
||||
if !auth_request.approved.unwrap_or(false)
|
||||
|| auth_request.authentication_date.is_some()
|
||||
|| request_expired
|
||||
|| auth_request.device_type != client_headers.device_type
|
||||
if auth_request.device_type != client_headers.device_type
|
||||
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||
|| !auth_request.check_access_code(code)
|
||||
{
|
||||
err!(
|
||||
"AuthRequest doesn't exist",
|
||||
"Auth request not approved, already used or expired, or invalid device, IP or code"
|
||||
)
|
||||
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||
}
|
||||
} else if let Some(device_identifier) = &data.device_identifier {
|
||||
// iOS/Android SSO logins send the email and device id but no password hash,
|
||||
@@ -302,7 +294,7 @@ pub async fn validate_email_code_str(
|
||||
|
||||
if !crypto::ct_eq(issued_token, token) {
|
||||
email_data.add_attempt();
|
||||
if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
||||
if email_data.attempts >= CONFIG.email_attempts_limit() {
|
||||
email_data.reset_token();
|
||||
}
|
||||
twofactor.data = email_data.to_json();
|
||||
@@ -321,7 +313,7 @@ pub async fn validate_email_code_str(
|
||||
twofactor.save(conn).await?;
|
||||
|
||||
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||
let max_time = i64::from(CONFIG.email_expiration_time());
|
||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||
err!(
|
||||
"Token has expired",
|
||||
|
||||
@@ -151,7 +151,7 @@ pub async fn enforce_2fa_policy(
|
||||
member.save(conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
||||
EventType::OrganizationUserRevoked,
|
||||
&member.uuid,
|
||||
&member.org_uuid,
|
||||
act_user_id,
|
||||
@@ -185,15 +185,7 @@ pub async fn enforce_2fa_policy_for_org(
|
||||
member.revoke();
|
||||
member.save(conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
||||
&member.uuid,
|
||||
org_id,
|
||||
act_user_id,
|
||||
device_type,
|
||||
ip,
|
||||
conn,
|
||||
)
|
||||
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -204,7 +196,7 @@ pub async fn enforce_2fa_policy_for_org(
|
||||
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
debug!("Sending notifications for incomplete 2FA logins");
|
||||
|
||||
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,21 +206,11 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
};
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
|
||||
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
|
||||
return;
|
||||
};
|
||||
let Some(time_before) = now.checked_sub_signed(time_limit) else {
|
||||
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
|
||||
return;
|
||||
};
|
||||
|
||||
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
||||
let time_before = now - time_limit;
|
||||
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
||||
for login in incomplete_logins {
|
||||
let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
|
||||
error!("User '{}', not found!", login.user_uuid);
|
||||
continue;
|
||||
};
|
||||
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
|
||||
info!(
|
||||
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
||||
user.email, login.ip_address
|
||||
|
||||
@@ -128,24 +128,23 @@ pub async fn validate_protected_action_otp(
|
||||
.await
|
||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||
pa_data.add_attempt();
|
||||
pa.data = pa_data.to_json();
|
||||
|
||||
// Fail after x attempts if the token has been used too many times.
|
||||
// Don't delete it, as we use it to keep track of attempts.
|
||||
if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
||||
if pa_data.attempts >= CONFIG.email_attempts_limit() {
|
||||
err!("Token has expired")
|
||||
}
|
||||
|
||||
// Check if the token has expired (Using the email 2fa expiration time)
|
||||
let max_time = i64::from(CONFIG.email_expiration_time());
|
||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||
pa.delete(conn).await?;
|
||||
err!("Token has expired")
|
||||
}
|
||||
|
||||
if !crypto::ct_eq(&pa_data.token, otp) {
|
||||
pa_data.add_attempt();
|
||||
pa.data = pa_data.to_json();
|
||||
|
||||
pa.save(conn).await?;
|
||||
err!("Token is invalid")
|
||||
}
|
||||
|
||||
+5
-16
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
||||
// Generate the cookie store
|
||||
let cookie_store = Arc::new(Jar::default());
|
||||
|
||||
let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
|
||||
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
||||
let pool_idle_timeout = Duration::from_secs(10);
|
||||
// Reuse the client between requests
|
||||
get_reqwest_client_builder(true)
|
||||
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
|
||||
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
||||
let meta = operator.stat(path).await?;
|
||||
let modified =
|
||||
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
||||
let age = SystemTime::now().duration_since(modified.into())?;
|
||||
|
||||
Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
|
||||
Ok(ttl > 0 && ttl <= age.as_secs())
|
||||
}
|
||||
|
||||
async fn icon_is_negcached(path: &str) -> bool {
|
||||
@@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
||||
TAG_BASE => {
|
||||
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
||||
let href = std::str::from_utf8(href).unwrap_or_default();
|
||||
debug!("Found base href: {}", href.escape_debug());
|
||||
debug!("Found base href: {href}");
|
||||
match base_url.join(href) {
|
||||
Ok(inner_url) => inner_url,
|
||||
_ => continue,
|
||||
@@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
||||
Ok(())
|
||||
}) {
|
||||
Ok(_) => {
|
||||
// Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
|
||||
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create
|
||||
if body.len() >= 67 {
|
||||
// Check if the icon type is allowed, else try an icon from the list.
|
||||
icon_type = get_icon_type(&body);
|
||||
@@ -663,17 +663,6 @@ impl reqwest::cookie::CookieStore for Jar {
|
||||
use time::Duration;
|
||||
|
||||
let mut cookie_store = self.0.write().unwrap();
|
||||
|
||||
// The store only skips expired cookies when reading them, so remove them here or they are kept forever
|
||||
let expired: Vec<_> = cookie_store
|
||||
.iter_any()
|
||||
.filter(|c| c.is_expired())
|
||||
.map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned()))
|
||||
.collect();
|
||||
for (domain, path, name) in expired {
|
||||
cookie_store.remove(&domain, &path, &name);
|
||||
}
|
||||
|
||||
let cookies = cookie_headers.filter_map(|val| {
|
||||
std::str::from_utf8(val.as_bytes())
|
||||
.map_err(RawCookieParseError::from)
|
||||
|
||||
+14
-161
@@ -29,7 +29,7 @@ use crate::{
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError,
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
||||
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||
TwoFactorType, User, UserId,
|
||||
},
|
||||
@@ -46,7 +46,6 @@ pub fn routes() -> Vec<Route> {
|
||||
post_prelogin,
|
||||
prelogin_password,
|
||||
register_verification_email,
|
||||
register_verification_email_clicked,
|
||||
register_finish,
|
||||
prevalidate,
|
||||
authorize,
|
||||
@@ -77,14 +76,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
password_login(data, &mut user_id, &conn, &client_header).await
|
||||
password_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
}
|
||||
"client_credentials" => {
|
||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||
|
||||
api_key_login(data, &mut user_id, &conn, &client_header).await
|
||||
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
}
|
||||
"authorization_code" if CONFIG.sso_enabled() => {
|
||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||
@@ -95,7 +94,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
sso_login(data, &mut user_id, &conn, &client_header).await
|
||||
sso_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
}
|
||||
"authorization_code" => err!("SSO sign-in is not available"),
|
||||
"send_access" => {
|
||||
@@ -112,7 +111,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
||||
.await?;
|
||||
Ok(Json(tokens.to_json()))
|
||||
}
|
||||
t => err!("Invalid type", t.escape_debug().to_string()),
|
||||
t => err!("Invalid type", t),
|
||||
};
|
||||
|
||||
if let Some(user_id) = user_id {
|
||||
@@ -179,13 +178,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
|
||||
}
|
||||
|
||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||
async fn sso_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||
|
||||
// Ratelimit the login
|
||||
@@ -323,8 +316,6 @@ async fn sso_login(
|
||||
}
|
||||
|
||||
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
||||
// Before saving, so that a client that can't log in doesn't create the account
|
||||
check_client_version(&user, client_header)?;
|
||||
user.verified_at = Some(now);
|
||||
user.save(conn).await?;
|
||||
|
||||
@@ -333,8 +324,6 @@ async fn sso_login(
|
||||
(user, device, None, None)
|
||||
}
|
||||
Some((user, _)) if !user.enabled => {
|
||||
// Set in both arms, not only after the match, so that their failed logins are logged
|
||||
*user_id = Some(user.uuid.clone());
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||
@@ -344,9 +333,6 @@ async fn sso_login(
|
||||
)
|
||||
}
|
||||
Some((mut user, sso_user)) => {
|
||||
*user_id = Some(user.uuid.clone());
|
||||
check_client_version(&user, client_header)?;
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
||||
@@ -381,13 +367,7 @@ async fn sso_login(
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
}
|
||||
|
||||
async fn password_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Validate scope
|
||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||
|
||||
@@ -457,8 +437,6 @@ async fn password_login(
|
||||
)
|
||||
}
|
||||
|
||||
check_client_version(&user, client_header)?;
|
||||
|
||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||
if data.auth_request.is_none() {
|
||||
kdf_upgrade(&mut user, password, conn).await?;
|
||||
@@ -469,9 +447,9 @@ async fn password_login(
|
||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||
if user.last_verifying_at.is_none()
|
||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||
> i64::from(CONFIG.signups_verify_resend_time())
|
||||
> CONFIG.signups_verify_resend_time().cast_signed()
|
||||
{
|
||||
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
|
||||
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||
// We want to send another email verification if we require signups to verify
|
||||
// their email address, and we haven't sent them a reminder in a while...
|
||||
@@ -520,66 +498,6 @@ async fn password_login(
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
}
|
||||
|
||||
/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account
|
||||
/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the
|
||||
/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L33-L79>
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Core/KeyManagement/Constants.cs#L5-L6>
|
||||
fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult {
|
||||
let Some((error, description)) = client_version_error(
|
||||
user.has_v2_encryption(),
|
||||
client_header.client_version.as_ref(),
|
||||
client_header.device_type,
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
Err(crate::error::Error::from((
|
||||
// Escaped: on a first SSO login the email comes from the provider and isn't validated yet
|
||||
format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()),
|
||||
json!({
|
||||
"error": error,
|
||||
"error_description": description,
|
||||
"ErrorModel": { "Message": description, "Object": "error" },
|
||||
}),
|
||||
))
|
||||
.with_event(crate::error::ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The error id and message of [`check_client_version`], `None` when the client may log in.
|
||||
fn client_version_error(
|
||||
is_v2: bool,
|
||||
client_version: Option<&semver::Version>,
|
||||
device_type: i32,
|
||||
) -> Option<(&'static str, &'static str)> {
|
||||
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1);
|
||||
const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2);
|
||||
|
||||
let Some(version) = client_version else {
|
||||
return Some((
|
||||
"version_header_missing",
|
||||
"No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"",
|
||||
));
|
||||
};
|
||||
if !is_v2 {
|
||||
return None;
|
||||
}
|
||||
// Upstream's desktop device types
|
||||
// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Libraries/Clients/DeviceTypes.cs#L14-L20>
|
||||
let is_desktop = matches!(
|
||||
DeviceType::from_i32(device_type),
|
||||
DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp
|
||||
);
|
||||
let min_version = if is_desktop {
|
||||
&MIN_V2_DESKTOP_CLIENT
|
||||
} else {
|
||||
&MIN_V2_CLIENT
|
||||
};
|
||||
(version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
|
||||
}
|
||||
|
||||
async fn authenticated_response(
|
||||
user: &User,
|
||||
device: &mut Device,
|
||||
@@ -627,7 +545,7 @@ async fn authenticated_response(
|
||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||
"MasterKeyEncryptedUserKey": user.akey,
|
||||
"MasterKeyWrappedUserKey": user.akey,
|
||||
"Salt": user.master_password_salt()
|
||||
"Salt": user.email
|
||||
})
|
||||
} else {
|
||||
Value::Null
|
||||
@@ -668,13 +586,7 @@ async fn authenticated_response(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn api_key_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
@@ -686,7 +598,7 @@ async fn api_key_login(
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
user_api_key_login(data, user_id, conn, client_header).await
|
||||
user_api_key_login(data, user_id, conn, ip).await
|
||||
}
|
||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||
_ => err!("Scope not supported"),
|
||||
@@ -697,9 +609,8 @@ async fn user_api_key_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
ip: &ClientIp,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
// Get the user via the client_id
|
||||
let client_id = data.client_id.as_ref().unwrap();
|
||||
let Some(client_user_id) = client_id.strip_prefix("user.") else {
|
||||
@@ -736,8 +647,6 @@ async fn user_api_key_login(
|
||||
)
|
||||
}
|
||||
|
||||
check_client_version(&user, client_header)?;
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && device.is_new() {
|
||||
@@ -782,7 +691,7 @@ async fn user_api_key_login(
|
||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||
"MasterKeyEncryptedUserKey": user.akey,
|
||||
"MasterKeyWrappedUserKey": user.akey,
|
||||
"Salt": user.master_password_salt()
|
||||
"Salt": user.email
|
||||
})
|
||||
} else {
|
||||
Value::Null
|
||||
@@ -1103,13 +1012,6 @@ struct RegisterVerificationData {
|
||||
// receiveMarketingEmails: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegisterVerificationEmailClickedData {
|
||||
email: String,
|
||||
email_verification_token: String,
|
||||
}
|
||||
|
||||
#[derive(rocket::Responder)]
|
||||
enum RegisterVerificationResponse {
|
||||
#[response(status = 204)]
|
||||
@@ -1172,25 +1074,6 @@ async fn register_verification_email(
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/accounts/register/verification-email-clicked", data = "<data>")]
|
||||
async fn register_verification_email_clicked(
|
||||
data: Json<RegisterVerificationEmailClickedData>,
|
||||
conn: DbConn,
|
||||
) -> EmptyResult {
|
||||
let data = data.into_inner();
|
||||
let token_valid = auth::decode_register_verify(&data.email_verification_token)
|
||||
.is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email));
|
||||
let user_exists = User::find_by_mail(&data.email, &conn)
|
||||
.await
|
||||
.is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some());
|
||||
|
||||
if !token_valid || user_exists {
|
||||
err!("Expired link. Please restart registration or try logging in. You may already have an account")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/accounts/register/finish", data = "<data>")]
|
||||
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
@@ -1419,33 +1302,3 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
|
||||
|
||||
Ok(Redirect::temporary(String::from(auth_url)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_version_error() {
|
||||
let version = |v| semver::Version::parse(v).unwrap();
|
||||
let web = DeviceType::ChromeBrowser as i32;
|
||||
let desktop = DeviceType::WindowsDesktop as i32;
|
||||
// Every login needs the header
|
||||
assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing"));
|
||||
assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing"));
|
||||
// V1 accounts log in from any version
|
||||
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None);
|
||||
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None);
|
||||
// V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app
|
||||
assert_eq!(
|
||||
client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0),
|
||||
Some("invalid_client_version")
|
||||
);
|
||||
assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None);
|
||||
assert_eq!(
|
||||
client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0),
|
||||
Some("invalid_client_version")
|
||||
);
|
||||
assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None);
|
||||
assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -23,14 +23,14 @@ pub use crate::api::{
|
||||
icons::routes as icons_routes,
|
||||
identity::routes as identity_routes,
|
||||
notifications::routes as notifications_routes,
|
||||
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||
push::{
|
||||
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
||||
register_push_device, unregister_push_device,
|
||||
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
|
||||
unregister_push_device,
|
||||
},
|
||||
web::catchers as web_catchers,
|
||||
web::routes as web_routes,
|
||||
web::{STATIC_FILES, invalidate_css_cache},
|
||||
web::{invalidate_css_cache, static_files},
|
||||
};
|
||||
use crate::{
|
||||
CONFIG,
|
||||
|
||||
+10
-27
@@ -212,7 +212,7 @@ fn websockets_hub<'r>(
|
||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||
break;
|
||||
}
|
||||
yield Message::Ping(PING.to_vec());
|
||||
yield Message::Ping(create_ping());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,7 +295,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||
break;
|
||||
}
|
||||
yield Message::Ping(PING.to_vec());
|
||||
yield Message::Ping(create_ping());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,6 @@ fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
|
||||
|
||||
const RECORD_SEPARATOR: u8 = 0x1e;
|
||||
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
||||
const PING: [u8; 3] = [0x02, 0x91, 0x06];
|
||||
|
||||
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
||||
struct InitialMessage<'a> {
|
||||
@@ -412,35 +411,23 @@ impl WebSocketUsers {
|
||||
}
|
||||
|
||||
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||
self.send_logout_with_reason(user, acting_device, None, conn).await;
|
||||
}
|
||||
|
||||
/// A logout that clients may handle differently depending on `reason`
|
||||
pub async fn send_logout_with_reason(
|
||||
&self,
|
||||
user: &User,
|
||||
acting_device: Option<&Device>,
|
||||
reason: Option<LogOutReason>,
|
||||
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 mut payload =
|
||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
|
||||
if let Some(reason) = reason {
|
||||
payload.push(("Reason".into(), (reason as i32).into()));
|
||||
}
|
||||
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
|
||||
let data = create_update(
|
||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
UpdateType::LogOut,
|
||||
acting_device_id,
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
self.send_update(&user.uuid, &data).await;
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_logout(user, acting_device, reason, conn).await;
|
||||
push_logout(user, acting_device, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,12 +691,8 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
||||
serialize(&value)
|
||||
}
|
||||
|
||||
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
pub enum LogOutReason {
|
||||
KeyRotation = 1,
|
||||
fn create_ping() -> Vec<u8> {
|
||||
serialize(&Value::Array(vec![6.into()]))
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||
|
||||
+13
-24
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
|
||||
api::{ApiResult, EmptyResult, UpdateType},
|
||||
db::{
|
||||
DbConn,
|
||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||
@@ -139,25 +139,21 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
|
||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let data = json!({
|
||||
"id": push_id.as_ref().unwrap(),
|
||||
});
|
||||
|
||||
let auth_api_token = get_auth_api_token().await?;
|
||||
|
||||
let auth_header = format!("Bearer {auth_api_token}");
|
||||
|
||||
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(ACCEPT, "application/json")
|
||||
match make_http_request(
|
||||
Method::POST,
|
||||
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
|
||||
)?
|
||||
.header(AUTHORIZATION, auth_header)
|
||||
.json(&data)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.await
|
||||
{
|
||||
err!(format!("An error occurred during device unregistration: {e}"));
|
||||
}
|
||||
|
||||
Ok(r) => r,
|
||||
Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -192,15 +188,8 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>, conn: &DbConn) {
|
||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||
push_logout_to_relay(user, acting_device, reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Sends the logout without checking for push devices first.
|
||||
// Needed after deleting a user, when the devices are already gone from the database.
|
||||
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>) {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user.uuid,
|
||||
"organizationId": (),
|
||||
@@ -209,13 +198,13 @@ pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason:
|
||||
"type": UpdateType::LogOut as i32,
|
||||
"payload": {
|
||||
"userId": user.uuid,
|
||||
"date": format_date(&user.updated_at),
|
||||
"reason": reason.map(|r| r as i32),
|
||||
"date": format_date(&user.updated_at)
|
||||
},
|
||||
"clientType": null,
|
||||
"installationId": null
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||
|
||||
+33
-64
@@ -1,14 +1,13 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, LazyLock, RwLock},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use rocket::{
|
||||
Catcher, Route,
|
||||
fs::NamedFile,
|
||||
http::ContentType,
|
||||
response::{Redirect, content::RawHtml as Html},
|
||||
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||
serde::json::Json,
|
||||
};
|
||||
use serde_json::Value;
|
||||
@@ -20,7 +19,7 @@ use crate::{
|
||||
crypto::sha256_hex,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{AttachmentId, CipherId, custom_roles_enabled},
|
||||
models::{AttachmentId, CipherId},
|
||||
},
|
||||
error::Error,
|
||||
util::{Cached, EtagCached},
|
||||
@@ -80,17 +79,16 @@ pub fn invalidate_css_cache() {
|
||||
}
|
||||
|
||||
#[get("/css/vaultwarden.css")]
|
||||
fn vaultwarden_css() -> EtagCached<String> {
|
||||
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
||||
// If reload_templates is false, and we already have the CSS Cached, return this
|
||||
if !CONFIG.reload_templates()
|
||||
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
||||
{
|
||||
return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag);
|
||||
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
|
||||
}
|
||||
|
||||
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
||||
let css_options = json!({
|
||||
"custom_roles_enabled": custom_roles_enabled(),
|
||||
"email_change_allowed": CONFIG.email_change_allowed(),
|
||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||
"load_user_scss": true,
|
||||
@@ -149,7 +147,7 @@ fn vaultwarden_css() -> EtagCached<String> {
|
||||
}
|
||||
|
||||
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||
EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag)
|
||||
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
@@ -258,7 +256,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
||||
// This endpoint/function is used during development and development only.
|
||||
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
||||
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
||||
// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below!
|
||||
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||
#[cfg(debug_assertions)]
|
||||
#[get("/vw_static/<filename>", rank = 1)]
|
||||
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
@@ -278,61 +276,32 @@ pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
None
|
||||
}
|
||||
|
||||
pub struct StaticFile {
|
||||
pub content_type: ContentType,
|
||||
pub bytes: &'static [u8],
|
||||
pub etag: String,
|
||||
}
|
||||
|
||||
pub static STATIC_FILES: LazyLock<HashMap<&'static str, StaticFile>> = LazyLock::new(|| {
|
||||
[
|
||||
("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]),
|
||||
("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]),
|
||||
("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]),
|
||||
("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]),
|
||||
("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]),
|
||||
("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]),
|
||||
("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]),
|
||||
("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]),
|
||||
("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]),
|
||||
("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]),
|
||||
("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]),
|
||||
("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]),
|
||||
(
|
||||
"admin_organizations.js",
|
||||
ContentType::JavaScript,
|
||||
&include_bytes!("../static/scripts/admin_organizations.js")[..],
|
||||
),
|
||||
(
|
||||
"admin_diagnostics.js",
|
||||
ContentType::JavaScript,
|
||||
&include_bytes!("../static/scripts/admin_diagnostics.js")[..],
|
||||
),
|
||||
("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]),
|
||||
("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]),
|
||||
("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]),
|
||||
("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]),
|
||||
("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, content_type, bytes)| {
|
||||
(
|
||||
name,
|
||||
StaticFile {
|
||||
content_type,
|
||||
bytes,
|
||||
etag: sha256_hex(bytes),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
#[get("/vw_static/<filename>", rank = 2)]
|
||||
pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error> {
|
||||
if let Some(file) = STATIC_FILES.get(filename) {
|
||||
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
|
||||
} else {
|
||||
err!(format!("Static file not found: {}", filename.escape_debug()))
|
||||
pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> {
|
||||
match filename {
|
||||
"404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))),
|
||||
"mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
||||
"logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
|
||||
"error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
|
||||
"hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
|
||||
"vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))),
|
||||
"vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))),
|
||||
"404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))),
|
||||
"admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))),
|
||||
"admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))),
|
||||
"admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))),
|
||||
"admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))),
|
||||
"admin_organizations.js" => {
|
||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js")))
|
||||
}
|
||||
"admin_diagnostics.js" => {
|
||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js")))
|
||||
}
|
||||
"bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))),
|
||||
"bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))),
|
||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||
_ => err!(format!("Static file not found: {filename}")),
|
||||
}
|
||||
}
|
||||
+47
-56
@@ -34,7 +34,7 @@ use crate::{
|
||||
models::{
|
||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, User, UserId,
|
||||
SendFileId, SendId, User, UserId, UserStampException,
|
||||
},
|
||||
},
|
||||
error::Error,
|
||||
@@ -314,17 +314,6 @@ impl LoginJwtClaims {
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
|
||||
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
|
||||
};
|
||||
let Some(exp) = now.checked_add_signed(hours_delta) else {
|
||||
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
|
||||
};
|
||||
Ok(exp)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InviteJwtClaims {
|
||||
// Not before
|
||||
@@ -348,19 +337,19 @@ pub fn generate_invite_claims(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
invited_by_email: Option<String>,
|
||||
) -> Result<InviteJwtClaims, Error> {
|
||||
) -> InviteJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||
Ok(InviteJwtClaims {
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
InviteJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_INVITE_ISSUER.to_string(),
|
||||
sub: user_id,
|
||||
email,
|
||||
org_id,
|
||||
member_id,
|
||||
invited_by_email,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -386,19 +375,19 @@ pub fn generate_emergency_access_invite_claims(
|
||||
emer_id: EmergencyAccessId,
|
||||
grantor_name: String,
|
||||
grantor_email: String,
|
||||
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
||||
) -> EmergencyAccessInviteJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||
Ok(EmergencyAccessInviteJwtClaims {
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
EmergencyAccessInviteJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||
sub: user_id,
|
||||
email,
|
||||
emer_id,
|
||||
grantor_name,
|
||||
grantor_email,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -503,17 +492,13 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) ->
|
||||
let time_now = Utc::now();
|
||||
TwoFactorRememberClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()),
|
||||
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
||||
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||
sub: device_uuid,
|
||||
user_uuid,
|
||||
}
|
||||
}
|
||||
|
||||
fn two_factor_remember_expiration(time_now: DateTime<Utc>, remember_days: i64) -> i64 {
|
||||
(time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BasicJwtClaims {
|
||||
// Not before
|
||||
@@ -536,43 +521,36 @@ impl BasicJwtClaims {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
|
||||
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||
Ok(BasicJwtClaims {
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_DELETE_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
|
||||
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||
Ok(BasicJwtClaims {
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
||||
sub: user_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
|
||||
pub fn generate_admin_claims() -> BasicJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
|
||||
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
|
||||
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
||||
};
|
||||
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
|
||||
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
||||
};
|
||||
Ok(BasicJwtClaims {
|
||||
BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||
sub: "admin_panel".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
||||
@@ -632,9 +610,6 @@ impl<'r> FromRequest<'r> for Host {
|
||||
pub struct ClientHeaders {
|
||||
pub device_type: i32,
|
||||
pub ip: ClientIp,
|
||||
/// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this
|
||||
/// doesn't log an error, as not every client sends it.
|
||||
pub client_version: Option<semver::Version>,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -651,13 +626,10 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
||||
.get_one("device-type")
|
||||
.and_then(|d| d.parse().ok())
|
||||
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||
let client_version =
|
||||
request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok());
|
||||
|
||||
Outcome::Success(ClientHeaders {
|
||||
device_type,
|
||||
ip,
|
||||
client_version,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -713,12 +685,31 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
};
|
||||
|
||||
if user.security_stamp != claims.sstamp {
|
||||
if let Some(stamp_exception) =
|
||||
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
|
||||
{
|
||||
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
||||
err_handler!("Error getting current route for stamp exception")
|
||||
};
|
||||
|
||||
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
|
||||
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
|
||||
// Check if the stamp exception has expired first.
|
||||
// Then, check if the current route matches any of the allowed routes.
|
||||
// After that check the stamp in exception matches the one in the claims.
|
||||
if Utc::now().timestamp() > stamp_exception.expire {
|
||||
// If the stamp exception has been expired remove it from the database.
|
||||
// This prevents checking this stamp exception for new requests.
|
||||
let mut user = user;
|
||||
user.reset_stamp_exception();
|
||||
if let Err(e) = user.save(&conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
}
|
||||
err_handler!("Stamp exception is expired")
|
||||
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
||||
err_handler!("Invalid security stamp for matched stamp exception")
|
||||
}
|
||||
} else {
|
||||
err_handler!("Invalid security stamp")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -72,11 +72,7 @@ impl SendTokens {
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<SendTokens> {
|
||||
let Some(send_id) = Self::as_send_id(access_id) else {
|
||||
return Self::invalid_error(
|
||||
&format!("Can't convert {}", access_id.escape_debug()),
|
||||
"send_id_invalid",
|
||||
false,
|
||||
);
|
||||
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
||||
};
|
||||
|
||||
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||
|
||||
+35
-142
@@ -111,7 +111,6 @@ macro_rules! make_config {
|
||||
_usr: ConfigBuilder,
|
||||
|
||||
_overrides: Vec<&'static str>,
|
||||
_config_file_status: ConfigFileStatus,
|
||||
}
|
||||
|
||||
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
||||
@@ -260,22 +259,11 @@ macro_rules! make_config {
|
||||
builder
|
||||
}
|
||||
|
||||
async fn from_file() -> Result<Option<Self>, Error> {
|
||||
// The Fs operator would create the folder if it doesn't exist, which we never want
|
||||
if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
async fn from_file() -> Result<Self, Error> {
|
||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
|
||||
Ok(bytes) => bytes,
|
||||
// No config file is fine, should not return any error
|
||||
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
|
||||
// Any other error might be an issue and should be passed back to the caller
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
|
||||
let config_bytes = operator.read(&CONFIG_FILENAME).await?;
|
||||
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
||||
Ok(Some(builder))
|
||||
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn clear_non_editable(&mut self) {
|
||||
@@ -452,8 +440,6 @@ macro_rules! make_config {
|
||||
"domain_origin",
|
||||
"domain_path",
|
||||
"domain",
|
||||
"duo_ikey",
|
||||
"duo_host",
|
||||
"helo_name",
|
||||
"org_creation_users",
|
||||
"signups_domains_whitelist",
|
||||
@@ -465,8 +451,6 @@ macro_rules! make_config {
|
||||
"sso_authority",
|
||||
"sso_callback_path",
|
||||
"sso_client_id",
|
||||
"yubico_client_id",
|
||||
"yubico_server",
|
||||
];
|
||||
|
||||
let cfg = {
|
||||
@@ -499,10 +483,6 @@ macro_rules! make_config {
|
||||
};
|
||||
overrides
|
||||
}
|
||||
|
||||
pub fn get_config_file_status(&self) -> ConfigFileStatus {
|
||||
self.inner.read().unwrap()._config_file_status
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -559,7 +539,7 @@ make_config! {
|
||||
jobs {
|
||||
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
||||
/// Set to 0 to globally disable scheduled jobs.
|
||||
job_poll_interval_ms: u32, false, def, 30_000;
|
||||
job_poll_interval_ms: u64, false, def, 30_000;
|
||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||
/// Defaults to hourly. Set blank to disable this job.
|
||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||
@@ -607,9 +587,6 @@ make_config! {
|
||||
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
||||
sends_allowed: bool, true, def, true;
|
||||
|
||||
/// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory.
|
||||
fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned();
|
||||
|
||||
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
||||
hibp_api_key: Pass, true, option;
|
||||
|
||||
@@ -621,16 +598,16 @@ make_config! {
|
||||
user_send_limit: i64, true, option;
|
||||
|
||||
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
||||
/// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
|
||||
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
|
||||
/// sure to inform all users of any changes to this setting.
|
||||
trash_auto_delete_days: u16, true, option;
|
||||
trash_auto_delete_days: i64, true, option;
|
||||
|
||||
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
||||
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
||||
/// where the correct master password was provided but the required 2FA step was not completed,
|
||||
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
||||
/// This setting applies globally to all users.
|
||||
incomplete_2fa_time_limit: u16, true, def, 3;
|
||||
incomplete_2fa_time_limit: i64, true, def, 3;
|
||||
|
||||
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
||||
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
||||
@@ -643,9 +620,9 @@ make_config! {
|
||||
/// this will prevent logins from succeeding until the address has been verified
|
||||
signups_verify: bool, true, def, false;
|
||||
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
||||
signups_verify_resend_time: u32, true, def, 3_600;
|
||||
signups_verify_resend_time: u64, true, def, 3_600;
|
||||
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
||||
signups_verify_resend_limit: u16, true, def, 6;
|
||||
signups_verify_resend_limit: u32, true, def, 6;
|
||||
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
||||
signups_domains_whitelist: String, true, def, String::new();
|
||||
/// Enable event logging |> Enables event logging for organizations.
|
||||
@@ -657,7 +634,7 @@ make_config! {
|
||||
invitations_allowed: bool, true, def, true;
|
||||
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
||||
/// email verification token and deletion request token will expire (must be at least 1)
|
||||
invitation_expiration_hours: u16, false, def, 120;
|
||||
invitation_expiration_hours: u32, false, def, 120;
|
||||
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
||||
emergency_access_allowed: bool, true, def, true;
|
||||
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
||||
@@ -678,8 +655,8 @@ make_config! {
|
||||
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
||||
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
||||
|
||||
/// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
|
||||
events_days_retain: u16, false, option;
|
||||
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
||||
events_days_retain: i64, false, option;
|
||||
},
|
||||
|
||||
client {
|
||||
@@ -717,13 +694,13 @@ make_config! {
|
||||
/// Temporary redirects are useful while testing different icon services, but once a service
|
||||
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
||||
/// are currently better supported by the Bitwarden clients.
|
||||
icon_redirect_code: u16, true, def, 302;
|
||||
icon_redirect_code: u32, true, def, 302;
|
||||
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
||||
icon_cache_ttl: u32, true, def, 2_592_000;
|
||||
icon_cache_ttl: u64, true, def, 2_592_000;
|
||||
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
||||
icon_cache_negttl: u32, true, def, 259_200;
|
||||
icon_cache_negttl: u64, true, def, 259_200;
|
||||
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
||||
icon_download_timeout: u16, true, def, 10;
|
||||
icon_download_timeout: u64, true, def, 10;
|
||||
|
||||
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
||||
icon_blacklist_regex: String, false, option;
|
||||
@@ -740,8 +717,6 @@ make_config! {
|
||||
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
||||
/// Note that the checkbox would still be present, but ignored.
|
||||
disable_2fa_remember: bool, true, def, false;
|
||||
/// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90).
|
||||
two_factor_remember_days: i64, true, def, 30;
|
||||
|
||||
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
||||
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
||||
@@ -776,11 +751,11 @@ make_config! {
|
||||
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
||||
db_connection_retries: u32, false, def, 15;
|
||||
|
||||
/// Timeout in seconds when acquiring database connection
|
||||
database_timeout: u32, false, def, 30;
|
||||
/// Timeout when acquiring database connection
|
||||
database_timeout: u64, false, def, 30;
|
||||
|
||||
/// Timeout in seconds before idle connections to the database are closed
|
||||
database_idle_timeout: u32, false, def, 600;
|
||||
database_idle_timeout: u64, false, def, 600;
|
||||
|
||||
/// Database connection max pool size
|
||||
database_max_conns: u32, false, def, 10;
|
||||
@@ -797,34 +772,30 @@ make_config! {
|
||||
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
|
||||
allowed_iframe_ancestors: String, true, def, String::new();
|
||||
|
||||
/// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||
allowed_connect_src: String, true, def, String::new();
|
||||
|
||||
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
||||
login_ratelimit_seconds: u16, false, def, 60;
|
||||
login_ratelimit_seconds: u64, false, def, 60;
|
||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||
login_ratelimit_max_burst: u16, false, def, 10;
|
||||
login_ratelimit_max_burst: u32, false, def, 10;
|
||||
|
||||
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||
unauthenticated_ratelimit_seconds: u16, false, def, 60;
|
||||
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
||||
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
|
||||
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
||||
|
||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||
admin_ratelimit_seconds: u16, false, def, 300;
|
||||
admin_ratelimit_seconds: u64, false, def, 300;
|
||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||
admin_ratelimit_max_burst: u16, false, def, 3;
|
||||
admin_ratelimit_max_burst: u32, false, def, 3;
|
||||
|
||||
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
||||
admin_session_lifetime: u32, true, def, 20;
|
||||
admin_session_lifetime: i64, true, def, 20;
|
||||
|
||||
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
||||
org_groups_enabled: bool, false, def, false;
|
||||
|
||||
/// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations.
|
||||
/// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled.
|
||||
experimental_custom_roles: bool, false, def, false;
|
||||
|
||||
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||
increase_note_size_limit: bool, true, def, false;
|
||||
@@ -873,8 +844,8 @@ make_config! {
|
||||
sso_master_password_policy: String, true, option;
|
||||
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
||||
sso_auth_only_not_session: bool, true, def, false;
|
||||
/// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
||||
sso_client_cache_expiration: u32, true, def, 0;
|
||||
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
||||
sso_client_cache_expiration: u64, true, def, 0;
|
||||
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
||||
sso_debug_tokens: bool, true, def, false;
|
||||
},
|
||||
@@ -932,7 +903,7 @@ make_config! {
|
||||
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
||||
smtp_auth_mechanism: String, true, option;
|
||||
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
||||
smtp_timeout: u16, true, def, 15;
|
||||
smtp_timeout: u64, true, def, 15;
|
||||
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
||||
helo_name: String, true, option;
|
||||
/// Embed images as email attachments.
|
||||
@@ -954,9 +925,9 @@ make_config! {
|
||||
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
||||
email_token_size: u8, true, def, 6;
|
||||
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
||||
email_expiration_time: u32, true, def, 600;
|
||||
email_expiration_time: u64, true, def, 600;
|
||||
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
||||
email_attempts_limit: u8, true, def, 3;
|
||||
email_attempts_limit: u64, true, def, 3;
|
||||
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
||||
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
||||
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
||||
@@ -1012,15 +983,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
||||
}
|
||||
|
||||
if cfg.database_min_conns > cfg.database_max_conns {
|
||||
err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
|
||||
}
|
||||
|
||||
if cfg.database_timeout < 1 {
|
||||
err!("`DATABASE_TIMEOUT` must be at least 1 second");
|
||||
}
|
||||
|
||||
if cfg.database_idle_timeout < 1 {
|
||||
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
|
||||
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
|
||||
}
|
||||
|
||||
if let Some(log_file) = &cfg.log_file
|
||||
@@ -1036,21 +999,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
||||
);
|
||||
}
|
||||
|
||||
let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| {
|
||||
url.scheme() == "https"
|
||||
&& url.has_host()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
&& cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url
|
||||
});
|
||||
if !valid_fill_assist_rules_url {
|
||||
err!(
|
||||
"`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace."
|
||||
);
|
||||
}
|
||||
|
||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||
for url in connect_src.split_whitespace() {
|
||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||
@@ -1285,10 +1233,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
||||
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
||||
}
|
||||
|
||||
if !(1..=90).contains(&cfg.two_factor_remember_days) {
|
||||
err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days")
|
||||
}
|
||||
|
||||
// Validate schedule crontab format
|
||||
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
||||
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
||||
@@ -1330,30 +1274,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
||||
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
|
||||
}
|
||||
|
||||
if cfg.login_ratelimit_seconds < 1 {
|
||||
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
|
||||
}
|
||||
|
||||
if cfg.login_ratelimit_max_burst < 1 {
|
||||
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
|
||||
}
|
||||
|
||||
if cfg.admin_ratelimit_seconds < 1 {
|
||||
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
|
||||
}
|
||||
|
||||
if cfg.admin_ratelimit_max_burst < 1 {
|
||||
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
|
||||
}
|
||||
|
||||
if cfg.unauthenticated_ratelimit_seconds < 1 {
|
||||
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
|
||||
}
|
||||
|
||||
if cfg.unauthenticated_ratelimit_max_burst < 1 {
|
||||
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
|
||||
}
|
||||
|
||||
if !cfg.disable_admin_token {
|
||||
match cfg.admin_token.as_ref() {
|
||||
Some(t) if t.starts_with("$argon2") => {
|
||||
@@ -1509,7 +1429,6 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
||||
// Autofill Team
|
||||
"undetermined-cipher-scenario-logic",
|
||||
"enable-basic-auth-response",
|
||||
"fill-assist-targeting-rules",
|
||||
"ssh-agent-v2",
|
||||
"windows-desktop-autotype",
|
||||
"windows-desktop-autotype-ga",
|
||||
@@ -1526,34 +1445,11 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
||||
"pm-32009-new-item-types",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Serialize)]
|
||||
pub enum ConfigFileStatus {
|
||||
NotFound,
|
||||
Loaded,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub async fn load() -> Result<Self, Error> {
|
||||
// Loading from env and file
|
||||
let env = ConfigBuilder::from_env();
|
||||
let (usr, config_file_status) = match ConfigBuilder::from_file().await {
|
||||
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
|
||||
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
|
||||
Err(e) => {
|
||||
println!(
|
||||
"[ERROR] Failed to load configuration from `{}`\n\
|
||||
#####################################################################################\n\
|
||||
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
|
||||
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
|
||||
# This file is not used until those errors are resolved. #\n\
|
||||
#####################################################################################\n\
|
||||
{e:?}\n",
|
||||
*CONFIG_FILE
|
||||
);
|
||||
(ConfigBuilder::default(), ConfigFileStatus::Error)
|
||||
}
|
||||
};
|
||||
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||
|
||||
// Create merged config, config file overwrites env
|
||||
let mut overrides = Vec::new();
|
||||
@@ -1573,7 +1469,6 @@ impl Config {
|
||||
_env: env,
|
||||
_usr: usr,
|
||||
_overrides: overrides,
|
||||
_config_file_status: config_file_status,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1607,7 +1502,6 @@ impl Config {
|
||||
writer.config = config;
|
||||
writer._usr = builder;
|
||||
writer._overrides = overrides;
|
||||
writer._config_file_status = ConfigFileStatus::Loaded;
|
||||
}
|
||||
|
||||
//Save to file
|
||||
@@ -1626,7 +1520,7 @@ impl Config {
|
||||
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
||||
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
||||
warn!("Failed to parse email address '{}'", email.escape_debug());
|
||||
warn!("Failed to parse email address '{email}'");
|
||||
return false;
|
||||
}
|
||||
let email_domain = e[0].to_lowercase();
|
||||
@@ -1699,7 +1593,6 @@ impl Config {
|
||||
writer.config = config;
|
||||
writer._usr = usr;
|
||||
writer._overrides = Vec::new();
|
||||
writer._config_file_status = ConfigFileStatus::NotFound;
|
||||
}
|
||||
|
||||
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||
|
||||
+3
-74
@@ -212,8 +212,8 @@ impl DbPool {
|
||||
let pool = Pool::builder()
|
||||
.max_size(max_conns)
|
||||
.min_idle(Some(CONFIG.database_min_conns()))
|
||||
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
|
||||
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
|
||||
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
|
||||
.connection_timeout(Duration::from_secs(CONFIG.database_timeout()))
|
||||
.connection_customizer(Box::new(DbConnOptions {
|
||||
init_stmts: conn_type.get_init_stmts(),
|
||||
}))
|
||||
@@ -233,7 +233,7 @@ impl DbPool {
|
||||
|
||||
// Get a connection from the pool
|
||||
pub async fn get(&self) -> Result<DbConn, Error> {
|
||||
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
|
||||
let duration = Duration::from_secs(CONFIG.database_timeout());
|
||||
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
||||
Ok(p) => p.expect("Semaphore should be open"),
|
||||
Err(_) => {
|
||||
@@ -534,74 +534,3 @@ mod postgresql_migrations {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
||||
#[cfg(all(test, sqlite))]
|
||||
pub mod test_db {
|
||||
use super::sqlite_migrations::MIGRATIONS;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel_migrations::MigrationHarness;
|
||||
|
||||
#[test]
|
||||
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
||||
use super::schema::users_organizations::dsl::*;
|
||||
use diesel::{migration::MigrationSource, prelude::*};
|
||||
let mut conn = SqliteConnection::establish(":memory:").unwrap();
|
||||
for migration in conn.pending_migrations(MIGRATIONS).unwrap() {
|
||||
if migration.name().version().to_string() == "20261010120000" {
|
||||
break;
|
||||
}
|
||||
conn.run_migration(&migration).unwrap();
|
||||
}
|
||||
conn.batch_execute(
|
||||
"PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b');
|
||||
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES
|
||||
('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1),
|
||||
('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = |conn: &mut SqliteConnection| {
|
||||
users_organizations
|
||||
.select((uuid, user_uuid, org_uuid, access_all, akey, status, atype))
|
||||
.order(uuid)
|
||||
.load::<(String, String, String, bool, String, i32, i32)>(conn)
|
||||
.unwrap()
|
||||
};
|
||||
let before = rows(&mut conn);
|
||||
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||
assert_eq!(rows(&mut conn), before);
|
||||
let grants = |conn: &mut SqliteConnection| {
|
||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>(
|
||||
"(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies
|
||||
OR create_new_collections OR edit_any_collection OR delete_any_collection
|
||||
OR access_event_logs OR access_import_export OR access_reports)",
|
||||
))
|
||||
.get_result::<i64>(conn)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(grants(&mut conn), 0);
|
||||
let migration = MigrationSource::<diesel::sqlite::Sqlite>::migrations(&MIGRATIONS)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|m| m.name().version().to_string() == "20261010120000")
|
||||
.unwrap();
|
||||
for member_status in [1, 2] {
|
||||
conn.batch_execute(&format!(
|
||||
"UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'"
|
||||
))
|
||||
.unwrap();
|
||||
let custom = rows(&mut conn);
|
||||
assert!(conn.revert_migration(&*migration).is_err());
|
||||
assert_eq!(rows(&mut conn), custom);
|
||||
assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::<bool>(&mut conn).unwrap());
|
||||
}
|
||||
conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'")
|
||||
.unwrap();
|
||||
conn.revert_migration(&*migration).unwrap();
|
||||
assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err());
|
||||
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||
assert_eq!(grants(&mut conn), 0);
|
||||
assert_eq!(rows(&mut conn), before);
|
||||
}
|
||||
}
|
||||
@@ -166,15 +166,6 @@ impl AuthRequest {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting auth requests for user")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
|
||||
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -197,7 +188,7 @@ impl AuthRequest {
|
||||
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
||||
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
||||
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
|
||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
|
||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||
auth_request.delete(conn).await.ok();
|
||||
}
|
||||
|
||||
+10
-34
@@ -25,7 +25,7 @@ use macros::UuidFromParam;
|
||||
|
||||
use super::{
|
||||
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
|
||||
MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
|
||||
MembershipStatus, MembershipType, OrganizationId, User, UserId,
|
||||
};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -123,15 +123,10 @@ impl Cipher {
|
||||
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
||||
}
|
||||
|
||||
let is_blob = cipher.is_blob();
|
||||
if let Err(e) = cipher.validate_content(is_blob) {
|
||||
if let Err(e) = cipher.validate_content(cipher.is_blob()) {
|
||||
validation_errors
|
||||
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
|
||||
}
|
||||
if let Err(e) = cipher.validate_type_data(is_blob) {
|
||||
validation_errors
|
||||
.insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap());
|
||||
}
|
||||
|
||||
// Validate the password history if it contains `null` values and if so, return a warning
|
||||
if let Some(Value::Array(password_history)) = &cipher.password_history {
|
||||
@@ -541,24 +536,14 @@ impl Cipher {
|
||||
|
||||
/// Purge all ciphers that are old enough to be auto-deleted.
|
||||
pub async fn purge_trash(conn: &DbConn) {
|
||||
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
|
||||
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
|
||||
error!("Unable to calculate auto delete days!");
|
||||
return;
|
||||
};
|
||||
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
|
||||
let now = Utc::now().naive_utc();
|
||||
let Some(dt) = now.checked_sub_signed(days) else {
|
||||
error!("Unable to calculate trash purge date!");
|
||||
return;
|
||||
};
|
||||
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
|
||||
for cipher in Self::find_deleted_before(&dt, conn).await {
|
||||
cipher.delete(conn).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_to_folder(
|
||||
&self,
|
||||
@@ -567,16 +552,7 @@ impl Cipher {
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await
|
||||
}
|
||||
|
||||
/// Same as `move_to_folder`, but the caller is responsible for updating the user's revision.
|
||||
pub async fn move_to_folder_without_revision_update(
|
||||
&self,
|
||||
folder_uuid: Option<FolderId>,
|
||||
user_uuid: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
||||
// No changes
|
||||
(None, None) => Ok(()),
|
||||
@@ -919,7 +895,7 @@ impl Cipher {
|
||||
|
||||
if !visible_only {
|
||||
query = query.or_filter(
|
||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,7 +930,7 @@ impl Cipher {
|
||||
|
||||
if !visible_only {
|
||||
query = query.or_filter(
|
||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1158,7 +1134,7 @@ impl Cipher {
|
||||
.or(collections_groups::collections_uuid
|
||||
.is_not_null() // Access via groups
|
||||
.and(collections_groups::read_only.eq(false)))
|
||||
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
|
||||
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner
|
||||
)
|
||||
.select(ciphers_collections::collection_uuid)
|
||||
.load::<CollectionId>(conn)
|
||||
@@ -1187,7 +1163,7 @@ impl Cipher {
|
||||
.or(users_collections::user_uuid
|
||||
.eq(user_uuid) // User has access to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
|
||||
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner
|
||||
)
|
||||
.select(ciphers_collections::collection_uuid)
|
||||
.load::<CollectionId>(conn)
|
||||
@@ -1229,7 +1205,7 @@ impl Cipher {
|
||||
)
|
||||
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
|
||||
.or_filter(users_organizations::access_all.eq(true)) // User has access all
|
||||
.or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
|
||||
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
||||
.or_filter(groups::access_all.eq(true)) //Access via group
|
||||
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
|
||||
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
|
||||
|
||||
use super::{
|
||||
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
|
||||
User, UserId, organization::ORG_ADMIN_ATYPES,
|
||||
User, UserId,
|
||||
};
|
||||
|
||||
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
|
||||
@@ -375,7 +375,7 @@ impl Collection {
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
)
|
||||
.or(
|
||||
@@ -412,7 +412,7 @@ impl Collection {
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
))
|
||||
.select(collections::all_columns)
|
||||
@@ -455,7 +455,7 @@ impl Collection {
|
||||
)
|
||||
.filter(
|
||||
users_organizations::atype
|
||||
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
|
||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid
|
||||
.eq(&self.uuid) // write access given to collection
|
||||
@@ -489,7 +489,7 @@ impl Collection {
|
||||
)
|
||||
.filter(
|
||||
users_organizations::atype
|
||||
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
|
||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid
|
||||
.eq(&self.uuid) // write access given to collection
|
||||
@@ -540,7 +540,7 @@ impl Collection {
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
)
|
||||
.or(
|
||||
@@ -600,7 +600,7 @@ impl Collection {
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
)
|
||||
.or(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
@@ -112,26 +112,8 @@ impl EmergencyAccess {
|
||||
"object": "emergencyAccessGranteeDetails",
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
|
||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
|
||||
err!("Emergency access wait time is out-of-bounds!")
|
||||
}
|
||||
|
||||
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
|
||||
|
||||
TimeDelta::try_days(i64::from(self.wait_time_days))
|
||||
.and_then(|w| initiated_at.checked_add_signed(w))
|
||||
.map_res("Emergency access deadline is invalid")
|
||||
}
|
||||
|
||||
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
|
||||
Ok(self.deadline_at()?.le(now))
|
||||
}
|
||||
}
|
||||
|
||||
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum EmergencyAccessType {
|
||||
View = 0,
|
||||
@@ -187,42 +169,27 @@ impl EmergencyAccess {
|
||||
|
||||
pub async fn update_access_status_and_save(
|
||||
&mut self,
|
||||
from_status: i32,
|
||||
to_status: i32,
|
||||
status: i32,
|
||||
date: &NaiveDateTime,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
let uuid = self.uuid.clone();
|
||||
let updated_at = *date;
|
||||
let res = conn
|
||||
.run(move |conn| {
|
||||
// Update the grantee so that it will refresh it's status.
|
||||
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
||||
self.status = status;
|
||||
date.clone_into(&mut self.updated_at);
|
||||
|
||||
conn.run(move |conn| {
|
||||
crate::util::retry(
|
||||
|| {
|
||||
diesel::update(
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(&uuid))
|
||||
.filter(emergency_access::status.eq(from_status)),
|
||||
)
|
||||
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
|
||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
||||
.execute(conn)
|
||||
},
|
||||
10,
|
||||
)
|
||||
.map_res("Error updating emergency access status")
|
||||
})
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(1) => {} // One record updated, which is valid
|
||||
Ok(_) => err!("Emergency access status was changed already"),
|
||||
Err(e) => err!("Error updating emergency access status", format!("{e}")),
|
||||
}
|
||||
|
||||
self.status = to_status;
|
||||
self.updated_at = updated_at;
|
||||
|
||||
// Update the grantee so that it will refresh it's status.
|
||||
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
||||
Ok(())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||
|
||||
+7
-14
@@ -79,7 +79,6 @@ pub enum EventType {
|
||||
CipherSoftDeleted = 1115,
|
||||
CipherRestored = 1116,
|
||||
CipherClientToggledCardNumberVisible = 1117,
|
||||
CipherClientHttpAuthReleased = 1133,
|
||||
|
||||
// Collection
|
||||
CollectionCreated = 1300,
|
||||
@@ -112,8 +111,8 @@ pub enum EventType {
|
||||
// OrganizationUserAutomaticallyConfirmed = 1517,
|
||||
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
||||
OrganizationUserAdminResetTwoFactor = 1519,
|
||||
OrganizationUserRevokedTwoFactorNonCompliance = 1520,
|
||||
OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
|
||||
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
|
||||
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
|
||||
OrganizationUserNotificationBannerActionClicked = 1522,
|
||||
|
||||
// Organization
|
||||
@@ -348,23 +347,17 @@ impl Event {
|
||||
}
|
||||
|
||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||
let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
|
||||
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
||||
};
|
||||
|
||||
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
|
||||
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
||||
};
|
||||
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
||||
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||
.execute(conn)
|
||||
.map_res("Error cleaning old events")
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,15 +137,15 @@ impl CollectionGroup {
|
||||
}
|
||||
|
||||
pub fn to_json_details_for_group(&self) -> Value {
|
||||
// Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group
|
||||
// permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage"
|
||||
// in GET /organizations/<org>/collections/details, while GET /groups/<id>/details reported the stored
|
||||
// value. Saving the collection access dialog then persisted manage=true.
|
||||
// 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
|
||||
// For backwards compatibility and migration proposes we keep checking read_only and hide_password
|
||||
json!({
|
||||
"id": self.groups_uuid,
|
||||
"readOnly": self.read_only,
|
||||
"hidePasswords": self.hide_passwords,
|
||||
"manage": self.manage,
|
||||
"manage": self.manage || (!self.read_only && !self.hide_passwords),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,22 +25,20 @@ pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
|
||||
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||
pub use self::emergency_access::{
|
||||
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
|
||||
};
|
||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
||||
pub use self::event::{Event, EventType};
|
||||
pub use self::favorite::Favorite;
|
||||
pub use self::folder::{Folder, FolderCipher, FolderId};
|
||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||
pub use self::organization::{
|
||||
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
|
||||
OrganizationApiKey, OrganizationId, custom_roles_enabled,
|
||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||
OrganizationId,
|
||||
};
|
||||
pub use self::send::{Send, SendFileId, SendId, SendType};
|
||||
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
|
||||
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
|
||||
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
|
||||
@@ -53,8 +53,6 @@ pub enum OrgPolicyType {
|
||||
// AutoConfirm = 18, // Not supported (not implemented yet)
|
||||
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
||||
OrganizationUserNotification = 20,
|
||||
// SendControls = 21, // Not supported yet
|
||||
FillAssist = 22,
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
||||
@@ -73,14 +71,6 @@ pub struct ResetPasswordDataModel {
|
||||
pub auto_enroll_enabled: bool,
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FillAssistPolicyData {
|
||||
#[serde(alias = "RulesUrl")]
|
||||
pub rules_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl OrgPolicy {
|
||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
||||
|
||||
+25
-326
@@ -1,7 +1,4 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
@@ -24,8 +21,8 @@ use crate::{
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use super::{
|
||||
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
|
||||
OrgPolicyType, TwoFactor, User, UserId,
|
||||
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
|
||||
TwoFactor, User, UserId,
|
||||
};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -44,7 +41,6 @@ pub struct Organization {
|
||||
#[diesel(table_name = users_organizations)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
|
||||
pub struct Membership {
|
||||
pub uuid: MembershipId,
|
||||
pub user_uuid: UserId,
|
||||
@@ -58,20 +54,6 @@ pub struct Membership {
|
||||
pub atype: i32,
|
||||
pub reset_password_key: Option<String>,
|
||||
pub external_id: Option<String>,
|
||||
|
||||
// The permissions of a Custom role, only accessed through `CustomPermission`.
|
||||
manage_users: bool,
|
||||
manage_groups: bool,
|
||||
manage_policies: bool,
|
||||
create_new_collections: bool,
|
||||
edit_any_collection: bool,
|
||||
delete_any_collection: bool,
|
||||
access_event_logs: bool,
|
||||
access_import_export: bool,
|
||||
access_reports: bool,
|
||||
|
||||
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
|
||||
pub v2_upgrade_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -107,68 +89,42 @@ impl MembershipStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
|
||||
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
|
||||
pub enum MembershipType {
|
||||
Owner = 0,
|
||||
Admin = 1,
|
||||
User = 2,
|
||||
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
|
||||
Manager = 3,
|
||||
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
|
||||
Custom = 4,
|
||||
}
|
||||
|
||||
impl MembershipType {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
#[expect(
|
||||
clippy::match_same_arms,
|
||||
reason = "Specifically define `4|Custom` since this is a hack, not a default"
|
||||
)]
|
||||
match s {
|
||||
"0" | "Owner" => Some(MembershipType::Owner),
|
||||
"1" | "Admin" => Some(MembershipType::Admin),
|
||||
"2" | "User" => Some(MembershipType::User),
|
||||
"3" | "Manager" => Some(MembershipType::Manager),
|
||||
"4" | "Custom" => Some(MembershipType::Custom),
|
||||
// HACK: We convert the custom role to a manager role
|
||||
"4" | "Custom" => Some(MembershipType::Manager),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The role a member invite or edit from the Bitwarden clients asks for, given the member's current
|
||||
/// role (`None` for an invite).
|
||||
///
|
||||
/// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a
|
||||
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing
|
||||
/// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never
|
||||
/// turns one into the other.
|
||||
pub fn requested(
|
||||
requested: MembershipType,
|
||||
current: Option<&Membership>,
|
||||
custom_roles_enabled: bool,
|
||||
) -> MembershipType {
|
||||
if requested != MembershipType::Custom {
|
||||
return requested;
|
||||
}
|
||||
match current {
|
||||
Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
|
||||
Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
|
||||
_ if custom_roles_enabled => MembershipType::Custom,
|
||||
_ => MembershipType::Manager,
|
||||
}
|
||||
}
|
||||
|
||||
/// The authority of a role, which is what roles are compared by. A Custom member gets its authority from
|
||||
/// its permissions, never from its role, so the role itself ranks right above User and below Manager.
|
||||
const fn rank(self) -> u8 {
|
||||
match self {
|
||||
MembershipType::User => 0,
|
||||
MembershipType::Custom => 1,
|
||||
MembershipType::Manager => 2,
|
||||
MembershipType::Admin => 3,
|
||||
MembershipType::Owner => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for MembershipType {
|
||||
fn cmp(&self, other: &MembershipType) -> Ordering {
|
||||
self.rank().cmp(&other.rank())
|
||||
// For easy comparison, map each variant to an access level (where 0 is lowest).
|
||||
const ACCESS_LEVEL: [i32; 4] = [
|
||||
3, // Owner
|
||||
2, // Admin
|
||||
0, // User
|
||||
1, // Manager && Custom
|
||||
];
|
||||
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,91 +180,6 @@ impl PartialOrd<MembershipType> for i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The granular permissions of the Custom role.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum CustomPermission {
|
||||
ManageUsers,
|
||||
ManageGroups,
|
||||
ManagePolicies,
|
||||
CreateNewCollections,
|
||||
EditAnyCollection,
|
||||
DeleteAnyCollection,
|
||||
AccessEventLogs,
|
||||
AccessImportExport,
|
||||
AccessReports,
|
||||
}
|
||||
|
||||
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
|
||||
/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
|
||||
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[];
|
||||
|
||||
impl CustomPermission {
|
||||
pub const ALL: [CustomPermission; 9] = [
|
||||
CustomPermission::ManageUsers,
|
||||
CustomPermission::ManageGroups,
|
||||
CustomPermission::ManagePolicies,
|
||||
CustomPermission::CreateNewCollections,
|
||||
CustomPermission::EditAnyCollection,
|
||||
CustomPermission::DeleteAnyCollection,
|
||||
CustomPermission::AccessEventLogs,
|
||||
CustomPermission::AccessImportExport,
|
||||
CustomPermission::AccessReports,
|
||||
];
|
||||
|
||||
/// The key of the permission in the `permissions` object of the Bitwarden clients.
|
||||
pub const fn json_key(self) -> &'static str {
|
||||
match self {
|
||||
CustomPermission::ManageUsers => "manageUsers",
|
||||
CustomPermission::ManageGroups => "manageGroups",
|
||||
CustomPermission::ManagePolicies => "managePolicies",
|
||||
CustomPermission::CreateNewCollections => "createNewCollections",
|
||||
CustomPermission::EditAnyCollection => "editAnyCollection",
|
||||
CustomPermission::DeleteAnyCollection => "deleteAnyCollection",
|
||||
CustomPermission::AccessEventLogs => "accessEventLogs",
|
||||
CustomPermission::AccessImportExport => "accessImportExport",
|
||||
CustomPermission::AccessReports => "accessReports",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_supported(self) -> bool {
|
||||
SUPPORTED_CUSTOM_PERMISSIONS.contains(&self)
|
||||
}
|
||||
|
||||
/// The permissions a member invite or edit grants a Custom role.
|
||||
///
|
||||
/// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this
|
||||
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
|
||||
/// permission this version does not enforce is rejected as well. The clients also send permissions
|
||||
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
|
||||
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
|
||||
let mut granted = HashSet::new();
|
||||
for permission in CustomPermission::ALL {
|
||||
match permissions.get(permission.json_key()) {
|
||||
None | Some(Value::Bool(false)) => {}
|
||||
Some(Value::Bool(true)) if permission.is_supported() => {
|
||||
granted.insert(permission);
|
||||
}
|
||||
Some(Value::Bool(true)) => {
|
||||
err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key()))
|
||||
}
|
||||
Some(_) => {
|
||||
err!(format!("The Custom role permission '{}' must be true or false", permission.json_key()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(granted)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored roles with authority over the whole organization. Queries match them by value, comparing
|
||||
/// `atype <= Admin` would also match any unknown value below it.
|
||||
pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32];
|
||||
|
||||
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
|
||||
pub fn custom_roles_enabled() -> bool {
|
||||
CONFIG.experimental_custom_roles()
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Organization {
|
||||
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
|
||||
@@ -400,94 +271,9 @@ impl Membership {
|
||||
atype: MembershipType::User as i32,
|
||||
reset_password_key: None,
|
||||
external_id: None,
|
||||
|
||||
manage_users: false,
|
||||
manage_groups: false,
|
||||
manage_policies: false,
|
||||
create_new_collections: false,
|
||||
edit_any_collection: false,
|
||||
delete_any_collection: false,
|
||||
access_event_logs: false,
|
||||
access_import_export: false,
|
||||
access_reports: false,
|
||||
|
||||
v2_upgrade_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool {
|
||||
match permission {
|
||||
CustomPermission::ManageUsers => &mut self.manage_users,
|
||||
CustomPermission::ManageGroups => &mut self.manage_groups,
|
||||
CustomPermission::ManagePolicies => &mut self.manage_policies,
|
||||
CustomPermission::CreateNewCollections => &mut self.create_new_collections,
|
||||
CustomPermission::EditAnyCollection => &mut self.edit_any_collection,
|
||||
CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection,
|
||||
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
|
||||
CustomPermission::AccessImportExport => &mut self.access_import_export,
|
||||
CustomPermission::AccessReports => &mut self.access_reports,
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are
|
||||
/// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy
|
||||
/// `access_all` flag, its reach only comes from its permissions.
|
||||
pub fn set_type(&mut self, new_type: MembershipType) {
|
||||
if self.atype != new_type as i32 {
|
||||
for permission in CustomPermission::ALL {
|
||||
*self.custom_permission_mut(permission) = false;
|
||||
}
|
||||
}
|
||||
if new_type == MembershipType::Custom {
|
||||
self.access_all = false;
|
||||
}
|
||||
self.atype = new_type as i32;
|
||||
}
|
||||
|
||||
/// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a
|
||||
/// permission this version does not enforce is kept as it is: it grants nothing here.
|
||||
pub fn set_custom_permissions(&mut self, permissions: &HashSet<CustomPermission>) {
|
||||
for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) {
|
||||
*self.custom_permission_mut(permission) = permissions.contains(&permission);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A
|
||||
/// conversion must not silently change their access, and a Custom role can't keep access to all collections
|
||||
/// (which also puts every collection in the Manager's own vault), nor manage any collection yet.
|
||||
pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> {
|
||||
if !self.has_type(MembershipType::Manager) {
|
||||
return None;
|
||||
}
|
||||
if self.access_all {
|
||||
return Some(
|
||||
"This Manager has access to all collections, which a Custom member can't have: the collections they \
|
||||
aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \
|
||||
or keep the Manager role.",
|
||||
);
|
||||
}
|
||||
// Whatever their status, the collections they manage directly, through a group or through a group with
|
||||
// access to all collections
|
||||
let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
|
||||
|| CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
||||
.await
|
||||
.iter()
|
||||
.any(|assignment| assignment.manage);
|
||||
for group_user in GroupUser::find_by_member(&self.uuid, conn).await {
|
||||
if manages {
|
||||
break;
|
||||
}
|
||||
manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn)
|
||||
.await
|
||||
.iter()
|
||||
.any(|assignment| assignment.manage);
|
||||
}
|
||||
manages.then_some(
|
||||
"This Manager manages collections, with the manage permission or through a group with access to all \
|
||||
collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn restore(&mut self) -> bool {
|
||||
if self.status < MembershipStatus::Invited as i32 {
|
||||
self.status += ACTIVATE_REVOKE_DIFF;
|
||||
@@ -737,8 +523,7 @@ impl Membership {
|
||||
|
||||
// These are per user
|
||||
"userId": self.user_uuid,
|
||||
// Accepted (not yet confirmed) members have no key yet; send null like upstream, not ""
|
||||
"key": (!self.akey.is_empty()).then_some(&self.akey),
|
||||
"key": self.akey,
|
||||
"status": self.status,
|
||||
"type": membership_type,
|
||||
"enabled": true,
|
||||
@@ -926,12 +711,6 @@ impl Membership {
|
||||
}
|
||||
|
||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||
// The legacy `access_all` flag reaches every collection, which a Custom role may only do through its
|
||||
// permissions, so it must never be stored on one. `set_type` takes care of this.
|
||||
if self.has_type(MembershipType::Custom) && self.access_all {
|
||||
err!("A Custom role membership can't have access to all collections")
|
||||
}
|
||||
|
||||
User::update_uuid_revision(&self.user_uuid, conn).await;
|
||||
|
||||
db_run! { conn:
|
||||
@@ -1448,93 +1227,13 @@ pub struct OrgApiKeyId(String);
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use MembershipType::{Admin, Custom, Manager, Owner, User};
|
||||
|
||||
fn member(role: MembershipType) -> Membership {
|
||||
let mut member = Membership::new("user".into(), "org".into(), None);
|
||||
member.atype = role as i32;
|
||||
member.status = MembershipStatus::Confirmed as i32;
|
||||
member
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_type_authority() {
|
||||
assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
|
||||
assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
|
||||
for raw in [99, -1] {
|
||||
assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
|
||||
}
|
||||
for (value, expected) in [
|
||||
("3", Some(Manager)),
|
||||
("Manager", Some(Manager)),
|
||||
("4", Some(Custom)),
|
||||
("Custom", Some(Custom)),
|
||||
("5", None),
|
||||
] {
|
||||
assert_eq!(MembershipType::from_str(value), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_membership_type() {
|
||||
for (current, disabled, enabled) in [
|
||||
(None, Manager, Custom),
|
||||
(Some(User), Manager, Custom),
|
||||
(Some(Manager), Manager, Manager),
|
||||
(Some(Custom), Custom, Custom),
|
||||
] {
|
||||
let current = current.map(member);
|
||||
for (flag, expected) in [(false, disabled), (true, enabled)] {
|
||||
assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected);
|
||||
}
|
||||
}
|
||||
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User);
|
||||
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User);
|
||||
for requested in [Owner, Admin, Manager] {
|
||||
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_permission_requests() {
|
||||
let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap();
|
||||
assert!(CustomPermission::requested(&ignored).unwrap().is_empty());
|
||||
for permission in CustomPermission::ALL {
|
||||
for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] {
|
||||
let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]);
|
||||
let result = CustomPermission::requested(&request);
|
||||
if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) {
|
||||
assert_eq!(result.unwrap(), HashSet::from([permission]));
|
||||
} else {
|
||||
assert!(result.is_err(), "{permission:?}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_changes_drop_custom_permissions() {
|
||||
for (from, to) in [
|
||||
(Custom, Owner),
|
||||
(Custom, Admin),
|
||||
(Custom, Manager),
|
||||
(Custom, User),
|
||||
(User, Custom),
|
||||
(Manager, Custom),
|
||||
(Custom, Custom),
|
||||
(Admin, User),
|
||||
] {
|
||||
let mut member = member(from);
|
||||
member.access_all = true;
|
||||
for permission in CustomPermission::ALL {
|
||||
*member.custom_permission_mut(permission) = true;
|
||||
}
|
||||
member.set_type(to);
|
||||
for permission in CustomPermission::ALL {
|
||||
assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}");
|
||||
}
|
||||
assert_eq!(member.access_all, to != Custom);
|
||||
}
|
||||
#[allow(non_snake_case)]
|
||||
fn partial_cmp_MembershipType() {
|
||||
assert!(MembershipType::Owner > MembershipType::Admin);
|
||||
assert!(MembershipType::Admin > MembershipType::Manager);
|
||||
assert!(MembershipType::Manager > MembershipType::User);
|
||||
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,7 @@ impl Send {
|
||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FileData {
|
||||
#[serde(rename = "size", alias = "Size")]
|
||||
size: NumberOrString,
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ impl TwoFactorIncomplete {
|
||||
ip: &ClientIp,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ impl TwoFactorIncomplete {
|
||||
}
|
||||
|
||||
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
||||
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
+26
-137
@@ -6,7 +6,6 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth::DEFAULT_ACCESS_VALIDITY,
|
||||
crypto,
|
||||
db::{
|
||||
DbConn,
|
||||
@@ -20,8 +19,8 @@ use crate::{
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use super::{
|
||||
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
|
||||
TwoFactorIncomplete, UserSignatureKeyPair,
|
||||
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
||||
UserSignatureKeyPair,
|
||||
};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||
@@ -78,8 +77,6 @@ pub struct User {
|
||||
pub signed_public_key: Option<String>,
|
||||
pub security_state: Option<String>,
|
||||
pub security_version: Option<i32>,
|
||||
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
|
||||
pub v2_upgrade_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
@@ -108,28 +105,13 @@ enum UserStatus {
|
||||
_Disabled = 2,
|
||||
}
|
||||
|
||||
/// A previous security stamp that is still accepted, until `expire`.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserStampException {
|
||||
/// The routes the stamp is still accepted on, or `None` for any route.
|
||||
pub routes: Option<Vec<String>>,
|
||||
pub routes: Vec<String>,
|
||||
pub security_stamp: String,
|
||||
pub expire: i64,
|
||||
}
|
||||
|
||||
impl UserStampException {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now().timestamp() > self.expire
|
||||
}
|
||||
|
||||
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
|
||||
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
|
||||
!self.is_expired()
|
||||
&& self.security_stamp == security_stamp
|
||||
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl User {
|
||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||
@@ -186,7 +168,6 @@ impl User {
|
||||
signed_public_key: None,
|
||||
security_state: None,
|
||||
security_version: None,
|
||||
v2_upgrade_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,19 +175,6 @@ impl User {
|
||||
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
|
||||
}
|
||||
|
||||
/// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2.
|
||||
/// Upstream only uses it to decide which accounts need a v2-capable client.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/Entities/User.cs#L245-L278>
|
||||
pub fn has_v2_encryption(&self) -> bool {
|
||||
self.security_version == Some(2)
|
||||
&& self
|
||||
.private_key
|
||||
.as_deref()
|
||||
.and_then(|private_key| private_key.split_once('.'))
|
||||
.is_some_and(|(enc_type, _)| enc_type.parse::<u8>() == Ok(7))
|
||||
}
|
||||
|
||||
pub fn check_valid_password(&self, password: &str) -> bool {
|
||||
crypto::verify_password_hash(
|
||||
password.as_bytes(),
|
||||
@@ -216,7 +184,6 @@ impl User {
|
||||
)
|
||||
}
|
||||
|
||||
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
|
||||
pub fn master_password_salt(&self) -> String {
|
||||
self.email.trim().to_lowercase()
|
||||
}
|
||||
@@ -271,33 +238,10 @@ impl User {
|
||||
|
||||
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
self.security_stamp = get_uuid();
|
||||
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
|
||||
let mut exceptions = self.stamp_exceptions();
|
||||
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
|
||||
self.set_stamp_exceptions(&exceptions);
|
||||
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
|
||||
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
let previous_stamp = self.security_stamp.clone();
|
||||
let mut grace: Vec<UserStampException> =
|
||||
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
|
||||
|
||||
self.reset_security_stamp(conn).await?;
|
||||
|
||||
grace.push(UserStampException {
|
||||
routes: None,
|
||||
security_stamp: previous_stamp,
|
||||
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
|
||||
});
|
||||
let mut exceptions = self.stamp_exceptions();
|
||||
exceptions.extend(grace);
|
||||
self.set_stamp_exceptions(&exceptions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -306,30 +250,17 @@ impl User {
|
||||
/// After these 2 minutes this stamp will expire.
|
||||
///
|
||||
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
||||
self.set_stamp_exceptions(&[UserStampException {
|
||||
routes: Some(route_exception),
|
||||
let stamp_exception = UserStampException {
|
||||
routes: route_exception,
|
||||
security_stamp: self.security_stamp.clone(),
|
||||
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||
}]);
|
||||
};
|
||||
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
|
||||
}
|
||||
|
||||
/// The previous security stamps that are still accepted, expired ones included.
|
||||
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
|
||||
let Some(stored) = self.stamp_exception.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Before key rotations kept sessions alive, only a single route exception was stored.
|
||||
serde_json::from_str::<Vec<UserStampException>>(stored)
|
||||
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
|
||||
self.stamp_exception = if exceptions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_string(&exceptions).unwrap_or_default())
|
||||
};
|
||||
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
|
||||
pub fn reset_stamp_exception(&mut self) {
|
||||
self.stamp_exception = None;
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
@@ -400,10 +331,6 @@ impl User {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn v2_upgrade_token_json(&self) -> Option<Value> {
|
||||
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
|
||||
}
|
||||
|
||||
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
||||
let mut orgs_json = Vec::new();
|
||||
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||
@@ -481,7 +408,7 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||
if member.atype == MembershipType::Owner
|
||||
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
||||
@@ -497,7 +424,6 @@ impl User {
|
||||
Cipher::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Favorite::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Folder::delete_all_by_user(&self.uuid, conn).await?;
|
||||
AuthRequest::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Device::delete_all_by_user(&self.uuid, conn).await?;
|
||||
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
|
||||
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
|
||||
@@ -505,7 +431,7 @@ impl User {
|
||||
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
||||
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user")
|
||||
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -673,26 +599,25 @@ impl Invitation {
|
||||
pub struct UserId(String);
|
||||
|
||||
#[derive(
|
||||
Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam,
|
||||
Clone,
|
||||
Debug,
|
||||
DieselNewType,
|
||||
FromForm,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
AsRef,
|
||||
Deref,
|
||||
Display,
|
||||
From,
|
||||
UuidFromParam,
|
||||
)]
|
||||
#[deref(forward)]
|
||||
#[from(forward)]
|
||||
pub struct KeyId(String);
|
||||
|
||||
/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other
|
||||
/// format would never match the id the clients send.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/KeyManagement/Models/Data/KeyId.cs#L20-L42>
|
||||
impl<'de> serde::Deserialize<'de> for KeyId {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let key_id = String::deserialize(deserializer)?;
|
||||
if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
|
||||
return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters"));
|
||||
}
|
||||
Ok(Self(key_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl SsoUser {
|
||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
@@ -746,39 +671,3 @@ impl SsoUser {
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_key_id_format() {
|
||||
let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap();
|
||||
assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef");
|
||||
|
||||
for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"]
|
||||
{
|
||||
assert!(serde_json::from_str::<KeyId>(&format!("\"{invalid}\"")).is_err(), "{invalid:?}");
|
||||
}
|
||||
|
||||
// An absent key id stays optional
|
||||
assert_eq!(serde_json::from_str::<Option<KeyId>>("null").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_v2_encryption() {
|
||||
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
||||
let mut user = User::new("user@example.com", None);
|
||||
user.private_key = private_key.map(String::from);
|
||||
user.security_version = security_version;
|
||||
user
|
||||
};
|
||||
|
||||
assert!(user(Some("7.cose"), Some(2)).has_v2_encryption());
|
||||
assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption());
|
||||
assert!(!user(Some("7.cose"), None).has_v2_encryption());
|
||||
assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption());
|
||||
assert!(!user(Some("7"), Some(2)).has_v2_encryption());
|
||||
assert!(!user(None, Some(2)).has_v2_encryption());
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,6 @@ table! {
|
||||
signed_public_key -> Nullable<Text>,
|
||||
security_state -> Nullable<Text>,
|
||||
security_version -> Nullable<Integer>,
|
||||
v2_upgrade_token -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,16 +259,6 @@ table! {
|
||||
atype -> Integer,
|
||||
reset_password_key -> Nullable<Text>,
|
||||
external_id -> Nullable<Text>,
|
||||
manage_users -> Bool,
|
||||
manage_groups -> Bool,
|
||||
manage_policies -> Bool,
|
||||
create_new_collections -> Bool,
|
||||
edit_any_collection -> Bool,
|
||||
delete_any_collection -> Bool,
|
||||
access_event_logs -> Bool,
|
||||
access_import_export -> Bool,
|
||||
access_reports -> Bool,
|
||||
v2_upgrade_token -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -425,12 +425,12 @@ macro_rules! err_json {
|
||||
macro_rules! err_handler {
|
||||
($expr:expr) => {{
|
||||
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
||||
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr));
|
||||
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
|
||||
}};
|
||||
($usr_msg:expr, $log_value:expr) => {{
|
||||
let usr_msg = $usr_msg;
|
||||
let log_value = $log_value;
|
||||
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
||||
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg));
|
||||
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg));
|
||||
}};
|
||||
}
|
||||
+9
-9
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
|
||||
|
||||
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
||||
.port(CONFIG.smtp_port())
|
||||
.timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
|
||||
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout())));
|
||||
|
||||
// Determine security
|
||||
let smtp_client = if CONFIG.smtp_security() == *"off" {
|
||||
@@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
|
||||
}
|
||||
|
||||
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
let claims = generate_delete_claims(user_id.to_string())?;
|
||||
let claims = generate_delete_claims(user_id.to_string());
|
||||
let delete_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
|
||||
}
|
||||
|
||||
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
let claims = generate_verify_email_claims(user_id)?;
|
||||
let claims = generate_verify_email_claims(user_id);
|
||||
let verify_email_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
|
||||
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||
query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true");
|
||||
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
|
||||
let Some(query_string) = query.query() else {
|
||||
err!("Failed to build verify URL query parameters")
|
||||
};
|
||||
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
|
||||
}
|
||||
|
||||
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
let claims = generate_verify_email_claims(user_id)?;
|
||||
let claims = generate_verify_email_claims(user_id);
|
||||
let verify_email_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -292,7 +292,7 @@ pub async fn send_invite(
|
||||
org_id.clone(),
|
||||
member_id.clone(),
|
||||
invited_by_email,
|
||||
)?;
|
||||
);
|
||||
let invite_token = encode_jwt(&claims);
|
||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||
{
|
||||
@@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite(
|
||||
emer_id.clone(),
|
||||
String::from(grantor_name),
|
||||
String::from(grantor_email),
|
||||
)?;
|
||||
);
|
||||
|
||||
// Build the query here to ensure proper escaping
|
||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||
@@ -738,8 +738,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text:
|
||||
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
||||
|
||||
let body = if CONFIG.smtp_embed_images() {
|
||||
let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec());
|
||||
let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec());
|
||||
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
|
||||
let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec());
|
||||
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
||||
MultiPart::related()
|
||||
.singlepart(SinglePart::html(body_html))
|
||||
|
||||
+3
-5
@@ -595,7 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
||||
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
||||
.attach(util::AppHeaders())
|
||||
.attach(util::Cors())
|
||||
.attach(util::BetterLogging::new(extra_debug, basepath))
|
||||
.attach(util::BetterLogging(extra_debug))
|
||||
.ignite()
|
||||
.await?;
|
||||
|
||||
@@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) {
|
||||
// Cleanup the event table of records x days old.
|
||||
if CONFIG.org_events_enabled()
|
||||
&& !CONFIG.event_cleanup_schedule().is_empty()
|
||||
&& CONFIG.events_days_retain().is_some_and(|d| d != 0)
|
||||
&& CONFIG.events_days_retain().is_some()
|
||||
{
|
||||
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
|
||||
runtime.spawn(api::event_cleanup_job(pool.clone()));
|
||||
@@ -754,9 +754,7 @@ fn schedule_jobs(pool: db::DbPool) {
|
||||
// tick, the one that was added earlier will run first.
|
||||
loop {
|
||||
sched.tick();
|
||||
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
|
||||
CONFIG.job_poll_interval_ms(),
|
||||
))));
|
||||
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms())));
|
||||
}
|
||||
})
|
||||
.expect("Error spawning job scheduler thread");
|
||||
|
||||
+6
-6
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
|
||||
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
||||
|
||||
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||
let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds()));
|
||||
let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
|
||||
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
|
||||
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
|
||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
||||
});
|
||||
|
||||
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||
let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds()));
|
||||
let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
|
||||
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
|
||||
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
|
||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
||||
});
|
||||
|
||||
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
|
||||
let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
|
||||
let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
|
||||
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
|
||||
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
|
||||
.expect("Non-zero unauthenticated ratelimit burst");
|
||||
RateLimiter::keyed(
|
||||
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
|
||||
|
||||
+2
-6
@@ -266,8 +266,8 @@ pub async fn exchange_code(
|
||||
(_, Some(re)) => {
|
||||
let error_msg = format!(
|
||||
"SSO authorization failed: {}, {}",
|
||||
re.error.escape_debug(),
|
||||
re.error_description.as_deref().unwrap_or_default().escape_debug()
|
||||
re.error,
|
||||
re.error_description.as_ref().unwrap_or(&String::new())
|
||||
);
|
||||
sso_auth.delete(conn).await?;
|
||||
err!(error_msg);
|
||||
@@ -309,11 +309,7 @@ pub async fn exchange_code(
|
||||
user_name: user_name.clone(),
|
||||
};
|
||||
|
||||
if CONFIG.sso_debug_tokens() {
|
||||
debug!("Authenticated user {authenticated_user:?}");
|
||||
} else {
|
||||
debug!("Authenticated user {identifier}");
|
||||
}
|
||||
sso_auth.auth_response = Some(authenticated_user.clone());
|
||||
sso_auth.updated_at = Utc::now().naive_utc();
|
||||
sso_auth.save(conn).await?;
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
|
||||
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(1)
|
||||
.time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration())))
|
||||
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration()))
|
||||
.build()
|
||||
});
|
||||
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
|
||||
@@ -260,7 +260,7 @@ impl Client {
|
||||
};
|
||||
|
||||
if CONFIG.sso_debug_tokens() {
|
||||
debug!("Id token: {id_token}");
|
||||
debug!("Id token: {}", id_token.to_string());
|
||||
debug!("Access token: {}", token_response.access_token().secret());
|
||||
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
||||
debug!("Expiration time: {:?}", token_response.expires_in());
|
||||
|
||||
+2
-9
@@ -93,9 +93,7 @@ async function generateSupportString(event, dj) {
|
||||
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
||||
supportString += `* Database type: ${dj.db_type}\n`;
|
||||
supportString += `* Database version: ${dj.db_version}\n`;
|
||||
supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`;
|
||||
supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n";
|
||||
supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`;
|
||||
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
|
||||
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
|
||||
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
|
||||
if (dj.ip_header_exists) {
|
||||
@@ -118,7 +116,7 @@ async function generateSupportString(event, dj) {
|
||||
}
|
||||
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
|
||||
if (dj.invalid_feature_flags != "") {
|
||||
supportString += "* Invalid feature flags: true :x:\n";
|
||||
supportString += "* Invalid feature flags: true\n";
|
||||
}
|
||||
|
||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||
@@ -134,11 +132,6 @@ async function generateSupportString(event, dj) {
|
||||
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
||||
supportString += "<details><summary>Show Config & Details</summary>\n";
|
||||
|
||||
// Show invalid environment variables if they exists
|
||||
if (dj.invalid_env_vars != "") {
|
||||
supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`;
|
||||
}
|
||||
|
||||
// Add overrides if they exists
|
||||
if (dj.overrides != "") {
|
||||
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
||||
|
||||
Vendored
+4
-12
@@ -173,14 +173,10 @@ const ORG_TYPES = {
|
||||
"name": "User",
|
||||
"bg": "blue"
|
||||
},
|
||||
"3": {
|
||||
"4": {
|
||||
"name": "Manager",
|
||||
"bg": "green"
|
||||
},
|
||||
"4": {
|
||||
"name": "Custom",
|
||||
"bg": "teal"
|
||||
},
|
||||
};
|
||||
|
||||
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
||||
@@ -191,19 +187,15 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
|
||||
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
|
||||
// Get org specific values
|
||||
const userOrgType = event.relatedTarget.dataset.vwOrgType;
|
||||
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
|
||||
const orgName = event.relatedTarget.dataset.vwOrgName;
|
||||
const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
|
||||
|
||||
document.getElementById("userOrgTypeForm").reset();
|
||||
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
|
||||
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
|
||||
document.getElementById("userOrgTypeUserUuid").value = userUuid;
|
||||
document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
|
||||
// The Custom role can only be selected while it is enabled
|
||||
const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`);
|
||||
if (userOrgTypeRadio) {
|
||||
userOrgTypeRadio.checked = true;
|
||||
}
|
||||
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true;
|
||||
}, false);
|
||||
|
||||
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
|
||||
@@ -230,7 +222,7 @@ function updateUserOrgType(event) {
|
||||
function initUserTable() {
|
||||
// Color all the org buttons per type
|
||||
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
|
||||
const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
|
||||
const orgType = ORG_TYPES[e.dataset.vwOrgType];
|
||||
e.style.backgroundColor = orgType.bg;
|
||||
if (orgType.font !== undefined) {
|
||||
e.style.color = orgType.font;
|
||||
|
||||
Vendored
+2
-2
@@ -4,10 +4,10 @@
|
||||
*
|
||||
* To rebuild or modify this file with the latest versions of the included
|
||||
* software please visit:
|
||||
* https://datatables.net/download/#bs5/dt-3.1.4
|
||||
* https://datatables.net/download/#bs5/dt-3.1.3
|
||||
*
|
||||
* Included libraries:
|
||||
* DataTables 3.1.4
|
||||
* DataTables 3.1.3
|
||||
*/
|
||||
|
||||
/*! DataTables Bootstrap 5 integration
|
||||
|
||||
Vendored
+7
-13
@@ -4,13 +4,13 @@
|
||||
*
|
||||
* To rebuild or modify this file with the latest versions of the included
|
||||
* software please visit:
|
||||
* https://datatables.net/download/#bs5/dt-3.1.4
|
||||
* https://datatables.net/download/#bs5/dt-3.1.3
|
||||
*
|
||||
* Included libraries:
|
||||
* DataTables 3.1.4
|
||||
* DataTables 3.1.3
|
||||
*/
|
||||
|
||||
/*! DataTables 3.1.4
|
||||
/*! DataTables 3.1.3
|
||||
* Copyright (c) SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
|
||||
@@ -453,9 +453,6 @@ function assignDeep(out, ...inputs) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (key === '__proto__') {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (!Array.isArray(out[key])) {
|
||||
out[key] = [];
|
||||
@@ -492,9 +489,6 @@ function assignDeep(out, ...inputs) {
|
||||
function assignDeepObjects(out, extender, breakRefs = false) {
|
||||
let val;
|
||||
for (let prop in extender) {
|
||||
if (prop === '__proto__') {
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(extender, prop)) {
|
||||
val = extender[prop];
|
||||
if (plainObject(val)) {
|
||||
@@ -2700,7 +2694,7 @@ class Dom {
|
||||
*/
|
||||
prepend(content) {
|
||||
return this.each(el => {
|
||||
if (arrayLike(content) || dom(content)) {
|
||||
if (content instanceof Dom) {
|
||||
// Reverse the array, so if there are multiple elements, they
|
||||
// end up being added sequentially, just like jQuery
|
||||
let itemsReversed = Array.from(content).reverse();
|
||||
@@ -8720,7 +8714,7 @@ const ext = {
|
||||
/**
|
||||
* DataTables build type (expanded by the download builder)
|
||||
*/
|
||||
builder: 'bs5/dt-3.1.4',
|
||||
builder: 'bs5/dt-3.1.3',
|
||||
/**
|
||||
* Buttons. For use with the Buttons extension for DataTables. This is
|
||||
* defined here so other extensions can define buttons regardless of load
|
||||
@@ -8866,7 +8860,7 @@ const ext = {
|
||||
* Software version
|
||||
* @type string
|
||||
*/
|
||||
version: '3.1.4'
|
||||
version: '3.1.3'
|
||||
};
|
||||
//
|
||||
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
|
||||
@@ -9565,7 +9559,7 @@ register('destroy()', function (remove) {
|
||||
callbackFire(settings, 'destroy', 'destroy', [settings], true);
|
||||
// If not being removed from the document, make all columns visible
|
||||
if (!remove) {
|
||||
new Api(settings).columns().visible(true);
|
||||
new Api(settings).columns().visible();
|
||||
}
|
||||
// Container width change listener
|
||||
if (settings.resizeObserver) {
|
||||
|
||||
@@ -69,26 +69,12 @@
|
||||
</dd>
|
||||
<dt class="col-sm-5">Uses config.json</dt>
|
||||
<dd class="col-sm-7">
|
||||
{{#if (eq page_data.config_file_status "Loaded")}}
|
||||
<span class="d-inline"><b>Yes</b></span>
|
||||
{{#if page_data.overrides}}
|
||||
<span class="d-inline"><b>Yes</b></span>
|
||||
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
||||
{{/if}}
|
||||
{{~else if (eq page_data.config_file_status "Error")}}
|
||||
<span class="d-inline"><b>Errors</b></span>
|
||||
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
|
||||
{{~else}}
|
||||
<span class="d-inline"><b>No</b></span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
<dt class="col-sm-5">Environment variables are valid</dt>
|
||||
<dd class="col-sm-7">
|
||||
{{#if page_data.invalid_env_vars}}
|
||||
<span class="d-inline"><b>No</b></span>
|
||||
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.
{{page_data.invalid_env_vars}}">Details</span>
|
||||
{{/if}}
|
||||
{{#unless page_data.invalid_env_vars}}
|
||||
<span class="d-block"><b>Yes</b></span>
|
||||
{{#unless page_data.overrides}}
|
||||
<span class="d-block"><b>No</b></span>
|
||||
{{/unless}}
|
||||
</dd>
|
||||
<dt class="col-sm-5">Uses custom templates</dt>
|
||||
|
||||
@@ -135,11 +135,6 @@
|
||||
<div class="radio">
|
||||
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager"> Manager</label>
|
||||
</div>
|
||||
{{#if custom_roles_enabled}}
|
||||
<div class="radio">
|
||||
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom"> Custom</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="radio">
|
||||
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin"> Admin</label>
|
||||
</div>
|
||||
|
||||
@@ -98,17 +98,10 @@ app-security > app-two-factor-setup > form {
|
||||
}
|
||||
|
||||
/* Hide unsupported Custom Role options */
|
||||
{{#if custom_roles_enabled}}
|
||||
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
|
||||
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{else}}
|
||||
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
|
||||
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Change collapsed menu icon to Vaultwarden */
|
||||
bit-nav-logo bit-nav-item a:before {
|
||||
|
||||
+32
-117
@@ -210,7 +210,7 @@ impl Fairing for Cors {
|
||||
pub struct Cached<R> {
|
||||
response: R,
|
||||
is_immutable: bool,
|
||||
ttl: u32,
|
||||
ttl: u64,
|
||||
}
|
||||
|
||||
impl<R> Cached<R> {
|
||||
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
|
||||
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> {
|
||||
Self {
|
||||
response,
|
||||
is_immutable,
|
||||
@@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
||||
res.set_raw_header("Cache-Control", cache_control_header);
|
||||
|
||||
let time_now = Local::now();
|
||||
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap();
|
||||
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap();
|
||||
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
||||
Ok(res)
|
||||
}
|
||||
@@ -259,16 +259,14 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
||||
|
||||
pub struct EtagCached<R> {
|
||||
response: R,
|
||||
content_type: ContentType,
|
||||
etag: String,
|
||||
}
|
||||
|
||||
impl<R> EtagCached<R> {
|
||||
/// An `etag` response should always be quoted
|
||||
pub fn new(response: R, content_type: ContentType, etag: &str) -> Self {
|
||||
pub fn new(response: R, etag: &str) -> Self {
|
||||
Self {
|
||||
response,
|
||||
content_type,
|
||||
etag: format!("\"{etag}\""),
|
||||
}
|
||||
}
|
||||
@@ -290,8 +288,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
||||
self.response.respond_to(request)?
|
||||
};
|
||||
|
||||
// Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control
|
||||
res.set_header(self.content_type);
|
||||
// Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control
|
||||
res.set_raw_header("Etag", self.etag);
|
||||
res.set_raw_header("Cache-Control", "public, no-cache");
|
||||
Ok(res)
|
||||
@@ -302,69 +299,19 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
||||
// Effectively ignores, any static file route, and the alive endpoint
|
||||
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
||||
|
||||
const MAX_LOGGED_QUERY_BYTES: usize = 100;
|
||||
|
||||
// Query values can hold secrets, like the access code of a login request, so we don't log them
|
||||
fn redact_query_values(query: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.map(|param| match param.split_once('=') {
|
||||
Some((name, _)) => format!("{name}=…"),
|
||||
None => param.to_owned(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
// When extra is true, we ignore the whitelist above and also print the mounts
|
||||
pub struct BetterLogging {
|
||||
extra: bool,
|
||||
log_requests: bool,
|
||||
log_response: bool,
|
||||
domain_path: String,
|
||||
}
|
||||
|
||||
impl BetterLogging {
|
||||
pub fn new(extra: bool, domain_path: &str) -> Self {
|
||||
Self {
|
||||
extra,
|
||||
log_requests: log_enabled!(target: "request", log::Level::Info),
|
||||
log_response: log_enabled!(target: "response", log::Level::Info),
|
||||
domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option<std::borrow::Cow<'p, str>> {
|
||||
if !self.extra && method == Method::Options {
|
||||
return None;
|
||||
}
|
||||
let path = path.percent_decode_lossy();
|
||||
if self.extra {
|
||||
return Some(path);
|
||||
}
|
||||
let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path);
|
||||
LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts
|
||||
pub struct BetterLogging(pub bool);
|
||||
#[rocket::async_trait]
|
||||
impl Fairing for BetterLogging {
|
||||
fn info(&self) -> Info {
|
||||
let mut kind = Kind::Liftoff;
|
||||
if self.log_requests {
|
||||
kind = kind | Kind::Request;
|
||||
}
|
||||
if self.log_response {
|
||||
kind = kind | Kind::Response;
|
||||
}
|
||||
Info {
|
||||
name: "Better Logging",
|
||||
kind,
|
||||
kind: Kind::Liftoff | Kind::Request | Kind::Response,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
||||
if self.extra {
|
||||
if self.0 {
|
||||
info!(target: "routes", "Routes loaded:");
|
||||
let mut routes: Vec<_> = rocket.routes().collect();
|
||||
routes.sort_by_key(|r| r.uri.path());
|
||||
@@ -388,29 +335,36 @@ impl Fairing for BetterLogging {
|
||||
}
|
||||
|
||||
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
||||
let uri = request.uri();
|
||||
let method = request.method();
|
||||
let Some(path) = self.log_path(method, uri.path().raw()) else {
|
||||
if !self.0 && method == Method::Options {
|
||||
return;
|
||||
};
|
||||
match uri.query() {
|
||||
Some(q) => {
|
||||
let query = redact_query_values(q.as_str());
|
||||
let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
|
||||
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
|
||||
}
|
||||
None => info!(target: "request", "{method} {}", path.escape_debug()),
|
||||
let uri = request.uri();
|
||||
let uri_path = uri.path();
|
||||
let uri_path_str = uri_path.url_decode_lossy();
|
||||
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
||||
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
||||
match uri.query() {
|
||||
Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]),
|
||||
None => info!(target: "request", "{method} {uri_path_str}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
||||
if self.log_path(request.method(), request.uri().path().raw()).is_none() {
|
||||
if !self.0 && request.method() == Method::Options {
|
||||
return;
|
||||
}
|
||||
let uri_path = request.uri().path();
|
||||
let uri_path_str = uri_path.url_decode_lossy();
|
||||
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
||||
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
||||
let status = response.status();
|
||||
match request.route() {
|
||||
Some(r) => info!(target: "response", "{r} => {status}"),
|
||||
None => info!(target: "response", "{status}"),
|
||||
if let Some(route) = request.route() {
|
||||
info!(target: "response", "{route} => {status}");
|
||||
} else {
|
||||
info!(target: "response", "{status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,34 +447,20 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn get_env<V>(key: &'static str) -> Option<V>
|
||||
pub fn get_env<V>(key: &str) -> Option<V>
|
||||
where
|
||||
V: FromStr,
|
||||
{
|
||||
let value = get_env_str_value(key);
|
||||
let value_is_some = value.is_some();
|
||||
let parsed: Option<V> = try_parse_string(value);
|
||||
if parsed.is_none() && value_is_some {
|
||||
println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead.");
|
||||
INVALID_ENV_VARS.lock().unwrap().push(key);
|
||||
}
|
||||
parsed
|
||||
try_parse_string(get_env_str_value(key))
|
||||
}
|
||||
|
||||
pub fn get_env_bool(key: &'static str) -> Option<bool> {
|
||||
pub fn get_env_bool(key: &str) -> Option<bool> {
|
||||
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
|
||||
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
|
||||
|
||||
match get_env_str_value(key) {
|
||||
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
|
||||
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
|
||||
Some(_) => {
|
||||
println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead.");
|
||||
INVALID_ENV_VARS.lock().unwrap().push(key);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1048,28 +988,3 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod query_log_tests {
|
||||
use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values};
|
||||
|
||||
#[test]
|
||||
fn test_redact_query_values() {
|
||||
assert_eq!(redact_query_values("code=SECRET"), "code=…");
|
||||
assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…");
|
||||
assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag");
|
||||
assert_eq!(redact_query_values("a=b=SECRET"), "a=…");
|
||||
assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…");
|
||||
assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…");
|
||||
assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…");
|
||||
assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…");
|
||||
assert_eq!(redact_query_values(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redacted_query_truncates_on_a_char_boundary() {
|
||||
let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1);
|
||||
let query = redact_query_values(&format!("{name}=SECRET"));
|
||||
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user