Compare commits

...
Author SHA1 Message Date
Mathijs van Veluw
eb212e23fa
Fix archiveDate update (#7722)
When `archiveDate` is set to `null` it should unarchive it for that
specific user, which is what Bitwarden does.

This should fix this by checking and validating if it is `null`

Fixes #7581

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-09 18:24:33 +02:00
Timshel
25dfedafd7
Use insert_into when possible (#6437)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-09-09 16:43:47 +02:00
Tom
e992cbb4f5
Fix iOS registration token response (#7714)
* Return registration token as text/plain for Accept: */*

* fix register verification response content negotiation
2026-09-09 14:31:03 +02:00
Chase Douglas
5b51b60f94
Route service clients through shared HTTP setup (#7639)
* storage: route OpenDAL through HTTP client

OpenDAL 0.58 requires applications to provide an HTTP transport. Its
default installer creates a standalone client, bypassing Vaultwarden
DNS, redirect, proxy, timeout, and request configuration.

Build the client through the internal HTTP interface and inject it into
OpenDAL's public reqwest transport.

* http: honor block setting on redirects

Clients can disable host blocking for administrator-configured private
services. DNS resolution honors this setting, but the redirect policy
still performs config-backed host checks.

Capture the setting in the redirect policy and skip those checks when
blocking is disabled. This also avoids re-entering CONFIG when a remote
configuration request is redirected during startup.

* http: make DNS setup bootstrap-safe

Remote configuration can require an HTTP client while CONFIG is still
initializing. Building the DNS resolver currently reads
CONFIG.dns_prefer_ipv6(), so loading an S3-backed config can deadlock.

Build one resolver without consulting CONFIG. Order addresses for each
lookup using the merged setting when available, falling back to the
environment and then IPv4-first during bootstrap.

* aws: use internal HTTP client

The AWS SDK connector builds a raw reqwest client, bypassing
Vaultwarden TLS, DNS, redirect, proxy, timeout, and request setup.

Construct it through the internal HTTP client interface and retain the
standard ten-second request deadline. Permit private AWS metadata and
service endpoints by disabling non-global IP blocking.

Preserve timeout errors when adapting reqwest failures to the AWS SDK so
the runtime receives the correct connector error category.

* Added comment for the prefer IPv function

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
Co-authored-by: BlackDex <black.dex@gmail.com>
2026-09-09 14:30:25 +02:00
Mathijs van Veluw
de7abaaafa
Update Rust and adjust DockerSettings (#7690)
- Update Rust to v1.98.1 which resolves a build issues with strange
  outcomes
- Adjusted the DockerSettings and render_template to extract the
  `rust_version` from the `rust-toolchain.toml` file. This should
  prevent mismatches and forgetting to update DockerSettings.
- Updated typos in GHA and Pre-Commit
- Updated all possible crates including hickory which has several CVE's
  fixed.

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-09 11:51:23 +02:00
niniconi
b7667e27bf
fix: Correct invalid comment syntax in .dockerignore (#7274)
Docker only recognizes `#` as a valid comment indicator in .dockerignore files. 
Using `//` causes the lines to be incorrectly parsed as glob patterns rather than comments. 
While this may not cause fatal errors if no matching files exist, it is syntactically invalid and could lead to unexpected behavior. Corrected the syntax to use `#`.
2026-09-08 13:42:58 +02:00
Bryan
f1c36b8c1d
fix(security): rate limit prelogin and auth request endpoints (#7681) 2026-09-08 12:14:16 +02:00
Bryan
f1ff613008
fix(security): revoke 2FA remember tokens when credentials or 2FA change (#7682) 2026-09-08 12:14:07 +02:00
Timshel
57fbed1bed
Support admin reset 2fa (#7435)
* Support admin reset 2fa

* Fix recovery email

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-09-08 12:14:01 +02:00
The CRahn
277e1536eb
Log IP/username on two-factor email-login credential failures (#7654)
The three "Username or password is incorrect" errors in
send_email_login() (email.rs) don't log the client IP or submitted
identifier, unlike the equivalent wrong-password error in
password_login() (identity.rs), which logs both via
format!("IP: {}. Username: {username}.", ip.ip).

This makes the two code paths inconsistent for the same underlying
error, and means log-based tooling that keys on the identity.rs
error's "IP: x.x.x.x" pattern can't do the same for this endpoint.
Bring email.rs's three call sites in line with identity.rs's existing
format. The two email-present branches log IP+Username (the email
submitted); the device-identifier-only branch (SSO path, no email in
scope) logs IP+Device instead of fabricating a username.

Verified: cargo build/test/clippy/fmt all pass with the sqlite feature
(matching one leg of this repo's own CI matrix), including the two
existing unit tests in this file.
2026-09-08 12:13:51 +02:00
Tom
2ffad8775d
Add pm-32413-multi-client-password-management feature flag (#7677) 2026-09-08 12:13:40 +02:00
Tom
32d85d03bb
Fix organization import failing with missing field groups (#7699) 2026-09-08 12:13:32 +02:00
Daniel García
a6c3bd6d18
Update rust docker version (#7689) 2026-09-03 21:17:50 +02:00
Mathijs van Veluw
6729e83521
Misc Updates (#7676)
* Misc Updates

- Update Rust to v1.98.0
- Update all the crates and adjusted code where needed
- Updated JavaScript libraries
  Removed jquery as this isn't needed anymore, adjusted code where needed
- Updated all GitHub Actions
- Fixed nightly clippy lint warnings

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

* Adjust email validation as suggested

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-03 00:07:00 +02:00
Timshel
fdc156b247
log_event take enum parameter not i32 (#7656)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-29 17:02:26 +02:00
Timshel
2073c03092
Add SSO_SIGNUPS_ALLOWED (#7272)
* Add SSO_SIGNUPS_ALLOWED

* Fix regression with domain_allowed in SSO onboarding

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-29 17:02:22 +02:00
Timshel
923f5d0b5e
Fix migration for MariaDB 12.2.2 (#7265)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-29 17:02:01 +02:00
xhon-pelushi
83724b301e
Ignore reset-password auto-enroll when mail is disabled (#7585)
Account recovery requires SMTP. When mail is off, treat the organization
reset-password auto-enroll policy as inactive so invite/accept flows are
not forced to supply a reset-password key.

Fixes #7459
2026-08-29 17:01:51 +02:00
Matt Van Horn
10e044f563
chore: remove duplicate "the" in ciphers.rs comment (#7254)
`src/api/core/ciphers.rs:170` comment said "similar to the the
userDecryptionOptions" -> "similar to the userDecryptionOptions".

Comment-only.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-29 17:01:45 +02:00
Mathijs van Veluw
fa2566d14f
Fix password change with newer web-vault (#7634) 2026-08-24 19:38:23 +02:00
Stefan Melmuk
46d71107f5
add dummy revisionDate (#7608) 2026-08-20 22:03:56 +02:00
Patrick Bönisch
9e78911a2f
Fix sendmail executable permission check (#7483)
* Fix sendmail executable permission check

* Use access check for sendmail command
2026-08-20 17:34:38 +02:00
lmogthb
0cefa4cca7
Include user email in successful login logs (#7496)
* Include user email in successful login logs

* Modified disable account log to display Email instead of Display Name

---------

Co-authored-by: Alejandro Olmos <aolmos@trevenque.es>
2026-08-07 14:09:43 +02:00
Mathijs van Veluw
b30cc08562
Misc fixes and updates (#7558)
* Update GHA and pre-commit

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

* Update admin diagnostics

Added a check if the templates are overridden and return which specific folder, `admin`, `email` or `scss`.
This way we could more quickly point users to possible outdated templates which they are using.

Also updated the Support String to use some emojis so we should be able to quicker see if there is something wrong.
Just checking `true` or `false` could be difficult sometimes, and sometimes what we had as `false` wasn't bad either.

Also adjusted the eslint comments so it will work with the latest version of eslint.

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

* Fix updating collections for a cipher

The newer clients expect a `cipherDetails` response on the `collections-admin` endpoints.
Without it, the client will cause an error and stops handling the update correctly.

This will fix this by returning the cipher json.

Fixes #7545
Fixes #7546

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

* Cache CSS file in a different way

Currently we set a cache ttl of 24 hours, and users need to do a force refresh if there is anything changed to the CSS file.
In the past we have had several issue reported which were related to a still cached CSS file.

This commit will change the caching and also cache the generated CSS file in memory.
Instead of letting the browser cache it for 24 hours we generate an ETag, this is just a hash of the contents.
This ETag is returned by the browser during a request, and we can match this, and if so, just return a `304` `Not Modified`.
If the ETag is not known, we return the new content.

This should make simple refreshes by clients get updated settings or a new version of Vaultwarden which has other CSS entries get updated instantly.
If a user does a hard refresh, we will not receive the ETag and the content will be served.

The same goes if someone has the `reload_templates` feature enabled, since then we should not cache anyway.
If someone adjust settings via the `/admin` interface, the cache will be invalidated and a new CSS will be generated.

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

* Fix showing events for a specific user

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

* Update crates and adjust code.

- Updated opendal and adjusted code where needed.
- Updated yubico_ng and adjusted code where needed.
  This version now supports using an own HttpClient and it pulls in no reqwest dependency anymore.
  Now it will use our own client which uses custom hickory DNS and other features.

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

* Update web-vault to v2026.7.0

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

* Fix hadolint warnings

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-08-06 20:22:12 +02:00
Timshel
55f883a566
Fix playwright test (#7548)
* Config server setting suppressOnboardingInterstitials

* Backport fix playwright tests

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-05 21:29:41 +02:00
Alex · ASEnough
74ceaf2354
Fix Debian cross-linking with xx-cargo (#7524)
* Fix Debian cross-linking with xx-cargo

* Fix SC2155 in Debian cross builds
2026-08-05 21:29:31 +02:00
Victor J. Fox
2629bcbe13
Always send initOrganization and orgUserHasExistingUser in org invite URL (#7482)
The bundled web vault (2026.6.4) requires seven query parameters in the
accept-organization URL and rejects the invite client-side when any of them is
null, showing only "Unable to accept invitation" without sending a request to
the server.

send_invite() never appended initOrganization, and appended
orgUserHasExistingUser only for users who already had an account, so every
organization invitation e-mail produced a link that could not be accepted.

Web vault 2026.4.1 (shipped with 1.36.0) read these parameters null-safely,
which is why this only appeared in 1.37.0.

Fixes #7481

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:13:44 +02:00
98 changed files with 15548 additions and 23119 deletions

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

View file

@ -316,6 +316,14 @@
## 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 ###
#########################
@ -382,6 +390,7 @@
##
## 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)
@ -510,6 +519,9 @@
## 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

View file

@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching
- name: Rust Caching
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.

View file

@ -20,7 +20,7 @@ jobs:
steps:
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.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:
@ -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@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint

View file

@ -58,13 +58,13 @@ jobs:
steps:
- name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
with:
platforms: "arm64,arm"
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.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:
@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -121,7 +121,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -137,7 +137,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@ -237,7 +237,7 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -287,7 +287,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -303,7 +303,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
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@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
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@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}

View file

@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: 'trivy-results.sarif'

View file

@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b # v1.50.1

View file

@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.

View file

@ -18,9 +18,10 @@ 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: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
rev: d43b6c087ac471e2ea7b8af622ff15f05c0c365b # v1.50.1
hooks:
- id: typos
always_run: true
- repo: local
hooks:
@ -38,8 +39,7 @@ repos:
entry: cargo test
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
@ -47,8 +47,7 @@ repos:
entry: cargo clippy
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
pass_filenames: false
- id: check-docker-templates
name: check docker templates

1056
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[workspace.package]
edition = "2024"
rust-version = "1.95.0"
rust-version = "1.96.1"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@ -12,7 +12,6 @@ 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
@ -41,6 +40,7 @@ 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,6 +58,7 @@ 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"
@ -65,7 +66,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.33"
log = "0.4.34"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
tracing = { version = "0.1.44", features = ["log"] }
@ -89,7 +90,7 @@ rmpv = "1.3.1" # MessagePack library
dashmap = "6.2.1"
# Async futures
futures = "0.3.33"
futures = "0.3.34"
tokio = { version = "1.53.1", features = [
"fs",
"io-util",
@ -106,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@ -119,27 +120,27 @@ 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.37.0", optional = true }
libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries
rand = "0.10.2"
ring = "0.17.14"
rustls = { version = "0.23.42", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.44", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.24.0", features = ["v4"] }
uuid = { version = "1.26.0", 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.54"
time = "0.3.55"
# Job scheduler
job_scheduler_ng = "2.4.0"
job_scheduler_ng = "2.5.0"
# Data encoding library Hex/Base32/Base64
data-encoding = "2.11.0"
data-encoding = "2.11.1"
# JWT library
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
@ -148,7 +149,7 @@ jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust
totp-lite = "2.0.1"
# Yubico Library
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] }
yubico_ng = { version = "1.0.0", default-features = false }
# WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db
@ -161,7 +162,7 @@ webauthn-rs-core = "0.5.5"
url = "2.5.8"
# Email libraries
lettre = { version = "0.11.22", default-features = false, features = [
lettre = { version = "0.11.23", default-features = false, features = [
# Misc
"tracing",
"serde",
@ -179,10 +180,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.3", features = ["dir_source"] }
handlebars = { version = "6.4.4", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.4", default-features = false, features = [
reqwest = { version = "0.13.5", default-features = false, features = [
# Misc
"charset",
"cookies",
@ -200,7 +201,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.1"
hickory-resolver = "0.26.2"
# Favicon extraction libraries
html5gum = "0.8.4"
@ -211,13 +212,13 @@ regex = { version = "1.13.1", default-features = false, features = [
] }
data-url = "0.3.2"
bytes = "1.12.1"
svg-hush = "0.9.6"
svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "2.0.2", features = ["async"] }
cached = { version = "4.0.0", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1"
cookie = "0.18.2"
cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL
@ -231,11 +232,11 @@ pastey = "0.2.3"
governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.0"
ipnet = "2.12.2"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.15", features = ["future"] }
moka = { version = "0.12.16", features = ["future"] }
# Check client versions for specific features.
semver = "1.0.28"
@ -244,10 +245,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.5"
which = "8.0.6"
# Argon2 library with support for the PHC format
argon2 = "0.5.3"
argon2 = "0.6.0"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.5.4"
@ -256,20 +257,21 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
opendal = { version = "0.59.1", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.1", default-features = false, features = ["rustls-no-provider"], optional = true }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.10.0", optional = true, default-features = false, features = [
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.14.0", optional = true }
http = { version = "1.4.2", optional = true }
reqsign-aws-v4 = { version = "3.0.2", optional = true }
reqsign-core = { version = "3.1.0", optional = true }
aws-smithy-runtime-api = { version = "1.16.0", 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 }
# Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces

View file

@ -1,11 +1,12 @@
---
vault_version: "v2026.6.4"
vault_image_digest: "sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427"
vault_version: "v2026.7.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
# Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.97.1 # Rust version to be used
# The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images

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.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
# $ 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]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.6.4]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c 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.97.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
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
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
@ -70,7 +70,7 @@ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file
cat /env-cargo
RUN source /env-cargo && \
RUN . /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 source /env-cargo && \
RUN . /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 source /env-cargo && \
RUN . /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,6 +126,7 @@ RUN source /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" \

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.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
# $ 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]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.6.4]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@ -36,7 +36,8 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / /
ARG TARGETARCH
ARG TARGETVARIANT
@ -80,7 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app
WORKDIR /app
RUN source /env-cargo && \
RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@ -95,9 +96,14 @@ ARG DB=sqlite,mysql,postgresql
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN source /env-cargo && \
# Workaround for xx related build issues
RUN . /env-cargo && \
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# 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
@ -108,15 +114,20 @@ COPY . .
ARG VW_VERSION
# Builds again, this time it will be the actual source files being build
RUN source /env-cargo && \
RUN . /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
# Workaround for xx related build issues
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# 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 ; \
@ -139,6 +150,7 @@ RUN source /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" \

View file

@ -28,8 +28,13 @@
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
#
{% macro xx_cargo_config() -%}
# Workaround for xx related build issues
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# 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
@ -52,6 +57,7 @@ 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
@ -111,7 +117,7 @@ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
cat /env-cargo
{% endif %}
RUN source /env-cargo && \
RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@ -131,7 +137,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 source /env-cargo && \
RUN . /env-cargo && \
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
@ -146,7 +152,7 @@ COPY . .
ARG VW_VERSION
# Builds again, this time it will be the actual source files being build
RUN source /env-cargo && \
RUN . /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 && \
@ -156,7 +162,7 @@ RUN source /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 ; \
@ -179,6 +185,7 @@ RUN source /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" \

View file

@ -3,17 +3,23 @@
import os
import argparse
import json
import tomllib
import yaml
import jinja2
# Load settings file
with open("DockerSettings.yaml", 'r') as yaml_file:
with open('DockerSettings.yaml', 'r', encoding='utf-8') 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.')

View file

@ -14,7 +14,7 @@ proc-macro = true
[dependencies]
quote = "1.0.47"
syn = "3.0.3"
syn = "3.0.5"
[lints]
workspace = true

View file

@ -1,15 +1,31 @@
-- 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;
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;
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;

View file

@ -21,11 +21,19 @@ 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 #
###################
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080
@ -39,8 +47,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
######################
ROCKET_ADDRESS=0.0.0.0
ROCKET_PORT=8000
DOMAIN=http://localhost:${ROCKET_PORT}
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
SSO_DEBUG_TOKENS=true
I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true

View file

@ -1,8 +1,8 @@
# Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/).
It uses its own `test.env` with different ports to not collide with a running dev instance.
\
It usse its own [test.env](/test/scenarios/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 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:
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:
```bash
npm ci --ignore-scripts
npm ci --ignore-scripts --allow-git=none --allow-remote=none
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_RUNNNING=true npx playwright test
PW_KEEP_SERVICE_RUNNING=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 specifc test (It might fail if it has dependency):
To run only a specific 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 "http://127.0.0.1:8003"
npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
```
## Override web-vault
@ -112,12 +112,11 @@ You can check the result running:
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
```
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
Then check `https://127.0.0.1:8003/admin/diagnostics` with `admin`.
# OpenID Connect test setup
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.
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.
## Setup
@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash
> docker compose --profile vaultwarden --env-file .env up
....
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
keycloakSetup_1 | Logging into https://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 indicates the correct setup of the Keycloak realm, client and user
(It is normal for this container to stop once the configuration is done).
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).
Then you can access :
- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
- `Vaultwarden` on https://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
@ -171,7 +169,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) 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)).
- `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)).
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
## Cleanup

View file

@ -17,7 +17,7 @@ done
set -e
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 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 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 "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
# 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
# ENJOY
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html

View file

@ -28,7 +28,7 @@ RUN mkdir /playwright
WORKDIR /playwright
COPY package.json package-lock.json .
RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox
RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox
COPY docker-compose.yml test.env ./
COPY compose ./compose

View file

@ -35,6 +35,7 @@ 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"]

View file

@ -22,3 +22,14 @@ 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")

View file

@ -24,12 +24,15 @@ 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
@ -58,7 +61,7 @@ services:
Mariadb:
profiles: ["playwright"]
container_name: playwright_mariadb
image: mariadb:11.2.4
image: mariadb:12.2.2
env_file: test.env
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
@ -70,7 +73,7 @@ services:
Mysql:
profiles: ["playwright"]
container_name: playwright_mysql
image: mysql:8.4.1
image: mysql:9.7.0
env_file: test.env
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@ -82,7 +85,7 @@ services:
Postgres:
profiles: ["playwright"]
container_name: playwright_postgres
image: postgres:16.3
image: postgres:18.4
env_file: test.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
@ -94,7 +97,7 @@ services:
Maildev:
profiles: ["vaultwarden", "maildev"]
container_name: maildev
image: timshel/maildev:3.0.4
image: timshel/maildev:3.2.19
ports:
- ${SMTP_PORT}:1025
- 1080:1080
@ -102,7 +105,7 @@ services:
Keycloak:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:26.3.4
image: quay.io/keycloak/keycloak:26.6.2
network_mode: "host"
command:
- start-dev
@ -112,12 +115,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.3.4
network_mode: "host"
KEYCLOAK_VERSION: 26.6.2
depends_on:
- Keycloak
restart: "no"

View file

@ -1,4 +1,4 @@
import { firefox, type FullConfig } from '@playwright/test';
import { type FullConfig } from '@playwright/test';
import { execSync } from 'node:child_process';
import fs from 'fs';

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_RUNNNING === "true" ) {
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
} else {
console.log(`Vaultwarden stopping`);
@ -231,6 +231,7 @@ 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();
@ -248,15 +249,3 @@ 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');
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,14 +8,14 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.56.1",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@3.2.5"
"@playwright/test": "1.60.0",
"dotenv": "17.4.2",
"dotenv-expand": "13.0.0",
"maildev": "npm:@timshel_npm/maildev@3.2.19"
},
"dependencies": {
"mysql2": "3.15.3",
"otpauth": "9.4.1",
"pg": "8.16.3"
"mysql2": "3.22.3",
"otpauth": "9.5.1",
"pg": "8.21.0"
}
}

View file

@ -25,10 +25,12 @@ 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: 120 * 1000,
actionTimeout: 20 * 1000,
navigationTimeout: 20 * 1000,
expect: { timeout: 20 * 1000 },
timeout: 240 * 1000,
actionTimeout: 40 * 1000,
navigationTimeout: 40 * 1000,
expect: { timeout: 40 * 1000 },
"permissions": ["clipboard-read"],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
@ -37,6 +39,10 @@ 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',

View file

@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
#####################
# Playwright Config #
#####################
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false}
PW_SMTP_FROM=vaultwarden@playwright.test
#####################
@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
###################
# Keycloak Config #
###################
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081
@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config #
######################
ROCKET_PORT=8003
DOMAIN=http://localhost:${ROCKET_PORT}
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${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}

View file

@ -1,6 +1,8 @@
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();
@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
test('Create', async ({ page }) => {
await createAccount(test, page, users.user1);
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 orgs.create(test, page, 'New organisation');
await test.step('Create Collection', async () => {
await page.getByRole('link', { name: 'Collections' }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Collection' }).click();
await page.getByLabel('Name (required)').fill('RandomCollec');
await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();

View file

@ -0,0 +1,56 @@
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);
});

View file

@ -41,13 +41,10 @@ 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.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 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");
@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email);
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);
await logUser(test, page, users.user1, {
mailBuffer: emails,
mail2fa: true,
});
emails.close();
});

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.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).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 expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));

View file

@ -4,6 +4,7 @@ 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();
@ -20,6 +21,7 @@ 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);
@ -45,7 +47,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: 'Require new members' }).check();
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
@ -66,18 +68,16 @@ 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.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
// 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.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,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
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);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
// 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, mail1Buffer);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await orgs.members(test, page, 'Test');
await orgs.confirm(test, page, 'Test', users.user2.email);
@ -123,25 +121,30 @@ test('Confirm invited user', async ({ page }) => {
});
test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2, mail2Buffer);
await logUser(test, page, users.user2, { mailBuffer: 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.user1, mail1Buffer);
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test');
await test.step(`Rrcover ${users.user2.email}`, async () => {
await test.step(`Recover ${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('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, 'Password reset success');
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
});
let user2 = {
@ -149,5 +152,9 @@ test('Recover user password', async ({ page }) => {
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, mail2Buffer);
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
});

View file

@ -0,0 +1,110 @@
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)
})
});

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', exact: true }).click();
await page.getByRole('button', { name: 'New Send', 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', exact: true }).click();
await page.getByRole('button', { name: 'New' }).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)' }).fill('password');
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).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();

View file

@ -11,10 +11,11 @@ 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.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
const secret = await page.getByLabel('Key').innerText();
const secret = await page.getByLabel('Key', { exact: true }).innerText();
let totp = new OTPAuth.TOTP({ secret, period: 30 });
await page.getByLabel(/Verification code/).fill(totp.generate());
@ -33,8 +34,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.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
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.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
@ -49,7 +50,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.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send email' }).click();
});
@ -81,8 +82,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.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
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.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();

View file

@ -0,0 +1,21 @@
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/);
});
}

View file

@ -5,7 +5,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
utils.stopComposeService(serviceName);
}
});

View file

@ -3,11 +3,14 @@ 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', async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
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 expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill(name);
await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name);
await page.getByRole('button', { name: 'Submit' }).click();
await utils.checkNotification(page, 'Organisation created');
@ -18,7 +21,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').getByRole('link', { name: `${name}` }).first().click();
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${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();
@ -30,11 +33,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').getByRole('link', { name: `${name}` }).first().click();
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await page.getByRole('link', { name: 'Members' }).click();
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible();
});
}
@ -42,13 +45,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.getByLabel('Email (required)').fill(email);
await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).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.getByLabel('Select collections').click();
await page.getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', 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.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});

View file

@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
test('Keycloak teardown', async () => {
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
console.log("Keep Keycloak running");
} else {
console.log("Keycloak stopping");

View file

@ -15,11 +15,8 @@ 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();
});
@ -33,26 +30,24 @@ export async function logNewUser(
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
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.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 === "Welcome");
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
await mailBuffer.expect((m) => m.subject === "Welcome");
});
}
});
@ -69,16 +64,14 @@ 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();
});
@ -117,14 +110,12 @@ 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 ){
if( mailBuffer && !options.notNewDevice ){
await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});

View file

@ -3,6 +3,7 @@ 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 () => {
@ -17,12 +18,11 @@ export async function createAccount(test, page: Page, user: { email: string, nam
await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
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.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,7 +35,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
});
}
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
export async function logUser(
test,
page: Page,
user: { email: string, password: string },
options: {
mailBuffer ?: MailBuffer,
mail2fa?: boolean,
notNewDevice?: boolean,
} = {}
) {
await test.step(`Log user ${user.email}`, async () => {
await utils.cleanLanding(page);
@ -43,16 +52,23 @@ export async function logUser(test, page: Page, user: { email: string, password:
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await utils.ignoreExtension(page);
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();
});
}
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
if( mailBuffer ){
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
if( options.mailBuffer && !options.notNewDevice ){
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
}
});
}

View file

@ -1,6 +1,7 @@
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";
@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: false,
SSO_ONLY: true,
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
@ -32,22 +33,64 @@ test.afterAll('Teardown', async ({}) => {
}
});
test('Create and activate 2FA', async ({ page }) => {
test('2FA email', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
await activateEmail(test, page, users.user1, mailBuffer);
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 logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: 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();
});

View file

@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
await page.getByRole('button', { name: 'Other' }).click();
// Unlock page
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).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();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
@ -58,6 +58,7 @@ 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);
@ -66,7 +67,6 @@ 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,12 +74,14 @@ 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 with master password' })).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1);
});

View file

@ -67,17 +67,16 @@ 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.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
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 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 () => {
@ -95,6 +94,7 @@ 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,13 +108,11 @@ 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 () => {

View file

@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Enforce password policy', async ({ page }) => {
test('Activate password policy', async ({ page }) => {
await logUser(test, page, users.user1);
await orgs.policies(test, page, '/Test');
@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.');
});
});
await utils.logout(test, page, users.user1);
test('Unlock trigger policyy', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
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 page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
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('button', { name: 'Unlock' }).click();
});
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
});
});

View file

@ -1,4 +1,4 @@
[toolchain]
channel = "1.97.1"
channel = "1.98.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"

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::PasswordHash::new(t) {
match argon2::password_hash::phc::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 as i32,
EventType::OrganizationUserDeleted,
&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 as i32,
EventType::OrganizationUserUnlinkedSso,
&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 as i32,
EventType::OrganizationUserUpdated,
&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 = 600, sync_writes = "default")]
#[cached(ttl_secs = 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,6 +716,36 @@ 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::*;
@ -770,6 +800,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "),
"template_overrides": check_template_overrides().join(", "),
"invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH,
"host_os": env::consts::OS,

View file

@ -595,29 +595,52 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
#[serde(rename_all = "camelCase")]
struct ChangePassData {
master_password_hash: String,
new_master_password_hash: String,
master_password_hint: Option<String>,
key: String,
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
// Outdated values, might still be used by older clients
new_master_password_hash: Option<String>,
key: Option<String>,
}
#[post("/accounts/password", data = "<data>")]
async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: ChangePassData = data.into_inner();
let mut user = headers.user;
let user = headers.user;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password")
}
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(user.password_hint.as_ref())?;
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
let (new_master_password_hash, new_key) =
if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) {
if authentication_data.kdf != unlock_data.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
if user.email != authentication_data.salt || user.email != unlock_data.salt {
err!("Invalid master password salt")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key)
} else {
err!("Invalid request!")
};
let mut user = user;
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(user.password_hint.as_ref())?;
user.set_password(
&data.new_master_password_hash,
Some(data.key),
&new_master_password_hash,
Some(new_key),
true,
Some(vec![
String::from("post_rotatekey"),
@ -1317,11 +1340,13 @@ pub struct PreloginData {
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: PreloginData = data.into_inner();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
@ -1329,7 +1354,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
};
Json(json!({
Ok(Json(json!({
"kdf": kdf_type,
"kdfIterations": kdf_iter,
"kdfMemory": kdf_mem,
@ -1341,7 +1366,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"parallelism": kdf_para
},
"salt": null,
}))
})))
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
@ -1572,6 +1597,8 @@ async fn post_auth_request(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let data = data.into_inner();
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
@ -1584,7 +1611,7 @@ async fn post_auth_request(
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
};
let mut auth_request = AuthRequest::new(
let auth_request = AuthRequest::new(
user.uuid.clone(),
data.device_identifier.clone(),
client_headers.device_type,
@ -1733,6 +1760,8 @@ async fn get_auth_request_response(
client_headers: ClientHeaders,
conn: DbConn,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
err!("AuthRequest doesn't exist", "User not found")
};

View file

@ -167,7 +167,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
api::core::get_eq_domains(&headers, true).into_inner()
};
// This is very similar to the the userDecryptionOptions sent in connect/token,
// This is very similar to 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 {
@ -537,11 +537,12 @@ pub async fn update_cipher_from_data(
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
if let Some(dt_str) = data.archived_date {
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
match data.archived_date {
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
}
},
None => cipher.unarchive(&headers.user.uuid, conn).await?,
}
if ut != UpdateType::None {
@ -553,15 +554,7 @@ pub async fn update_cipher_from_data(
(_, _) => EventType::CipherUpdated,
};
log_event(
event_type as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
.await;
}
nt.send_cipher_update(
@ -850,7 +843,7 @@ async fn post_collections_update(
.await;
log_event(
EventType::CipherUpdatedCollections as i32,
EventType::CipherUpdatedCollections,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@ -870,7 +863,7 @@ async fn put_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
) -> JsonResult {
post_collections_admin(cipher_id, data, headers, conn, nt).await
}
@ -881,7 +874,7 @@ async fn post_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
) -> JsonResult {
let data: CollectionsAdminData = data.into_inner();
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
@ -930,7 +923,7 @@ async fn post_collections_admin(
.await;
log_event(
EventType::CipherUpdatedCollections as i32,
EventType::CipherUpdatedCollections,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@ -940,7 +933,7 @@ async fn post_collections_admin(
)
.await;
Ok(())
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
}
#[derive(Deserialize)]
@ -1335,7 +1328,7 @@ async fn save_attachment(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherAttachmentCreated as i32,
EventType::CipherAttachmentCreated,
&cipher.uuid,
org_id,
&headers.user.uuid,
@ -1696,7 +1689,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 as i32,
EventType::OrganizationPurgedVault,
&organization.org_id,
&organization.org_id,
&user.uuid,
@ -1824,9 +1817,9 @@ async fn delete_cipher_by_uuid(
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|| *delete_options == CipherDeleteOptions::SoftMulti
{
EventType::CipherSoftDeleted as i32
EventType::CipherSoftDeleted
} else {
EventType::CipherDeleted as i32
EventType::CipherDeleted
};
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
@ -1895,7 +1888,7 @@ async fn restore_cipher_by_uuid(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherRestored as i32,
EventType::CipherRestored,
&cipher.uuid.clone(),
org_id,
&headers.user.uuid,
@ -1972,7 +1965,7 @@ async fn delete_cipher_attachment_by_id(
if let Some(ref org_id) = cipher.organization_uuid {
log_event(
EventType::CipherAttachmentDeleted as i32,
EventType::CipherAttachmentDeleted,
&cipher.uuid,
org_id,
&headers.user.uuid,

View file

@ -10,7 +10,7 @@ use crate::{
auth::{AdminHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
},
util::parse_date,
};
@ -267,7 +267,7 @@ async fn log_user_event_impl(
}
pub async fn log_event(
event_type: i32,
event_type: EventType,
source_uuid: &str,
org_id: &OrganizationId,
act_user_id: &UserId,
@ -278,7 +278,7 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() {
return;
}
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
}
#[expect(clippy::too_many_arguments)]

View file

@ -238,7 +238,7 @@ fn config() -> Json<Value> {
"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": false
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
},
"environment": {
"vault": domain,

View file

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive;
use rocket::{Route, serde::json::Json};
use rocket::{Route, http::Status, serde::json::Json};
use serde_json::Value;
use crate::{
@ -17,7 +17,8 @@ use crate::{
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
},
},
mail,
@ -132,7 +133,6 @@ 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 as i32,
EventType::OrganizationUserLeft,
&membership.uuid,
&org_id,
&headers.user.uuid,
@ -327,7 +327,7 @@ async fn post_organization(
org.save(&conn).await?;
log_event(
EventType::OrganizationUpdated as i32,
EventType::OrganizationUpdated,
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", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@ -514,7 +514,7 @@ async fn post_organization_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionCreated as i32,
EventType::CollectionCreated,
&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 as i32,
EventType::CollectionUpdated,
&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 as i32,
EventType::CollectionUpdated,
&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 as i32,
EventType::CollectionDeleted,
&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", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", 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", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
let mut users_json = Vec::new();
@ -1148,7 +1148,7 @@ async fn send_invite(
}
log_event(
EventType::OrganizationUserInvited as i32,
EventType::OrganizationUserInvited,
&new_member.uuid,
&org_id,
&headers.user.uuid,
@ -1447,7 +1447,7 @@ async fn confirm_invite_impl(
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
log_event(
EventType::OrganizationUserConfirmed as i32,
EventType::OrganizationUserConfirmed,
&member_to_confirm.uuid,
org_id,
&headers.user.uuid,
@ -1637,7 +1637,7 @@ async fn edit_member(
}
log_event(
EventType::OrganizationUserUpdated as i32,
EventType::OrganizationUserUpdated,
&member_to_edit.uuid,
&org_id,
&headers.user.uuid,
@ -1724,7 +1724,7 @@ async fn delete_member_impl(
}
log_event(
EventType::OrganizationUserRemoved as i32,
EventType::OrganizationUserRemoved,
&member_to_delete.uuid,
org_id,
&headers.user.uuid,
@ -1793,11 +1793,22 @@ 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<FullCollectionData>,
collections: Vec<ImportCollectionData>,
collection_relationships: Vec<RelationsData>,
}
@ -2144,7 +2155,7 @@ async fn put_policy(
}
log_event(
EventType::OrganizationUserRemoved as i32,
EventType::OrganizationUserRemoved,
&member.uuid,
&org_id,
&headers.user.uuid,
@ -2170,7 +2181,7 @@ async fn put_policy(
policy.save(&conn).await?;
log_event(
EventType::PolicyUpdated as i32,
EventType::PolicyUpdated,
policy.uuid.as_ref(),
&org_id,
&headers.user.uuid,
@ -2339,7 +2350,7 @@ async fn revoke_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
EventType::OrganizationUserRevoked,
&member.uuid,
org_id,
&headers.user.uuid,
@ -2437,7 +2448,7 @@ async fn restore_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRestored as i32,
EventType::OrganizationUserRestored,
&member.uuid,
org_id,
&headers.user.uuid,
@ -2476,7 +2487,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", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@ -2605,7 +2616,7 @@ async fn post_groups(
let group = group_request.to_group(&org_id);
log_event(
EventType::GroupCreated as i32,
EventType::GroupCreated,
&group.uuid,
&org_id,
&headers.user.uuid,
@ -2646,7 +2657,7 @@ async fn put_group(
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
log_event(
EventType::GroupUpdated as i32,
EventType::GroupUpdated,
&updated_group.uuid,
&org_id,
&headers.user.uuid,
@ -2679,7 +2690,7 @@ async fn add_update_group(
user_entry.save(conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
EventType::OrganizationUserUpdatedGroups,
&assigned_member,
&org_id,
&headers.user.uuid,
@ -2754,7 +2765,7 @@ async fn delete_group_impl(
};
log_event(
EventType::GroupDeleted as i32,
EventType::GroupDeleted,
&group.uuid,
org_id,
&headers.user.uuid,
@ -2865,7 +2876,7 @@ async fn put_group_members(
user_entry.save(&conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
EventType::OrganizationUserUpdatedGroups,
&assigned_member,
&org_id,
&headers.user.uuid,
@ -2903,7 +2914,7 @@ async fn post_delete_group_member(
}
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
EventType::OrganizationUserUpdatedGroups,
&member_id,
&org_id,
&headers.user.uuid,
@ -2927,8 +2938,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String,
key: String,
new_master_password_hash: Option<String>,
key: Option<String>,
#[serde(default)]
reset_master_password: bool,
@ -2972,12 +2983,7 @@ async fn put_recover_account(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
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")
}
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
// Deprecated since `v2026.4.2`
@ -2997,7 +3003,7 @@ async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
req: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@ -3012,7 +3018,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization")
};
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found")
};
@ -3025,29 +3031,56 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
// 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 {
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
{
err!(format!("Error sending user reset password email: {e:#?}"));
}
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
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?;
}
}
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
log_event(
EventType::OrganizationUserAdminResetPassword as i32,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&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;
}
Ok(())
}
@ -3166,9 +3199,9 @@ async fn put_reset_password_enrollment(
membership.save(&conn).await?;
let event_type = if membership.reset_password_key.is_some() {
EventType::OrganizationUserResetPasswordEnroll as i32
EventType::OrganizationUserResetPasswordEnroll
} else {
EventType::OrganizationUserResetPasswordWithdraw as i32
EventType::OrganizationUserResetPasswordWithdraw
};
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)

View file

@ -63,13 +63,19 @@ 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.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
};
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.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@ -96,7 +102,10 @@ 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.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
)
};
user

View file

@ -16,8 +16,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
@ -151,6 +151,7 @@ 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;
}
@ -190,7 +191,7 @@ pub async fn enforce_2fa_policy(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
EventType::OrganizationUserRevoked,
&member.uuid,
&member.org_uuid,
act_user_id,
@ -224,15 +225,7 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke();
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
act_user_id,
device_type,
ip,
conn,
)
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
.await;
}
}

View file

@ -1,6 +1,10 @@
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use yubico::{config::Config, verify_async};
use yubico_ng::{
Verifier, YubicoError,
config::Config,
transport::{AsyncTransport, Response},
};
use crate::{
CONFIG,
@ -14,12 +18,39 @@ 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 {
@ -44,8 +75,7 @@ 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().cloned().collect()
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
}
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
@ -73,13 +103,15 @@ 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 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 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);
}
.map_res("Failed to verify OTP")
let client = HttpClientTransport::new()?;
let verifier = Verifier::with_client(config, client)?;
verifier.verify(otp).await.map_res("Failed to verify OTP")
}
#[post("/two-factor/get-yubikey", data = "<data>")]
@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
let yubikeys = parse_yubikeys(&data);
if yubikeys.is_empty() {
return Ok(Json(json!({
"enabled": false,
"object": "twoFactorU2f",
})));
// 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.");
}
// Ensure they are valid OTPs

View file

@ -3,7 +3,7 @@ use num_traits::FromPrimitive;
use rocket::{
Route,
form::{Form, FromForm},
http::{Cookie, CookieJar, SameSite},
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
response::Redirect,
serde::json::Json,
};
@ -234,6 +234,24 @@ 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",
@ -281,7 +299,15 @@ 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_email_domain_allowed(&user_infos.email) {
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
}
);
}
err!(
"Email domain not allowed",
ErrorEvent {
@ -318,7 +344,7 @@ async fn sso_login(
Some((user, _)) if !user.enabled => {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
format!("IP: {}. Username: {}.", ip.ip, user.email),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@ -577,7 +603,7 @@ async fn authenticated_response(
result["TwoFactorToken"] = Value::String(token);
}
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip);
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
Ok(Json(result))
}
@ -879,6 +905,12 @@ 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;
@ -1024,13 +1056,13 @@ async fn json_err_twofactor(
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/register", data = "<data>")]
@ -1051,11 +1083,18 @@ 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> {
@ -1093,7 +1132,11 @@ 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(RegisterVerificationResponse::Token(Json(token)))
Ok(if accepts_json(accept) {
RegisterVerificationResponse::Token(Json(token))
} else {
RegisterVerificationResponse::PlainToken(token)
})
}
}

View file

@ -30,7 +30,7 @@ pub use crate::api::{
},
web::catchers as web_catchers,
web::routes as web_routes,
web::static_files,
web::{invalidate_css_cache, static_files},
};
use crate::{
CONFIG,

View file

@ -1,4 +1,7 @@
use std::path::{Path, PathBuf};
use std::{
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use rocket::{
Catcher, Route,
@ -13,12 +16,13 @@ use crate::{
CONFIG,
api::{ApiResult, EmptyResult, core::now},
auth::decode_file_download,
crypto::sha256_hex,
db::{
DbConn,
models::{AttachmentId, CipherId},
},
error::Error,
util::Cached,
util::{Cached, EtagCached},
};
pub fn routes() -> Vec<Route> {
@ -63,8 +67,27 @@ 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() -> Cached<Css<String>> {
fn vaultwarden_css() -> EtagCached<Css<String>> {
// If reload_templates is false, and we already have the CSS Cached, return this
if !CONFIG.reload_templates()
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
{
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
}
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,
@ -112,8 +135,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
}
};
// Cache for one day should be enough and not too much
Cached::ttl(Css(css), 86_400, false)
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)
}
#[get("/")]
@ -268,9 +301,6 @@ 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}")),
}
}

View file

@ -23,14 +23,14 @@ use rocket::{
use crate::{
CONFIG,
api::ApiResult,
api::{ApiResult, core::log_event},
config::PathType,
db::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
},
},
error::Error,
@ -822,6 +822,12 @@ 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;

View file

@ -659,6 +659,11 @@ 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.
@ -812,6 +817,8 @@ 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.
@ -1157,14 +1164,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if !metadata.permissions().mode() & 0o111 != 0 {
if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() {
err!(format!("sendmail command at `{path:?}` isn't executable"));
}
}
}
}
} else {
if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() {
err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`")
@ -1268,7 +1272,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::PasswordHash::new(t) {
if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) {
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
}
}
@ -1421,6 +1425,7 @@ 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
"ssh-agent",
"ssh-agent-v2",
@ -1501,6 +1506,9 @@ 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(())
}
@ -1539,6 +1547,17 @@ 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.
@ -1583,6 +1602,9 @@ 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(())
}
@ -1723,7 +1745,7 @@ where
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/admin_reset_password", ".html");
reg!("email/admin_account_recovery", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html");

View file

@ -41,17 +41,20 @@ impl Archive {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
diesel::replace_into(archives::table)
mysql {
diesel::insert_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 {
postgresql, sqlite {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),

View file

@ -82,24 +82,16 @@ impl Attachment {
impl Attachment {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(attachments::table)
mysql {
diesel::insert_into(attachments::table)
.values(self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving attachment")
}
Err(e) => Err(e.into()),
}.map_res("Error saving attachment")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(attachments::table)
.values(self)
.on_conflict(attachments::id)

View file

@ -82,31 +82,23 @@ impl AuthRequest {
}
impl AuthRequest {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(auth_requests::table)
.values(&*self)
.execute(conn)
{
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 {
mysql {
diesel::insert_into(auth_requests::table)
.values(&*self)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving auth_request")
}
postgresql, sqlite {
diesel::insert_into(auth_requests::table)
.values(self)
.on_conflict(auth_requests::uuid)
.do_update()
.set(&*self)
.set(self)
.execute(conn)
.map_res("Error saving auth_request")
}

View file

@ -440,24 +440,16 @@ impl Cipher {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(ciphers::table)
mysql {
diesel::insert_into(ciphers::table)
.values(&*self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
}
Err(e) => Err(e.into()),
}.map_res("Error saving cipher")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(ciphers::table)
.values(&*self)
.on_conflict(ciphers::uuid)

View file

@ -168,24 +168,16 @@ impl Collection {
self.update_users_revision(conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(collections::table)
mysql {
diesel::insert_into(collections::table)
.values(self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving collection")
}
Err(e) => Err(e.into()),
}.map_res("Error saving collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(collections::table)
.values(self)
.on_conflict(collections::uuid)
@ -728,53 +720,30 @@ 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:
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)
{
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),
))
mysql {
diesel::insert_into(users_collections::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
.execute(conn)
.map_res("Error adding user to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_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),
))
.values(values)
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
.do_update()
.set((
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.set(values)
.execute(conn)
.map_res("Error adding user to collection")
}
@ -909,19 +878,18 @@ impl CollectionCipher {
Self::update_users_revision(collection_uuid, conn).await;
db_run! { conn:
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)
mysql {
diesel::insert_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 {
postgresql, sqlite {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),

View file

@ -146,15 +146,18 @@ impl Device {
}
db_run! { conn:
sqlite, mysql {
mysql {
crate::util::retry(||
diesel::replace_into(devices::table)
diesel::insert_into(devices::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn),
10,
).map_res("Error saving device")
}
postgresql {
postgresql, sqlite {
crate::util::retry(||
diesel::insert_into(devices::table)
.values(&*self)
@ -266,10 +269,22 @@ 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)]

View file

@ -146,24 +146,16 @@ impl EmergencyAccess {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(emergency_access::table)
mysql {
diesel::insert_into(emergency_access::table)
.values(&*self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error updating emergency access")
.map_res("Error saving emergency access")
}
Err(e) => Err(e.into()),
}.map_res("Error saving emergency access")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(emergency_access::table)
.values(&*self)
.on_conflict(emergency_access::uuid)

View file

@ -43,7 +43,7 @@ pub struct Event {
pub provider_org_uuid: Option<String>,
}
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
#[derive(Debug, Copy, Clone)]
pub enum EventType {
// User
@ -108,6 +108,12 @@ 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,
@ -202,13 +208,16 @@ impl Event {
/// Basic Queries
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(event::table)
mysql {
diesel::insert_into(event::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(event::table)
.values(self)
.on_conflict(event::uuid)
@ -298,12 +307,16 @@ impl Event {
) -> Vec<Self> {
conn.run(move |conn| {
event::table
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid)))
.inner_join(
users_organizations::table
.on(users_organizations::uuid.eq(member_uuid).and(users_organizations::org_uuid.eq(org_uuid))),
)
.filter(event::org_uuid.eq(org_uuid))
.filter(event::event_date.between(start, end))
.filter(
event::user_uuid
.eq(users_organizations::user_uuid.nullable())
event::org_user_uuid
.eq(member_uuid)
.or(event::user_uuid.eq(users_organizations::user_uuid.nullable()))
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
)
.select(event::all_columns)

View file

@ -77,24 +77,16 @@ impl Folder {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(folders::table)
mysql {
diesel::insert_into(folders::table)
.values(&*self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving folder")
}
Err(e) => Err(e.into()),
}.map_res("Error saving folder")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(folders::table)
.values(&*self)
.on_conflict(folders::uuid)
@ -147,16 +139,15 @@ impl Folder {
impl FolderCipher {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
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)
mysql {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to folder")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid))

View file

@ -166,24 +166,16 @@ impl Group {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups::table)
mysql {
diesel::insert_into(groups::table)
.values(&*self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving group")
}
Err(e) => Err(e.into()),
}.map_res("Error saving group")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(groups::table)
.values(&*self)
.on_conflict(groups::uuid)
@ -326,53 +318,30 @@ 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:
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)
{
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),
))
mysql {
diesel::insert_into(collections_groups::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
.execute(conn)
.map_res("Error adding group to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding group to collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_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),
))
.values(values)
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
.do_update()
.set((
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.set(values)
.execute(conn)
.map_res("Error adding group to collection")
}
@ -497,43 +466,25 @@ 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:
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)
{
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),
))
mysql {
diesel::insert_into(groups_users::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding user to group")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to group")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.values(values)
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
.do_update()
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.do_nothing()
.execute(conn)
.map_res("Error adding user to group")
}

View file

@ -91,6 +91,7 @@ impl OrgPolicy {
"type": self.atype,
"data": data_json,
"enabled": self.enabled,
"revisionDate": null,
"object": "policy",
});
@ -317,6 +318,13 @@ 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) => {

View file

@ -353,25 +353,16 @@ impl Organization {
}
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(organizations::table)
mysql {
diesel::insert_into(organizations::table)
.values(self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving organization")
}
Err(e) => Err(e.into()),
}.map_res("Error saving organization")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(organizations::table)
.values(self)
.on_conflict(organizations::uuid)
@ -753,24 +744,16 @@ impl Membership {
User::update_uuid_revision(&self.user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(users_organizations::table)
mysql {
diesel::insert_into(users_organizations::table)
.values(self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error adding user to organization")
},
Err(e) => Err(e.into()),
}.map_res("Error adding user to organization")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(users_organizations::table)
.values(self)
.on_conflict(users_organizations::uuid)
@ -1186,25 +1169,16 @@ impl Membership {
impl OrganizationApiKey {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(organization_api_key::table)
mysql {
diesel::insert_into(organization_api_key::table)
.values(self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving organization")
}
Err(e) => Err(e.into()),
}.map_res("Error saving organization")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(organization_api_key::table)
.values(self)
.on_conflict((organization_api_key::uuid, organization_api_key::org_uuid))

View file

@ -202,24 +202,16 @@ impl Send {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(sends::table)
mysql {
diesel::insert_into(sends::table)
.values(&*self)
.execute(conn)
{
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))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving send")
}
Err(e) => Err(e.into()),
}.map_res("Error saving send")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(sends::table)
.values(&*self)
.on_conflict(sends::uuid)

View file

@ -463,15 +463,17 @@ impl Invitation {
}
db_run! { conn:
sqlite, mysql {
// Not checking for ForeignKey Constraints here
// Table invitations does not have any ForeignKey Constraints.
diesel::replace_into(invitations::table)
mysql {
diesel::insert_into(invitations::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error saving invitation")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(invitations::table)
.values(self)
.on_conflict(invitations::email)
@ -528,13 +530,13 @@ pub struct UserId(String);
impl SsoUser {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(sso_users::table)
mysql {
diesel::insert_into(sso_users::table)
.values(self)
.execute(conn)
.map_res("Error saving SSO user")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(sso_users::table)
.values(self)
.execute(conn)

View file

@ -58,7 +58,7 @@ use serde_json::{Error as SerdeErr, Value};
use std::io::Error as IoErr;
use std::time::SystemTimeError as TimeErr;
use webauthn_rs::prelude::WebauthnError as WebauthnErr;
use yubico::yubicoerror::YubicoError as YubiErr;
use yubico_ng::error::YubicoError as YubiErr;
#[derive(Serialize)]
pub struct Empty {}

View file

@ -14,7 +14,10 @@ use reqwest::{
};
use url::Host;
use crate::{CONFIG, util::is_global};
use crate::{
CONFIG,
util::{get_env_bool, is_global},
};
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
static INSTANCE: LazyLock<Client> =
@ -36,7 +39,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
let mut headers = header::HeaderMap::new();
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 5 {
return attempt.error("Too many redirects");
}
@ -45,7 +48,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
return attempt.error("Invalid host");
};
if let Err(e) = should_block_host(&host) {
if enforce_block && let Err(e) = should_block_host(&host) {
return attempt.error(e);
}
@ -59,6 +62,14 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
.timeout(Duration::from_secs(10))
}
fn dns_prefer_ipv6() -> bool {
// CONFIG may require DNS to initialize, so avoid forcing it during bootstrap.
match LazyLock::get(&CONFIG) {
Some(config) => config.dns_prefer_ipv6(),
None => get_env_bool("DNS_PREFER_IPV6").unwrap_or(false),
}
}
fn should_block_ip(ip: IpAddr) -> bool {
if !CONFIG.http_request_block_non_global_ips() {
return false;
@ -258,12 +269,8 @@ impl CustomDnsResolver {
fn new() -> Arc<Self> {
TokioResolver::builder(TokioRuntimeProvider::default())
.and_then(|mut builder| {
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
// This might cause issues on IPv4 only systems or containers
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
if !CONFIG.dns_prefer_ipv6() {
// Query both families; the preferred order is applied per lookup below.
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
}
builder.build()
})
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
@ -289,6 +296,17 @@ impl CustomDnsResolver {
}
}
fn sort_addresses(addresses: &mut [SocketAddr], prefer_ipv6: bool) {
// `sort_by_key` orders `false` before `true`.
// When IPv6 is preferred, IPv6 addresses return `false` for `is_ipv4()` and sort first.
// When IPv4 is preferred, IPv4 addresses return `false` for `is_ipv6()` and sort first.
if prefer_ipv6 {
addresses.sort_by_key(SocketAddr::is_ipv4);
} else {
addresses.sort_by_key(SocketAddr::is_ipv6);
}
}
fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
let Ok(host) = get_valid_host(name) else {
return Err(CustomHttpClientError::Invalid {
@ -320,7 +338,9 @@ impl Resolve for CustomDns {
let this = Arc::clone(&self.resolver);
Box::pin(async move {
let name = name.as_str();
let results = this.resolve_domain(name, enforce_block).await?;
let mut results = this.resolve_domain(name, enforce_block).await?;
// Recheck after bootstrap so long-lived clients adopt the loaded config.
sort_addresses(&mut results, dns_prefer_ipv6());
if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address");
}
@ -339,10 +359,29 @@ pub(crate) mod aws {
};
use reqwest::Client;
use super::get_reqwest_client_builder;
// Adapter that wraps reqwest to be compatible with the AWS SDK
#[derive(Debug)]
pub(crate) struct AwsReqwestConnector {
pub(crate) client: Client,
client: Client,
}
impl AwsReqwestConnector {
pub(crate) fn new() -> Self {
let client = get_reqwest_client_builder(false).build().expect("Failed to build AWS HTTP client");
Self {
client,
}
}
}
fn connector_error(error: reqwest::Error) -> ConnectorError {
if error.is_timeout() {
ConnectorError::timeout(Box::new(error))
} else {
ConnectorError::io(Box::new(error))
}
}
impl HttpConnector for AwsReqwestConnector {
@ -362,10 +401,10 @@ pub(crate) mod aws {
req_builder = req_builder.body(body_bytes.to_vec());
}
let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?;
let response = req_builder.send().await.map_err(connector_error)?;
let status = response.status().into();
let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?;
let bytes = response.bytes().await.map_err(connector_error)?;
Ok(HttpResponse::new(status, bytes.into()))
};
@ -391,7 +430,7 @@ pub(crate) mod aws {
mod tests {
use super::*;
use crate::util::is_global_hardcoded;
use std::net::Ipv4Addr;
use std::net::{Ipv4Addr, Ipv6Addr};
use url::Host;
// ===
@ -404,6 +443,26 @@ mod tests {
}
}
#[test]
fn dns_setup_does_not_initialize_config() {
assert!(LazyLock::get(&CONFIG).is_none());
drop(CustomDns::instance(false));
assert!(LazyLock::get(&CONFIG).is_none());
}
#[test]
fn dns_preference_orders_addresses() {
let ipv4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0);
let mut addresses = [ipv6, ipv4];
sort_addresses(&mut addresses, false);
assert_eq!(addresses, [ipv4, ipv6]);
sort_addresses(&mut addresses, true);
assert_eq!(addresses, [ipv6, ipv4]);
}
#[test]
fn dotted_decimal_loopback_normalizes() {
let ip = parse_to_ip("127.0.0.1").unwrap();

View file

@ -307,9 +307,18 @@ pub async fn send_invite(
if CONFIG.sso_enabled() && CONFIG.sso_only() {
query_params.append_pair("orgSsoIdentifier", &org_id);
}
if user.private_key.is_some() {
query_params.append_pair("orgUserHasExistingUser", "true");
}
// The web vault requires both of these parameters to be present.
// If either is missing it rejects the invite client-side, before any
// request reaches the server, showing only "Unable to accept invitation".
query_params.append_pair("initOrganization", "false");
let org_user_has_existing_user = if user.private_key.is_some() {
"true"
} else {
"false"
};
query_params.append_pair("orgUserHasExistingUser", org_user_has_existing_user);
}
let Some(query_string) = query.query() else {
@ -624,14 +633,24 @@ pub async fn send_test(address: &str) -> EmptyResult {
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult {
pub async fn send_admin_account_recovery(
address: &str,
user_name: &str,
org_name: &str,
reset_password: bool,
reset_2fa: bool,
fallback_2fa_email: bool,
) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/admin_reset_password",
"email/admin_account_recovery",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"user_name": user_name,
"org_name": org_name,
"reset_password": reset_password,
"reset_2fa": reset_2fa,
"fallback_2fa_email": fallback_2fa_email,
}),
)?;
send_email(address, &subject, body_html, body_text).await

View file

@ -137,9 +137,7 @@ fn parse_args() {
if let Some(command) = pargs.subcommand().unwrap_or_default() {
if command == "hash" {
use argon2::{
Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13, password_hash::SaltString,
};
use argon2::{Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13};
let mut argon2_params = ParamsBuilder::new();
let preset: Option<String> = pargs.opt_value_from_str(["-p", "--preset"]).unwrap_or_default();
@ -172,10 +170,10 @@ fn parse_args() {
}
let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap());
let salt = SaltString::encode_b64(&crypto::get_random_bytes::<32>()).unwrap();
let salt = crypto::get_random_bytes::<32>();
let argon2_timer = tokio::time::Instant::now();
if let Ok(password_hash) = argon2.hash_password(password.as_bytes(), &salt) {
if let Ok(password_hash) = argon2.hash_password_with_salt(password.as_bytes(), &salt) {
println!(
"\n\
ADMIN_TOKEN='{password_hash}'\n\n\

View file

@ -1,6 +1,5 @@
"use strict";
/* eslint-env es2017, browser */
/* exported BASE_URL, _post _delete */
/* exported BASE_URL, _post, _delete */
function getBaseUrl() {
// If the base URL is `https://vaultwarden.example.com/base/path/admin/`,

View file

@ -1,5 +1,4 @@
"use strict";
/* eslint-env es2017, browser */
/* global BASE_URL:readable, bootstrap:readable */
var dnsCheck = false;
@ -80,37 +79,44 @@ async function generateSupportString(event, dj) {
event.preventDefault();
event.stopPropagation();
// Health check Markdown emoji, if something is a failure or not
const chk = v => v ? "true :white_check_mark:" : "false :x:";
// Yes/No Markdown emoji, if something is not a failure, but just yes or no
const yn = v => v ? "yes :heavy_plus_sign:" : "no :heavy_minus_sign:";
const template_overrides = dj.template_overrides !== "" ? ` (${dj.template_overrides})` : "";
let supportString = "### Your environment (Generated via diagnostics page)\n\n";
supportString += `* Vaultwarden version: v${dj.current_release}\n`;
supportString += `* Web-vault version: v${dj.active_web_release}\n`;
supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`;
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`;
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Uses config.json: ${dj.overrides !== ""}\n`;
supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`;
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
if (dj.ip_header_exists) {
supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`;
supportString += `* IP Header check: ${chk(dj.ip_header_match)} (${dj.ip_header_name})\n`;
}
supportString += `* Internet access: ${dj.has_http_access}\n`;
supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`;
supportString += `* DNS Check: ${dnsCheck}\n`;
supportString += `* Internet access: ${chk(dj.has_http_access)}\n`;
supportString += `* Internet access via a proxy: ${yn(dj.uses_proxy)}\n`;
supportString += `* DNS Check: ${chk(dnsCheck)}\n`;
if (dj.tz_env !== "") {
supportString += `* TZ environment: ${dj.tz_env}\n`;
}
supportString += `* Browser/Server Time Check: ${timeCheck}\n`;
supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`;
supportString += `* Domain Configuration Check: ${domainCheck}\n`;
supportString += `* HTTPS Check: ${httpsCheck}\n`;
supportString += `* Browser/Server Time Check: ${chk(timeCheck)}\n`;
supportString += `* Server/NTP Time Check: ${chk(ntpTimeCheck)}\n`;
supportString += `* Domain Configuration Check: ${chk(domainCheck)}\n`;
supportString += `* HTTPS Check: ${chk(httpsCheck)}\n`;
if (dj.enable_websocket) {
supportString += `* Websocket Check: ${websocketCheck}\n`;
supportString += `* Websocket Check: ${chk(websocketCheck)}\n`;
} else {
supportString += "* Websocket Check: disabled\n";
}
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
if (dj.invalid_feature_flags != "") {
supportString += `* Invalid feature flags: true\n`;
supportString += "* Invalid feature flags: true\n";
}
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {

View file

@ -1,6 +1,5 @@
"use strict";
/* eslint-env es2017, browser, jquery */
/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
/* global DataTable, _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
function deleteOrganization(event) {
event.preventDefault();
@ -42,8 +41,9 @@ function initActions() {
// onLoad events
document.addEventListener("DOMContentLoaded", (/*event*/) => {
jQuery("#orgs-table").DataTable({
"drawCallback": function() {
const columnCount = document.getElementById("orgs-table").querySelectorAll("thead th").length;
new DataTable("#orgs-table", {
"drawCallback": function () {
initActions();
},
"stateSave": true,
@ -54,7 +54,7 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
],
"pageLength": -1, // Default show all
"columnDefs": [{
"targets": [4,5],
"targets": [columnCount - 2, columnCount - 1], // Do not include the last two columns into the search/order features
"searchable": false,
"orderable": false
}]

View file

@ -1,5 +1,4 @@
"use strict";
/* eslint-env es2017, browser */
/* global _post:readable, BASE_URL:readable */
function smtpTest(event) {

View file

@ -1,6 +1,5 @@
"use strict";
/* eslint-env es2017, browser, jquery */
/* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */
/* global DataTable, _post:readable, _delete:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
function deleteUser(event) {
event.preventDefault();
@ -142,7 +141,7 @@ function inviteUser(event) {
);
}
function resendUserInvite (event) {
function resendUserInvite(event) {
event.preventDefault();
event.stopPropagation();
const id = event.target.parentNode.dataset.vwUserUuid;
@ -180,37 +179,9 @@ const ORG_TYPES = {
},
};
// Special sort function to sort dates in ISO format
jQuery.extend(jQuery.fn.dataTableExt.oSort, {
"date-iso-pre": function(a) {
let x;
const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim();
if (sortDate !== "") {
const dtParts = sortDate.split(" ");
const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"];
const dateParts = dtParts[0].split("-");
x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1;
if (isNaN(x)) {
x = 0;
}
} else {
x = Infinity;
}
return x;
},
"date-iso-asc": function(a, b) {
return a - b;
},
"date-iso-desc": function(a, b) {
return b - a;
}
});
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
// Fill the form and title
userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
// Get shared values
const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail;
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
@ -228,7 +199,7 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
}, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
userOrgTypeDialog.addEventListener("hide.bs.modal", function() {
userOrgTypeDialog.addEventListener("hide.bs.modal", function () {
document.getElementById("userOrgTypeDialogOrgName").textContent = "";
document.getElementById("userOrgTypeDialogUserEmail").textContent = "";
document.getElementById("userOrgTypeUserUuid").value = "";
@ -250,7 +221,7 @@ function updateUserOrgType(event) {
function initUserTable() {
// Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) {
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
const orgType = ORG_TYPES[e.dataset.vwOrgType];
e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) {
@ -286,12 +257,37 @@ function initUserTable() {
}
}
// Special sort function to sort dates in ISO format and have anything else as 0
DataTable.ext.type.order["date-iso-pre"] = function (a) {
let x;
const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim();
if (sortDate !== "") {
const dtParts = sortDate.split(" ");
const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"];
const dateParts = dtParts[0].split("-");
x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1;
if (isNaN(x)) {
x = 0;
}
} else {
x = Infinity;
}
return x;
};
// onLoad events
document.addEventListener("DOMContentLoaded", (/*event*/) => {
const size = jQuery("#users-table > thead th").length;
const ssoOffset = size-7;
jQuery("#users-table").DataTable({
"drawCallback": function() {
DataTable.ext.type.detect.unshift(function (data) {
if (typeof data !== "string") { return null; }
return data.indexOf("data-sort-type=\"date-iso\"") !== -1
? "date-iso"
: null;
});
const columnCount = document.getElementById("users-table").querySelectorAll("thead th").length;
new DataTable("#users-table", {
"typeDetect": true,
"drawCallback": function () {
initUserTable();
},
"stateSave": true,
@ -302,10 +298,7 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
],
"pageLength": -1, // Default show all
"columnDefs": [{
"targets": [1 + ssoOffset, 2 + ssoOffset],
"type": "date-iso"
}, {
"targets": size-1,
"targets": columnCount - 1, // Do not include the last column into the search/order features
"searchable": false,
"orderable": false
}]

View file

@ -4,25 +4,32 @@
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-2.3.8
* https://datatables.net/download/#bs5/dt-3.0.3
*
* Included libraries:
* DataTables 2.3.8
* DataTables 3.0.3
*/
/*! DataTables Bootstrap 5 integration
* © SpryMedia Ltd - datatables.net/license
*/
:root {
--dt-row-selected: 13, 110, 253;
--dt-row-selected-text: 255, 255, 255;
--dt-row-selected-link: 228, 228, 228;
--dt-row-stripe: 0, 0, 0;
--dt-row-hover: 0, 0, 0;
--dt-column-ordering: 0, 0, 0;
--dt-header-align-items: center;
--dt-header-vertical-align: middle;
--dt-html-background: white;
--dt_background-selected: 13, 110, 253;
--dt_color-selected: 255, 255, 255;
--dt_link_color-selected: 228, 228, 228;
--dt-row_background: transparent;
--dt-row_background-selected: var(--dt_background-selected);
--dt-row-text_color-selected: var(--dt_color-selected);
--dt-row-link_color-selected: var(--dt_link_color-selected);
--dt-row_background-stripe: 0, 0, 0;
--dt-row_background-hover: 0, 0, 0;
--dt-column-ordering_background: 0, 0, 0;
--dt-header-cell_align-items: center;
--dt-header-cell_vertical-align: middle;
--dt-html_background: white;
}
:root.dark {
--dt-html-background: rgb(33, 37, 41);
--dt-html_background: rgb(33, 37, 41);
}
table.dataTable tbody td.dt-control {
@ -44,16 +51,13 @@ table.dataTable tbody tr.dt-hasChild td.dt-control:before {
border-bottom: 0px solid transparent;
border-right: 5px solid transparent;
}
table.dataTable tfoot:empty {
display: none;
}
html.dark table.dataTable td.dt-control:before,
:root.dark table.dataTable td.dt-control:before,
:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
:root[data-theme=dark] table.dataTable td.dt-control:before {
border-left-color: rgba(255, 255, 255, 0.5);
}
html.dark table.dataTable tr.dt-hasChild td.dt-control:before,
:root.dark table.dataTable tr.dt-hasChild td.dt-control:before,
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
border-top-color: rgba(255, 255, 255, 0.5);
@ -84,6 +88,25 @@ div.dt-scroll-body tfoot tr td div.dt-scroll-sizing {
overflow: hidden !important;
}
/*! DataTables Bootstrap 5 integration
* © SpryMedia Ltd - datatables.net/license
*/
:root {
--dt-order-arrow_color: rgb(51, 51, 51);
--dt-order-arrow_color-current: rgb(51, 51, 51);
--dt-order-arrow-height: 7px;
--dt-order-arrow_opacity: 0.125;
--dt-order-arrow_opacity-current: 0.65;
--dt-order-arrow-width: 8px;
--dt-order-arrow-gap: 1px;
--dt-order-header_outline-hover: 2px solid rgba(0, 0, 0, 0.05);
}
:root.dark, :root[data-bs-theme=dark], :root[data-theme=dark] {
--dt-order-arrow_color: rgb(229, 233, 238);
--dt-order-arrow_color-current: rgb(229, 233, 238);
--dt-order-header_outline-hover: 2px solid rgba(255, 255, 255, 0.05);
}
table.dataTable thead > tr > th:active,
table.dataTable thead > tr > td:active {
outline: none;
@ -91,20 +114,18 @@ table.dataTable thead > tr > td:active {
table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before,
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before,
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before {
position: absolute;
display: block;
bottom: 50%;
content: "\25B2";
content: "\25B2"/"";
bottom: calc(50% + var(--dt-order-arrow-gap));
border-bottom: var(--dt-order-arrow-height) solid var(--dt-order-arrow_color);
border-left: calc(var(--dt-order-arrow-width) / 2) solid transparent;
border-right: calc(var(--dt-order-arrow-width) / 2) solid transparent;
}
table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after,
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
position: absolute;
display: block;
top: 50%;
content: "\25BC";
content: "\25BC"/"";
top: calc(50% + 1px);
border-top: var(--dt-order-arrow-height) solid var(--dt-order-arrow_color);
border-left: calc(var(--dt-order-arrow-width) / 2) solid transparent;
border-right: calc(var(--dt-order-arrow-width) / 2) solid transparent;
}
table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order,
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order,
@ -112,8 +133,8 @@ table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order,
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order,
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order {
position: relative;
width: 12px;
height: 20px;
width: var(--dt-order-arrow-width);
align-self: stretch;
}
table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before,
@ -124,10 +145,14 @@ table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before,
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:after,
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:before,
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
position: absolute;
display: block;
content: " ";
height: 0;
width: 0;
left: 0;
opacity: 0.125;
line-height: 9px;
font-size: 0.8em;
color: var(--dt-order-arrow_color);
opacity: var(--dt-order-arrow_opacity);
}
table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc,
table.dataTable thead > tr > td.dt-orderable-asc,
@ -137,13 +162,18 @@ table.dataTable thead > tr > td.dt-orderable-desc {
table.dataTable thead > tr > th.dt-orderable-asc:hover, table.dataTable thead > tr > th.dt-orderable-desc:hover,
table.dataTable thead > tr > td.dt-orderable-asc:hover,
table.dataTable thead > tr > td.dt-orderable-desc:hover {
outline: 2px solid rgba(0, 0, 0, 0.05);
outline: var(--dt-order-header_outline-hover);
outline-offset: -2px;
}
table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before,
table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before,
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before {
border-bottom-color: var(--dt-order-arrow_color-current);
opacity: var(--dt-order-arrow_opacity-current);
}
table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
opacity: 0.6;
border-top-color: var(--dt-order-arrow_color-current);
opacity: var(--dt-order-arrow_opacity-current);
}
table.dataTable thead > tr > th.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty, table.dataTable thead > tr > th.sorting_desc_disabled .dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled .dt-column-order:before,
table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty,
@ -166,7 +196,7 @@ table.dataTable tfoot > tr > td div.dt-column-header,
table.dataTable tfoot > tr > td div.dt-column-footer {
display: flex;
justify-content: space-between;
align-items: var(--dt-header-align-items);
align-items: var(--dt-header-cell_align-items);
gap: 4px;
}
table.dataTable thead > tr > th div.dt-column-header .dt-column-title,
@ -202,7 +232,14 @@ div.dt-scroll-body > table.dataTable > thead > tr > td {
:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-desc:hover,
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-asc:hover,
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-desc:hover {
outline: 2px solid rgba(255, 255, 255, 0.05);
outline: var(--dt-order-header_outline-hover);
}
/*! DataTables Bootstrap 5 integration
* © SpryMedia Ltd - datatables.net/license
*/
:root {
--dt-processing-circle_background: var(--dt_background-selected);
}
div.dt-processing {
@ -228,8 +265,7 @@ div.dt-processing > div:last-child > div {
width: 13px;
height: 13px;
border-radius: 50%;
background: rgb(13, 110, 253);
background: rgb(var(--dt-row-selected));
background: rgb(var(--dt-processing-circle_background));
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
div.dt-processing > div:last-child > div:nth-child(1) {
@ -342,7 +378,7 @@ table.dataTable thead td,
table.dataTable tfoot th,
table.dataTable tfoot td {
text-align: left;
vertical-align: var(--dt-header-vertical-align);
vertical-align: var(--dt-header-cell_vertical-align);
}
table.dataTable thead th.dt-head-left,
table.dataTable thead td.dt-head-left,
@ -425,11 +461,16 @@ table.dataTable tbody td.dt-body-nowrap {
white-space: nowrap;
}
/*! Bootstrap 5 integration for DataTables
*
* ©2020 SpryMedia Ltd, all rights reserved.
* License: MIT datatables.net/license/mit
*/
:root {
--dt_background-selected: 13, 110, 253;
}
:root[data-bs-theme=dark] {
--dt-row_background-hover: 255, 255, 255;
--dt-row_background-stripe: 255, 255, 255;
--dt-column-ordering_background: 255, 255, 255;
}
table.table.dataTable {
clear: both;
margin-bottom: 0;
@ -443,31 +484,26 @@ table.table.dataTable > :not(caption) > * > * {
background-color: var(--bs-table-bg);
}
table.table.dataTable > tbody > tr {
background-color: transparent;
background-color: var(--dt-row_background);
}
table.table.dataTable > tbody > tr.selected > * {
box-shadow: inset 0 0 0 9999px rgb(13, 110, 253);
box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected));
color: rgb(255, 255, 255);
color: rgb(var(--dt-row-selected-text));
box-shadow: inset 0 0 0 9999px rgb(var(--dt-row_background-selected));
color: rgb(var(--dt-row-text_color-selected));
}
table.table.dataTable > tbody > tr.selected a {
color: rgb(228, 228, 228);
color: rgb(var(--dt-row-selected-link));
color: rgb(var(--dt-row-link_color-selected));
}
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-stripe), 0.05);
}
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1).selected > * {
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.95);
}
table.table.dataTable.table-hover > tbody > tr:hover > * {
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-hover), 0.075);
}
table.table.dataTable.table-hover > tbody > tr.selected:hover > * {
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.975);
}
div.dt-container div.dt-layout-start > *:not(:last-child) {
@ -616,10 +652,4 @@ div.table-responsive > div.dt-container > div.row > div[class^=col-]:last-child
padding-right: 0;
}
:root[data-bs-theme=dark] {
--dt-row-hover: 255, 255, 255;
--dt-row-stripe: 255, 255, 255;
--dt-column-ordering: 255, 255, 255;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,16 @@
<span class="d-block"><b>No</b></span>
{{/unless}}
</dd>
<dt class="col-sm-5">Uses custom templates</dt>
<dd class="col-sm-7">
{{#if page_data.template_overrides}}
<span class="d-inline"><b>Yes</b></span>
<span class="badge bg-info text-dark abbr-badge" title="Custom template files are used.&#013;&#010;{{page_data.template_overrides}}">Details</span>
{{/if}}
{{#unless page_data.template_overrides}}
<span class="d-block"><b>No</b></span>
{{/unless}}
</dd>
<dt class="col-sm-5">Uses a reverse proxy</dt>
<dd class="col-sm-7">
{{#if page_data.ip_header_exists}}

View file

@ -10,7 +10,7 @@
<th class="vw-entries">Entries</th>
<th class="vw-attachments">Attachments</th>
<th class="vw-misc">Misc</th>
<th class="vw-actions">Actions</th>
<th class="vw-actions text-end">Actions</th>
</tr>
</thead>
<tbody>
@ -59,7 +59,6 @@
</main>
<link rel="stylesheet" href="{{urlpath}}/vw_static/datatables.css" />
<script src="{{urlpath}}/vw_static/jquery-4.0.0.slim.js"></script>
<script src="{{urlpath}}/vw_static/datatables.js"></script>
<script src="{{urlpath}}/vw_static/admin_organizations.js"></script>
<script src="{{urlpath}}/vw_static/jdenticon-3.3.0.js"></script>

View file

@ -14,7 +14,7 @@
<th class="vw-entries">Entries</th>
<th class="vw-attachments">Attachments</th>
<th class="vw-organizations">Organizations</th>
<th class="vw-actions">Actions</th>
<th class="vw-actions text-end">Actions</th>
</tr>
</thead>
<tbody>
@ -47,10 +47,10 @@
</td>
{{/if}}
<td>
<span class="d-block">{{created_at}}</span>
<span class="d-block" data-sort-type="date-iso">{{created_at}}</span>
</td>
<td>
<span class="d-block">{{last_active}}</span>
<span class="d-block" data-sort-type="date-iso">{{last_active}}</span>
</td>
<td>
<span class="d-block">{{cipher_count}}</span>
@ -153,7 +153,6 @@
</main>
<link rel="stylesheet" href="{{urlpath}}/vw_static/datatables.css" />
<script src="{{urlpath}}/vw_static/jquery-4.0.0.slim.js"></script>
<script src="{{urlpath}}/vw_static/datatables.js"></script>
<script src="{{urlpath}}/vw_static/admin_users.js"></script>
<script src="{{urlpath}}/vw_static/jdenticon-3.3.0.js"></script>

View file

@ -0,0 +1,12 @@
Admin account recovery from {{org_name}} organization
<!---------------->
{{#if reset_password}}
The master password for {{user_name}} has been changed.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}

View file

@ -1,10 +1,17 @@
Master Password Has Been Changed
Admin account recovery from {{org_name}} organization
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed by an administrator in your <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization. If you did not initiate this request, please reach out to your administrator immediately.
{{#if reset_password}}
The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
<br>
If you did not initiate this request, please reach out to your administrator immediately.
</td>
</tr>
</table>

View file

@ -1,4 +0,0 @@
Master Password Has Been Changed
<!---------------->
The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}

View file

@ -67,7 +67,7 @@ pub(crate) fn operator_for_path(path: &str) -> Result<opendal::Operator, crate::
s3::operator_for_path(path)?
} else {
let builder = opendal::services::Fs::default().root(path);
opendal::Operator::new(builder)?.finish()
opendal::Operator::new(builder)?
};
OPERATORS_BY_PATH.insert(path.to_owned(), operator.clone());
@ -77,10 +77,18 @@ pub(crate) fn operator_for_path(path: &str) -> Result<opendal::Operator, crate::
#[cfg(s3)]
mod s3 {
use std::sync::LazyLock;
use opendal_http_transport_reqwest::ReqwestTransport;
use reqwest::Url;
use crate::error::Error;
static HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
// Storage endpoints are administrator-configured and may be private.
crate::http_client::get_reqwest_client_builder(false).build().expect("Failed to build OpenDAL HTTP client")
});
pub(super) fn is_uri(path: &str) -> bool {
path.starts_with("s3://")
}
@ -177,12 +185,7 @@ mod s3 {
let chain = DEFAULT_CREDENTIAL_CHAIN
.get_or_init(|| {
let reqwest_client = reqwest::Client::builder().build().unwrap();
let connector = AwsReqwestConnector {
client: reqwest_client,
};
let conf = ProviderConfig::default().with_http_client(connector);
let conf = ProviderConfig::default().with_http_client(AwsReqwestConnector::new());
DefaultCredentialsChain::builder().configure(conf).build()
})
@ -236,7 +239,9 @@ mod s3 {
builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider));
}
Ok(opendal::Operator::new(builder)?.finish())
let http_transport = opendal::HttpTransporter::new(ReqwestTransport::new(HTTP_CLIENT.clone()));
let context = opendal::OperationContext::new().with_http_transport(http_transport);
Ok(opendal::Operator::new(builder)?.with_context(context))
}
fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool {

View file

@ -257,6 +257,44 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
}
}
pub struct EtagCached<R> {
response: R,
etag: String,
}
impl<R> EtagCached<R> {
/// An `etag` response should always be quoted
pub fn new(response: R, etag: &str) -> Self {
Self {
response,
etag: format!("\"{etag}\""),
}
}
}
impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagCached<R> {
fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
// Check and validate a `If-None-Match` ETag header
// Multiple tags could be returned for the same URI if the browser has multiple versions cached
// Also, weak tags are prefixed with `W/`, but ETags are always weak, so just strip it too before comparing
let etag_matches = request
.headers()
.get_one("If-None-Match")
.is_some_and(|v| v.split(',').any(|t| t.trim().trim_start_matches("W/") == self.etag));
let mut res = if etag_matches {
Response::build().status(Status::NotModified).ok()?
} else {
self.response.respond_to(request)?
};
// Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control
res.set_raw_header("Etag", self.etag);
res.set_raw_header("Cache-Control", "public, no-cache");
Ok(res)
}
}
// Log all the routes from the main paths list, and the attachments endpoint
// Effectively ignores, any static file route, and the alive endpoint
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
@ -499,10 +537,7 @@ pub fn is_valid_email(email: &str) -> bool {
let Ok(email_url) = url::Url::parse(&format!("https://{}", email.domain())) else {
return false;
};
if email_url.path().ne("/") || email_url.domain().is_none() || email_url.query().is_some() {
return false;
}
true
email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none()
}
//