Compare commits

..
Author SHA1 Message Date
Daniel García 6a0c75320d Fix org import 2026-07-24 16:36:11 +02:00
Daniel García 08dce4bcf9 Fmt 2026-07-24 16:32:50 +02:00
Daniel García 5f03e74feb Fix get_groups_data when not using full_access 2026-07-24 16:20:34 +02:00
Daniel García 94a798e9dd Fix get_groups_data 2026-07-24 15:47:51 +02:00
Daniel García 5a02e80a5a Trusted proxies, unauthenticated rate limits and various fixes 2026-07-20 01:24:46 +02:00
122 changed files with 23848 additions and 17567 deletions

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
# Ignore everything
// Ignore everything
*
# Allow what is needed
// Allow what is needed
!.git
!docker/healthcheck.sh
!docker/start.sh
-15
View File
@@ -316,14 +316,6 @@
## unauthenticated access to potentially sensitive data.
# SHOW_PASSWORD_HINT=false
#########################
### Client settings ###
#########################
## Control whether clients onboarding interstitials are suppressed
## (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals)
# CLIENT_SUPPRESS_ONBOARDING=false
#########################
### Advanced settings ###
#########################
@@ -390,7 +382,6 @@
##
## The following flags are available:
## - "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": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
@@ -402,13 +393,10 @@
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "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)
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.
@@ -522,9 +510,6 @@
## Prevent users from logging in directly without going through SSO
# SSO_ONLY=false
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
# SSO_SIGNUPS_ALLOWED=true
## On SSO Signup if a user with a matching email already exists make the association
# SSO_SIGNUPS_MATCH_EMAIL=true
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
# Checkout the repo
- name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching
- name: Rust Caching
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
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.
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Checkout the repo
- name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# End Checkout the repo
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -32,7 +32,7 @@ jobs:
# Checkout the repo
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# End Checkout the repo
@@ -41,12 +41,12 @@ jobs:
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint
+14 -14
View File
@@ -58,13 +58,13 @@ jobs:
steps:
- name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
platforms: "arm64,arm"
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -77,7 +77,7 @@ jobs:
# Checkout the repo
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# We need fetch-depth of 0 so we also get all the tag metadata
with:
persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -237,7 +237,7 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -272,7 +272,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +365,7 @@ jobs:
# Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: 'trivy-results.sarif'
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
security-events: write # To write the security report
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
+5 -4
View File
@@ -18,10 +18,9 @@ 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: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
hooks:
- id: typos
always_run: true
- repo: local
hooks:
@@ -39,7 +38,8 @@ repos:
entry: cargo test
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
@@ -47,7 +47,8 @@ repos:
entry: cargo clippy
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: check-docker-templates
name: check docker templates
-2
View File
@@ -15,10 +15,8 @@ extend-ignore-re = [
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSE",
"COSEKey",
"COSEAlgorithm",
"CoseEncrypt0",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream
Generated
+732 -789
View File
File diff suppressed because it is too large. Load diff
+37 -39
View File
@@ -1,6 +1,6 @@
[workspace.package]
edition = "2024"
rust-version = "1.96.1"
rust-version = "1.94.1"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@@ -12,6 +12,7 @@ members = ["macros"]
name = "vaultwarden"
version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md"
build = "build.rs"
repository.workspace = true
edition.workspace = true
@@ -40,7 +41,6 @@ vendored_openssl = ["openssl/vendored"]
enable_mimalloc = ["dep:mimalloc"]
s3 = [
"opendal/services-s3",
"dep:opendal-http-transport-reqwest",
"dep:aws-config",
"dep:aws-credential-types",
"dep:aws-smithy-runtime-api",
@@ -58,7 +58,6 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
unstable = []
[target."cfg(unix)".dependencies]
nix = { version = "0.31.3", features = ["fs"] }
# Logging
syslog = "7.0.0"
@@ -66,7 +65,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.34"
log = "0.4.33"
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"] }
@@ -76,7 +75,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries
num-traits = "0.2.19"
num-derive = "0.5.1"
num-derive = "0.4.2"
bigdecimal = "0.4.10"
# Web framework
@@ -90,8 +89,8 @@ rmpv = "1.3.1" # MessagePack library
dashmap = "6.2.1"
# Async futures
futures = "0.3.34"
tokio = { version = "1.53.1", features = [
futures = "0.3.32"
tokio = { version = "1.52.3", features = [
"fs",
"io-util",
"net",
@@ -100,14 +99,14 @@ tokio = { version = "1.53.1", features = [
"signal",
"time",
] }
tokio-util = { version = "0.7.19", features = ["compat"] }
tokio-util = { version = "0.7.18", features = ["compat"] }
# A generic serialization/deserialization framework
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel = { version = "2.3.10", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -120,36 +119,36 @@ derive_more = { version = "2.1.1", features = [
diesel-derive-newtype = "2.1.3"
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.38.2", optional = true }
libsqlite3-sys = { version = "0.37.0", optional = true }
# Crypto-related libraries
rand = "0.10.2"
ring = "0.17.14"
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.41", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.26.1", features = ["v4"] }
uuid = { version = "1.23.4", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4"
time = "0.3.55"
time = "0.3.53"
# Job scheduler
job_scheduler_ng = "2.5.0"
job_scheduler_ng = "2.4.0"
# Data encoding library Hex/Base32/Base64
data-encoding = "2.11.1"
data-encoding = "2.11.0"
# JWT library
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library
totp-lite = "2.0.1"
# Yubico Library
yubico_ng = { version = "1.0.0", default-features = false }
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] }
# WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db
@@ -162,7 +161,7 @@ webauthn-rs-core = "0.5.5"
url = "2.5.8"
# Email libraries
lettre = { version = "0.11.23", default-features = false, features = [
lettre = { version = "0.11.22", default-features = false, features = [
# Misc
"tracing",
"serde",
@@ -180,10 +179,10 @@ percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9"
# HTML Template library
handlebars = { version = "6.4.4", features = ["dir_source"] }
handlebars = { version = "6.4.2", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.5", default-features = false, features = [
reqwest = { version = "0.13.4", default-features = false, features = [
# Misc
"charset",
"cookies",
@@ -201,24 +200,24 @@ reqwest = { version = "0.13.5", default-features = false, features = [
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.3"
hickory-resolver = "0.26.1"
# Favicon extraction libraries
html5gum = "0.8.4"
regex = { version = "1.13.1", default-features = false, features = [
regex = { version = "1.12.4", default-features = false, features = [
"perf",
"std",
"unicode-perl",
] }
data-url = "0.3.2"
bytes = "1.12.1"
svg-hush = "0.9.7"
svg-hush = "0.9.6"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "4.0.0", features = ["async"] }
cached = { version = "2.0.2", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2"
cookie = "0.18.1"
cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL
@@ -232,11 +231,11 @@ pastey = "0.2.3"
governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.2"
ipnet = "2.12.0"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.16", features = ["future"] }
moka = { version = "0.12.15", features = ["future"] }
# Check client versions for specific features.
semver = "1.0.28"
@@ -245,10 +244,10 @@ semver = "1.0.28"
# Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
which = "8.0.6"
which = "8.0.4"
# Argon2 library with support for the PHC format
argon2 = "0.6.0"
argon2 = "0.5.3"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.5.4"
@@ -257,21 +256,20 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true }
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
aws-config = { version = "1.8.18", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.16.2", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.0", optional = true }
reqsign-core = { version = "3.3.1", optional = true }
aws-credential-types = { version = "1.2.14", optional = true }
aws-smithy-runtime-api = { version = "1.13.0", optional = true }
http = { version = "1.4.2", optional = true }
reqsign-aws-v4 = { version = "3.0.1", optional = true }
reqsign-core = { version = "3.0.1", optional = true }
# Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces
+3 -4
View File
@@ -1,12 +1,11 @@
---
vault_version: "v2026.7.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
vault_version: "v2026.6.2"
vault_image_digest: "sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a"
# Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
# The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used
rust_version: 1.96.1 # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images
+15 -16
View File
@@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.2
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a
# [docker.io/vaultwarden/web-vault:v2026.6.2]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a 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.98.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.1 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.96.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.96.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.96.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.96.1 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
@@ -66,11 +66,11 @@ RUN USER=root cargo new --bin /app
WORKDIR /app
# Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
# Output the current contents of the file
cat /env-cargo
RUN . /env-cargo && \
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -86,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN . /env-cargo && \
RUN source /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
find . -not -path "./target*" -delete
@@ -97,13 +97,13 @@ COPY . .
ARG VW_VERSION
# Builds again, this time it will be the actual source files being build
RUN . /env-cargo && \
RUN source /env-cargo && \
# Make sure that we actually build the project by updating the src/main.rs timestamp
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -126,7 +126,6 @@ RUN . /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
#
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
ENV ROCKET_PROFILE="release" \
+13 -25
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.2
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a
# [docker.io/vaultwarden/web-vault:v2026.6.2]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,8 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build
# hadolint ignore=DL3067
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.96.1-slim-trixie AS build
COPY --from=xx / /
ARG TARGETARCH
ARG TARGETVARIANT
@@ -81,7 +80,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app
WORKDIR /app
RUN . /env-cargo && \
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -96,14 +95,9 @@ ARG DB=sqlite,mysql,postgresql
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN . /env-cargo && \
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
RUN source /env-cargo && \
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete
@@ -114,20 +108,15 @@ COPY . .
ARG VW_VERSION
# Builds again, this time it will be the actual source files being build
RUN . /env-cargo && \
RUN source /env-cargo && \
# Make sure that we actually build the project by updating the src/main.rs timestamp
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -150,7 +139,6 @@ RUN . /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
#
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
ENV ROCKET_PROFILE="release" \
+6 -13
View File
@@ -28,13 +28,8 @@
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
#
{% macro xx_cargo_config() -%}
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
{%- endmacro %}
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
@@ -57,7 +52,6 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
{% if base == "debian" %}
# hadolint ignore=DL3067
COPY --from=xx / /
{% endif %}
ARG TARGETARCH
@@ -112,12 +106,12 @@ WORKDIR /app
{% if base == "alpine" %}
# Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
# Output the current contents of the file
cat /env-cargo
{% endif %}
RUN . /env-cargo && \
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -137,7 +131,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN . /env-cargo && \
RUN source /env-cargo && \
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
@@ -152,7 +146,7 @@ COPY . .
ARG VW_VERSION
# Builds again, this time it will be the actual source files being build
RUN . /env-cargo && \
RUN source /env-cargo && \
# Make sure that we actually build the project by updating the src/main.rs timestamp
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
@@ -162,7 +156,7 @@ RUN . /env-cargo && \
{% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
{% endif %}
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -185,7 +179,6 @@ RUN . /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
#
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
ENV ROCKET_PROFILE="release" \
+2 -8
View File
@@ -3,23 +3,17 @@
import os
import argparse
import json
import tomllib
import yaml
import jinja2
# Load settings file
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
with open("DockerSettings.yaml", 'r') as yaml_file:
yaml_data = yaml.safe_load(yaml_file)
# Extract the rust_version from the rust-toolchain.toml file
script_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
settings_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.getcwd()),
)
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
args_parser = argparse.ArgumentParser()
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
+2 -2
View File
@@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true
[dependencies]
quote = "1.0.47"
syn = "3.0.6"
quote = "1.0.46"
syn = "2.0.118"
[lints]
workspace = true
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,31 +1,15 @@
SELECT if (
EXISTS(
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
)
,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
) INTO @drop_stmt;
PREPARE drop_stmt FROM @drop_stmt;
EXECUTE drop_stmt;
SELECT if (
EXISTS(
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = '1'
)
,'ALTER TABLE sso_users DROP FOREIGN KEY `1`'
,'SELECT "info: FK sso_users 1 does not exist."'
) INTO @drop_stmt;
PREPARE drop_stmt FROM @drop_stmt;
EXECUTE drop_stmt;
DEALLOCATE PREPARE drop_stmt;
-- Dynamically create DROP FOREIGN KEY
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
-- This checks if the key exists, and if so, will drop it.
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
CONSTRAINT_SCHEMA = DATABASE() AND
TABLE_NAME = 'sso_users' AND
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
'SELECT 1');
PREPARE stmt FROM @drop_sso_fk;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,7 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -1,16 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,7 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -1,15 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,7 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -1,15 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
+3 -13
View File
@@ -21,19 +21,11 @@ TEST_USER3=test3
TEST_USER3_PASSWORD=${TEST_USER3}
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
TEST_USER4=test4
TEST_USER4_PASSWORD=${TEST_USER4}
TEST_USER4_MAIL=${TEST_USER4}@yopmail.com
TEST_USER5=test5
TEST_USER5_PASSWORD=${TEST_USER5}
TEST_USER5_MAIL=${TEST_USER5}@yopmail.com
###################
# Keycloak Config #
###################
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080
@@ -47,10 +39,8 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
######################
ROCKET_ADDRESS=0.0.0.0
ROCKET_PORT=8000
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
DOMAIN=http://localhost:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
SSO_DEBUG_TOKENS=true
I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true
+16 -14
View File
@@ -1,8 +1,8 @@
# Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/).
\
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
It uses its own `test.env` with different ports to not collide with a running dev instance.
## Install
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
### Running Playwright outside docker
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
You'll additionally need `nodejs` then run:
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
You will additionally need `nodejs` then run:
```bash
npm ci --ignore-scripts --allow-git=none --allow-remote=none
npm ci --ignore-scripts
npx playwright install-deps
npx playwright install firefox
```
@@ -65,7 +65,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
```bash
PW_KEEP_SERVICE_RUNNING=true npx playwright test
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
```
### Running specific tests
@@ -77,7 +77,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
```
To run only a specific test (It might fail if it has dependency):
To run only a specifc test (It might fail if it has dependency):
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
@@ -92,7 +92,7 @@ This does not start the server, you will need to start it manually.
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
npx playwright codegen "http://127.0.0.1:8003"
```
## Override web-vault
@@ -112,11 +112,12 @@ You can check the result running:
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
```
Then check `https://127.0.0.1:8003/admin/diagnostics` with `admin`.
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
# OpenID Connect test setup
Additionally this `docker-compose` template allow to run locally `Vaultwarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
Additionally this `docker-compose` template allows to run locally Vaultwarden,
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
## Setup
@@ -130,17 +131,18 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash
> docker compose --profile vaultwarden --env-file .env up
....
keycloakSetup_1 | Logging into https://127.0.0.1:8080 as user admin of realm master
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
keycloakSetup_1 | Created new realm with id 'test'
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
oidc_keycloakSetup_1 exited with code 0
```
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user
(It is normal for this container to stop once the configuration is done).
Then you can access :
- `Vaultwarden` on https://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
- `Maildev` on http://0.0.0.0:1080
@@ -169,7 +171,7 @@ docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild V
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
The content of the file will be loaded as environment variables in all containers.
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
## Cleanup
+2 -2
View File
@@ -17,7 +17,7 @@ done
set -e
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
@@ -39,6 +39,6 @@ kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLi
# THEN in another terminal:
# docker exec -it keycloakSetup-dev /bin/bash
# export PATH=$PATH:/opt/keycloak/bin
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
# ENJOY
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
+1 -1
View File
@@ -28,7 +28,7 @@ RUN mkdir /playwright
WORKDIR /playwright
COPY package.json package-lock.json .
RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox
RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox
COPY docker-compose.yml test.env ./
COPY compose ./compose
-1
View File
@@ -35,7 +35,6 @@ WORKDIR /
COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden .
COPY --from=build /data ./data
COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"]
-11
View File
@@ -22,14 +22,3 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
mv build /web-vault
fi
# Lower the KDF iterations default for faster tests.
sed -i 's/(6e5,2e6,6e5)/(1e5,2e6,1e5)/' /web-vault/app/main.*.js
# Generate a self signed cert
mkdir -p /data/ssl; cd /data/ssl
openssl req -x509 -out localhost.crt -keyout localhost.key \
-newkey rsa:2048 -nodes -sha256 \
-subj '/CN=localhost' -extensions EXT -config <( \
printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
+7 -10
View File
@@ -24,15 +24,12 @@ services:
environment:
- ADMIN_TOKEN
- DATABASE_URL
- CLIENT_SUPPRESS_ONBOARDING
- EMAIL_2FA_AUTO_FALLBACK
- I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST
- SMTP_FROM
- SMTP_DEBUG
- SSO_AUTH_ONLY_NOT_SESSION
- SSO_DEBUG_TOKENS
- SSO_ENABLED
- SSO_FRONTEND
@@ -61,7 +58,7 @@ services:
Mariadb:
profiles: ["playwright"]
container_name: playwright_mariadb
image: mariadb:12.2.2
image: mariadb:11.2.4
env_file: test.env
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
@@ -73,7 +70,7 @@ services:
Mysql:
profiles: ["playwright"]
container_name: playwright_mysql
image: mysql:9.7.0
image: mysql:8.4.1
env_file: test.env
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@@ -85,7 +82,7 @@ services:
Postgres:
profiles: ["playwright"]
container_name: playwright_postgres
image: postgres:18.4
image: postgres:16.3
env_file: test.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
@@ -97,7 +94,7 @@ services:
Maildev:
profiles: ["vaultwarden", "maildev"]
container_name: maildev
image: timshel/maildev:3.2.19
image: timshel/maildev:3.0.4
ports:
- ${SMTP_PORT}:1025
- 1080:1080
@@ -105,7 +102,7 @@ services:
Keycloak:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:26.6.2
image: quay.io/keycloak/keycloak:26.3.4
network_mode: "host"
command:
- start-dev
@@ -115,12 +112,12 @@ services:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloakSetup-${ENV:-dev}
image: keycloak_setup-${ENV:-dev}
network_mode: "host"
build:
context: compose/keycloak
dockerfile: Dockerfile
args:
KEYCLOAK_VERSION: 26.6.2
KEYCLOAK_VERSION: 26.3.4
network_mode: "host"
depends_on:
- Keycloak
restart: "no"
+1 -1
View File
@@ -1,4 +1,4 @@
import { type FullConfig } from '@playwright/test';
import { firefox, type FullConfig } from '@playwright/test';
import { execSync } from 'node:child_process';
import fs from 'fs';
+13 -2
View File
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
}
export async function stopVault(force: boolean = false) {
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
} else {
console.log(`Vaultwarden stopping`);
@@ -231,7 +231,6 @@ export async function checkNotification(page: Page, hasText: string) {
}
export async function cleanLanding(page: Page) {
await page.context().clearCookies();
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('button').nth(0)).toBeVisible();
@@ -249,3 +248,15 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
});
}
export async function ignoreExtension(page: Page) {
await page.waitForLoadState('domcontentloaded');
try {
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
await page.getByRole('link', { name: 'Skip to web app' }).click();
} catch (error) {
console.log('Extension setup not visible. Continuing');
}
}
+580 -582
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -8,14 +8,14 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.60.0",
"dotenv": "17.4.2",
"dotenv-expand": "13.0.0",
"maildev": "npm:@timshel_npm/maildev@3.2.19"
"@playwright/test": "1.56.1",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@3.2.5"
},
"dependencies": {
"mysql2": "3.22.3",
"otpauth": "9.5.1",
"pg": "8.21.0"
"mysql2": "3.15.3",
"otpauth": "9.4.1",
"pg": "8.16.3"
}
}
+4 -10
View File
@@ -25,12 +25,10 @@ export default defineConfig({
/* Long global timeout for complex tests
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
*/
timeout: 240 * 1000,
actionTimeout: 40 * 1000,
navigationTimeout: 40 * 1000,
expect: { timeout: 40 * 1000 },
"permissions": ["clipboard-read"],
timeout: 120 * 1000,
actionTimeout: 20 * 1000,
navigationTimeout: 20 * 1000,
expect: { timeout: 20 * 1000 },
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
@@ -39,10 +37,6 @@ export default defineConfig({
browserName: 'firefox',
locale: 'en-GB',
timezoneId: 'Europe/London',
ignoreHTTPSErrors: true,
launchOptions: {
args: ['--ignore-certificate-errors']
},
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
trace: 'on',
+4 -6
View File
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
#####################
# Playwright Config #
#####################
PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false}
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
PW_SMTP_FROM=vaultwarden@playwright.test
#####################
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
###################
# Keycloak Config #
###################
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081
@@ -52,12 +52,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config #
######################
ROCKET_PORT=8003
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
DOMAIN=http://localhost:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100
ADMIN_TOKEN=admin
CLIENT_SUPPRESS_ONBOARDING=true
SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT}
+11 -5
View File
@@ -1,8 +1,6 @@
import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { createAccount } from './setups/user';
let users = utils.loadEnv();
@@ -18,12 +16,20 @@ test.afterAll('Teardown', async ({}) => {
test('Create', async ({ page }) => {
await createAccount(test, page, users.user1);
await orgs.create(test, page, 'New organisation');
await test.step('Create Org', async () => {
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill('Test');
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await utils.checkNotification(page, 'Organisation created');
});
await test.step('Create Collection', async () => {
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('link', { name: 'Collections' }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Collection' }).click();
await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
await page.getByLabel('Name (required)').fill('RandomCollec');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
-56
View File
@@ -1,56 +0,0 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP } from './setups/2fa';
let users = utils.loadEnv();
let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Change Key settings', async ({ page }) => {
await createAccount(test, page, users.user1);
await test.step('Change SHA-256 Iterations', async () => {
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Keys' }).click();
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('700000');
await page.getByRole('button', { name: 'Update encryption settings' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Update settings' }).click();
await page.getByRole('heading', { name: 'Log in' }).click();
});
await logUser(test, page, users.user1);
await test.step('Switch to Argon2', async () => {
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Keys' }).click();
await page.locator('.ng-arrow-wrapper').click();
await page.getByText('Argon2id').click();
await page.getByRole('spinbutton', { name: 'KDF memory (MB) * (required)'}).fill('16');
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('2');
await page.getByRole('spinbutton', { name: 'KDF parallelism * (required)'}).fill('1');
await page.getByRole('button', { name: 'Update encryption settings' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Update settings' }).click();
await page.getByRole('heading', { name: 'Log in' }).click();
});
await logUser(test, page, users.user1);
});
+25 -6
View File
@@ -41,10 +41,13 @@ test('Account creation', async ({ page }) => {
test('Login', async ({ context, page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, { mailBuffer });
await logUser(test, page, users.user1, mailBuffer);
await test.step('verify email', async () => {
await page.getByRole('button', { name: "Send email" }).click();
await page.getByText('Verify your account\'s email').click();
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
await page.getByRole('button', { name: 'Send email' }).click();
await utils.checkNotification(page, 'Check your email inbox for a verification link');
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
@@ -75,10 +78,26 @@ test('Activate 2fa', async ({ page }) => {
test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, {
mailBuffer: emails,
mail2fa: true,
});
await test.step('login', async () => {
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
const code = await retrieveEmailCode(test, page, emails);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Add it later' }).click();
await page.getByRole('link', { name: 'Skip to web app' }).click();
await expect(page).toHaveTitle(/Vaults/);
})
await disableEmail(test, page, users.user1);
emails.close();
});
+2 -2
View File
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
+21 -28
View File
@@ -4,7 +4,6 @@ import { MailDev } from 'maildev';
import * as utils from '../global-utils';
import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv();
@@ -21,7 +20,6 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
EMAIL_2FA_AUTO_FALLBACK: "true",
});
mail1Buffer = mailServer.buffer(users.user1.email);
@@ -47,7 +45,7 @@ test('Invite users', async ({ page }) => {
await orgs.policies(test, page, 'Test');
await page.getByRole('button', { name: 'Account recovery' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
await page.getByRole('checkbox', { name: 'Require new members' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
@@ -68,16 +66,18 @@ test('invited with new account', async ({ page }) => {
await page.goto(link);
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
// await page.getByLabel('Name').fill(users.user2.name);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
//await page.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
// await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Successfully accepted your invitation');
});
await test.step('Check mails', async () => {
@@ -100,19 +100,21 @@ test('invited with existing account', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
});
test('Confirm invited user', async ({ page }) => {
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await logUser(test, page, users.user1, mail1Buffer);
await orgs.members(test, page, 'Test');
await orgs.confirm(test, page, 'Test', users.user2.email);
@@ -121,30 +123,25 @@ test('Confirm invited user', async ({ page }) => {
});
test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await logUser(test, page, users.user2, mail2Buffer);
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await logUser(test, page, users.user1, mail1Buffer);
let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test');
await test.step(`Recover ${users.user2.email}`, async () => {
await test.step(`Rrcover ${users.user2.email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Password reset success');
});
let user2 = {
@@ -152,9 +149,5 @@ test('Recover user password', async ({ page }) => {
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
await logUser(test, page, user2, mail2Buffer);
});
-110
View File
@@ -1,110 +0,0 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
let users = utils.loadEnv();
let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
const context = await browser.newContext();
const page = await context.newPage();
await createAccount(test, page, users.user1);
await context.close();
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Password', async ({ context, page }, testInfo: TestInfo) => {
const label = 'Test Password';
await logUser(test, page, users.user1);
await test.step('Create password entry', async () => {
await page.getByRole('button', { name: 'New item' }).click();
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Item added');
await page.getByRole('button', { name: 'Close' }).click();
});
// Log again
await logUser(test, page, users.user1);
await test.step('Check', async () => {
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
await page.getByTestId('copy-username').click();
await utils.checkNotification(page, 'Username copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
await page.getByTestId('copy-password').click();
await utils.checkNotification(page, 'Password copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
await page.getByRole('button', { name: 'Close' }).click();
});
await test.step('Delete', async () => {
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Item sent to bin');
});
// Log again
await logUser(test, page, users.user1);
await test.step('Deleted', async () => {
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
});
});
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
const label = 'Test SSH key';
await logUser(test, page, users.user1);
const privateKey = await test.step('Create key entry', async () => {
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'SSH key' }).click();
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Item added');
await page.getByRole('button', { name: 'Copy private key' }).click();
await utils.checkNotification(page, 'Private key copied');
return await page.evaluate(() => navigator.clipboard.readText());
});
// Log again
await logUser(test, page, users.user1);
await test.step('Check', async () => {
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
await page.getByRole('button', { name: 'Copy private key' }).click();
await utils.checkNotification(page, 'Private key copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
await page.getByRole('button', { name: 'Close' }).click();
});
await test.step('Delete', async () => {
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Item sent to bin');
});
// Log again
await logUser(test, page, users.user1);
await test.step('Deleted', async () => {
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
})
});
+8 -8
View File
@@ -21,11 +21,11 @@ test('Send', async ({ browser, page }) => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New Send', exact: true }).click();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
@@ -46,14 +46,14 @@ test('Send', async ({ browser, page }) => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
await page.getByRole('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
@@ -64,7 +64,7 @@ test('Send', async ({ browser, page }) => {
await test.step('View with password', async () => {
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page2.getByRole('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
+7 -8
View File
@@ -11,11 +11,10 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
const secret = await page.getByLabel('Key', { exact: true }).innerText();
const secret = await page.getByLabel('Key').innerText();
let totp = new OTPAuth.TOTP({ secret, period: 30 });
await page.getByLabel(/Verification code/).fill(totp.generate());
@@ -34,8 +33,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
@@ -50,7 +49,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send email' }).click();
});
@@ -82,8 +81,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
-21
View File
@@ -1,21 +0,0 @@
import { expect, type Browser, Page } from '@playwright/test';
import * as utils from '../../global-utils';
utils.loadEnv();
export async function login(test, page: Page) {
await test.step(`Admin login`, async () => {
await page.goto('/admin');
await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN);
await page.getByRole('button', { name: 'Enter' }).click();
});
}
export async function invite(test, page: Page, email: string) {
await test.step(`Invite user with ${email}`, async () => {
await page.getByRole('link', { name: 'Users' }).click();
await page.getByRole('textbox', { name: 'Enter email' }).fill(email);
await page.getByRole('button', { name: 'Invite' }).click();
await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/);
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
utils.stopComposeService(serviceName);
}
});
+11 -14
View File
@@ -3,14 +3,11 @@ import { expect, type Browser,Page } from '@playwright/test';
import * as utils from '../../global-utils';
export async function create(test, page: Page, name: string) {
await test.step(`Create Org ${name}`, async () => {
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
if( await pm_locator.count() > 0 ){
pm_locator.first().click();
}
await test.step('Create Org', async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name);
await page.getByLabel('Organisation name (required)').fill(name);
await page.getByRole('button', { name: 'Submit' }).click();
await utils.checkNotification(page, 'Organisation created');
@@ -21,7 +18,7 @@ export async function policies(test, page: Page, name: string) {
await test.step(`Navigate to ${name} policies`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Policies' }).click();
@@ -33,11 +30,11 @@ export async function members(test, page: Page, name: string) {
await test.step(`Navigate to ${name} members`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.getByRole('link', { name: 'Members' }).click();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
});
}
@@ -45,13 +42,13 @@ export async function invite(test, page: Page, name: string, email: string) {
await test.step(`Invite ${email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('button', { name: 'Invite member' }).click();
await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email);
await page.getByLabel('Email (required)').fill(email);
await page.getByRole('tab', { name: 'Collections' }).click();
await page.getByRole('combobox', { name: 'Permission' }).click();
await page.getByText('Edit items', { exact: true }).click();
await page.getByRole('combobox', { name: 'Select collections' }).click();
await page.getByLabel('Options List').getByText('Default collection').click();
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
await page.getByLabel('Select collections').click();
await page.getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
+1 -1
View File
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
test('Keycloak teardown', async () => {
if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
console.log("Keep Keycloak running");
} else {
console.log("Keycloak stopping");
+17 -8
View File
@@ -15,8 +15,11 @@ export async function logNewUser(
options: { mailBuffer?: MailBuffer } = {}
) {
await test.step(`Create user ${user.name}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
@@ -30,24 +33,26 @@ export async function logNewUser(
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click();
});
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
});
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
if( options.mailBuffer ){
let mailBuffer = options.mailBuffer;
await test.step('Check emails', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
await mailBuffer.expect((m) => m.subject === "Welcome");
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});
}
});
@@ -64,14 +69,16 @@ export async function logUser(
mailBuffer ?: MailBuffer,
totp?: OTPAuth.TOTP,
mail2fa?: boolean,
notNewDevice?: boolean,
} = {}
) {
let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
@@ -110,12 +117,14 @@ export async function logUser(
await page.getByRole('button', { name: 'Unlock' }).click();
});
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
});
if( mailBuffer && !options.notNewDevice ){
if( mailBuffer ){
await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});
+9 -25
View File
@@ -3,7 +3,6 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev';
import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa';
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Create user ${user.name}`, async () => {
@@ -18,11 +17,12 @@ export async function createAccount(test, page: Page, user: { email: string, nam
await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created')
await utils.ignoreExtension(page);
// We are now in the default vault page
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
@@ -35,16 +35,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam
});
}
export async function logUser(
test,
page: Page,
user: { email: string, password: string },
options: {
mailBuffer ?: MailBuffer,
mail2fa?: boolean,
notNewDevice?: boolean,
} = {}
) {
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Log user ${user.email}`, async () => {
await utils.cleanLanding(page);
@@ -52,23 +43,16 @@ export async function logUser(
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
if( options.mail2fa ){
await test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
let code = await retrieveEmailCode(test, page, options.mailBuffer);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
});
}
await utils.ignoreExtension(page);
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
if( options.mailBuffer && !options.notNewDevice ){
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
if( mailBuffer ){
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
}
});
}
+9 -52
View File
@@ -1,7 +1,6 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import * as admin from "./setups/admin";
import { logNewUser, logUser } from './setups/sso';
import { activateEmail, disableEmail } from './setups/2fa';
import * as utils from "../global-utils";
@@ -20,7 +19,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: true,
SSO_ONLY: false,
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
@@ -33,64 +32,22 @@ test.afterAll('Teardown', async ({}) => {
}
});
test('2FA email', async ({ page }) => {
test('Create and activate 2FA', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
await activateEmail(test, page, users.user1, mailBuffer);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
mailBuffer.close();
});
test('Log and disable', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
await disableEmail(test, page, users.user1);
mailBuffer.close();
});
test('Admin invite', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user2.email);
await admin.login(test, page);
await admin.invite(test, page, users.user2.email);
const link = await test.step('Extract email link', async () => {
const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden");
await page.setContent(invited.html);
return await page.getByTestId("invite").getAttribute("href");
});
await test.step('Redirect to Keycloak', async () => {
await page.goto(link);
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(users.user2.name);
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
});
await test.step('Check mails', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
await mailBuffer.expect((m) => m.subject === "Welcome");
});
mailBuffer.close();
});
+4 -6
View File
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
await page.getByRole('button', { name: 'Other' }).click();
// Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
@@ -58,7 +58,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
// Landing page
await page.goto('/');
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
// Check that SSO login is available
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
@@ -67,6 +66,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
});
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, {
SSO_ENABLED: false
@@ -74,14 +74,12 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
// Landing page
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// No SSO button (rely on a correct selector checked in previous test)
await page.getByLabel('Master password');
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
// Can continue to Master password
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1);
});
@@ -67,16 +67,17 @@ test('invited with new account', async ({ page }) => {
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
});
await test.step('Check mails', async () => {
@@ -94,7 +95,6 @@ test('invited with existing account', async ({ page }) => {
await test.step('Redirect to Keycloak', async () => {
await page.goto(link);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
await test.step('Keycloak login', async () => {
@@ -108,11 +108,13 @@ test('invited with existing account', async ({ page }) => {
await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Unlock' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
});
await test.step('Check mails', async () => {
+8 -19
View File
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Activate password policy', async ({ page }) => {
test('Enforce password policy', async ({ page }) => {
await logUser(test, page, users.user1);
await orgs.policies(test, page, '/Test');
@@ -61,27 +61,16 @@ test('Activate password policy', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.');
});
});
test('Unlock trigger policyy', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await utils.logout(test, page, users.user1);
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
await test.step(`Unlock trigger policy`, async () => {
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
await page.getByRole('button', { name: 'Use single sign-on' }).click();
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(users.user2.name);
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Unlock vault', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
await page.getByLabel('Master password').fill(users.user2.password);
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Unlock' }).click();
});
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.98.1"
channel = "1.96.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+5 -36
View File
@@ -231,7 +231,7 @@ fn validate_token(token: &str) -> bool {
None => false,
Some(t) if t.starts_with("$argon2") => {
use argon2::password_hash::PasswordVerifier;
match argon2::password_hash::phc::PasswordHash::new(t) {
match argon2::password_hash::PasswordHash::new(t) {
Ok(h) => {
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
@@ -425,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
for membership in memberships {
log_event(
EventType::OrganizationUserDeleted,
EventType::OrganizationUserDeleted as i32,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -446,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
for membership in memberships {
log_event(
EventType::OrganizationUserUnlinkedSso,
EventType::OrganizationUserUnlinkedSso as i32,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -571,7 +571,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
log_event(
EventType::OrganizationUserUpdated,
EventType::OrganizationUserUpdated as i32,
&member_to_edit.uuid,
&data.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -647,7 +647,7 @@ use cached::macros::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
/// Any cache will be lost if Vaultwarden is restarted
#[cached(ttl_secs = 600, sync_writes = "default")]
#[cached(ttl = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
if has_http_access {
@@ -716,36 +716,6 @@ fn web_vault_compare(active: &str, latest: &str) -> i8 {
}
}
fn check_template_overrides() -> Vec<&'static str> {
let template_folder = std::path::PathBuf::from(CONFIG.templates_folder());
let mut overrides = Vec::new();
for folder in ["admin", "email", "scss"] {
if folder_has_hbs_files(&template_folder.join(folder)) {
overrides.push(folder);
}
}
if folder_has_hbs_files(&template_folder) {
overrides.push("other");
}
overrides
}
fn folder_has_hbs_files(dir: &std::path::Path) -> bool {
let Ok(files) = std::fs::read_dir(dir) else {
// No files in this directory at all, so we can return false
return false;
};
files.flatten().any(|f| {
// Validate if it is a file and if it has the `.hbs` extension and starts with a-z or 0-9
f.file_type().is_ok_and(|t| t.is_file())
&& f.path().extension().is_some_and(|e| e.eq_ignore_ascii_case("hbs"))
&& f.file_name().to_str().is_some_and(|n| n.starts_with(|c: char| c.is_ascii_alphanumeric()))
})
}
#[get("/diagnostics")]
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
use chrono::prelude::*;
@@ -800,7 +770,6 @@ 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(", "),
"template_overrides": check_template_overrides().join(", "),
"invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH,
"host_os": env::consts::OS,
+147 -852
View File
File diff suppressed because it is too large. Load diff
+48 -166
View File
@@ -6,7 +6,6 @@ use rocket::{
Route,
form::{Form, FromForm},
fs::TempFile,
http::Status,
serde::json::Json,
};
use serde_json::Value;
@@ -22,9 +21,8 @@ use crate::{
DbConn, DbPool,
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -163,19 +161,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let policies_json: Vec<Value> =
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
.await
.iter()
.map(OrgPolicy::to_json)
.collect();
let domains_json = if data.exclude_domains {
Value::Null
} else {
api::core::get_eq_domains(&headers, true).into_inner()
};
// This is very similar to the userDecryptionOptions sent in connect/token,
// This is very similar to the the userDecryptionOptions sent in connect/token,
// but as of 2025-12-19 they're both using different casing conventions.
let has_master_password = !headers.user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
@@ -190,32 +182,23 @@ 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.email,
"containedKeyId": headers.user.key_id,
"salt": headers.user.email
})
} else {
Value::Null
};
// Upstream omits these two when unset rather than sending null.
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
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,
"folders": folders_json,
"collections": collections_json,
"policies": policies_json,
"policiesNew": policies_new_json,
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": user_decryption,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
},
"object": "sync"
})))
}
@@ -277,23 +260,15 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
BankAccount = 6
DriversLicense = 7
Passport = 8
*/
pub r#type: i32,
// Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub name: String,
pub notes: Option<String>,
fields: Option<Value>,
@@ -303,12 +278,6 @@ pub struct CipherData {
card: Option<Value>,
identity: Option<Value>,
ssh_key: Option<Value>,
bank_account: Option<Value>,
drivers_license: Option<Value>,
passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>,
reprompt: Option<i32>,
@@ -331,70 +300,6 @@ pub struct CipherData {
archived_date: Option<String>,
}
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// A user-owned cipher must be encrypted with the user's current key. Organization ciphers use the
/// organization key, which has no id yet, and either id may be missing: from a client that predates
/// the field, or a user whose key id isn't known yet. There is nothing to compare in those cases.
///
/// Ref: upstream's `CiphersController.ValidateCipherEncryptedByUser`
fn validate_encrypted_by_user_key(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether the content is a single blob rather than the per-type fields. This parses `data`, so
/// callers that need the answer more than once should keep it.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Checks the content the way upstream's model validation does, before anything is saved.
/// `is_blob` is [`Self::is_blob`].
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Api/Vault/Models/Request/CipherRequestModel.cs>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
@@ -428,17 +333,12 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user_key(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type, String::new());
let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone());
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -462,19 +362,13 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user_key(&data, &headers.user, data.organization_id.is_some())?;
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
// which results in a warning message being logged. This field isn't
// needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, String::new());
let mut cipher = Cipher::new(data.r#type, data.name.clone());
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -523,9 +417,6 @@ pub async fn update_cipher_from_data(
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
@@ -619,53 +510,38 @@ pub async fn update_cipher_from_data(
3 => data.card,
4 => data.identity,
5 => data.ssh_key,
6 => data.bank_account,
7 => data.drivers_license,
8 => data.passport,
_ => err!("Invalid type"),
};
if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds all of the content, the name included, so nothing is kept outside it. The name
// column can't be null, so it's left empty; `to_json` reports it as null, as upstream does.
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object.
type_data.as_object_mut().unwrap().remove("response");
data.as_object_mut().unwrap().remove("response");
// Remove the 'Response' key from every Uri.
if type_data["uris"].is_array() {
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
}
// `validate_content` made sure there is a name
cipher.name = data.name.unwrap_or_default();
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
data
} else {
err!("Data missing")
};
cipher.key = data.key;
cipher.name = data.name;
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?;
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
match data.archived_date {
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
if let Some(dt_str) = data.archived_date {
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 '{dt_str}': {err}"),
},
None => cipher.unarchive(&headers.user.uuid, conn).await?,
}
}
if ut != UpdateType::None {
@@ -677,8 +553,16 @@ pub async fn update_cipher_from_data(
(_, _) => EventType::CipherUpdated,
};
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
.await;
log_event(
event_type as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
}
nt.send_cipher_update(
ut,
@@ -750,7 +634,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, String::new());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -818,8 +702,6 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
validate_encrypted_by_user_key(&data, &headers.user, cipher.organization_uuid.is_some())?;
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?))
@@ -968,7 +850,7 @@ async fn post_collections_update(
.await;
log_event(
EventType::CipherUpdatedCollections,
EventType::CipherUpdatedCollections as i32,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@@ -988,7 +870,7 @@ async fn put_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
) -> EmptyResult {
post_collections_admin(cipher_id, data, headers, conn, nt).await
}
@@ -999,7 +881,7 @@ async fn post_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
) -> EmptyResult {
let data: CollectionsAdminData = data.into_inner();
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
@@ -1048,7 +930,7 @@ async fn post_collections_admin(
.await;
log_event(
EventType::CipherUpdatedCollections,
EventType::CipherUpdatedCollections as i32,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@@ -1058,7 +940,7 @@ async fn post_collections_admin(
)
.await;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
Ok(())
}
#[derive(Deserialize)]
@@ -1453,7 +1335,7 @@ async fn save_attachment(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherAttachmentCreated,
EventType::CipherAttachmentCreated as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
@@ -1814,7 +1696,7 @@ async fn purge_org_vault(
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
log_event(
EventType::OrganizationPurgedVault,
EventType::OrganizationPurgedVault as i32,
&organization.org_id,
&organization.org_id,
&user.uuid,
@@ -1942,9 +1824,9 @@ async fn delete_cipher_by_uuid(
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|| *delete_options == CipherDeleteOptions::SoftMulti
{
EventType::CipherSoftDeleted
EventType::CipherSoftDeleted as i32
} else {
EventType::CipherDeleted
EventType::CipherDeleted as i32
};
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
@@ -2013,7 +1895,7 @@ async fn restore_cipher_by_uuid(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherRestored,
EventType::CipherRestored as i32,
&cipher.uuid.clone(),
org_id,
&headers.user.uuid,
@@ -2090,7 +1972,7 @@ async fn delete_cipher_attachment_by_id(
if let Some(ref org_id) = cipher.organization_uuid {
log_event(
EventType::CipherAttachmentDeleted,
EventType::CipherAttachmentDeleted as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
+3 -23
View File
@@ -10,7 +10,7 @@ use crate::{
auth::{AdminHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
},
util::parse_date,
};
@@ -199,26 +199,6 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
// Only the vault notification banner click is accepted from clients. The rest of
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
if let Some(org_id) = &event.organization_id
&& let Some(membership) =
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
{
log_event_impl(
event.r#type,
&membership.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
Some(event_date),
&headers.ip.ip,
&conn,
)
.await;
}
}
_ => {
// 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.
@@ -287,7 +267,7 @@ async fn log_user_event_impl(
}
pub async fn log_event(
event_type: EventType,
event_type: i32,
source_uuid: &str,
org_id: &OrganizationId,
act_user_id: &UserId,
@@ -298,7 +278,7 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() {
return;
}
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
}
#[expect(clippy::too_many_arguments)]
+2 -9
View File
@@ -228,17 +228,14 @@ fn config() -> Json<Value> {
// Version history:
// - Individual cipher key encryption: 2024.2.0
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2026.6.0",
"version": "2025.12.0",
"gitHash": option_env!("GIT_REV"),
"server": {
"name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden"
},
"settings": {
"disableUserRegistration": CONFIG.is_signup_disabled(),
// When enabled, this setting signals to clients that onboarding interstitials
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
"disableUserRegistration": CONFIG.is_signup_disabled()
},
"environment": {
"vault": domain,
@@ -254,10 +251,6 @@ fn config() -> Json<Value> {
"vapidPublicKey": null
},
"featureStates": feature_states,
// Not supported right now
// Used for by clients to learn if the server requires extra work to establish a connection.
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
"communication": null,
"object": "config",
}))
}
+59 -93
View File
@@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive;
use rocket::{Route, http::Status, serde::json::Json};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use crate::{
@@ -17,8 +17,7 @@ use crate::{
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
},
},
mail,
@@ -133,6 +132,7 @@ struct FullCollectionData {
name: String,
groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>,
}
@@ -269,7 +269,7 @@ async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, c
}
log_event(
EventType::OrganizationUserLeft,
EventType::OrganizationUserLeft as i32,
&membership.uuid,
&org_id,
&headers.user.uuid,
@@ -327,7 +327,7 @@ async fn post_organization(
org.save(&conn).await?;
log_event(
EventType::OrganizationUpdated,
EventType::OrganizationUpdated as i32,
org_id.as_ref(),
&org_id,
&headers.user.uuid,
@@ -391,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
@@ -514,7 +514,7 @@ async fn post_organization_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionCreated,
EventType::CollectionCreated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -597,7 +597,7 @@ async fn post_bulk_access_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated,
EventType::CollectionUpdated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -674,7 +674,7 @@ async fn post_organization_collection_update(
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated,
EventType::CollectionUpdated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -723,7 +723,7 @@ async fn delete_organization_collection_impl(
err!("Collection not found", "Collection does not exist or does not belong to this organization")
};
log_event(
EventType::CollectionDeleted,
EventType::CollectionDeleted as i32,
&collection.uuid,
org_id,
&headers.user.uuid,
@@ -887,11 +887,11 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
@@ -955,7 +955,7 @@ async fn get_members(
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
let mut users_json = Vec::new();
@@ -1075,7 +1075,7 @@ async fn send_invite(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut user_created: bool;
let mut user_created: bool = false;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
let user = match User::find_by_mail(email, &conn).await {
@@ -1110,7 +1110,6 @@ async fn send_invite(
member_status = MembershipStatus::Accepted as i32;
}
}
user_created = false;
user
}
};
@@ -1149,7 +1148,7 @@ async fn send_invite(
}
log_event(
EventType::OrganizationUserInvited,
EventType::OrganizationUserInvited as i32,
&new_member.uuid,
&org_id,
&headers.user.uuid,
@@ -1448,7 +1447,7 @@ async fn confirm_invite_impl(
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
log_event(
EventType::OrganizationUserConfirmed,
EventType::OrganizationUserConfirmed as i32,
&member_to_confirm.uuid,
org_id,
&headers.user.uuid,
@@ -1638,7 +1637,7 @@ async fn edit_member(
}
log_event(
EventType::OrganizationUserUpdated,
EventType::OrganizationUserUpdated as i32,
&member_to_edit.uuid,
&org_id,
&headers.user.uuid,
@@ -1725,7 +1724,7 @@ async fn delete_member_impl(
}
log_event(
EventType::OrganizationUserRemoved,
EventType::OrganizationUserRemoved as i32,
&member_to_delete.uuid,
org_id,
&headers.user.uuid,
@@ -1794,22 +1793,11 @@ async fn bulk_public_keys(
use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data;
// The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
// mandatory: clients are free to leave them out.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportCollectionData {
name: String,
id: Option<CollectionId>,
external_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportData {
ciphers: Vec<CipherData>,
collections: Vec<ImportCollectionData>,
collections: Vec<FullCollectionData>,
collection_relationships: Vec<RelationsData>,
}
@@ -1885,7 +1873,7 @@ async fn post_org_import(
cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, String::new());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -2156,7 +2144,7 @@ async fn put_policy(
}
log_event(
EventType::OrganizationUserRemoved,
EventType::OrganizationUserRemoved as i32,
&member.uuid,
&org_id,
&headers.user.uuid,
@@ -2182,7 +2170,7 @@ async fn put_policy(
policy.save(&conn).await?;
log_event(
EventType::PolicyUpdated,
EventType::PolicyUpdated as i32,
policy.uuid.as_ref(),
&org_id,
&headers.user.uuid,
@@ -2351,7 +2339,7 @@ async fn revoke_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked,
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
&headers.user.uuid,
@@ -2449,7 +2437,7 @@ async fn restore_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRestored,
EventType::OrganizationUserRestored as i32,
&member.uuid,
org_id,
&headers.user.uuid,
@@ -2488,7 +2476,7 @@ async fn get_groups_data(
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@@ -2617,7 +2605,7 @@ async fn post_groups(
let group = group_request.to_group(&org_id);
log_event(
EventType::GroupCreated,
EventType::GroupCreated as i32,
&group.uuid,
&org_id,
&headers.user.uuid,
@@ -2658,7 +2646,7 @@ async fn put_group(
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
log_event(
EventType::GroupUpdated,
EventType::GroupUpdated as i32,
&updated_group.uuid,
&org_id,
&headers.user.uuid,
@@ -2691,7 +2679,7 @@ async fn add_update_group(
user_entry.save(conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups,
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
@@ -2766,7 +2754,7 @@ async fn delete_group_impl(
};
log_event(
EventType::GroupDeleted,
EventType::GroupDeleted as i32,
&group.uuid,
org_id,
&headers.user.uuid,
@@ -2877,7 +2865,7 @@ async fn put_group_members(
user_entry.save(&conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups,
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
@@ -2915,7 +2903,7 @@ async fn post_delete_group_member(
}
log_event(
EventType::OrganizationUserUpdatedGroups,
EventType::OrganizationUserUpdatedGroups as i32,
&member_id,
&org_id,
&headers.user.uuid,
@@ -2939,8 +2927,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: Option<String>,
key: Option<String>,
new_master_password_hash: String,
key: String,
#[serde(default)]
reset_master_password: bool,
@@ -2984,7 +2972,12 @@ async fn put_recover_account(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
}
// Deprecated since `v2026.4.2`
@@ -3004,7 +2997,7 @@ async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
req: OrganizationUserRecoverAccountRequest,
reset_request: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@@ -3019,7 +3012,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization")
};
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found")
};
@@ -3032,56 +3025,29 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
} else {
false
};
// Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery(
&user.email,
user.display_name(),
&org.name,
req.reset_master_password,
req.reset_two_factor,
fallback_2fa_email,
)
.await
{
// Sending email before resetting password to ensure working email configuration and the resulting
// user notification. Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await {
err!(format!("Error sending user reset password email: {e:#?}"));
}
if req.reset_master_password {
if let Some(key) = req.key
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
}
if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
}
}
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
if req.reset_master_password {
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
}
if req.reset_two_factor {
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
}
log_event(
EventType::OrganizationUserAdminResetPassword as i32,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(())
}
@@ -3200,9 +3166,9 @@ async fn put_reset_password_enrollment(
membership.save(&conn).await?;
let event_type = if membership.reset_password_key.is_some() {
EventType::OrganizationUserResetPasswordEnroll
EventType::OrganizationUserResetPasswordEnroll as i32
} else {
EventType::OrganizationUserResetPasswordWithdraw
EventType::OrganizationUserResetPasswordWithdraw as i32
};
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
+22 -8
View File
@@ -453,9 +453,6 @@ async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> Json
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
@@ -486,7 +483,17 @@ async fn post_access_legacy(
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
if !send.is_accessible() {
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
@@ -534,9 +541,6 @@ async fn post_access_file(
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
@@ -563,7 +567,17 @@ async fn post_access_file_legacy(
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if !send.is_accessible() {
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
+3 -12
View File
@@ -63,19 +63,13 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
err!("Username or password is incorrect. Try again.")
};
if let Some(master_password_hash) = master_password_hash {
// Check password
if !user.check_valid_password(master_password_hash) {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
err!("Username or password is incorrect. Try again.")
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@@ -102,10 +96,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
};
// SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
)
err!("Username or password is incorrect. Try again.")
};
user
+13 -6
View File
@@ -16,8 +16,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
@@ -151,7 +151,6 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}
@@ -191,7 +190,7 @@ pub async fn enforce_2fa_policy(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked,
EventType::OrganizationUserRevoked as i32,
&member.uuid,
&member.org_uuid,
act_user_id,
@@ -225,8 +224,16 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke();
member.save(conn).await?;
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
.await;
log_event(
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
act_user_id,
device_type,
ip,
conn,
)
.await;
}
}
+13 -44
View File
@@ -1,10 +1,6 @@
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use yubico_ng::{
Verifier, YubicoError,
config::Config,
transport::{AsyncTransport, Response},
};
use yubico::{config::Config, verify_async};
use crate::{
CONFIG,
@@ -18,39 +14,12 @@ use crate::{
models::{EventType, TwoFactor, TwoFactorType},
},
error::{Error, MapResult},
http_client,
};
pub fn routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
}
struct HttpClientTransport {
client: reqwest::Client,
}
impl HttpClientTransport {
fn new() -> Result<Self, reqwest::Error> {
http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(
|client| Self {
client,
},
)
}
}
impl AsyncTransport for HttpClientTransport {
type Error = YubicoError;
async fn yubico_get(&self, url: &str) -> Result<Response, Self::Error> {
let response = self.client.get(url).send().await.map_err(YubicoError::transport)?;
Ok(Response {
status: response.status().as_u16(),
body: response.text().await.map_err(YubicoError::transport)?,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnableYubikeyData {
@@ -75,7 +44,8 @@ pub struct YubikeyMetadata {
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
data_keys.into_iter().flatten().cloned().collect()
}
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
@@ -103,15 +73,13 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
if let Some(yubico_server) = CONFIG.yubico_server() {
config = config.set_api_host(yubico_server);
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
match CONFIG.yubico_server() {
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
None => verify_async(otp, config).await,
}
let client = HttpClientTransport::new()?;
let verifier = Verifier::with_client(config, client)?;
verifier.verify(otp).await.map_res("Failed to verify OTP")
.map_res("Failed to verify OTP")
}
#[post("/two-factor/get-yubikey", data = "<data>")]
@@ -169,9 +137,10 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
let yubikeys = parse_yubikeys(&data);
if yubikeys.is_empty() {
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
// To remove all keys users should click the `Deactivate all keys` button
err!("A key is required.");
return Ok(Json(json!({
"enabled": false,
"object": "twoFactorU2f",
})));
}
// Ensure they are valid OTPs
+33 -54
View File
@@ -3,7 +3,7 @@ use num_traits::FromPrimitive;
use rocket::{
Route,
form::{Form, FromForm},
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
http::{Cookie, CookieJar, SameSite},
response::Redirect,
serde::json::Json,
};
@@ -234,24 +234,6 @@ async fn sso_login(
}
)
}
Some((user, None))
if user.private_key.is_none()
&& !CONFIG.sso_signups_allowed()
&& !CONFIG.is_email_domain_allowed(&user.email)
&& !CONFIG.mail_enabled()
&& Invitation::find_by_mail(&user.email, conn).await.is_none() =>
{
error!(
"Login failure ({}), no invitation with email ({}) was found",
user_infos.identifier, user.email
);
err_silent!(
"Missing invitation",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
@@ -299,15 +281,7 @@ async fn sso_login(
// Will trigger 2FA flow if needed
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
None => {
if !CONFIG.is_sso_signup_allowed(&user_infos.email) {
if CONFIG.signups_domains_whitelist().is_empty() {
err!(
"Signups are disabled. You will need an invitation",
ErrorEvent {
event: EventType::UserFailedLogIn
}
);
}
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
err!(
"Email domain not allowed",
ErrorEvent {
@@ -344,7 +318,7 @@ async fn sso_login(
Some((user, _)) if !user.enabled => {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.email),
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@@ -560,7 +534,18 @@ async fn authenticated_response(
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let mut result = json!({
"access_token": auth_tokens.access_token(),
@@ -592,7 +577,7 @@ async fn authenticated_response(
result["TwoFactorToken"] = Value::String(token);
}
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip);
Ok(Json(result))
}
@@ -700,7 +685,18 @@ async fn user_api_key_login(
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
// Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires.
@@ -883,12 +879,6 @@ async fn twofactor_auth(
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
// No device may keep skipping 2FA once every second factor is gone.
// `device` is cleared in memory too, since saving it later would restore its token.
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
@@ -1034,13 +1024,13 @@ async fn json_err_twofactor(
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
}
#[post("/accounts/register", data = "<data>")]
@@ -1061,18 +1051,11 @@ enum RegisterVerificationResponse {
#[response(status = 204)]
NoContent(()),
Token(Json<String>),
PlainToken(String),
}
// Return JSON only when the client explicitly requests it, otherwise return plain text.
fn accepts_json(accept: Option<&Accept>) -> bool {
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
}
#[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email(
data: Json<RegisterVerificationData>,
accept: Option<&Accept>,
ip: ClientIp,
conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> {
@@ -1110,11 +1093,7 @@ async fn register_verification_email(
} else {
// If email verification is not required, return the token directly
// the clients will use this token to finish the registration
Ok(if accepts_json(accept) {
RegisterVerificationResponse::Token(Json(token))
} else {
RegisterVerificationResponse::PlainToken(token)
})
Ok(RegisterVerificationResponse::Token(Json(token)))
}
}
+2 -2
View File
@@ -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_send_update, push_user_update, register_push_device,
unregister_push_device,
},
web::catchers as web_catchers,
web::routes as web_routes,
web::{invalidate_css_cache, static_files},
web::static_files,
};
use crate::{
CONFIG,
+6 -28
View File
@@ -383,36 +383,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`, e.g. syncing instead of
/// logging out after a key rotation that kept the sessions alive.
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;
}
}
@@ -680,15 +667,6 @@ fn create_ping() -> Vec<u8> {
serialize(&Value::Array(vec![6.into()]))
}
/// Why a logout was pushed. Absent for a plain logout.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/Enums/PushNotificationLogOutReason.cs>
/// (`KdfChange = 0` is not sent by vaultwarden yet.)
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum LogOutReason {
KeyRotation = 1,
}
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType {
+3 -4
View File
@@ -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},
@@ -188,7 +188,7 @@ 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 {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
@@ -198,8 +198,7 @@ pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Op
"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
+8 -38
View File
@@ -1,7 +1,4 @@
use std::{
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use std::path::{Path, PathBuf};
use rocket::{
Catcher, Route,
@@ -16,13 +13,12 @@ use crate::{
CONFIG,
api::{ApiResult, EmptyResult, core::now},
auth::decode_file_download,
crypto::sha256_hex,
db::{
DbConn,
models::{AttachmentId, CipherId},
},
error::Error,
util::{Cached, EtagCached},
util::Cached,
};
pub fn routes() -> Vec<Route> {
@@ -67,27 +63,8 @@ fn not_found() -> ApiResult<Html<String>> {
Ok(Html(text))
}
struct CssCache {
css: String,
etag: String,
}
static CSS_CACHE: RwLock<Option<Arc<CssCache>>> = RwLock::new(None);
pub fn invalidate_css_cache() {
*CSS_CACHE.write().unwrap() = None;
}
#[get("/css/vaultwarden.css")]
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(Css(cached.css.clone()), &cached.etag);
}
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
fn vaultwarden_css() -> Cached<Css<String>> {
let css_options = json!({
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,
@@ -135,18 +112,8 @@ fn vaultwarden_css() -> EtagCached<Css<String>> {
}
};
let etag = sha256_hex(css.as_bytes());
let cached = Arc::new(CssCache {
css,
etag,
});
if !CONFIG.reload_templates() {
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
}
// Etag Caching will let the browser send us an etag to verify and send new content if needed
EtagCached::new(Css(cached.css.clone()), &cached.etag)
// Cache for one day should be enough and not too much
Cached::ttl(Css(css), 86_400, false)
}
#[get("/")]
@@ -301,6 +268,9 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
"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"))),
"jquery-4.0.0.slim.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
}
_ => err!(format!("Static file not found: {filename}")),
}
}
+27 -15
View File
@@ -23,14 +23,14 @@ use rocket::{
use crate::{
CONFIG,
api::{ApiResult, core::log_event},
api::ApiResult,
config::PathType,
db::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
},
},
error::Error,
@@ -667,13 +667,31 @@ impl<'r> FromRequest<'r> for Headers {
};
if user.security_stamp != claims.sstamp {
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
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 dropped the next time the stamp is reset, not here: writing them
// back from this request could undo a reset made since the user was read.
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(&current_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")
}
}
@@ -804,12 +822,6 @@ pub struct AdminHeaders {
pub org_id: OrganizationId,
}
impl AdminHeaders {
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
type Error = &'static str;
+12 -2
View File
@@ -85,8 +85,18 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
if !send.is_accessible() {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
{
return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true);
}
if Utc::now().naive_utc() >= send.deletion_date {
return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true);
}
if send.disabled {
return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true);
}
if send.password_hash.is_some() {
+7 -38
View File
@@ -659,11 +659,6 @@ make_config! {
events_days_retain: i64, false, option;
},
client {
/// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals
client_suppress_onboarding: bool, true, def, false;
},
/// Advanced settings
advanced {
/// Client IP header |> If not present, the remote IP is used.
@@ -817,8 +812,6 @@ make_config! {
sso_enabled: bool, true, def, false;
/// Only SSO login |> Disable Email+Master Password login
sso_only: bool, true, def, false;
/// Allow SSO flow to create account |> You probably want to disable it when using a public provider
sso_signups_allowed: bool, true, def, true;
/// Allow email association |> Associate existing non-SSO user based on email
sso_signups_match_email: bool, true, def, true;
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
@@ -1164,8 +1157,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
#[cfg(unix)]
if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() {
err!(format!("sendmail command at `{path:?}` isn't executable"));
{
use std::os::unix::fs::PermissionsExt;
if !metadata.permissions().mode() & 0o111 != 0 {
err!(format!("sendmail command at `{path:?}` isn't executable"));
}
}
}
}
@@ -1272,7 +1268,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => {
if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) {
if let Err(e) = argon2::password_hash::PasswordHash::new(t) {
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
}
}
@@ -1425,31 +1421,21 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team
"enable-basic-auth-response",
"ssh-agent",
"ssh-agent-v2",
// Key Management Team
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
"pm-31050-no-logout-key-upgrade-rotation",
"pm-27278-v2-password-registration",
"enable-account-encryption-v2-jit-password-registration",
"pm-30144-sdk-key-rotation",
"force-upgrade-v2-encryption",
// Mobile Team
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
"pm-34171-card-scanner",
// Platform Team
"pm-30529-webauthn-related-origins",
// Vault Team
"pm-32009-new-item-types",
];
impl Config {
@@ -1515,9 +1501,6 @@ impl Config {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.write(&CONFIG_FILENAME, config_str).await?;
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(())
}
@@ -1556,17 +1539,6 @@ impl Config {
}
}
/// Tests whether SSO signup is allowed for an email address, taking into
/// account the sso_signups_allowed and signups_domains_whitelist settings.
pub fn is_sso_signup_allowed(&self, email: &str) -> bool {
if self.signups_domains_whitelist().is_empty() {
self.sso_signups_allowed()
} else {
// The whitelist setting overrides the signups_allowed setting.
self.is_email_domain_allowed(email)
}
}
// The registration link should be hidden if
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
// - The SSO is activated and password login is disabled.
@@ -1611,9 +1583,6 @@ impl Config {
writer._overrides = Vec::new();
}
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(())
}
@@ -1754,7 +1723,7 @@ where
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/admin_account_recovery", ".html");
reg!("email/admin_reset_password", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html");
+3 -6
View File
@@ -41,20 +41,17 @@ impl Archive {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
mysql {
diesel::insert_into(archives::table)
sqlite, mysql {
diesel::replace_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
archives::cipher_uuid.eq(cipher_uuid),
archives::archived_at.eq(archived_at),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(archives::archived_at.eq(archived_at))
.execute(conn)
.map_res("Error saving archive")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
+15 -7
View File
@@ -82,16 +82,24 @@ impl Attachment {
impl Attachment {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(attachments::table)
sqlite, mysql {
match diesel::replace_into(attachments::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving attachment")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(attachments::table)
.filter(attachments::id.eq(&self.id))
.set(self)
.execute(conn)
.map_res("Error saving attachment")
}
Err(e) => Err(e.into()),
}.map_res("Error saving attachment")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(attachments::table)
.values(self)
.on_conflict(attachments::id)
+19 -11
View File
@@ -82,23 +82,31 @@ impl AuthRequest {
}
impl AuthRequest {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(auth_requests::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
sqlite, mysql {
match diesel::replace_into(auth_requests::table)
.values(&*self)
.execute(conn)
.map_res("Error saving auth_request")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error auth_request")
}
Err(e) => Err(e.into()),
}.map_res("Error auth_request")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(auth_requests::table)
.values(self)
.values(&*self)
.on_conflict(auth_requests::uuid)
.do_update()
.set(self)
.set(&*self)
.execute(conn)
.map_res("Error saving auth_request")
}
+186 -225
View File
@@ -42,15 +42,13 @@ pub struct Cipher {
pub key: Option<String>,
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/Vault/Enums/CipherType.cs
// Login = 1,
// SecureNote = 2,
// Card = 3,
// Identity = 4,
// SSHKey = 5
// BankAccount = 6,
// DriversLicense = 7,
// Passport = 8,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
*/
pub atype: i32,
pub name: String,
pub notes: Option<String>,
@@ -63,22 +61,6 @@ pub struct Cipher {
pub reprompt: Option<i32>,
}
/// Whether `data` is a sealed cipher blob rather than the legacy per-type JSON.
///
/// Ciphers of v2 accounts are encrypted as a single blob that carries everything, name included,
/// and is opaque to us. It is recognized the same way upstream does, by a top-level
/// `format_version` key that the legacy JSON never has.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/Vault/Entities/Cipher.cs>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType {
None = 0,
Password = 1,
@@ -126,11 +108,6 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
}
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());
}
// 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 {
for pwh in password_history {
@@ -175,11 +152,6 @@ impl Cipher {
) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date};
// Parsed once here, since `data` can be large and this runs for every cipher in a sync.
// `LowerCase` only lowercases the first letter of each key, so it keeps `format_version`.
let type_data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -215,179 +187,72 @@ impl Cipher {
(false, false, false)
};
// Like upstream, a cipher that was stored without fields or password history reports them as
// null rather than as an empty list; clients keep the two apart.
let fields_json: Option<Vec<_>> = self.fields.as_ref().map(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
.ok()
.map(|d| {
d.into_iter()
.map(|mut f| {
// Check if the `type` key is a number, strings break some clients
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
// If not try to convert the string value to a number and fallback to `1`
// If it is both not a number and not a string, fallback to `1`
match f.data.get("type") {
Some(t) if t.is_number() => {}
Some(t) if t.is_string() => {
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
f.data["type"] = json!(type_num);
}
_ => {
f.data["type"] = json!(1);
}
let fields_json: Vec<_> = self
.fields
.as_ref()
.and_then(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
.ok()
})
.map(|d| {
d.into_iter()
.map(|mut f| {
// Check if the `type` key is a number, strings break some clients
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
// If not try to convert the string value to a number and fallback to `1`
// If it is both not a number and not a string, fallback to `1`
match f.data.get("type") {
Some(t) if t.is_number() => {}
Some(t) if t.is_string() => {
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
f.data["type"] = json!(type_num);
}
f.data
})
.collect()
})
.unwrap_or_default()
});
_ => {
f.data["type"] = json!(1);
}
}
f.data
})
.collect()
})
.unwrap_or_default();
let password_history_json: Option<Vec<_>> = self.password_history.as_ref().map(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
.ok()
.map(|d| {
// Check every password history item if they are valid and return it.
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
d.into_iter()
.filter_map(|d| match d.data.get("password") {
Some(p) if p.is_string() => Some(d.data),
_ => None,
})
.map(|mut d| {
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
validate_and_format_date(l)
} else {
"1970-01-01T00:00:00.000000Z".to_owned()
};
d["lastUsedDate"] = json!(lud);
d
})
.collect()
})
.unwrap_or_default()
});
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
Cow::from(cipher_collections)
} else {
Cow::from(Vec::new())
}
} else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await)
};
// There are three types of cipher response models in upstream
// Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order
// of increasing level of detail). vaultwarden currently only
// supports the "cipherDetails" type, though it seems like the
// Bitwarden clients will ignore extra fields.
//
// Ref: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Vault/Models/Response/CipherResponseModel.cs#L14
let mut json_object = json!({
"object": "cipherDetails",
"id": self.uuid,
"type": self.atype,
"creationDate": format_date(&self.created_at),
"revisionDate": format_date(&self.updated_at),
"deletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))),
"reprompt": self.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32).unwrap_or(RepromptType::None as i32),
"organizationId": self.organization_uuid,
"key": self.key,
"attachments": attachments_json,
// We have UseTotp set to true by default within the Organization model.
// This variable together with UsersGetPremium is used to show or hide the TOTP counter.
"organizationUseTotp": true,
// This field is specific to the cipherDetails type.
"collectionIds": collection_ids,
"name": self.name,
"notes": self.notes,
"fields": fields_json,
"passwordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated
"login": null,
"secureNote": null,
"card": null,
"identity": null,
"sshKey": null,
"bankAccount": null,
"driversLicense": null,
"passport": null,
});
// These values are only needed for user/default syncs
// Not during an organizational sync like `get_org_details`
// Skip adding these fields in that case
if sync_type == CipherSyncType::User {
json_object["folderId"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_folders.get(&self.uuid).cloned()
} else {
self.get_folder_uuid(user_uuid, conn).await
});
json_object["favorite"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_favorites.contains(&self.uuid)
} else {
self.is_favorite(user_uuid, conn).await
});
json_object["archivedDate"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_archives.get(&self.uuid).map_or(Value::Null, |d| Value::String(format_date(d)))
} else {
self.get_archived_at(user_uuid, conn).await.map_or(Value::Null, |d| Value::String(format_date(&d)))
});
// These values are true by default, but can be false if the
// cipher belongs to a collection or group where the org owner has enabled
// the "Read Only" or "Hide Passwords" restrictions for the user.
json_object["edit"] = json!(!read_only);
json_object["viewPassword"] = json!(!hide_passwords);
// The new key used by clients since v2025.6.0
json_object["permissions"] = json!({
"delete": !read_only,
"restore": !read_only,
});
}
let key = match self.atype {
1 => "login",
2 => "secureNote",
3 => "card",
4 => "identity",
5 => "sshKey",
6 => "bankAccount",
7 => "driversLicense",
8 => "passport",
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
if is_blob_encrypted {
// The blob holds all of the content, so it is sent back as-is and the structured fields
// stay null, as upstream does. Only the name needs clearing, the others are never stored.
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object)
}
/// The per-type data (`login`, `card`, …) of a legacy cipher, from `type_data` as parsed from
/// `self.data`, with fixups for values that are known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
let password_history_json: Vec<_> = self
.password_history
.as_ref()
.and_then(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
.ok()
})
.map(|d| {
// Check every password history item if they are valid and return it.
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
d.into_iter()
.filter_map(|d| match d.data.get("password") {
Some(p) if p.is_string() => Some(d.data),
_ => None,
})
.map(|mut d| {
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
validate_and_format_date(l)
} else {
"1970-01-01T00:00:00.000000Z".to_owned()
};
d["lastUsedDate"] = json!(lud);
d
})
.collect()
})
.unwrap_or_default();
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = type_data
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
@@ -441,7 +306,109 @@ impl Cipher {
type_data_json = Value::Null;
}
type_data_json
// Clone the type_data and add some default value.
let mut data_json = type_data_json.clone();
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// data_json should always contain the following keys with every atype
data_json["fields"] = json!(fields_json);
data_json["name"] = json!(self.name);
data_json["notes"] = json!(self.notes);
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
Cow::from(cipher_collections)
} else {
Cow::from(Vec::new())
}
} else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await)
};
// There are three types of cipher response models in upstream
// Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order
// of increasing level of detail). vaultwarden currently only
// supports the "cipherDetails" type, though it seems like the
// Bitwarden clients will ignore extra fields.
//
// Ref: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Vault/Models/Response/CipherResponseModel.cs#L14
let mut json_object = json!({
"object": "cipherDetails",
"id": self.uuid,
"type": self.atype,
"creationDate": format_date(&self.created_at),
"revisionDate": format_date(&self.updated_at),
"deletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))),
"reprompt": self.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32).unwrap_or(RepromptType::None as i32),
"organizationId": self.organization_uuid,
"key": self.key,
"attachments": attachments_json,
// We have UseTotp set to true by default within the Organization model.
// This variable together with UsersGetPremium is used to show or hide the TOTP counter.
"organizationUseTotp": true,
// This field is specific to the cipherDetails type.
"collectionIds": collection_ids,
"name": self.name,
"notes": self.notes,
"fields": fields_json,
"data": data_json,
"passwordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated
"login": null,
"secureNote": null,
"card": null,
"identity": null,
"sshKey": null,
});
// These values are only needed for user/default syncs
// Not during an organizational sync like `get_org_details`
// Skip adding these fields in that case
if sync_type == CipherSyncType::User {
json_object["folderId"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_folders.get(&self.uuid).cloned()
} else {
self.get_folder_uuid(user_uuid, conn).await
});
json_object["favorite"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_favorites.contains(&self.uuid)
} else {
self.is_favorite(user_uuid, conn).await
});
json_object["archivedDate"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_archives.get(&self.uuid).map_or(Value::Null, |d| Value::String(format_date(d)))
} else {
self.get_archived_at(user_uuid, conn).await.map_or(Value::Null, |d| Value::String(format_date(&d)))
});
// These values are true by default, but can be false if the
// cipher belongs to a collection or group where the org owner has enabled
// the "Read Only" or "Hide Passwords" restrictions for the user.
json_object["edit"] = json!(!read_only);
json_object["viewPassword"] = json!(!hide_passwords);
// The new key used by clients since v2025.6.0
json_object["permissions"] = json!({
"delete": !read_only,
"restore": !read_only,
});
}
let key = match self.atype {
1 => "login",
2 => "secureNote",
3 => "card",
4 => "identity",
5 => "sshKey",
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
json_object[key] = type_data_json;
Ok(json_object)
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
@@ -477,16 +444,24 @@ impl Cipher {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(ciphers::table)
sqlite, mysql {
match diesel::replace_into(ciphers::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(ciphers::table)
.filter(ciphers::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
}
Err(e) => Err(e.into()),
}.map_res("Error saving cipher")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(ciphers::table)
.values(&*self)
.on_conflict(ciphers::uuid)
@@ -694,27 +669,16 @@ impl Cipher {
}
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
// The user must have a confirmed membership in the cipher's
// organization, since users_collections rows are kept when a
// membership is revoked or not confirmed yet.
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.inner_join(
users_collections::table.on(ciphers_collections::collection_uuid
.eq(users_collections::collection_uuid)
// Only allow collection access via the confirmed membership.
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
.and(users_collections::user_uuid.eq(user_uuid))),
)
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn)
@@ -745,9 +709,6 @@ impl Cipher {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
// Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)
.expect("Error getting group access restrictions")
+62 -36
View File
@@ -21,7 +21,6 @@ use super::{
User, UserId,
};
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = collections)]
#[diesel(treat_none_as_null = true)]
@@ -72,11 +71,6 @@ impl Collection {
"id": self.uuid,
"organizationId": self.org_uuid,
"name": self.name,
// Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection.
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs
"type": 0,
// This is only used together with MyItems/DefaultUserCollection, which we do not yet support.
"defaultUserCollectionEmail": null,
"object": "collection",
})
}
@@ -168,16 +162,24 @@ impl Collection {
self.update_users_revision(conn).await;
db_run! { conn:
mysql {
diesel::insert_into(collections::table)
sqlite, mysql {
match diesel::replace_into(collections::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving collection")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections::table)
.filter(collections::uuid.eq(&self.uuid))
.set(self)
.execute(conn)
.map_res("Error saving collection")
}
Err(e) => Err(e.into()),
}.map_res("Error saving collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(collections::table)
.values(self)
.on_conflict(collections::uuid)
@@ -720,30 +722,53 @@ impl CollectionUser {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
let values = (
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
);
db_run! { conn:
mysql {
diesel::insert_into(users_collections::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
sqlite, mysql {
match diesel::replace_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(users_collections::table)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_collections::collection_uuid.eq(collection_uuid))
.set((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(users_collections::table)
.values(values)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
.do_update()
.set(values)
.set((
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
}
@@ -878,18 +903,19 @@ impl CollectionCipher {
Self::update_users_revision(collection_uuid, conn).await;
db_run! { conn:
mysql {
diesel::insert_into(ciphers_collections::table)
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
ciphers_collections::collection_uuid.eq(collection_uuid),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
+3 -18
View File
@@ -146,18 +146,15 @@ impl Device {
}
db_run! { conn:
mysql {
sqlite, mysql {
crate::util::retry(||
diesel::insert_into(devices::table)
diesel::replace_into(devices::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn),
10,
).map_res("Error saving device")
}
postgresql, sqlite {
postgresql {
crate::util::retry(||
diesel::insert_into(devices::table)
.values(&*self)
@@ -269,22 +266,10 @@ impl Device {
let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices {
device.refresh_token = Device::generate_refresh_token();
device.twofactor_remember = None;
device.save(false, conn).await?;
}
Ok(())
}
pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table)
.filter(devices::user_uuid.eq(user_uuid))
.set(devices::twofactor_remember.eq::<Option<String>>(None))
.execute(conn)
.map_res("Error removing two factor remember tokens")
})
.await
}
}
#[derive(Display)]
+15 -7
View File
@@ -146,16 +146,24 @@ impl EmergencyAccess {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(emergency_access::table)
sqlite, mysql {
match diesel::replace_into(emergency_access::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving emergency access")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(emergency_access::table)
.filter(emergency_access::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error updating emergency access")
}
Err(e) => Err(e.into()),
}.map_res("Error saving emergency access")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(emergency_access::table)
.values(&*self)
.on_conflict(emergency_access::uuid)
+16 -29
View File
@@ -43,7 +43,7 @@ pub struct Event {
pub provider_org_uuid: Option<String>,
}
// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
#[derive(Debug, Copy, Clone)]
pub enum EventType {
// User
@@ -108,12 +108,6 @@ pub enum EventType {
OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
OrganizationUserNotificationBannerActionClicked = 1522,
// Organization
OrganizationUpdated = 1600,
@@ -208,23 +202,20 @@ impl Event {
/// Basic Queries
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(event::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
sqlite, mysql {
diesel::replace_into(event::table)
.values(self)
.execute(conn)
.map_res("Error saving event")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(event::table)
.values(self)
.on_conflict(event::uuid)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
.values(self)
.on_conflict(event::uuid)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
}
}
}
@@ -307,16 +298,12 @@ impl Event {
) -> Vec<Self> {
conn.run(move |conn| {
event::table
.inner_join(
users_organizations::table
.on(users_organizations::uuid.eq(member_uuid).and(users_organizations::org_uuid.eq(org_uuid))),
)
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid)))
.filter(event::org_uuid.eq(org_uuid))
.filter(event::event_date.between(start, end))
.filter(
event::org_user_uuid
.eq(member_uuid)
.or(event::user_uuid.eq(users_organizations::user_uuid.nullable()))
event::user_uuid
.eq(users_organizations::user_uuid.nullable())
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
)
.select(event::all_columns)
+21 -12
View File
@@ -77,16 +77,24 @@ impl Folder {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(folders::table)
sqlite, mysql {
match diesel::replace_into(folders::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving folder")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(folders::table)
.filter(folders::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving folder")
}
Err(e) => Err(e.into()),
}.map_res("Error saving folder")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(folders::table)
.values(&*self)
.on_conflict(folders::uuid)
@@ -139,15 +147,16 @@ impl Folder {
impl FolderCipher {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(folders_ciphers::table)
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(folders_ciphers::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to folder")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid))
+89 -43
View File
@@ -13,7 +13,7 @@ use crate::{
};
use macros::UuidFromParam;
use super::{CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId};
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = groups)]
@@ -166,16 +166,24 @@ impl Group {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(groups::table)
sqlite, mysql {
match diesel::replace_into(groups::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving group")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups::table)
.filter(groups::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving group")
}
Err(e) => Err(e.into()),
}.map_res("Error saving group")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(groups::table)
.values(&*self)
.on_conflict(groups::uuid)
@@ -268,9 +276,6 @@ impl Group {
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
// Only allow full access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::organizations_uuid.eq(org_uuid))
.filter(groups::access_all.eq(true))
.select(groups::access_all)
@@ -321,30 +326,53 @@ impl CollectionGroup {
group_user.update_user_revision(conn).await;
}
let values = (
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
);
db_run! { conn:
mysql {
diesel::insert_into(collections_groups::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
sqlite, mysql {
match diesel::replace_into(collections_groups::table)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
))
.execute(conn)
.map_res("Error adding group to collection")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections_groups::table)
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
.set((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
))
.execute(conn)
.map_res("Error adding group to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding group to collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(collections_groups::table)
.values(values)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
.do_update()
.set(values)
.set((
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.execute(conn)
.map_res("Error adding group to collection")
}
@@ -469,25 +497,43 @@ impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.update_user_revision(conn).await;
let values = (
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
);
db_run! { conn:
mysql {
diesel::insert_into(groups_users::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
sqlite, mysql {
match diesel::replace_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
.map_res("Error adding user to group")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups_users::table)
.filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid))
.filter(groups_users::groups_uuid.eq(&self.groups_uuid))
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
.map_res("Error adding user to group")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to group")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(groups_users::table)
.values(values)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
.do_nothing()
.do_update()
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
.map_res("Error adding user to group")
}
+2 -4
View File
@@ -17,12 +17,11 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod user_signature_key_pair;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
@@ -40,5 +39,4 @@ 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_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
-29
View File
@@ -49,7 +49,6 @@ pub enum OrgPolicyType {
// AutotypeDefaultSetting = 17, // Not supported yet
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20,
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
@@ -92,7 +91,6 @@ impl OrgPolicy {
"type": self.atype,
"data": data_json,
"enabled": self.enabled,
"revisionDate": null,
"object": "policy",
});
@@ -187,26 +185,6 @@ impl OrgPolicy {
.await
}
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
org_policies::table
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(org_policies::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(
users_organizations::status
.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
)
.select(org_policies::all_columns)
.load::<Self>(conn)
.expect("Error loading org_policy")
})
.await
}
pub async fn find_by_org_and_type(
org_uuid: &OrganizationId,
policy_type: OrgPolicyType,
@@ -339,13 +317,6 @@ impl OrgPolicy {
}
pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool {
// Account recovery depends on outbound mail. When SMTP is disabled, treat the
// auto-enroll policy as inactive so invites/registration are not forced to
// supply a reset-password key (see check_reset_password_applicable).
if !CONFIG.mail_enabled() {
return false;
}
match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await {
Some(policy) => match serde_json::from_str::<ResetPasswordDataModel>(&policy.data) {
Ok(opts) => {
+47 -54
View File
@@ -57,10 +57,6 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
/// A copy of the user's v2 upgrade token, kept when a v1 -> v2 upgrade leaves the account
/// recovery key (`reset_password_key`) wrapped by the v1 user key. It lets the organization
/// unwrap the v2 user key from it. Opaque to us, like `User::v2_upgrade_token`.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -221,18 +217,11 @@ impl Organization {
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true,
"useApi": true,
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
"useResetPassword": CONFIG.mail_enabled(),
"allowAdminAccessToAllCollectionItems": true,
"limitCollectionCreation": true,
"limitCollectionDeletion": true,
"limitItemDeletion": false,
"businessName": self.name,
"businessAddress1": null,
@@ -278,7 +267,6 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
v2_upgrade_token: None,
}
}
@@ -358,16 +346,25 @@ impl Organization {
}
db_run! { conn:
mysql {
diesel::insert_into(organizations::table)
sqlite, mysql {
match diesel::replace_into(organizations::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving organization")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(organizations::table)
.filter(organizations::uuid.eq(&self.uuid))
.set(self)
.execute(conn)
.map_res("Error saving organization")
}
Err(e) => Err(e.into()),
}.map_res("Error saving organization")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(organizations::table)
.values(self)
.on_conflict(organizations::uuid)
@@ -498,12 +495,6 @@ impl Membership {
"useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false,
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"organizationUserId": self.uuid,
"providerId": null,
@@ -749,16 +740,24 @@ impl Membership {
User::update_uuid_revision(&self.user_uuid, conn).await;
db_run! { conn:
mysql {
diesel::insert_into(users_organizations::table)
sqlite, mysql {
match diesel::replace_into(users_organizations::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error adding user to organization")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(users_organizations::table)
.filter(users_organizations::uuid.eq(&self.uuid))
.set(self)
.execute(conn)
.map_res("Error adding user to organization")
},
Err(e) => Err(e.into()),
}.map_res("Error adding user to organization")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(users_organizations::table)
.values(self)
.on_conflict(users_organizations::uuid)
@@ -849,21 +848,6 @@ impl Membership {
.await
}
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(
users_organizations::status
.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
)
.load::<Self>(conn)
.unwrap_or_default()
})
.await
}
pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
@@ -1189,16 +1173,25 @@ impl Membership {
impl OrganizationApiKey {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(organization_api_key::table)
sqlite, mysql {
match diesel::replace_into(organization_api_key::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving organization")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(organization_api_key::table)
.filter(organization_api_key::uuid.eq(&self.uuid))
.set(self)
.execute(conn)
.map_res("Error saving organization")
}
Err(e) => Err(e.into()),
}.map_res("Error saving organization")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(organization_api_key::table)
.values(self)
.on_conflict((organization_api_key::uuid, organization_api_key::org_uuid))
+15 -23
View File
@@ -202,16 +202,24 @@ impl Send {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(sends::table)
sqlite, mysql {
match diesel::replace_into(sends::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving send")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(sends::table)
.filter(sends::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving send")
}
Err(e) => Err(e.into()),
}.map_res("Error saving send")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(sends::table)
.values(&*self)
.on_conflict(sends::uuid)
@@ -254,22 +262,6 @@ impl Send {
Ok(true)
}
/// Whether the Send is currently within its validity window: not disabled, not past its
/// expiration date, and not past its deletion date. Does not consider `max_access_count`
/// (consumed at token issuance) or the password.
pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc();
if self.disabled {
return false;
}
if let Some(expiration) = self.expiration_date
&& now >= expiration
{
return false;
}
now < self.deletion_date
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await;
+16 -197
View File
@@ -6,7 +6,6 @@ use serde_json::Value;
use crate::{
CONFIG,
api::EmptyResult,
auth::DEFAULT_ACCESS_VALIDITY,
crypto,
db::{
DbConn,
@@ -21,7 +20,6 @@ use macros::UuidFromParam;
use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
UserSignatureKeyPair,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -71,17 +69,6 @@ pub struct User {
pub avatar_color: Option<String>,
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>,
// "v2" account cryptographic state. Either all of these are set (together with a row in
// `user_signature_key_pairs`) or none of them are; see `User::is_v2`.
pub signed_public_key: Option<String>,
pub security_state: Option<String>,
pub security_version: Option<i32>,
/// JSON `{"wrappedUserKey1": ..., "wrappedUserKey2": ...}`, letting clients that still hold the
/// v1 user key obtain the v2 one after another client performed the upgrade. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable)]
@@ -110,28 +97,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;
@@ -182,20 +154,9 @@ impl User {
avatar_color: None,
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None,
signed_public_key: None,
security_state: None,
security_version: None,
v2_upgrade_token: None,
}
}
pub fn is_v2(&self) -> bool {
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
}
pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash(
password.as_bytes(),
@@ -255,41 +216,10 @@ impl User {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid();
// A reset is meant to end the other sessions, which a key rotation's grace would undo. The
// route exceptions stay, since they are set right before a reset, for the stamp it replaces.
// This is also where expired exceptions get dropped.
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 security stamp after a key rotation, while letting the access tokens already
/// issued keep working until they expire.
///
/// This is what upstream does: there the stamp is only checked when a token is refreshed, so a
/// rotation invalidates refresh tokens but not the access tokens in use. Clients rely on it to
/// keep a session going across a rotation. Chained rotations keep every earlier stamp that is
/// still within its window.
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
@@ -298,30 +228,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 {
@@ -336,72 +253,12 @@ impl User {
/// Database methods
impl User {
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
if !self.is_v2() {
return None;
}
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
}
pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
if self.private_key.is_none() {
return Value::Null;
}
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair",
},
"signatureKeyPair": signature_key_pair,
"securityState": security_state,
"object": "privateKeys"
})
}
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
None => (Value::Null, Value::Null),
};
json!({
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"verifyingKey": verifying_key,
"object": "publicKeys"
})
}
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 {
orgs_json.push(c.to_json(conn).await);
}
let mut orgs_new_json = Vec::new();
for c in Membership::find_accepted_and_confirmed_by_user(&self.uuid, conn).await {
orgs_new_json.push(c.to_json(conn).await);
}
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
// TODO: Might want to save the status field in the DB
@@ -411,11 +268,8 @@ impl User {
UserStatus::Enabled
};
let account_keys = self.account_keys_json(conn).await;
json!({
"_status": status as i32,
"accountKeys": account_keys,
"id": self.uuid,
"name": self.name,
"email": self.email,
@@ -428,7 +282,6 @@ impl User {
"privateKey": self.private_key,
"securityStamp": self.security_stamp,
"organizations": orgs_json,
"organizationsNew": orgs_new_json,
"providers": [],
"providerOrganizations": [],
"forcePasswordReset": false,
@@ -487,7 +340,6 @@ impl User {
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?;
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| {
@@ -594,17 +446,15 @@ impl Invitation {
}
db_run! { conn:
// Not checking for ForeignKey Constraints here
// Table invitations does not have any ForeignKey Constraints.
mysql {
diesel::insert_into(invitations::table)
sqlite, mysql {
// Not checking for ForeignKey Constraints here
// Table invitations does not have any ForeignKey Constraints.
diesel::replace_into(invitations::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error saving invitation")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(invitations::table)
.values(self)
.on_conflict(invitations::email)
@@ -658,47 +508,16 @@ impl Invitation {
#[from(forward)]
pub struct UserId(String);
#[derive(
Clone,
Debug,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
UuidFromParam,
)]
#[deref(forward)]
#[from(forward)]
pub struct KeyId(#[serde(deserialize_with = "deserialize_key_id")] String);
/// Rejects a key id from a request that isn't 16 bytes as lowercase hex, as upstream's `[KeyId]` does.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Models/Data/KeyId.cs>
fn deserialize_key_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let key_id = <String as serde::Deserialize>::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 a 32 character lowercase hex-encoded string."));
}
Ok(key_id)
}
impl SsoUser {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(sso_users::table)
sqlite, mysql {
diesel::replace_into(sso_users::table)
.values(self)
.execute(conn)
.map_res("Error saving SSO user")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(sso_users::table)
.values(self)
.execute(conn)
-137
View File
@@ -1,137 +0,0 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
db::{DbConn, schema::user_signature_key_pairs},
error::MapResult,
util::get_uuid,
};
use super::UserId;
/// A user's signature key pair, part of the "v2" account cryptographic state.
///
/// Upstream keeps this in its own table rather than as columns on the user, with a unique index on
/// the user id. The stated intent is to eventually keep superseded key pairs around (an `active`
/// flag was sketched but not shipped), which is why this is modelled as a row with its own identity
/// instead of a set of user attributes.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql>
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = user_signature_key_pairs)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct UserSignatureKeyPair {
pub uuid: UserSignatureKeyPairId,
pub user_uuid: UserId,
pub signature_algorithm: i32,
/// The signing (private) key, wrapped by the user key.
pub signing_key: String,
/// The COSE-encoded public verifying key.
pub verifying_key: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureAlgorithm {
Ed25519 = 0,
MlDsa44 = 1,
}
impl SignatureAlgorithm {
pub fn parse(algorithm: &str) -> Option<Self> {
match algorithm {
"ed25519" => Some(Self::Ed25519),
"mldsa44" => Some(Self::MlDsa44),
_ => None,
}
}
}
/// Local methods
impl UserSignatureKeyPair {
pub fn new(
user_uuid: UserId,
signature_algorithm: SignatureAlgorithm,
signing_key: String,
verifying_key: String,
) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: UserSignatureKeyPairId(get_uuid()),
user_uuid,
signature_algorithm: signature_algorithm as i32,
signing_key,
verifying_key,
created_at: now,
updated_at: now,
}
}
pub fn to_json(&self) -> Value {
json!({
"wrappedSigningKey": self.signing_key,
"verifyingKey": self.verifying_key,
"object": "signatureKeyPair",
})
}
}
/// Database methods
impl UserSignatureKeyPair {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
postgresql, sqlite {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(user_signature_key_pairs::user_uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
}
}
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
user_signature_key_pairs::table
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting user signature key pairs")
})
.await
}
}
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserSignatureKeyPairId(String);
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more