Compare commits

...
92 Commits
Author SHA1 Message Date
Mathijs van Veluw 7c188967e7 Update crates, Rust and other dependencies (#7814)
- Update Rust to v1.99.0
- Updated GitHub Actions
- Updated DataTables

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-05 14:39:21 +02:00
Daniel García 895ec2b1e7 Align API behaviour with upstream and return errors on malformed input (#7810)
- Attachments: reject a declared v2 attachment size above the upload limit,
  keep display sizes within the largest unit, and make download links valid
  for 1 minute like upstream.
- Ciphers: moving a personal cipher into an organization needs collection
  write access or full access, like upstream.
- Events: /events/collect only accepts the client event types upstream accepts.
- Client IP: for list headers like X-Forwarded-For, use the rightmost address
  that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence.
- Prelogin: unknown emails get stable KDF settings from upstream's list.
- Login with device: an auth request can be used for one login, like upstream.
- Directory Connector import: never revokes, restores or removes Owners,
  like upstream.
- SSO: check the PKCE verifier again when the code exchange is reused during
  2FA, and accept the same redirect URIs as upstream (desktop localhost
  callback for AppImage and dev builds, CLI on ports 8065-8070).
- Identity: send the merged master password policy in the 2FA response and
  use PascalCase policy keys in identity responses, like upstream.
  Organization API key login no longer needs device fields. Recovery code
  login normalises the code, uses upstream's error message, logs a failed 2FA
  event and sends a "two-step login recovered" email.
- Add GET /api/accounts/keys.
- Return errors instead of panicking on malformed event dates, cipher data,
  key rotation cipher ids, bulk confirm entries and YubiKey OTPs.
2026-10-05 14:38:01 +02:00
Daniel García 1f99d9d276 Remove legacy API endpoints and compatibility code dropped upstream (#7809)
Remove endpoints, response fields, feature flags and compatibility code
that upstream Bitwarden has removed and that no current client uses:

- Legacy POST /identity/accounts/register and POST /api/accounts/prelogin;
  register/finish is rate limited like the other unauthenticated endpoints.
- Legacy organization routes: PUT groups/{id}/users, PUT policies/{type}/vnext,
  PUT users/{id}/reset-password, GET billing/metadata, and the POST aliases of
  the organization, collection, group and member PUT/DELETE routes.
- POST /two-factor/disable (PUT stays).
- ResetMasterPassword, captchaBypassToken, the legacy registration name field,
  userIsManagedByOrganization/managedByOrganization and accessAll.
- The automatic email 2FA code for clients older than 2025.5.0.
- DUO_USE_IFRAME and the Duo Traditional Prompt code.
- Experimental client feature flags that no client reads anymore.
- Web vault CSS selectors for web vaults older than 2025.5.1.

Also accept the PascalCase fields Android 2026.9.0+ sends on registration
and password change, and align a few account, login/2FA and organization
checks with upstream.

Adds tests for the registration body formats.
2026-10-04 22:16:55 +02:00
Daniel García f4f1a8e105 Send API cleanup: remove legacy endpoints and align access with upstream (#7806) 2026-10-03 23:05:56 +02:00
1c89e1772b 2fa email fallback need a verified email (#7770)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
Co-authored-by: Mathijs van Veluw <black.dex@gmail.com>
2026-10-03 14:01:26 +02:00
Tom 3714b504f6 Fix Clippy warnings across all targets (#7782) 2026-10-03 13:50:29 +02:00
Tom f9f011d50c Hide the whole change-email section when EMAIL_CHANGE_ALLOWED is false (#7759) 2026-10-03 13:49:53 +02:00
Julian Speckmann cb89088bc3 Add Windows native credential sync to supported feature flags (#7798) 2026-10-03 13:49:44 +02:00
Pier Carlo Cadoppi 4444442075 Add undetermined-cipher-scenario-logic feature flag (#7802)
Introduced in Browser 2026.2.0 via bitwarden/clients#18395. Rewrites the
post-submit add/update login notification triggering logic, deferring
cipher-type inference until the vault is unlocked. Client-side only.
2026-10-03 13:49:37 +02:00
Mathijs van Veluw 061694d0cb Fix cortex-a53 build issues when using xx-cargo (#7774)
When using xx-cargo it uses a different linker then the default one.
This causes some default fixes Debian/Ubuntu add for the Cortex-A53
architectures to not be included.

This commit fixes that by checking if the target architecture is arm64
and if so, will add the needed fix argument. This should prevent issues
reported here #7754.

We can not say this with 100% certainty since this bug sometimes appears
and sometimes it does not.

Fixes #7754

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-25 21:53:06 +02:00
Daniel García 32098ca7d1 Require confirmed membership for user access checks, not for admin views (#7763) 2026-09-23 23:22:43 +02:00
Abhi ShandilyaandDaniel García d2660324e6 Fix revoked org members retaining access to org ciphers (#7554)
The cipher access restriction queries (direct collection access, group
collection access, and full access via groups) never checked the
organization membership status. Since users_collections and groups_users
rows are kept when a membership is revoked, revoked (and not yet
confirmed) members kept read, write, delete, attachment and
collection-move access to org ciphers via the direct-by-UUID endpoints.

Require a confirmed membership in the cipher's organization in all
three queries, matching the behavior of the sync queries.

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-09-23 19:44:05 +02:00
Stefan Melmuk cc67d644f6 set user_created bool for each separate invitation (#7753) 2026-09-18 20:35:11 +02:00
Berk D. Demir 3347698712 Add pm-34171-card-scanner feature flag (#7477)
Introduced in 2026.4.1 Mobile releases (Android and iOS), enables use of
device camera to autofill a card type item.

N.B. This feature relies on Google ML Kit. F-Droid policy disallows
Google ML Kit. Because of this, F-Droid builds do not ship with card
scanner feature. (See https://github.com/bitwarden/android/pull/6890)
2026-09-18 20:34:41 +02:00
Mathijs van Veluw a24683636a Update Crates, GHA and JS (#7751)
Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-18 18:44:50 +02:00
Berk D. Demir 64c56411f6 Add pm-32009-new-item-types feature flag (#7478)
Introduced in 2026.4.0 for all clients, enables new item types bank
account, driver's license, and passport.
2026-09-18 16:53:27 +02:00
Tom c3f5477525 Add accepted organization data to sync response (#7666) 2026-09-18 16:53:17 +02:00
TimshelandTimshel 9c8aa2359f User key id (#7693)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-09-18 16:53:06 +02:00
Tom 8719f86fcf Add basic auth response client feature flag (#7745) 2026-09-18 16:52:57 +02:00
Tom fddc8e71be Support the vault banner policy (#7748) 2026-09-18 16:52:49 +02:00
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
TimshelandTimshel 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 DouglasandBlackDex 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
TimshelandTimshel 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
TimshelandTimshel 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
TimshelandTimshel 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
TimshelandTimshel 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 HornandMatt 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
lmogthbandAlejandro Olmos 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
TimshelandTimshel 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. FoxandClaude Opus 5 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
Daniel García 46ae59eaf4 Trusted proxy support, unauthenticated rate limit & other fixes (#7472)
* Trusted proxies, unauthenticated rate limits and various fixes

* Fix get_groups_data

* Fix get_groups_data when not using full_access

* Fmt

* Fix org import

* deduplicate send validation
2026-07-24 18:27:32 +02:00
Mathijs van Veluw a6a88e7929 Update API response, crates and GHA (#7470)
- Updated API response to more closely match v2026.6.0+ server versions.
- Updated all the crates
- Updated Rust to v1.97.1
- Updated the web-vault to v2026.6.4
- Updated GitHub Actions

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-07-24 17:33:10 +02:00
TimshelandTimshel 5040bcb7c0 Remove unused fields (#7458)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-24 16:40:34 +02:00
Tom 660faee68e Fix custom role dialog selectors (#7442) 2026-07-21 22:06:45 +02:00
Daniel 683a23e43c Fix compilation with newer rust-musl version (#7453) 2026-07-21 21:54:10 +02:00
TimshelandTimshel 4a9bcb0694 Remove old compatibility code (#7434)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-21 19:59:40 +02:00
Mathijs van Veluw 169aa5efcc Misc updates and fixes (#7406)
* Misc updates and fixes

- Updated Rust to v1.96.1
- Updated all the crates
- Updated GitHub Actions
- Updated the web-vault to v2026.6.2
- Updated Alpine to v3.24
- Fixed several clippy lints
- The `send` UUID wrappers didn't need the special namespace anymore since an updated crate, so removed this extra mod.

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

* Update MSRV to v1.94.1

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-07-08 22:10:29 +02:00
Denis Pisarev 64d28ab66e improve CI (#6991)
* ci: remove dead BASE_TAGS reference in release bake step

  steps.determine-version doesn't exist in docker-build; the expression
  resolves to empty string. The HCL default (testing) would have
  applied, but it's moot - the bake uses push-by-digest=true so tags are
  only set in merge-manifests. Dead code.

* ci: replace unsecured curl hadolint download with an official action

  hadolint/hadolint-action uses a Docker-based runner with hadolint
  pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian,so no binary
  downloaded at runtime. Pinning the action to a commit SHA covers the
  Dockerfile that specifies the image version, closing the supply-chain
  gap from the previous unverified curl | sudo install.

  Split {debian,alpine}: the action takes a single dockerfile argument,
  so debian and alpine are linted separately.

* ci: pin ubuntu-latest to ubuntu-24.04 in merge-manifests and zizmor

  ubuntu-latest is a moving target that can silently change the runner OS
  on the next GitHub-side update. All other jobs in this repo already pin
  to ubuntu-24.04; this makes merge-manifests and zizmor consistent.

* ci: return BASE_TAGS - it's needed for bake step
2026-07-08 22:08:20 +02:00
pilotstewandClaude Opus 4.8 4720cdbe86 Add pm-26340-linux-biometrics-v2 feature flag (#7358)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:59:57 +02:00
TimshelandTimshel 5447ee6af2 SSO use ClientSecretPost if ClientSecretBasic is not available (#7357)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:48 +02:00
TimshelandTimshel 5c5e8e1a6f 2026.6.0 send support (#7346)
* 2026.6.0 send support

* Prevent creating and editing a Send with email verification

* Review fixes

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:36 +02:00
TimshelandTimshel 7320a1db4b PutPolicy now using vnext format (#7296)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:26 +02:00
TimshelandTimshel a058a35ccd [v2026.5.0] Registration request update (#7295)
* Registration request update

* Review fix

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:17 +02:00
TimshelandTimshel a16b5afaaa Org membership delete remove Invitation (#7284)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:06 +02:00
kvdb fddc16d2b8 fix(sends): emit hideEmail as non-null boolean in sync response (#7283)
The /api/sync response serialized a Send hide_email field directly from
Option<bool>, so a NULL value in the sends table (the column is
Nullable<Bool> with no default) produced "hideEmail": null.

The Bitwarden Android client deserializes SyncResponseJson.Send.hideEmail
as a non-null Kotlin Boolean and aborts the entire sync with a
JsonDecodingException when it encounters null. Web, desktop and CLI
clients coerce null to false, so only accounts with at least one Send
are affected and only on Android.

Default None to false at the serialization boundary, matching the
official Bitwarden server where hideEmail is non-nullable. This needs no
database migration and fixes both legacy NULL rows and any future NULLs.
The hide_email field stays Option<bool> internally.
2026-07-07 15:58:54 +02:00
TimshelandTimshel ec7fa137b7 Admin password recovery endpoint change (#7270)
* Admin password recovery endpoint change

* Use default to keep compatibility

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:58:41 +02:00
TimshelandTimshel b25f715364 Fix enforce blocked (#7246)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:58:34 +02:00
Mathijs van Veluw d6a3d539ed Update Rust, Crates and GHA (#7307)
- Updated Rust to v1.96.0
- Updated all the crates, and adjusted code where needed
- Fixed some nightly reported clippy lints
- Updated all the GitHub actions

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-06-05 21:52:52 +02:00
user71424q d626ea81ab Serve Apple app site association file (#7191) 2026-05-17 21:46:10 +02:00
Mathijs van Veluw 1ba2c6a26c Switch to Edition 2024, more clippy lints, and less macro calls (#7200)
* Update to Rust 2024 Edition

Updated to the Rust 2024 Edition and added and fixed several lint checks.
This is a large change which, because of the extra lints, added some possible fixes for issues.

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

* Reorder and merge imports

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

* Remove "db_run!" macro calls where possible

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-05-17 19:38:49 +02:00
Mathijs van Veluw 22f5e0496c Updates and fixes (#7235)
* Update crates and gha

Updated all the crates
Updated GitHub Actions

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

* Fix restoring revoked user

A new endpoint is used to restore a revoked user.
This commit fixes that.

Fixes #7224

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

* Update datatables

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-05-17 00:43:58 +02:00
Daniel 70f9dfbe8b Switch to xx-cargo (#6640)
- removes a lot of additional configuration lines from the Dockerfile
- includes the workaround for the `openssl-sys` build issues with improper `pkg-config` setup
- for reference: https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
2026-05-16 21:19:01 +02:00
mfw78 54895ad4be Reject unrecognised DATABASE_URL instead of silent SQLite fallback (#7061)
* Panic on unrecognised DATABASE_URL instead of silent SQLite fallback

Previously, any DATABASE_URL that did not match the mysql: or postgresql:
prefix was silently treated as a SQLite file path. This caused data loss
in containerised environments when the URL was misconfigured (typos,
quoting issues), as vaultwarden would create an ephemeral SQLite database
that was wiped on restart.

Now, an explicit sqlite:// prefix is supported and used as the default.
Bare paths without a recognised scheme are still accepted for backwards
compatibility, but only if the database file already exists. If not, the
process panics with a clear error message.

Relates to #2835, #1910, #860.

* Use err!() instead of panic!() for unrecognised DATABASE_URL

Follow the established codebase convention where configuration
validation errors use err!() to propagate gracefully, rather than
panic!(). The error propagates through from_config() and is caught
by create_db_pool() which logs and calls exit(1).

* Use 'scheme' instead of 'prefix' in DATABASE_URL messages

Per review feedback, 'scheme' is the more accurate term for the
sqlite:// portion of the URL.
2026-05-16 21:18:53 +02:00
TimshelandTimshel a057c7deae sso_auth improvements (#7197)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-05-16 21:18:46 +02:00
Stefan Melmuk 2f85b62d2f fix email 2fa for bw cli (#7225) 2026-05-16 21:18:39 +02:00
Mathijs van Veluw 9bc14e6e77 Fix SSO Cookie path (#7187)
Signed-off-by: BlackDex <black.dex@gmail.com>
2026-05-15 20:31:58 +02:00
Chase Douglas cdf711bb30 OpenDAL S3 parameter support (#6127)
* deps: upgrade the reqwest stack to 0.13

The reqwest 0.13 rustls feature selects the aws-lc provider. Use
rustls-no-provider instead, add rustls 0.23 with the ring provider, and
install that provider at process startup. This keeps Vaultwarden on the
existing ring crypto provider while giving reqwest, OpenDAL and lettre a
process-wide rustls provider.

Disable openidconnect default features and provide a small
AsyncHttpClient wrapper around Vaultwarden's shared reqwest client
builder. This preserves custom DNS, request blocking, timeouts and the
no-redirect OIDC behavior without openidconnect enabling its own reqwest
stack.

Upgrade yubico_ng to 0.15.0 and OpenDAL to 0.56.0. OpenDAL 0.56 also
moves S3 signing to reqsign 3, so switch the optional S3 dependencies
from reqsign/anyhow to reqsign-core and reqsign-aws-v4 and adapt the AWS
SDK credential bridge to the new ProvideCredential API.

Adjust the local OpenDAL call sites for the 0.56 API: use the FS_SCHEME
constant for filesystem checks and replace deprecated remove_all() with
delete_with(...).recursive(true) for Send file cleanup.

* storage: add OpenDAL S3 URI options

OpenDAL S3 storage accepts bucket and root path data today, but
serverless deployments also need URI query parameters to describe provider
behavior in one DATA_FOLDER value.

Update OpenDAL to 0.56.0 and build S3 operators with
S3Config::from_uri(). Keep Vaultwarden's AWS SDK credential chain by
installing a reqsign provider when the URI does not explicitly request
OpenDAL-native credential handling.

Move path handling and operator construction into storage.rs so S3-specific
parsing, credential setup, and URI path manipulation stay out of
configuration handling. Local filesystem behavior is unchanged, and S3
child paths are derived before query strings.
2026-05-15 20:30:31 +02:00
Mathijs van Veluw f21a3adae2 Update hickory (#7175)
Signed-off-by: BlackDex <black.dex@gmail.com>
2026-05-02 18:56:15 +02:00
Mathijs van Veluw 07aa377af7 Update crates and web-vault (#7171)
- Update crates including fixing a regression of Diesel
- Update web-vault to v2026.4.1
- Adjusted the README to address the secure context and needing HTTPS

Fixes #7132
Closes #7137

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-30 21:45:45 +02:00
Eldred HabertandDaniel García 14258caec9 Allow SQLite to be linked against dynamically (#7057)
Keeping the default behaviour of SQLite being built statically,
so as not to break anyone's workflow, but allowing for downstream
packagers to link dynamically against SQLite (where it's fine because
that's the point of package managers).

Note that SQLite is still *not* enabled by default, thanks to the `?` operator.

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-04-29 22:59:18 +02:00
cb46fcb948 fix: return Err instead of panic on unknown cipher atype in to_json() (#7068)
`Cipher::to_json()` returns `Result<Value, Error>` but its match arm for
unknown `atype` values called `panic!("Wrong type")` instead of
propagating an error. This means if a cipher with an invalid/unknown type
ends up in the database (via direct DB edits, data migration issues, or
future type additions in the upstream Bitwarden protocol), the entire
server process would crash on the next sync request.

Replace the `panic!` with `err!()` so callers receive a proper `Err` and
can handle or log it gracefully without taking down the server.

Co-authored-by: easonysliu <easonysliu@tencent.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-04-29 22:58:50 +02:00
Johny JiménezandDaniel García b89648a136 Replace organization_uuid unwrap with proper error handling (#6936)
The collection update endpoints (post_collections_update and
post_collections_admin) call .unwrap() on cipher.organization_uuid
in four places. If a user-owned cipher without an organization
somehow reaches these code paths, the server would panic.

Extract the organization UUID early with a descriptive error message
instead of relying on .unwrap(), preventing potential panics and
providing a clear API error response.

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-04-29 22:58:39 +02:00
Daniel García c3bd1eb565 Fix merge conflict (#7164) 2026-04-29 22:47:42 +02:00
Shocker 8c3c969938 Fix favicon fetching to check all icon links instead of just the first one (#6880)
* Fix favicon fetching to check all icon links instead of just the first one

* revert max icons limit removal

* optimize code

* code formatting
2026-04-29 22:32:48 +02:00
Matt Aaron 38a6850b8d Add support for archiving items (#6916)
* Add archiving

* Update Diesel macros and remove unnecessary SUPPORTED_FEATURE_FLAG

* Add IF EXISTS to down.sql migratinos

* Rename migration folders, separate logic based on PR threads
2026-04-29 22:29:42 +02:00
Mathijs van Veluw d297e274a3 Several SSO Fixes (#7163)
* Ensure SSO token is only usable on the same client

This commit adds an extra check via cookies to ensure the same browser/client is used to request and provide the SSO token.
Previously it would be able to provide a custom link which attackers could use to steal data.
While an attacker would still need the Master Password to be able to decrypt or execute specific actions, they were able to fetch encrypted data.

Solved with some help of Claude Code.

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

* Check email-verified on SSO login/create

This commit prevents possible account takeover via SSO which doesn't check/validate or provide validated status of the email.
It was checked at other locations, but was skipped here.

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

* Prevent data disclosure via SSO endpoints

This commit prevents some data disclosure and user enumeration by only returning the fake SSO identifier.
Since we do not check the identifier anywhere useful, returning the fake one is just fine.

During an invite to an org, that link contains the correct UUID and will be used for the master password requirements.
For anything else, server admins should set the `SSO_MASTER_PASSWORD_POLICY` env variable.

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

* Adjust admin layout to fix issues when SSO is enabled

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-29 22:25:36 +02:00
Mathijs van Veluw a354e57659 Fix Host/IP resolving (#7162)
IPv4 addresses can also be in decimal or hex formats.
These were not checked during the Global IP check, and could bypass it.

We now convert everything to the right format before running this check and it will catch these formats.

Also updated the `is_global()` function to match Rust's still unstable version.
And updated the Image Magic checks to be more precise and filter out any possible broken or invalid formats.

While at it, also added several checks to ensure these special formatted IPv4 addresses are still blocked and punycode domains are also correctly resolved.

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-29 22:20:59 +02:00
Mathijs van Veluw 5cc7360816 Update crates and fix a nightly lint (#7161)
Updated all the crates including two which reported a possible CVE
Updated Typos

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-04-29 22:10:26 +02:00
TimshelandTimshel 62748100f0 Fix hardcoded sso identifier (#7157)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-04-28 19:09:47 +02:00
Daniel fcbdebd6d7 Apply ref_option lint findings (#7143)
Quote from the lint description:
"More flexibility, better memory optimization, and more idiomatic Rust code.

&Option<T> in a function signature breaks encapsulation because the caller must own T and move it into an Option to call with it. When returned, the owner must internally store it as Option<T> in order to return it. At a lower level, &Option<T> points to memory with the presence bit flag plus the T value, whereas Option<&T> is usually optimized to a single pointer, so it may be more optimal."
2026-04-28 18:34:40 +02:00
Daniel 454b8e2a35 Apply duration_suboptimal_units lint findings (#7144)
Quote from lint description:
"Using a smaller unit for a duration that is evenly divisible by a larger unit reduces readability. Readers have to mentally convert values, which can be error-prone and makes the code less clear."
2026-04-28 18:34:15 +02:00
Daniel 7883da554e Add DuckDuckGo browser device type (#7147)
- sync with upstream
2026-04-28 18:34:03 +02:00
Stefan Melmuk fd2b6528a9 add new /identity/accounts/prelogin/password (#7156) 2026-04-28 18:33:52 +02:00
TimshelandTimshel cc57e60886 Dummy identifier need to pass for a guid (#7154)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-04-28 18:33:49 +02:00
TimshelandTimshel e5681258f0 SSO fallback to UserInfo preferred_username (#7128)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-04-28 18:33:45 +02:00
163 changed files with 22696 additions and 28435 deletions

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
// Ignore everything # Ignore everything
* *
// Allow what is needed # Allow what is needed
!.git !.git
!docker/healthcheck.sh !docker/healthcheck.sh
!docker/start.sh !docker/start.sh
+39 -22
View File
@@ -50,10 +50,11 @@
######################### #########################
## Database URL ## Database URL
## When using SQLite, this is the path to the DB file, and it defaults to ## When using SQLite, this should use the sqlite:// scheme followed by the path
## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this ## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3.
## must be set to a local sqlite3 file path. ## Bare paths without the sqlite:// scheme are supported for backwards compatibility,
# DATABASE_URL=data/db.sqlite3 ## but only if the database file already exists.
# DATABASE_URL=sqlite://data/db.sqlite3
## When using MySQL, specify an appropriate connection URI. ## When using MySQL, specify an appropriate connection URI.
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html ## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
# DATABASE_URL=mysql://user:password@host[:port]/database_name # DATABASE_URL=mysql://user:password@host[:port]/database_name
@@ -179,7 +180,7 @@
## Defaults to every minute. Set blank to disable this job. ## Defaults to every minute. Set blank to disable this job.
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *" # AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
## ##
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. ## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
## Defaults to every minute. Set blank to disable this job. ## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
# #
@@ -315,6 +316,14 @@
## unauthenticated access to potentially sensitive data. ## unauthenticated access to potentially sensitive data.
# SHOW_PASSWORD_HINT=false # 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 ### ### Advanced settings ###
######################### #########################
@@ -323,6 +332,16 @@
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP ## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
# IP_HEADER=X-Real-IP # IP_HEADER=X-Real-IP
## Which addresses the header above is accepted from, defaults to "local".
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
## proxy as IPs and CIDR ranges if it connects from a public address.
## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of
## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service ## Icon service
## The predefined icon services are: internal, bitwarden, duckduckgo, google. ## The predefined icon services are: internal, bitwarden, duckduckgo, google.
## To specify a custom icon service, set a URL template with exactly one instance of `{}`, ## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@@ -373,20 +392,13 @@
## ##
## The following flags are available: ## The following flags are available:
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0) ## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "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)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "pm-30529-webauthn-related-origins": ## - "pm-30529-webauthn-related-origins":
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS= # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
@@ -459,6 +471,13 @@
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2. ## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
# LOGIN_RATELIMIT_MAX_BURST=10 # LOGIN_RATELIMIT_MAX_BURST=10
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
## BETA FEATURE: Groups ## BETA FEATURE: Groups
## Controls whether group support is enabled for organizations ## Controls whether group support is enabled for organizations
## This setting applies to organizations. ## This setting applies to organizations.
@@ -493,6 +512,9 @@
## Prevent users from logging in directly without going through SSO ## Prevent users from logging in directly without going through SSO
# SSO_ONLY=false # 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 ## On SSO Signup if a user with a matching email already exists make the association
# SSO_SIGNUPS_MATCH_EMAIL=true # SSO_SIGNUPS_MATCH_EMAIL=true
@@ -555,11 +577,6 @@
# DUO_HOST=<API Hostname> # DUO_HOST=<API Hostname>
## After that, you should be able to follow the rest of the guide linked above, ## After that, you should be able to follow the rest of the guide linked above,
## ignoring the fields that ask for the values that you already configured beforehand. ## ignoring the fields that ask for the values that you already configured beforehand.
##
## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'.
## Duo no longer supports this, but it still works for some integrations.
## If you aren't sure, leave this alone.
# DUO_USE_IFRAME=false
## Email 2FA settings ## Email 2FA settings
## Email token size ## Email token size
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
@@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching # Enable Rust Caching
- name: Rust Caching - name: Rust Caching
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with: with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example. # Like changing the build host from Ubuntu 20.04 to 22.04 for example.
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
+13 -12
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -30,24 +30,25 @@ jobs:
driver-opts: | driver-opts: |
network=host network=host
# Download hadolint - https://github.com/hadolint/hadolint/releases
- name: Download hadolint
run: |
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
sudo chmod +x /usr/local/bin/hadolint
env:
HADOLINT_VERSION: 2.14.0
# End Download hadolint
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
# Test Dockerfiles with hadolint # Test Dockerfiles with hadolint
- name: Run hadolint # Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
run: hadolint docker/Dockerfile.{debian,alpine} # so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint # End Test Dockerfiles with hadolint
# Test Dockerfiles with docker build checks # Test Dockerfiles with docker build checks
+15 -15
View File
@@ -58,13 +58,13 @@ jobs:
steps: steps:
- name: Initialize QEMU binfmt support - name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with: with:
platforms: "arm64,arm" platforms: "arm64,arm"
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -77,7 +77,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# We need fetch-depth of 0 so we also get all the tag metadata # We need fetch-depth of 0 so we also get all the tag metadata
with: with:
persist-credentials: false persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
# Login to GitHub Container Registry # Login to GitHub Container Registry
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -137,7 +137,7 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@@ -185,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers - name: Bake ${{ matrix.base_image }} containers
id: bake_vw id: bake_vw
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0 uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
env: env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -237,7 +237,7 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries # Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries - name: Attest binaries
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -249,7 +249,7 @@ jobs:
merge-manifests: merge-manifests:
name: Merge manifests name: Merge manifests
runs-on: ubuntu-latest runs-on: ubuntu-24.04
needs: docker-build needs: docker-build
environment: environment:
name: release name: release
@@ -272,7 +272,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -287,7 +287,7 @@ jobs:
# Login to GitHub Container Registry # Login to GitHub Container Registry
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -303,7 +303,7 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +365,7 @@ jobs:
# Attest container images # Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }} - name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }} - name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }} - name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab - name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1 uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
+3 -3
View File
@@ -14,17 +14,17 @@ on:
jobs: jobs:
zizmor: zizmor:
name: Run zizmor name: Run zizmor
runs-on: ubuntu-latest runs-on: ubuntu-24.04
permissions: permissions:
security-events: write # To write the security report security-events: write # To write the security report
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.
+4 -5
View File
@@ -18,9 +18,10 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos - repo: https://github.com/crate-ci/typos
rev: cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1 rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
hooks: hooks:
- id: typos - id: typos
always_run: true
- repo: local - repo: local
hooks: hooks:
@@ -38,8 +39,7 @@ repos:
entry: cargo test entry: cargo test
language: system language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ] args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, file ] types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false pass_filenames: false
- id: cargo-clippy - id: cargo-clippy
name: cargo clippy name: cargo clippy
@@ -47,8 +47,7 @@ repos:
entry: cargo clippy entry: cargo clippy
language: system language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ] args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, file ] types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false pass_filenames: false
- id: check-docker-templates - id: check-docker-templates
name: check docker templates name: check docker templates
+2
View File
@@ -23,4 +23,6 @@ extend-ignore-re = [
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 # https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 # https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
"AuthRequestResponseRecieved", "AuthRequestResponseRecieved",
# Ignore Punycode/IDN tests
"xn--.+"
] ]
Generated
+1124 -1251
View File
File diff suppressed because it is too large. Load diff
+161 -93
View File
@@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
edition = "2021" edition = "2024"
rust-version = "1.93.0" rust-version = "1.97.1"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden" repository = "https://github.com/dani-garcia/vaultwarden"
publish = false publish = false
@@ -12,9 +12,7 @@ members = ["macros"]
name = "vaultwarden" name = "vaultwarden"
version = "1.0.0" version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"] authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md"
build = "build.rs" build = "build.rs"
resolver = "2"
repository.workspace = true repository.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -24,20 +22,32 @@ publish.workspace = true
[features] [features]
default = [ default = [
# "sqlite", # "sqlite",
# "sqlite_system",
# "mysql", # "mysql",
# "postgresql", # "postgresql",
] ]
# Empty to keep compatibility, prefer to set USE_SYSLOG=true # Empty to keep compatibility, prefer to set USE_SYSLOG=true
enable_syslog = [] enable_syslog = []
# Please enable at least one of these DB backends.
mysql = ["diesel/mysql", "diesel_migrations/mysql"] mysql = ["diesel/mysql", "diesel_migrations/mysql"]
postgresql = ["diesel/postgres", "diesel_migrations/postgres"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"] sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Statically link SQLite into the binary instead of dynamically.
# Enable to use a vendored and statically linked openssl # Enable to use a vendored and statically linked openssl
vendored_openssl = ["openssl/vendored"] vendored_openssl = ["openssl/vendored"]
# Enable MiMalloc memory allocator to replace the default malloc # Enable MiMalloc memory allocator to replace the default malloc
# This can improve performance for Alpine builds # This can improve performance for Alpine builds
enable_mimalloc = ["dep:mimalloc"] enable_mimalloc = ["dep:mimalloc"]
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"] s3 = [
"opendal/services-s3",
"dep:opendal-http-transport-reqwest",
"dep:aws-config",
"dep:aws-credential-types",
"dep:aws-smithy-runtime-api",
"dep:http",
"dep:reqsign-aws-v4",
"dep:reqsign-core",
]
# OIDC specific features # OIDC specific features
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
@@ -48,6 +58,7 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
unstable = [] unstable = []
[target."cfg(unix)".dependencies] [target."cfg(unix)".dependencies]
nix = { version = "0.31.3", features = ["fs"] }
# Logging # Logging
syslog = "7.0.0" syslog = "7.0.0"
@@ -55,154 +66,212 @@ syslog = "7.0.0"
macros = { path = "./macros" } macros = { path = "./macros" }
# Logging # Logging
log = "0.4.29" log = "0.4.34"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work # 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"] }
# A `dotenv` implementation for Rust # A `dotenv` implementation for Rust
dotenvy = { version = "0.15.7", default-features = false } dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries # Numerical libraries
num-traits = "0.2.19" num-traits = "0.2.19"
num-derive = "0.4.2" num-derive = "0.5.1"
bigdecimal = "0.4.10" bigdecimal = "0.4.11"
# Web framework # Web framework
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false } rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
rocket_ws = { version ="0.1.1" } rocket_ws = { version = "0.1.1" }
# WebSockets libraries # WebSockets libraries
rmpv = "1.3.1" # MessagePack library rmpv = "1.3.1" # MessagePack library
# Concurrent HashMap used for WebSocket messaging and favicons # Concurrent HashMap used for WebSocket messaging and favicons
dashmap = "6.1.0" dashmap = "6.2.1"
# Async futures # Async futures
futures = "0.3.32" futures = "0.3.34"
tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } tokio = { version = "1.53.2", features = [
tokio-util = { version = "0.7.18", features = ["compat"]} "fs",
"io-util",
"net",
"parking_lot",
"rt-multi-thread",
"signal",
"time",
] }
tokio-util = { version = "0.7.19", features = ["compat"] }
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.151"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.7", features = ["chrono", "r2d2", "numeric"] } diesel_migrations = "2.3.2"
diesel_migrations = "2.3.1"
derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] } derive_more = { version = "2.1.1", features = [
diesel-derive-newtype = "2.1.2" "as_ref",
"deref",
"display",
"from",
"into",
] }
diesel-derive-newtype = "2.1.3"
# Bundled/Static SQLite # SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.36.0", features = ["bundled"], optional = true } libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.1" rand = "0.10.3"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.23.1", features = ["v4"] } uuid = { version = "1.27.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false } chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
time = "0.3.47" time = "0.3.55"
# Job scheduler # Job scheduler
job_scheduler_ng = "2.4.0" job_scheduler_ng = "2.5.0"
# Data encoding library Hex/Base32/Base64 # Data encoding library Hex/Base32/Base64
data-encoding = "2.10.0" data-encoding = "2.11.1"
# JWT library # JWT library
jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false } jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library # TOTP library
totp-lite = "2.0.1" totp-lite = "2.0.1"
# Yubico Library # Yubico Library
yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false } yubico_ng = { version = "1.0.0", default-features = false }
# WebAuthn libraries # WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db # danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration # danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.4" webauthn-rs-proto = "0.5.5"
webauthn-rs-core = "0.5.4" webauthn-rs-core = "0.5.5"
# Handling of URL's for WebAuthn and favicons # Handling of URL's for WebAuthn and favicons
url = "2.5.8" url = "2.5.8"
# Email libraries # Email libraries
lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false } lettre = { version = "0.11.23", default-features = false, features = [
# Misc
"tracing",
"serde",
"builder",
"hostname",
# TLS/Security
"ring",
"rustls-native-certs",
"tokio1-rustls",
# Transport
"smtp-transport",
"sendmail-transport",
] }
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9" email_address = "0.2.9"
# HTML Template library # HTML Template library
handlebars = { version = "6.4.0", features = ["dir_source"] } handlebars = { version = "6.4.4", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API) # HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false} reqwest = { version = "0.13.5", default-features = false, features = [
hickory-resolver = "0.26.0" # Misc
"charset",
"cookies",
"http2",
"json",
"form",
"rustls-no-provider",
"stream",
# Compression
"brotli",
"deflate",
"gzip",
"zstd",
# Proxy
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.3"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.3" html5gum = "0.8.4"
regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false } regex = { version = "1.13.1", default-features = false, features = [
"perf",
"std",
"unicode-perl",
] }
data-url = "0.3.2" data-url = "0.3.2"
bytes = "1.11.1" bytes = "1.12.1"
svg-hush = "0.9.6" svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
cached = { version = "0.59.0", features = ["async"] } cached = { version = "4.0.1", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction # Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1" cookie = "0.18.2"
cookie_store = "0.22.1" cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL # Used by U2F, JWT and PostgreSQL
openssl = "0.10.78" openssl = "0.10.81"
# CLI argument parsing # CLI argument parsing
pico-args = "0.5.0" pico-args = "0.5.0"
# Macro ident concatenation # Macro ident concatenation
pastey = "0.2.1" pastey = "0.2.3"
governor = "0.10.4" governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.2"
# OIDC for SSO # OIDC for SSO
openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] } 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. # Check client versions for specific features.
semver = "1.0.28" semver = "1.0.28"
# Allow overriding the default memory allocator # Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow # Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true } mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
which = "8.0.2" which = "8.0.6"
# Argon2 library with support for the PHC format # 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 # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.4.0" rpassword = "7.5.4"
# Loading a dynamic CSS Stylesheet # Loading a dynamic CSS Stylesheet
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.55.0", features = ["services-fs"], default-features = false } opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
# For retrieving AWS credentials, including temporary SSO credentials # For retrieving AWS credentials, including temporary SSO credentials
anyhow = { version = "1.0.102", optional = true } aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } "behavior-version-latest",
aws-credential-types = { version = "1.2.14", optional = true } "credentials-process",
aws-smithy-runtime-api = { version = "1.12.0", optional = true } "rt-tokio",
http = { version = "1.4.0", optional = true } "sso",
reqsign = { version = "0.16.5", optional = true } ] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces # The debug symbols are to provide better panic traces
@@ -262,75 +331,74 @@ unsafe_code = "forbid"
non_ascii_idents = "forbid" non_ascii_idents = "forbid"
# Deny # Deny
deprecated_in_future = "deny" warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end
# Deny lint groups
deprecated_safe = { level = "deny", priority = -1 } deprecated_safe = { level = "deny", priority = -1 }
future_incompatible = { level = "deny", priority = -1 } future_incompatible = { level = "deny", priority = -1 }
keyword_idents = { level = "deny", priority = -1 } keyword_idents = { level = "deny", priority = -1 }
let_underscore = { level = "deny", priority = -1 } let_underscore = { level = "deny", priority = -1 }
nonstandard_style = { level = "deny", priority = -1 } nonstandard_style = { level = "deny", priority = -1 }
noop_method_call = "deny"
refining_impl_trait = { level = "deny", priority = -1 } refining_impl_trait = { level = "deny", priority = -1 }
rust_2018_idioms = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 }
rust_2021_compatibility = { level = "deny", priority = -1 } rust_2021_compatibility = { level = "deny", priority = -1 }
rust_2024_compatibility = { level = "deny", priority = -1 } rust_2024_compatibility = { level = "deny", priority = -1 }
unused = { level = "deny", priority = -1 }
# Deny individual lints
closure_returning_async_block = "deny"
deprecated_in_future = "deny"
single_use_lifetimes = "deny" single_use_lifetimes = "deny"
trivial_casts = "deny" trivial_casts = "deny"
trivial_numeric_casts = "deny" trivial_numeric_casts = "deny"
unused = { level = "deny", priority = -1 }
unused_import_braces = "deny" unused_import_braces = "deny"
unused_lifetimes = "deny" unused_lifetimes = "deny"
unused_qualifications = "deny" unused_qualifications = "deny"
variant_size_differences = "deny" variant_size_differences = "deny"
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
if_let_rescope = "allow"
tail_expr_drop_order = "allow"
# https://rust-lang.github.io/rust-clippy/stable/index.html # https://rust-lang.github.io/rust-clippy/stable/index.html
[workspace.lints.clippy] [workspace.lints.clippy]
# Warn # Warn only so you can still use these during development, but not in the final code
dbg_macro = "warn" dbg_macro = "warn"
todo = "warn" todo = "warn"
# Ignore/Allow # Ignore/Allow
result_large_err = "allow" result_large_err = "allow"
# Deny # Warn on these lint group (Some might be warn by default already though)
# Will be denied during CI!
complexity = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
perf = { level = "warn", priority = -1 }
style = { level = "warn", priority = -1 }
suspicious = { level = "warn", priority = -1 }
# Deny individual lints
branches_sharing_code = "deny" branches_sharing_code = "deny"
case_sensitive_file_extension_comparisons = "deny"
cast_lossless = "deny"
clone_on_ref_ptr = "deny" clone_on_ref_ptr = "deny"
equatable_if_let = "deny" equatable_if_let = "deny"
excessive_precision = "deny"
filter_map_next = "deny"
float_cmp_const = "deny" float_cmp_const = "deny"
implicit_clone = "deny"
inefficient_to_string = "deny"
iter_on_empty_collections = "deny" iter_on_empty_collections = "deny"
iter_on_single_items = "deny" iter_on_single_items = "deny"
linkedlist = "deny"
macro_use_imports = "deny"
manual_assert = "deny"
manual_instant_elapsed = "deny"
manual_string_new = "deny"
match_wildcard_for_single_variants = "deny"
mem_forget = "deny" mem_forget = "deny"
needless_borrow = "deny"
needless_collect = "deny" needless_collect = "deny"
needless_continue = "deny"
needless_lifetimes = "deny"
option_option = "deny"
redundant_clone = "deny" redundant_clone = "deny"
string_add_assign = "deny"
unnecessary_join = "deny"
unnecessary_self_imports = "deny" unnecessary_self_imports = "deny"
unnested_or_patterns = "deny"
unused_async = "deny"
unused_self = "deny"
useless_let_if_seq = "deny" useless_let_if_seq = "deny"
verbose_file_reads = "deny" verbose_file_reads = "deny"
zero_sized_map_values = "deny" str_to_string = "deny"
# Pedantic Opt-Outs
inline_always = "allow" # We use this sparsely
struct_field_names = "allow" # Noisy and some items are Bitwarden controlled
large_futures = "allow" # Causes a fail in some Rocket macro's, since we experience no issues, allow it
too_many_lines = "allow" # For now, allow this, good to enable in the future and see if we can refactor
unnecessary_wraps = "allow" # Too much false positives because of Rocket integrations
# We do not use these doc items
doc_link_with_quotes = "allow"
doc_markdown = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
[lints] [lints]
workspace = true workspace = true
+3 -2
View File
@@ -59,8 +59,9 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
## Usage ## Usage
> [!IMPORTANT] > [!IMPORTANT]
> The web-vault requires the use a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). > The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). <br>
> That means it will only work via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). > That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). <br>
> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples).
The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server).
See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags. See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags.
+10 -12
View File
@@ -1,22 +1,21 @@
use std::env; use std::{env, io::Error, process::Command};
use std::process::Command;
fn main() { fn main() {
// This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros // These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one.
println!("cargo:rustc-cfg=sqlite"); println!("cargo:rustc-cfg=sqlite");
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
println!("cargo:rustc-cfg=mysql"); println!("cargo:rustc-cfg=mysql");
#[cfg(feature = "postgresql")] #[cfg(feature = "postgresql")]
println!("cargo:rustc-cfg=postgresql"); println!("cargo:rustc-cfg=postgresql");
#[cfg(feature = "s3")] #[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))]
println!("cargo:rustc-cfg=s3");
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
compile_error!( compile_error!(
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite" "You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
); );
#[cfg(feature = "s3")]
println!("cargo:rustc-cfg=s3");
// Use check-cfg to let cargo know which cfg's we define, // Use check-cfg to let cargo know which cfg's we define,
// and avoid warnings when they are used in the code. // and avoid warnings when they are used in the code.
println!("cargo::rustc-check-cfg=cfg(sqlite)"); println!("cargo::rustc-check-cfg=cfg(sqlite)");
@@ -42,13 +41,12 @@ fn main() {
} }
} }
fn run(args: &[&str]) -> Result<String, std::io::Error> { fn run(args: &[&str]) -> Result<String, Error> {
let out = Command::new(args[0]).args(&args[1..]).output()?; let out = Command::new(args[0]).args(&args[1..]).output()?;
if !out.status.success() { if !out.status.success() {
use std::io::Error;
return Err(Error::other("Command not successful")); return Err(Error::other("Command not successful"));
} }
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string()) Ok(String::from_utf8(out.stdout).unwrap().trim().to_owned())
} }
/// This method reads info from Git, namely tags, branch, and revision /// This method reads info from Git, namely tags, branch, and revision
@@ -58,7 +56,7 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
/// - `env!("GIT_BRANCH")` /// - `env!("GIT_BRANCH")`
/// - `env!("GIT_REV")` /// - `env!("GIT_REV")`
/// - `env!("VW_VERSION")` /// - `env!("VW_VERSION")`
fn version_from_git_info() -> Result<String, std::io::Error> { fn version_from_git_info() -> Result<String, Error> {
// The exact tag for the current commit, can be empty when // The exact tag for the current commit, can be empty when
// the current commit doesn't have an associated tag // the current commit doesn't have an associated tag
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok(); let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
+5 -4
View File
@@ -1,13 +1,14 @@
--- ---
vault_version: "v2026.3.1" vault_version: "v2026.7.0"
vault_image_digest: "sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767" vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
# Cross Compile Docker Helper Scripts v1.9.0 # Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.95.0 # 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 debian_version: trixie # Debian release name to be used
alpine_version: "3.23" # Alpine version to be used alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images # For which platforms/architectures will we try to build images
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
# Determine the build images per OS/Arch # Determine the build images per OS/Arch
+17 -17
View File
@@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1 # $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767] # [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.3.1] # [docker.io/vaultwarden/web-vault:v2026.7.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
########################## ALPINE BUILD IMAGES ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used ## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@@ -57,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
# Debian Trixie uses libpq v17 # Debian Trixie uses libpq v17
PQ_LIB_DIR="/usr/local/musl/pq17/lib" PQ_LIB_DIR="/usr/local/musl/pq17/lib"
# Create CARGO_HOME folder and don't download rust docs # Create CARGO_HOME folder and don't download rust docs
RUN mkdir -pv "${CARGO_HOME}" && \ RUN mkdir -pv "${CARGO_HOME}" && \
rustup set profile minimal rustup set profile minimal
@@ -67,11 +66,11 @@ RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
RUN source /env-cargo && \ RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files # Copies over *only* your manifests and build files
@@ -87,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN . /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
@@ -98,13 +97,13 @@ COPY . .
ARG VW_VERSION ARG VW_VERSION
# Builds again, this time it will be the actual source files being build # 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 # 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 # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # 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}" && \ 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 ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -127,7 +126,8 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23 # hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \
ROCKET_ADDRESS=0.0.0.0 \ ROCKET_ADDRESS=0.0.0.0 \
+50 -44
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1 # $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767] # [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.3.1] # [docker.io/vaultwarden/web-vault:v2026.7.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
########################## Cross Compile Docker Helper Scripts ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,7 +36,8 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@@ -51,7 +52,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-256color \ TERM=xterm-256color \
CARGO_HOME="/root/.cargo" \ CARGO_HOME="/root/.cargo" \
USER="root" USER="root"
# Install clang to get `xx-cargo` working # Install clang && xx-c-essentials to get `xx-cargo` working
# Install pkg-config to allow amd64 builds to find all libraries # Install pkg-config to allow amd64 builds to find all libraries
# Install git so build.rs can determine the correct version # Install git so build.rs can determine the correct version
# Install the libc cross packages based upon the debian-arch # Install the libc cross packages based upon the debian-arch
@@ -59,19 +60,16 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
--no-install-recommends \ --no-install-recommends \
clang \ clang \
pkg-config \ git && \
git \
"libc6-$(xx-info debian-arch)-cross" \
"libc6-dev-$(xx-info debian-arch)-cross" \
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
xx-apt-get install -y \ xx-apt-get install -y \
--no-install-recommends \ --no-install-recommends \
gcc \
libpq-dev \ libpq-dev \
libpq5 \ libpq5 \
libssl-dev \ libssl-dev \
libmariadb-dev \ libmariadb-dev \
zlib1g-dev && \ pkg-config \
zlib1g-dev \
xx-c-essentials && \
# Run xx-cargo early, since it sometimes seems to break when run at a later stage # Run xx-cargo early, since it sometimes seems to break when run at a later stage
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
@@ -83,30 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
# Environment variables for Cargo on Debian based builds RUN . /env-cargo && \
ARG TARGET_PKG_CONFIG_PATH
RUN source /env-cargo && \
if xx-info is-cross ; then \
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
# Because of this we generate the needed environment variables here which we can load in the needed steps.
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CROSS_COMPILE=1" >> /env-cargo && \
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
else \
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
fi && \
echo "# End of env-cargo" >> /env-cargo ; \
fi && \
# Output the current contents of the file
cat /env-cargo
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files # Copies over *only* your manifests and build files
@@ -121,8 +96,23 @@ ARG DB=sqlite,mysql,postgresql
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN . /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ # 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
@@ -132,13 +122,28 @@ COPY . .
ARG VW_VERSION ARG VW_VERSION
# Builds again, this time it will be the actual source files being build # 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 # 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 # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # 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}" && \ # Configure xx-cargo for target pkg-config and Debian transitive library lookup
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ # 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -161,6 +166,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \
+40 -39
View File
@@ -27,6 +27,24 @@
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }} # $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}] # [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
# #
{% macro xx_cargo_config() -%}
# 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
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 FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
{% if base == "debian" %} {% if base == "debian" %}
@@ -47,6 +65,7 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
{% if base == "debian" %} {% if base == "debian" %}
# hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
{% endif %} {% endif %}
ARG TARGETARCH ARG TARGETARCH
@@ -66,10 +85,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
# Debian Trixie uses libpq v17 # Debian Trixie uses libpq v17
PQ_LIB_DIR="/usr/local/musl/pq17/lib" PQ_LIB_DIR="/usr/local/musl/pq17/lib"
{% endif %} {%- endif %}
{% if base == "debian" %} {% if base == "debian" %}
# Install clang to get `xx-cargo` working # Install clang && xx-c-essentials to get `xx-cargo` working
# Install pkg-config to allow amd64 builds to find all libraries # Install pkg-config to allow amd64 builds to find all libraries
# Install git so build.rs can determine the correct version # Install git so build.rs can determine the correct version
# Install the libc cross packages based upon the debian-arch # Install the libc cross packages based upon the debian-arch
@@ -77,19 +96,16 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
--no-install-recommends \ --no-install-recommends \
clang \ clang \
pkg-config \ git && \
git \
"libc6-$(xx-info debian-arch)-cross" \
"libc6-dev-$(xx-info debian-arch)-cross" \
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
xx-apt-get install -y \ xx-apt-get install -y \
--no-install-recommends \ --no-install-recommends \
gcc \
libpq-dev \ libpq-dev \
libpq5 \ libpq5 \
libssl-dev \ libssl-dev \
libmariadb-dev \ libmariadb-dev \
zlib1g-dev && \ pkg-config \
zlib1g-dev \
xx-c-essentials && \
# Run xx-cargo early, since it sometimes seems to break when run at a later stage # Run xx-cargo early, since it sometimes seems to break when run at a later stage
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
{% endif %} {% endif %}
@@ -102,38 +118,14 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
{% if base == "debian" %} {% if base == "alpine" %}
# Environment variables for Cargo on Debian based builds
ARG TARGET_PKG_CONFIG_PATH
RUN source /env-cargo && \
if xx-info is-cross ; then \
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
# Because of this we generate the needed environment variables here which we can load in the needed steps.
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CROSS_COMPILE=1" >> /env-cargo && \
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
else \
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
fi && \
echo "# End of env-cargo" >> /env-cargo ; \
fi && \
# Output the current contents of the file
cat /env-cargo
{% elif base == "alpine" %}
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
{% endif %} {% endif %}
RUN source /env-cargo && \ RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files # Copies over *only* your manifests and build files
@@ -153,8 +145,12 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN . /env-cargo && \
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
{% endif %}
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
@@ -164,13 +160,17 @@ COPY . .
ARG VW_VERSION ARG VW_VERSION
# Builds again, this time it will be the actual source files being build # 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 # 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 # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # Create a symlink to the binary target folder to easy copy the binary in the final stage
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ {% endif %}
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -193,6 +193,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }} FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \
+8 -2
View File
@@ -3,17 +3,23 @@
import os import os
import argparse import argparse
import json import json
import tomllib
import yaml import yaml
import jinja2 import jinja2
# Load settings file # 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) 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( settings_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.getcwd()), 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 = argparse.ArgumentParser()
args_parser.add_argument('template_file', help='Jinja2 template file to render.') args_parser.add_argument('template_file', help='Jinja2 template file to render.')
+2 -2
View File
@@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
quote = "1.0.45" quote = "1.0.47"
syn = "2.0.117" syn = "3.0.6"
[lints] [lints]
workspace = true workspace = true
+5 -4
View File
@@ -1,14 +1,15 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::quote;
use syn::{DeriveInput, parse_macro_input};
#[proc_macro_derive(UuidFromParam)] #[proc_macro_derive(UuidFromParam)]
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream { pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap(); let ast = parse_macro_input!(input as DeriveInput);
impl_derive_uuid_macro(&ast) impl_derive_uuid_macro(&ast)
} }
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream { fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream {
let name = &ast.ident; let name = &ast.ident;
let gen_derive = quote! { let gen_derive = quote! {
#[automatically_derived] #[automatically_derived]
@@ -30,12 +31,12 @@ fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
#[proc_macro_derive(IdFromParam)] #[proc_macro_derive(IdFromParam)]
pub fn derive_id_from_param(input: TokenStream) -> TokenStream { pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap(); let ast = parse_macro_input!(input as DeriveInput);
impl_derive_safestring_macro(&ast) impl_derive_safestring_macro(&ast)
} }
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream { fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream {
let name = &ast.ident; let name = &ast.ident;
let gen_derive = quote! { let gen_derive = quote! {
#[automatically_derived] #[automatically_derived]
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,15 +1,31 @@
-- Dynamically create DROP FOREIGN KEY SELECT if (
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists EXISTS(
-- This checks if the key exists, and if so, will drop it. SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE WHERE TABLE_SCHEMA = DATABASE()
CONSTRAINT_SCHEMA = DATABASE() AND AND TABLE_NAME = 'sso_users'
TABLE_NAME = 'sso_users' AND AND CONSTRAINT_TYPE = 'FOREIGN KEY'
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
CONSTRAINT_TYPE = 'FOREIGN KEY') = true, )
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1', ,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
'SELECT 1'); ,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
PREPARE stmt FROM @drop_sso_fk; ) INTO @drop_stmt;
EXECUTE stmt; PREPARE drop_stmt FROM @drop_stmt;
DEALLOCATE PREPARE 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; ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS archives;
@@ -0,0 +1,10 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL,
cipher_uuid CHAR(36) NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid),
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE,
FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE
);
@@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;
@@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
@@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN code_response_error;
@@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS archives;
@@ -0,0 +1,8 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
archived_at TIMESTAMP NOT NULL DEFAULT now(),
PRIMARY KEY (user_uuid, cipher_uuid)
);
@@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;
@@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
@@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error;
@@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS archives;
@@ -0,0 +1,8 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid)
);
@@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;
@@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
@@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN code_response_error;
@@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
+13 -3
View File
@@ -21,11 +21,19 @@ TEST_USER3=test3
TEST_USER3_PASSWORD=${TEST_USER3} TEST_USER3_PASSWORD=${TEST_USER3}
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com 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 Config #
################### ###################
KEYCLOAK_ADMIN=admin KC_BOOTSTRAP_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080 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_ADDRESS=0.0.0.0
ROCKET_PORT=8000 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 LOG_LEVEL=info,oidcwarden::sso=debug
SSO_DEBUG_TOKENS=true
I_REALLY_WANT_VOLATILE_STORAGE=true I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true SSO_ENABLED=true
+14 -16
View File
@@ -1,8 +1,8 @@
# Integration tests # Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/). 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 ## Install
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
### Running Playwright outside docker ### 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. It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
You will additionally need `nodejs` then run: You'll additionally need `nodejs` then run:
```bash ```bash
npm ci --ignore-scripts npm ci --ignore-scripts --allow-git=none --allow-remote=none
npx playwright install-deps npx playwright install-deps
npx playwright install firefox 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): If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
```bash ```bash
PW_KEEP_SERVICE_RUNNNING=true npx playwright test PW_KEEP_SERVICE_RUNNING=true npx playwright test
``` ```
### Running specific tests ### 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 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 ```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" 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 ```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
npx playwright codegen "http://127.0.0.1:8003" npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
``` ```
## Override web-vault ## 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 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 # OpenID Connect test setup
Additionally this `docker-compose` template allows to run locally Vaultwarden, 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.
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
## Setup ## Setup
@@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash ```bash
> docker compose --profile vaultwarden --env-file .env up > 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 | Created new realm with id 'test'
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
oidc_keycloakSetup_1 exited with code 0 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 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).
(It is normal for this container to stop once the configuration is done).
Then you can access : 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` - `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 - `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). 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. 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_*`) - All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
## Cleanup ## Cleanup
+2 -2
View File
@@ -17,7 +17,7 @@ done
set -e 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 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 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: # THEN in another terminal:
# docker exec -it keycloakSetup-dev /bin/bash # docker exec -it keycloakSetup-dev /bin/bash
# export PATH=$PATH:/opt/keycloak/bin # 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 # ENJOY
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html # Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
+1 -1
View File
@@ -28,7 +28,7 @@ RUN mkdir /playwright
WORKDIR /playwright WORKDIR /playwright
COPY package.json package-lock.json . 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 docker-compose.yml test.env ./
COPY compose ./compose COPY compose ./compose
+1
View File
@@ -35,6 +35,7 @@ WORKDIR /
COPY --from=prebuilt /start.sh . COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden . COPY --from=prebuilt /vaultwarden .
COPY --from=build /data ./data
COPY --from=build /web-vault ./web-vault COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"] ENTRYPOINT ["/start.sh"]
+11
View File
@@ -22,3 +22,14 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
mv build /web-vault mv build /web-vault
fi 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")
+10 -7
View File
@@ -24,12 +24,15 @@ services:
environment: environment:
- ADMIN_TOKEN - ADMIN_TOKEN
- DATABASE_URL - DATABASE_URL
- CLIENT_SUPPRESS_ONBOARDING
- EMAIL_2FA_AUTO_FALLBACK
- I_REALLY_WANT_VOLATILE_STORAGE - I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL - LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST - LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST - SMTP_HOST
- SMTP_FROM - SMTP_FROM
- SMTP_DEBUG - SMTP_DEBUG
- SSO_AUTH_ONLY_NOT_SESSION
- SSO_DEBUG_TOKENS - SSO_DEBUG_TOKENS
- SSO_ENABLED - SSO_ENABLED
- SSO_FRONTEND - SSO_FRONTEND
@@ -58,7 +61,7 @@ services:
Mariadb: Mariadb:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_mariadb container_name: playwright_mariadb
image: mariadb:11.2.4 image: mariadb:12.2.2
env_file: test.env env_file: test.env
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
@@ -70,7 +73,7 @@ services:
Mysql: Mysql:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_mysql container_name: playwright_mysql
image: mysql:8.4.1 image: mysql:9.7.0
env_file: test.env env_file: test.env
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@@ -82,7 +85,7 @@ services:
Postgres: Postgres:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_postgres container_name: playwright_postgres
image: postgres:16.3 image: postgres:18.4
env_file: test.env env_file: test.env
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
@@ -94,7 +97,7 @@ services:
Maildev: Maildev:
profiles: ["vaultwarden", "maildev"] profiles: ["vaultwarden", "maildev"]
container_name: maildev container_name: maildev
image: timshel/maildev:3.0.4 image: timshel/maildev:3.2.19
ports: ports:
- ${SMTP_PORT}:1025 - ${SMTP_PORT}:1025
- 1080:1080 - 1080:1080
@@ -102,7 +105,7 @@ services:
Keycloak: Keycloak:
profiles: ["keycloak", "vaultwarden"] profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev} container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:26.3.4 image: quay.io/keycloak/keycloak:26.6.2
network_mode: "host" network_mode: "host"
command: command:
- start-dev - start-dev
@@ -112,12 +115,12 @@ services:
profiles: ["keycloak", "vaultwarden"] profiles: ["keycloak", "vaultwarden"]
container_name: keycloakSetup-${ENV:-dev} container_name: keycloakSetup-${ENV:-dev}
image: keycloak_setup-${ENV:-dev} image: keycloak_setup-${ENV:-dev}
network_mode: "host"
build: build:
context: compose/keycloak context: compose/keycloak
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
KEYCLOAK_VERSION: 26.3.4 KEYCLOAK_VERSION: 26.6.2
network_mode: "host"
depends_on: depends_on:
- Keycloak - Keycloak
restart: "no" restart: "no"
+1 -1
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 { execSync } from 'node:child_process';
import fs from 'fs'; import fs from 'fs';
+2 -13
View File
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
} }
export async function stopVault(force: boolean = false) { 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}`); console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
} else { } else {
console.log(`Vaultwarden stopping`); console.log(`Vaultwarden stopping`);
@@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
} }
export async function cleanLanding(page: Page) { export async function cleanLanding(page: Page) {
await page.context().clearCookies();
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('button').nth(0)).toBeVisible(); 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(); 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');
}
}
+582 -580
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -8,14 +8,14 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.56.1", "@playwright/test": "1.60.0",
"dotenv": "17.2.3", "dotenv": "17.4.2",
"dotenv-expand": "12.0.3", "dotenv-expand": "13.0.0",
"maildev": "npm:@timshel_npm/maildev@3.2.5" "maildev": "npm:@timshel_npm/maildev@3.2.19"
}, },
"dependencies": { "dependencies": {
"mysql2": "3.15.3", "mysql2": "3.22.3",
"otpauth": "9.4.1", "otpauth": "9.5.1",
"pg": "8.16.3" "pg": "8.21.0"
} }
} }
+10 -4
View File
@@ -25,10 +25,12 @@ export default defineConfig({
/* Long global timeout for complex tests /* Long global timeout for complex tests
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
*/ */
timeout: 120 * 1000, timeout: 240 * 1000,
actionTimeout: 20 * 1000, actionTimeout: 40 * 1000,
navigationTimeout: 20 * 1000, navigationTimeout: 40 * 1000,
expect: { timeout: 20 * 1000 }, expect: { timeout: 40 * 1000 },
"permissions": ["clipboard-read"],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
@@ -37,6 +39,10 @@ export default defineConfig({
browserName: 'firefox', browserName: 'firefox',
locale: 'en-GB', locale: 'en-GB',
timezoneId: 'Europe/London', 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 */ /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
trace: 'on', trace: 'on',
+6 -4
View File
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
##################### #####################
# Playwright Config # # 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 PW_SMTP_FROM=vaultwarden@playwright.test
##################### #####################
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
################### ###################
# Keycloak Config # # Keycloak Config #
################### ###################
KEYCLOAK_ADMIN=admin KC_BOOTSTRAP_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081 KC_HTTP_PORT=8081
@@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config # # Vaultwarden Config #
###################### ######################
ROCKET_PORT=8003 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 LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100 LOGIN_RATELIMIT_MAX_BURST=100
ADMIN_TOKEN=admin ADMIN_TOKEN=admin
CLIENT_SUPPRESS_ONBOARDING=true
SMTP_SECURITY=off SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT} SMTP_PORT=${MAILDEV_SMTP_PORT}
+5 -11
View File
@@ -1,6 +1,8 @@
import { test, expect, type TestInfo } from '@playwright/test'; import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { createAccount } from './setups/user'; import { createAccount } from './setups/user';
let users = utils.loadEnv(); let users = utils.loadEnv();
@@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
test('Create', async ({ page }) => { test('Create', async ({ page }) => {
await createAccount(test, page, users.user1); await createAccount(test, page, users.user1);
await test.step('Create Org', async () => { await orgs.create(test, page, 'New organisation');
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill('Test');
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await utils.checkNotification(page, 'Organisation created');
});
await test.step('Create Collection', async () => { await test.step('Create Collection', async () => {
await page.getByRole('link', { name: 'Collections' }).click(); await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Collection' }).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 page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec'); await utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
+56
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);
});
+6 -25
View File
@@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
test('Login', async ({ context, page }) => { test('Login', async ({ context, page }) => {
const mailBuffer = mailserver.buffer(users.user1.email); 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 test.step('verify email', async () => {
await page.getByText('Verify your account\'s email').click(); await page.getByRole('button', { name: "Send email" }).click();
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
await page.getByRole('button', { name: 'Send email' }).click();
await utils.checkNotification(page, 'Check your email inbox for a verification link'); await utils.checkNotification(page, 'Check your email inbox for a verification link');
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
@@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
test('2fa', async ({ page }) => { test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email); const emails = mailserver.buffer(users.user1.email);
await test.step('login', async () => { await logUser(test, page, users.user1, {
await page.goto('/'); mailBuffer: emails,
mail2fa: true,
await page.getByLabel(/Email address/).fill(users.user1.email); });
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
const code = await retrieveEmailCode(test, page, emails);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Add it later' }).click();
await page.getByRole('link', { name: 'Skip to web app' }).click();
await expect(page).toHaveTitle(/Vaults/);
})
await disableEmail(test, page, users.user1);
emails.close(); emails.close();
}); });
+2 -2
View File
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
+54 -13
View File
@@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
import * as utils from '../global-utils'; import * as utils from '../global-utils';
import * as orgs from './setups/orgs'; import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user'; import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv(); let users = utils.loadEnv();
@@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, { await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM, SMTP_FROM: process.env.PW_SMTP_FROM,
EMAIL_2FA_AUTO_FALLBACK: "true",
}); });
mail1Buffer = mailServer.buffer(users.user1.email); mail1Buffer = mailServer.buffer(users.user1.email);
@@ -40,6 +42,16 @@ test('Invite users', async ({ page }) => {
await createAccount(test, page, users.user1, mail1Buffer); await createAccount(test, page, users.user1, mail1Buffer);
await orgs.create(test, page, 'Test'); await orgs.create(test, page, 'Test');
await test.step(`Set account recovery`, async () => {
await orgs.policies(test, page, 'Test');
await page.getByRole('button', { name: 'Account recovery' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
await orgs.members(test, page, 'Test'); await orgs.members(test, page, 'Test');
await orgs.invite(test, page, 'Test', users.user2.email); await orgs.invite(test, page, 'Test', users.user2.email);
await orgs.invite(test, page, 'Test', users.user3.email, { await orgs.invite(test, page, 'Test', users.user3.email, {
@@ -56,18 +68,16 @@ test('invited with new account', async ({ page }) => {
await page.goto(link); await page.goto(link);
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
//await page.getByLabel('Name').fill(users.user2.name); // await page.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').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 page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created'); await utils.checkNotification(page, 'Your new account has been created');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// Redirected to the vault // Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
// await utils.checkNotification(page, 'You have been logged in!'); // await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Successfully accepted your invitation');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
@@ -90,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page // Unlock page
await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
}); });
test('Confirm invited user', async ({ page }) => { 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.members(test, page, 'Test');
await orgs.confirm(test, page, 'Test', users.user2.email); await orgs.confirm(test, page, 'Test', users.user2.email);
@@ -113,7 +121,40 @@ test('Confirm invited user', async ({ page }) => {
}); });
test('Organization is visible', 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 page.getByRole('button', { name: 'vault: Test', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test');
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('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
});
let user2 = {
email: users.user2.email,
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
});
+110
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)
})
});
+72
View File
@@ -0,0 +1,72 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount } from './setups/user';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Send', async ({ browser, page }) => {
await createAccount(test, page, users.user1);
const send_url = await test.step('Create', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New Send', exact: true }).click();
await page.getByRole('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('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
return await page.evaluate(() => navigator.clipboard.readText());
});
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await test.step('View', async () => {
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
});
const pwd_url = await test.step('Create with password', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('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('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
return await page.evaluate(() => navigator.clipboard.readText());
});
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('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
});
});
+8 -7
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: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').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(); 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 }); let totp = new OTPAuth.TOTP({ secret, period: 30 });
await page.getByLabel(/Verification code/).fill(totp.generate()); 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: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByLabel('Master password (required)').click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).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: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).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: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send email' }).click(); await page.getByRole('button', { name: 'Send email' }).click();
}); });
@@ -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: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).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: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click(); await page.getByRole('button', { name: 'Yes' }).click();
+21
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/);
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
utils.loadEnv(); utils.loadEnv();
test('DB teardown ?', async ({ serviceName }) => { test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
utils.stopComposeService(serviceName); utils.stopComposeService(serviceName);
} }
}); });
+14 -11
View File
@@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
export async function create(test, page: Page, name: string) { export async function create(test, page: Page, name: string) {
await test.step('Create Org', async () => { await test.step(`Create Org ${name}`, async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); 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 expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'New organisation' }).click(); 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 page.getByRole('button', { name: 'Submit' }).click();
await utils.checkNotification(page, 'Organisation created'); 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 test.step(`Navigate to ${name} policies`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).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 expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Policies' }).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 test.step(`Navigate to ${name} members`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).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 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('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 test.step(`Invite ${email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('button', { name: 'Invite member' }).click(); 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('tab', { name: 'Collections' }).click();
await page.getByRole('combobox', { name: 'Permission' }).click(); await page.getByRole('combobox', { name: 'Permission' }).click();
await page.getByText('Edit items', { exact: true }).click(); await page.getByText('Edit items', { exact: true }).click();
await page.getByLabel('Select collections').click(); await page.getByRole('combobox', { name: 'Select collections' }).click();
await page.getByText('Default collection').click(); await page.getByLabel('Options List').getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', exact: true }).click(); await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited'); await utils.checkNotification(page, 'User(s) invited');
}); });
+1 -1
View File
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv(); utils.loadEnv();
test('Keycloak teardown', async () => { 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"); console.log("Keep Keycloak running");
} else { } else {
console.log("Keycloak stopping"); console.log("Keycloak stopping");
+8 -17
View File
@@ -15,11 +15,8 @@ export async function logNewUser(
options: { mailBuffer?: MailBuffer } = {} options: { mailBuffer?: MailBuffer } = {}
) { ) {
await test.step(`Create user ${user.name}`, async () => { await test.step(`Create user ${user.name}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => { await test.step('Landing page', async () => {
await utils.cleanLanding(page); await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click(); 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 test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
}); });
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
}); });
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
if( options.mailBuffer ){ if( options.mailBuffer ){
let mailBuffer = options.mailBuffer; let mailBuffer = options.mailBuffer;
await test.step('Check emails', async () => { await test.step('Check emails', async () => {
await mailBuffer.expect((m) => m.subject === "Welcome");
await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); 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, mailBuffer ?: MailBuffer,
totp?: OTPAuth.TOTP, totp?: OTPAuth.TOTP,
mail2fa?: boolean, mail2fa?: boolean,
notNewDevice?: boolean,
} = {} } = {}
) { ) {
let mailBuffer = options.mailBuffer; let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => { await test.step(`Log user ${user.email}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => { await test.step('Landing page', async () => {
await utils.cleanLanding(page); await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click(); 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 page.getByRole('button', { name: 'Unlock' }).click();
}); });
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
}); });
if( mailBuffer ){ if( mailBuffer && !options.notNewDevice ){
await test.step('Check email', async () => { await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
}); });
+25 -9
View File
@@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev'; import { type MailBuffer } from 'maildev';
import * as utils from '../../global-utils'; 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) { export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Create user ${user.name}`, async () => { 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(); await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation // Vault finish Creation
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created') await utils.checkNotification(page, 'Your new account has been created')
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
@@ -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 test.step(`Log user ${user.email}`, async () => {
await utils.cleanLanding(page); 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(); await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page // Unlock page
await page.getByLabel('Master password').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); 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 // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
if( mailBuffer ){ if( options.mailBuffer && !options.notNewDevice ){
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
} }
}); });
} }
+52 -9
View File
@@ -1,6 +1,7 @@
import { test, expect, type TestInfo } from '@playwright/test'; import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev'; import { MailDev } from 'maildev';
import * as admin from "./setups/admin";
import { logNewUser, logUser } from './setups/sso'; import { logNewUser, logUser } from './setups/sso';
import { activateEmail, disableEmail } from './setups/2fa'; import { activateEmail, disableEmail } from './setups/2fa';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
@@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, { await utils.startVault(browser, testInfo, {
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: false, SSO_ONLY: true,
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM, 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); const mailBuffer = mailserver.buffer(users.user1.email);
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
await activateEmail(test, page, users.user1, mailBuffer); await activateEmail(test, page, users.user1, mailBuffer);
mailBuffer.close(); await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
});
test('Log and disable', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
await disableEmail(test, page, users.user1); await disableEmail(test, page, users.user1);
mailBuffer.close(); 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();
});
+6 -4
View File
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
await page.getByRole('button', { name: 'Other' }).click(); await page.getByRole('button', { name: 'Other' }).click();
// Unlock page // Unlock page
await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
@@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
// Landing page // Landing page
await page.goto('/'); await page.goto('/');
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
// Check that SSO login is available // Check that SSO login is available
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); 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); await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
}); });
test('No SSO login', async ({ page }, testInfo: TestInfo) => { test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, { await utils.restartVault(page, testInfo, {
SSO_ENABLED: false SSO_ENABLED: false
@@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
// Landing page // Landing page
await page.goto('/'); await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// No SSO button (rely on a correct selector checked in previous test) // 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); await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
// Can continue to Master password // Can continue to Master password
await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click(); 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);
}); });
@@ -67,17 +67,16 @@ test('invited with new account', async ({ page }) => {
await test.step('Create Vault account', async () => { await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').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 page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
}); });
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
@@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
await test.step('Redirect to Keycloak', async () => { await test.step('Redirect to Keycloak', async () => {
await page.goto(link); await page.goto(link);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
}); });
await test.step('Keycloak login', async () => { await test.step('Keycloak login', async () => {
@@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
await expect(page).toHaveTitle('Vaultwarden Web'); await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password); await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Unlock' }).click(); await page.getByRole('button', { name: 'Unlock' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
}); });
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
+24 -13
View File
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); 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 logUser(test, page, users.user1);
await orgs.policies(test, page, '/Test'); await orgs.policies(test, page, '/Test');
@@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.'); await utils.checkNotification(page, 'Edited policy Master password requirements.');
}); });
});
await utils.logout(test, page, users.user1);
test('Unlock trigger policyy', async ({ page }) => {
await test.step(`Unlock trigger policy`, async () => { await page.goto('/', { waitUntil: 'domcontentloaded' });
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 page.getByRole('button', { name: 'Unlock' }).click(); await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Update master password' })).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();
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.95.0" channel = "1.99.0"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"
+1 -1
View File
@@ -1,4 +1,4 @@
edition = "2021" edition = "2024"
max_width = 120 max_width = 120
newline_style = "Unix" newline_style = "Unix"
use_small_heuristics = "Off" use_small_heuristics = "Off"
+116 -89
View File
@@ -2,40 +2,40 @@ use std::{env, sync::LazyLock};
use reqwest::Method; use reqwest::Method;
use rocket::{ use rocket::{
Catcher, Route,
form::Form, form::Form,
http::{Cookie, CookieJar, MediaType, SameSite, Status}, http::{Cookie, CookieJar, MediaType, SameSite, Status},
request::{FromRequest, Outcome, Request}, request::{FromRequest, Outcome, Request},
response::{content::RawHtml as Html, Redirect}, response::{Redirect, content::RawHtml as Html},
serde::json::Json, serde::json::Json,
Catcher, Route,
}; };
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
CONFIG, VERSION,
api::{ api::{
ApiResult, EmptyResult, JsonResult, Notify,
core::{log_event, two_factor}, core::{log_event, two_factor},
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify, unregister_push_device,
}, },
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure}, auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
config::ConfigBuilder, config::ConfigBuilder,
db::{ db::{
backup_sqlite, get_sql_server_version, ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version,
models::{ models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
}, },
DbConn, DbConnType, ACTIVE_DB_TYPE,
}, },
error::{Error, MapResult}, error::{Error, MapResult},
http_client::make_http_request, http_client::make_http_request,
mail, mail,
sso::FAKE_SSO_IDENTIFIER, sso::FAKE_SSO_IDENTIFIER,
util::{ util::{
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size, FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString, get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
}, },
CONFIG, VERSION,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -93,8 +93,7 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
}); });
#[cfg(sqlite)] #[cfg(sqlite)]
static CAN_BACKUP: LazyLock<bool> = static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite));
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
#[cfg(not(sqlite))] #[cfg(not(sqlite))]
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false); static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
@@ -200,13 +199,7 @@ fn post_admin_login(
} }
// If the token is invalid, redirect to login page // If the token is invalid, redirect to login page
if !_validate_token(&data.token) { if validate_token(&data.token) {
error!("Invalid admin token. IP: {}", ip.ip);
Err(AdminResponse::Unauthorized(render_admin_login(
Some("Invalid admin token, please try again."),
redirect.as_deref(),
)))
} else {
// If the token received is valid, generate JWT and save it as a cookie // If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims(); let claims = generate_admin_claims();
let jwt = encode_jwt(&claims); let jwt = encode_jwt(&claims);
@@ -224,15 +217,21 @@ fn post_admin_login(
} else { } else {
Err(AdminResponse::Ok(render_admin_page())) Err(AdminResponse::Ok(render_admin_page()))
} }
} else {
error!("Invalid admin token. IP: {}", ip.ip);
Err(AdminResponse::Unauthorized(render_admin_login(
Some("Invalid admin token, please try again."),
redirect.as_deref(),
)))
} }
} }
fn _validate_token(token: &str) -> bool { fn validate_token(token: &str) -> bool {
match CONFIG.admin_token().as_ref() { match CONFIG.admin_token().as_ref() {
None => false, None => false,
Some(t) if t.starts_with("$argon2") => { Some(t) if t.starts_with("$argon2") => {
use argon2::password_hash::PasswordVerifier; use argon2::password_hash::PasswordVerifier;
match argon2::password_hash::PasswordHash::new(t) { match argon2::password_hash::phc::PasswordHash::new(t) {
Ok(h) => { Ok(h) => {
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance. // 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() argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
@@ -307,21 +306,14 @@ async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
#[post("/invite", format = "application/json", data = "<data>")] #[post("/invite", format = "application/json", data = "<data>")]
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult { async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
let data: InviteData = data.into_inner(); async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if User::find_by_mail(&data.email, &conn).await.is_some() {
err_code!("User already exists", Status::Conflict.code)
}
let mut user = User::new(&data.email, None);
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org_id: OrganizationId = if CONFIG.sso_enabled() { let org_id: OrganizationId = if CONFIG.sso_enabled() {
FAKE_SSO_IDENTIFIER.into() FAKE_SSO_IDENTIFIER.into()
} else { } else {
FAKE_ADMIN_UUID.into() FAKE_ADMIN_UUID.into()
}; };
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else { } else {
let invitation = Invitation::new(&user.email); let invitation = Invitation::new(&user.email);
@@ -329,7 +321,14 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
} }
} }
_generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; let data: InviteData = data.into_inner();
if User::find_by_mail(&data.email, &conn).await.is_some() {
err_code!("User already exists", Status::Conflict.code)
}
let mut user = User::new(&data.email, None);
generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
Ok(Json(user.to_json(&conn).await)) Ok(Json(user.to_json(&conn).await))
@@ -386,7 +385,7 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
None => json!("Never"), None => json!("Never"),
}; };
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new())); usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
users_json.push(usr); users_json.push(usr);
} }
@@ -426,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
for membership in memberships { for membership in memberships {
log_event( log_event(
EventType::OrganizationUserDeleted as i32, EventType::OrganizationUserDeleted,
&membership.uuid, &membership.uuid,
&membership.org_uuid, &membership.org_uuid,
&ACTING_ADMIN_USER.into(), &ACTING_ADMIN_USER.into(),
@@ -447,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
for membership in memberships { for membership in memberships {
log_event( log_event(
EventType::OrganizationUserUnlinkedSso as i32, EventType::OrganizationUserUnlinkedSso,
&membership.uuid, &membership.uuid,
&membership.org_uuid, &membership.org_uuid,
&ACTING_ADMIN_USER.into(), &ACTING_ADMIN_USER.into(),
@@ -469,10 +468,10 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
for device in Device::find_push_devices_by_user(&user.uuid, &conn).await { for device in Device::find_push_devices_by_user(&user.uuid, &conn).await {
match unregister_push_device(&device.push_uuid).await { match unregister_push_device(device.push_uuid.as_ref()).await {
Ok(r) => r, Ok(r) => r,
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"), Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
}; }
} }
} }
@@ -509,6 +508,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?; let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?; two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
user.totp_recover = None; user.totp_recover = None;
user.save(&conn).await user.save(&conn).await
@@ -528,7 +528,7 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
} else { } else {
FAKE_ADMIN_UUID.into() FAKE_ADMIN_UUID.into()
}; };
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else { } else {
Ok(()) Ok(())
@@ -554,9 +554,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = match MembershipType::from_str(&data.user_type.into_string()) { let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
Some(new_type) => new_type as i32, new_type as i32
None => err!("Invalid type"), } else {
err!("Invalid type")
}; };
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
@@ -571,7 +572,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
log_event( log_event(
EventType::OrganizationUserUpdated as i32, EventType::OrganizationUserUpdated,
&member_to_edit.uuid, &member_to_edit.uuid,
&data.org_uuid, &data.org_uuid,
&ACTING_ADMIN_USER.into(), &ACTING_ADMIN_USER.into(),
@@ -643,11 +644,11 @@ async fn has_http_access() -> bool {
} }
} }
use cached::proc_macro::cached; 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 /// 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 /// 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 /// Any cache will be lost if Vaultwarden is restarted
#[cached(time = 600, sync_writes = "default")] #[cached(ttl_secs = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) { 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 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 { if has_http_access {
@@ -656,42 +657,40 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) {
.await .await
{ {
Ok(r) => r.tag_name, Ok(r) => r.tag_name,
_ => "-".to_string(), _ => "-".to_owned(),
}, },
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await { match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
Ok(mut c) => { Ok(mut c) => {
c.sha.truncate(8); c.sha.truncate(8);
c.sha c.sha
} }
_ => "-".to_string(), _ => "-".to_owned(),
}, },
// Do not fetch the web-vault version when running within a container // Do not fetch the web-vault version when running within a container
// The web-vault version is embedded within the container it self, and should not be updated manually // The web-vault version is embedded within the container it self, and should not be updated manually
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
.await .await
{ {
Ok(r) => r.tag_name.trim_start_matches('v').to_string(), Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
_ => "-".to_string(), _ => "-".to_owned(),
}, },
) )
} else { } else {
("-".to_string(), "-".to_string(), "-".to_string()) ("-".to_owned(), "-".to_owned(), "-".to_owned())
} }
} }
async fn get_ntp_time(has_http_access: bool) -> String { async fn get_ntp_time(has_http_access: bool) -> String {
if has_http_access { if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await { for line in cf_trace.lines() {
for line in cf_trace.lines() { if let Some((key, value)) = line.split_once('=')
if let Some((key, value)) = line.split_once('=') { && key == "ts"
if key == "ts" { {
let ts = value.split_once('.').map_or(value, |(s, _)| s); let ts = value.split_once('.').map_or(value, |(s, _)| s);
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") { if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(); return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
}
break;
}
} }
break;
} }
} }
} }
@@ -718,6 +717,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")] #[get("/diagnostics")]
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> { async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
use chrono::prelude::*; use chrono::prelude::*;
@@ -734,7 +763,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
// Check if we are able to resolve DNS entries // Check if we are able to resolve DNS entries
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) { let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
Ok(Some(a)) => a.ip().to_string(), Ok(Some(a)) => a.ip().to_string(),
_ => "Unable to resolve domain name.".to_string(), _ => "Unable to resolve domain name.".to_owned(),
}; };
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await; let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
@@ -745,7 +774,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags( let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
&CONFIG.experimental_client_feature_flags(), &CONFIG.experimental_client_feature_flags(),
FeatureFlagFilter::InvalidOnly, &FeatureFlagFilter::InvalidOnly,
) )
.into_keys() .into_keys()
.collect(); .collect();
@@ -772,6 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await, "db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()), "admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "), "overrides": &CONFIG.get_overrides().join(", "),
"template_overrides": check_template_overrides().join(", "),
"invalid_feature_flags": invalid_feature_flags, "invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH, "host_arch": env::consts::ARCH,
"host_os": env::consts::OS, "host_os": env::consts::OS,
@@ -834,33 +864,30 @@ impl<'r> FromRequest<'r> for AdminToken {
type Error = &'static str; type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let ip = match ClientIp::from_request(request).await { let Outcome::Success(ip) = ClientIp::from_request(request).await else {
Outcome::Success(ip) => ip, err_handler!("Error getting Client IP")
_ => err_handler!("Error getting Client IP"),
}; };
if !CONFIG.disable_admin_token() { if !CONFIG.disable_admin_token() {
let cookies = request.cookies(); let cookies = request.cookies();
let access_token = match cookies.get(COOKIE_NAME) { let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
Some(cookie) => cookie.value(), cookie.value()
None => { } else {
let requested_page = let requested_page =
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string(); request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page // When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
// Else, return a 401 failure, which will be caught // Else, return a 401 failure, which will be caught
if requested_page.is_empty() { if requested_page.is_empty() {
return Outcome::Forward(Status::Unauthorized); return Outcome::Forward(Status::Unauthorized);
} else {
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
}
} }
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
}; };
if decode_admin(access_token).is_err() { if decode_admin(access_token).is_err() {
// Remove admin cookie // Remove admin cookie
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
return Outcome::Error((Status::Unauthorized, "Session expired")); return Outcome::Error((Status::Unauthorized, "Session expired"));
} }
} }
@@ -879,23 +906,23 @@ mod tests {
fn validate_web_vault_compare() { fn validate_web_vault_compare() {
// web_vault_compare(active, latest) // web_vault_compare(active, latest)
// Test normal versions // Test normal versions
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1); assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0); assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1); assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
// Test patched/+build.n versions // Test patched/+build.n versions
// Newer latest version // Newer latest version
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1); assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1); assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1); assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1); assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
// Equal versions // Equal versions
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0); assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0); assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
// Newer active version // Newer active version
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1); assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1); assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1); assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1); assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
} }
} }
+445 -207
View File
File diff suppressed because it is too large. Load diff
+380 -167
View File
File diff suppressed because it is too large. Load diff
+29 -25
View File
@@ -1,23 +1,23 @@
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use rocket::{serde::json::Json, Route}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
CONFIG,
api::{ api::{
core::{CipherSyncData, CipherSyncType},
EmptyResult, JsonResult, EmptyResult, JsonResult,
core::{CipherSyncData, CipherSyncType},
}, },
auth::{decode_emergency_access_invite, Headers}, auth::{Headers, decode_emergency_access_invite},
db::{ db::{
DbConn, DbPool,
models::{ models::{
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
}, },
DbConn, DbPool,
}, },
mail, mail,
util::NumberOrString, util::NumberOrString,
CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -55,7 +55,7 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
for ea in emergency_access_list { for ea in emergency_access_list {
if let Some(grantee) = ea.to_json_grantee_details(&conn).await { if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
emergency_access_list_json.push(grantee) emergency_access_list_json.push(grantee);
} }
} }
@@ -89,11 +89,14 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
check_emergency_access_enabled()?; check_emergency_access_enabled()?;
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await { if let Some(emergency_access) =
Some(emergency_access) => Ok(Json( EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
{
Ok(Json(
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"), emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
)), ))
None => err!("Emergency access not valid."), } else {
err!("Emergency access not valid.")
} }
} }
@@ -136,9 +139,10 @@ async fn post_emergency_access(
err!("Emergency access not valid.") err!("Emergency access not valid.")
}; };
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
Some(new_type) => new_type as i32, new_type as i32
None => err!("Invalid emergency access type."), } else {
err!("Invalid emergency access type.")
}; };
emergency_access.atype = new_type; emergency_access.atype = new_type;
@@ -205,9 +209,10 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let emergency_access_status = EmergencyAccessStatus::Invited as i32; let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
Some(new_type) => new_type as i32, new_type as i32
None => err!("Invalid emergency access type."), } else {
err!("Invalid emergency access type.")
}; };
let grantor_user = headers.user; let grantor_user = headers.user;
@@ -220,7 +225,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => { None => {
if !CONFIG.invitations_allowed() { if !CONFIG.invitations_allowed() {
err!(format!("Grantee user does not exist: {email}")) err!(format!("Grantee user does not exist: {}", email.escape_debug()))
} }
if !CONFIG.is_email_domain_allowed(&email) { if !CONFIG.is_email_domain_allowed(&email) {
@@ -342,12 +347,11 @@ async fn accept_invite(
err!("Claim email does not match current users email") err!("Claim email does not match current users email")
} }
let grantee_user = match User::find_by_mail(&claims.email, &conn).await { let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await {
Some(user) => { Invitation::take(&claims.email, &conn).await;
Invitation::take(&claims.email, &conn).await; user
user } else {
} err!("Invited user not found")
None => err!("Invited user not found"),
}; };
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
@@ -766,7 +770,7 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
} }
} }
} else { } else {
error!("Failed to get DB connection while searching emergency request timed out") error!("Failed to get DB connection while searching emergency request timed out");
} }
} }
@@ -825,6 +829,6 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
} }
} }
} else { } else {
error!("Failed to get DB connection while searching emergency notification reminder") error!("Failed to get DB connection while searching emergency notification reminder");
} }
} }
+96 -79
View File
@@ -1,18 +1,19 @@
use std::net::IpAddr; use std::net::IpAddr;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use rocket::{form::FromForm, serde::json::Json, Route}; use rocket::{Route, form::FromForm, serde::json::Json};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
api::{EmptyResult, JsonResult}, CONFIG,
api::{ApiResult, EmptyResult, JsonResult},
auth::{AdminHeaders, Headers}, auth::{AdminHeaders, Headers},
db::{ db::{
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
DbConn, DbPool, DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
}, },
error::MapResult,
util::parse_date, util::parse_date,
CONFIG,
}; };
/// ############################################################################################################### /// ###############################################################################################################
@@ -29,6 +30,14 @@ struct EventRange {
continuation_token: Option<String>, continuation_token: Option<String>,
} }
impl EventRange {
fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> {
let start_date = parse_date(&self.start).map_res("Invalid start date")?;
let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?;
Ok((start_date, end_date))
}
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")] #[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
@@ -38,21 +47,16 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// Return an empty vec when we org events are disabled. // Return an empty vec when we org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
Vec::with_capacity(0) let (start_date, end_date) = data.date_range()?;
} else {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await .await
.iter() .iter()
.map(|e| e.to_json()) .map(Event::to_json)
.collect() .collect()
} else {
Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -64,27 +68,16 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
#[get("/ciphers/<cipher_id>/events?<data..>")] #[get("/ciphers/<cipher_id>/events?<data..>")]
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult { async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
// Return an empty vec when we org events are disabled. // Return an empty vec when org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled()
Vec::with_capacity(0) && Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
} else { {
let mut events_json = Vec::with_capacity(0); let (start_date, end_date) = data.date_range()?;
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn) Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
.await } else {
.iter() Vec::new()
.map(|e| e.to_json())
.collect()
}
events_json
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -107,21 +100,16 @@ async fn get_user_events(
} }
// Return an empty vec when we org events are disabled. // Return an empty vec when we org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
Vec::with_capacity(0) let (start_date, end_date) = data.date_range()?;
} else {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await .await
.iter() .iter()
.map(|e| e.to_json()) .map(Event::to_json)
.collect() .collect()
} else {
Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -134,7 +122,8 @@ async fn get_user_events(
fn get_continuation_token(events_json: &[Value]) -> Option<&str> { fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
// When the length of the vec equals the max page_size there probably is more data // When the length of the vec equals the max page_size there probably is more data
// When it is less, then all events are loaded. // When it is less, then all events are loaded.
if events_json.len() as i64 == Event::PAGE_SIZE { #[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")]
if events_json.len() == Event::PAGE_SIZE as usize {
if let Some(last_event) = events_json.last() { if let Some(last_event) = events_json.last() {
last_event["date"].as_str() last_event["date"].as_str()
} else { } else {
@@ -173,10 +162,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
} }
for event in data.iter() { for event in data.iter() {
let event_date = parse_date(&event.date); let event_date = parse_date(&event.date).map_res("Invalid event date")?;
// Only the event types upstream accepts from clients, the rest are written server-side.
match event.r#type { match event.r#type {
1000..=1099 => { t if t == EventType::UserClientExportedVault as i32 => {
_log_user_event( log_user_event_impl(
event.r#type, event.r#type,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
@@ -186,9 +176,12 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
) )
.await; .await;
} }
1600..=1699 => { t if t == EventType::OrganizationClientExportedVault as i32 => {
if let Some(org_id) = &event.organization_id { // Only allow logging events for an organization the user is actually a member of.
_log_event( if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
{
log_event_impl(
event.r#type, event.r#type,
org_id, org_id,
org_id, org_id,
@@ -201,25 +194,49 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
} }
_ => { // Only the vault notification banner click is accepted from clients. The rest of
if let Some(cipher_uuid) = &event.cipher_id { // the 1500..=1599 range is written server-side and must not be forgeable by a client.
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await { t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
if let Some(org_id) = cipher.organization_uuid { if let Some(org_id) = &event.organization_id
_log_event( && let Some(membership) =
event.r#type, Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
cipher_uuid, {
&org_id, log_event_impl(
&headers.user.uuid, event.r#type,
headers.device.atype, &membership.uuid,
Some(event_date), org_id,
&headers.ip.ip, &headers.user.uuid,
&conn, headers.device.atype,
) Some(event_date),
.await; &headers.ip.ip,
} &conn,
} )
.await;
} }
} }
// Client-side cipher events (viewed, copied, toggled, autofilled).
1107..=1114 | 1117 | 1119..=1132 => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
&& let Some(org_id) = cipher.organization_uuid
{
log_event_impl(
event.r#type,
cipher_uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
Some(event_date),
&headers.ip.ip,
&conn,
)
.await;
}
}
_ => {}
} }
} }
Ok(()) Ok(())
@@ -229,10 +246,10 @@ pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32,
if !CONFIG.org_events_enabled() { if !CONFIG.org_events_enabled() {
return; return;
} }
_log_user_event(event_type, user_id, device_type, None, ip, conn).await; log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
} }
async fn _log_user_event( async fn log_user_event_impl(
event_type: i32, event_type: i32,
user_id: &UserId, user_id: &UserId,
device_type: i32, device_type: i32,
@@ -267,7 +284,7 @@ async fn _log_user_event(
} }
pub async fn log_event( pub async fn log_event(
event_type: i32, event_type: EventType,
source_uuid: &str, source_uuid: &str,
org_id: &OrganizationId, org_id: &OrganizationId,
act_user_id: &UserId, act_user_id: &UserId,
@@ -278,11 +295,11 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() { if !CONFIG.org_events_enabled() {
return; return;
} }
_log_event(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;
} }
#[allow(clippy::too_many_arguments)] #[expect(clippy::too_many_arguments)]
async fn _log_event( async fn log_event_impl(
event_type: i32, event_type: i32,
source_uuid: &str, source_uuid: &str,
org_id: &OrganizationId, org_id: &OrganizationId,
@@ -298,24 +315,24 @@ async fn _log_event(
// 1000..=1099 Are user events, they need to be logged via log_user_event() // 1000..=1099 Are user events, they need to be logged via log_user_event()
// Cipher Events // Cipher Events
1100..=1199 => { 1100..=1199 => {
event.cipher_uuid = Some(source_uuid.to_string().into()); event.cipher_uuid = Some(source_uuid.to_owned().into());
} }
// Collection Events // Collection Events
1300..=1399 => { 1300..=1399 => {
event.collection_uuid = Some(source_uuid.to_string().into()); event.collection_uuid = Some(source_uuid.to_owned().into());
} }
// Group Events // Group Events
1400..=1499 => { 1400..=1499 => {
event.group_uuid = Some(source_uuid.to_string().into()); event.group_uuid = Some(source_uuid.to_owned().into());
} }
// Org User Events // Org User Events
1500..=1599 => { 1500..=1599 => {
event.org_user_uuid = Some(source_uuid.to_string().into()); event.org_user_uuid = Some(source_uuid.to_owned().into());
} }
// 1600..=1699 Are organizational events, and they do not need the source_uuid // 1600..=1699 Are organizational events, and they do not need the source_uuid
// Policy Events // Policy Events
1700..=1799 => { 1700..=1799 => {
event.policy_uuid = Some(source_uuid.to_string().into()); event.policy_uuid = Some(source_uuid.to_owned().into());
} }
// Ignore others // Ignore others
_ => {} _ => {}
@@ -338,6 +355,6 @@ pub async fn event_cleanup_job(pool: DbPool) {
if let Ok(conn) = pool.get().await { if let Ok(conn) = pool.get().await {
Event::clean_events(&conn).await.ok(); Event::clean_events(&conn).await.ok();
} else { } else {
error!("Failed to get DB connection while trying to cleanup the events table") error!("Failed to get DB connection while trying to cleanup the events table");
} }
} }
+5 -4
View File
@@ -5,8 +5,8 @@ use crate::{
api::{EmptyResult, JsonResult, Notify, UpdateType}, api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers, auth::Headers,
db::{ db::{
models::{Folder, FolderId},
DbConn, DbConn,
models::{Folder, FolderId},
}, },
util::deser_opt_nonempty_str, util::deser_opt_nonempty_str,
}; };
@@ -29,9 +29,10 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
#[get("/folders/<folder_id>")] #[get("/folders/<folder_id>")]
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult { async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await { if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
Some(folder) => Ok(Json(folder.to_json())), Ok(Json(folder.to_json()))
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"), } else {
err!("Invalid folder", "Folder does not exist or belongs to another user")
} }
} }
+59 -42
View File
@@ -1,4 +1,6 @@
pub mod accounts; pub mod accounts;
pub mod two_factor;
mod ciphers; mod ciphers;
mod emergency_access; mod emergency_access;
mod events; mod events;
@@ -6,17 +8,32 @@ mod folders;
mod organizations; mod organizations;
mod public; mod public;
mod sends; mod sends;
pub mod two_factor;
pub use accounts::purge_auth_requests; pub use accounts::purge_auth_requests;
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
pub use events::{event_cleanup_job, log_event, log_user_event}; pub use events::{event_cleanup_job, log_event, log_user_event};
use reqwest::Method;
pub use sends::purge_sends; pub use sends::purge_sends;
use reqwest::Method;
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
use crate::{
CONFIG,
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers,
db::{
DbConn,
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
},
error::Error,
http_client::make_http_request,
mail,
util::{FeatureFlagFilter, parse_experimental_client_feature_flags},
};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains]; let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
let mut hibp_routes = routes![hibp_breach]; let mut hibp_routes = routes![hibp_breach];
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn]; let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
@@ -44,25 +61,6 @@ pub fn events_routes() -> Vec<Route> {
routes routes
} }
//
// Move this somewhere else
//
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
use crate::{
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers,
db::{
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
DbConn,
},
error::Error,
http_client::make_http_request,
mail,
util::{parse_experimental_client_feature_flags, FeatureFlagFilter},
CONFIG,
};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct GlobalDomain { struct GlobalDomain {
@@ -73,15 +71,17 @@ struct GlobalDomain {
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json"); const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
#[get("/settings/domains")] #[get("/settings/domains")]
fn get_eq_domains(headers: Headers) -> Json<Value> { fn get_settings_domains(headers: Headers) -> Json<Value> {
_get_eq_domains(&headers, false) get_eq_domains(&headers, false)
} }
fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> { fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
let user = &headers.user;
use serde_json::from_str; use serde_json::from_str;
let user = &headers.user;
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap(); let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap(); let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
@@ -110,28 +110,39 @@ struct EquivDomainData {
} }
#[post("/settings/domains", data = "<data>")] #[post("/settings/domains", data = "<data>")]
async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_settings_domains(
data: Json<EquivDomainData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
use serde_json::to_string;
let data: EquivDomainData = data.into_inner(); let data: EquivDomainData = data.into_inner();
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default(); let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
let equivalent_domains = data.equivalent_domains.unwrap_or_default(); let equivalent_domains = data.equivalent_domains.unwrap_or_default();
let mut user = headers.user; let mut user = headers.user;
use serde_json::to_string;
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string()); user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_owned());
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string()); user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned());
user.save(&conn).await?; user.save(&conn).await?;
nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await; nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
#[put("/settings/domains", data = "<data>")] #[put("/settings/domains", data = "<data>")]
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn put_settings_domains(
post_eq_domains(data, headers, conn, nt).await data: Json<EquivDomainData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
post_settings_domains(data, headers, conn, nt).await
} }
#[get("/hibp/breach?<username>")] #[get("/hibp/breach?<username>")]
@@ -204,11 +215,11 @@ fn config() -> Json<Value> {
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12 // Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31 // Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 // iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
let feature_states = parse_experimental_client_feature_flags( let mut feature_states = parse_experimental_client_feature_flags(
&CONFIG.experimental_client_feature_flags(), &CONFIG.experimental_client_feature_flags(),
FeatureFlagFilter::ValidOnly, &FeatureFlagFilter::ValidOnly,
); );
// Add default feature_states here if needed, currently no features are needed by default. feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns
@@ -217,14 +228,17 @@ fn config() -> Json<Value> {
// Version history: // Version history:
// - Individual cipher key encryption: 2024.2.0 // - Individual cipher key encryption: 2024.2.0
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0 // - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2025.12.0", "version": "2026.6.0",
"gitHash": option_env!("GIT_REV"), "gitHash": option_env!("GIT_REV"),
"server": { "server": {
"name": "Vaultwarden", "name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden" "url": "https://github.com/dani-garcia/vaultwarden"
}, },
"settings": { "settings": {
"disableUserRegistration": CONFIG.is_signup_disabled() "disableUserRegistration": CONFIG.is_signup_disabled(),
// When enabled, this setting signals to clients that onboarding interstitials
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
}, },
"environment": { "environment": {
"vault": domain, "vault": domain,
@@ -240,6 +254,10 @@ fn config() -> Json<Value> {
"vapidPublicKey": null "vapidPublicKey": null
}, },
"featureStates": feature_states, "featureStates": feature_states,
// Not supported right now
// Used for by clients to learn if the server requires extra work to establish a connection.
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
"communication": null,
"object": "config", "object": "config",
})) }))
} }
@@ -278,9 +296,8 @@ async fn accept_org_invite(
member.save(conn).await?; member.save(conn).await?;
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
Some(org) => org, err!("Organization not found.")
None => err!("Organization not found."),
}; };
// User was invited to an organization, so they must be confirmed manually after acceptance // User was invited to an organization, so they must be confirmed manually after acceptance
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
File diff suppressed because it is too large. Load diff
+70 -84
View File
@@ -1,23 +1,24 @@
use chrono::Utc;
use rocket::{
request::{FromRequest, Outcome},
serde::json::Json,
Request, Route,
};
use std::collections::HashSet; use std::collections::HashSet;
use chrono::Utc;
use rocket::{
Request, Route,
request::{FromRequest, Outcome},
serde::json::Json,
};
use crate::{ use crate::{
CONFIG,
api::EmptyResult, api::EmptyResult,
auth, auth,
db::{ db::{
DbConn,
models::{ models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization, Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
OrganizationApiKey, OrganizationId, User, OrganizationApiKey, OrganizationId, User,
}, },
DbConn,
}, },
mail, CONFIG, mail,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -57,25 +58,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0; let org_id = token.0;
let data = data.into_inner(); let data = data.into_inner();
// Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
for user_data in &data.members { for user_data in &data.members {
let mut user_created: bool = false; let mut user_created: bool = false;
if user_data.deleted { if user_data.deleted {
// If user is marked for deletion and it exists, revoke it // If user is marked for deletion and it exists, revoke it
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
// Only revoke a user if it is not the last confirmed owner let revoked = member.atype != MembershipType::Owner && member.revoke();
let revoked = if member.atype == MembershipType::Owner
&& member.status == MembershipStatus::Confirmed as i32
{
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't revoke the last owner");
false
} else {
member.revoke()
}
} else {
member.revoke()
};
let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
if revoked || ext_modified { if revoked || ext_modified {
member.save(&conn).await?; member.save(&conn).await?;
@@ -83,26 +72,32 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
} }
// If user is part of the organization, restore it // If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let restored = member.restore(); let mut restored = member.atype != MembershipType::Owner && member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
warn!("Not restoring {}: {e:?}", user_data.email);
member.revoke();
restored = false;
}
if restored || ext_modified { if restored || ext_modified {
member.save(&conn).await?; member.save(&conn).await?;
} }
} else { } else {
// If user is not part of the organization // If user is not part of the organization
let user = match User::find_by_mail(&user_data.email, &conn).await { let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await {
Some(user) => user, // exists in vaultwarden user
None => { } else {
// User does not exist yet // User does not exist yet
let mut new_user = User::new(&user_data.email, None); let mut new_user = User::new(&user_data.email, None);
new_user.save(&conn).await?; new_user.save(&conn).await?;
if !CONFIG.mail_enabled() { if !CONFIG.mail_enabled() {
Invitation::new(&new_user.email).save(&conn).await?; Invitation::new(&new_user.email).save(&conn).await?;
}
user_created = true;
new_user
} }
user_created = true;
new_user
}; };
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
MembershipStatus::Invited as i32 MembershipStatus::Invited as i32
@@ -110,9 +105,10 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
}; };
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await { let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
Some(org) => (org.name, org.billing_email), (org.name, org.billing_email)
None => err!("Error looking up organization"), } else {
err!("Error looking up organization")
}; };
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
@@ -123,37 +119,33 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
new_member.save(&conn).await?; new_member.save(&conn).await?;
if CONFIG.mail_enabled() { if CONFIG.mail_enabled()
if let Err(e) = && let Err(e) =
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
{ {
// Upon error delete the user, invite and org member records when needed // Upon error delete the user, invite and org member records when needed
if user_created { if user_created {
user.delete(&conn).await?; user.delete(&conn).await?;
} else { } else {
new_member.delete(&conn).await?; new_member.delete(&conn).await?;
}
err!(format!("Error sending invite: {e:?} "));
} }
err!(format!("Error sending invite: {e:?} "));
} }
} }
} }
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
for group_data in &data.groups { for group_data in &data.groups {
let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await { let group_uuid = if let Some(group) =
Some(group) => group.uuid, Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
None => { {
let mut group = Group::new( group.uuid
org_id.clone(), } else {
group_data.name.clone(), let mut group =
false, Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()));
Some(group_data.external_id.clone()), group.save(&conn).await?;
); group.uuid
group.save(&conn).await?;
group.uuid
}
}; };
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?; GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
@@ -174,18 +166,11 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
// Generate a HashSet to quickly verify if a member is listed or not. // Generate a HashSet to quickly verify if a member is listed or not.
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect(); let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
for member in Membership::find_by_org(&org_id, &conn).await { for member in Membership::find_by_org(&org_id, &conn).await {
if let Some(ref user_external_id) = member.external_id { if let Some(ref user_external_id) = member.external_id
if !sync_members.contains(user_external_id) { && !sync_members.contains(user_external_id)
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { && member.atype != MembershipType::Owner
// Removing owner, check that there is at least one other confirmed owner {
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 member.delete(&conn).await?;
{
warn!("Can't delete the last owner");
continue;
}
}
member.delete(&conn).await?;
}
} }
} }
} }
@@ -202,12 +187,14 @@ impl<'r> FromRequest<'r> for PublicToken {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = request.headers(); let headers = request.headers();
// Get access_token // Get access_token
let access_token: &str = match headers.get_one("Authorization") { let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
Some(a) => match a.rsplit("Bearer ").next() { if let Some(split) = a.rsplit("Bearer ").next() {
Some(split) => split, split
None => err_handler!("No access token provided"), } else {
}, err_handler!("No access token provided")
None => err_handler!("No access token provided"), }
} else {
err_handler!("No access token provided")
}; };
// Check JWT token is valid and get device and user from it // Check JWT token is valid and get device and user from it
let Ok(claims) = auth::decode_api_org(access_token) else { let Ok(claims) = auth::decode_api_org(access_token) else {
@@ -229,14 +216,13 @@ impl<'r> FromRequest<'r> for PublicToken {
// Check if claims.sub is org_api_key.uuid // Check if claims.sub is org_api_key.uuid
// Check if claims.client_sub is org_api_key.org_uuid // Check if claims.client_sub is org_api_key.org_uuid
let conn = match DbConn::from_request(request).await { let Outcome::Success(conn) = DbConn::from_request(request).await else {
Outcome::Success(conn) => conn, err_handler!("Error getting DB")
_ => err_handler!("Error getting DB"),
}; };
let Some(org_id) = claims.client_id.strip_prefix("organization.") else { let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
err_handler!("Malformed client_id") err_handler!("Malformed client_id")
}; };
let org_id: OrganizationId = org_id.to_string().into(); let org_id: OrganizationId = org_id.to_owned().into();
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else { let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
err_handler!("Invalid client_id") err_handler!("Invalid client_id")
}; };
+65 -187
View File
@@ -10,20 +10,20 @@ use rocket::{
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host}, auth::{Headers, Host, SendHeaders},
config::PathType, config::PathType,
db::{ db::{
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
DbConn, DbPool, DbConn, DbPool,
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
}, },
util::{save_temp_file, NumberOrString}, util::{NumberOrString, save_temp_file},
CONFIG,
}; };
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| { static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); let dt = DateTime::UNIX_EPOCH.naive_utc();
Device { Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(), uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
created_at: dt, created_at: dt,
@@ -39,14 +39,13 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
}); });
// The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
const SIZE_525_MB: i64 = 550_502_400; pub(crate) const SIZE_525_MB: i64 = 550_502_400;
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
routes![ routes![
get_sends, get_sends,
get_send, get_send,
post_send, post_send,
post_send_file,
post_access, post_access,
post_access_file, post_access_file,
put_send, put_send,
@@ -63,7 +62,7 @@ pub async fn purge_sends(pool: DbPool) {
if let Ok(conn) = pool.get().await { if let Ok(conn) = pool.get().await {
Send::purge(&conn).await; Send::purge(&conn).await;
} else { } else {
error!("Failed to get DB connection while purging sends") error!("Failed to get DB connection while purging sends");
} }
} }
@@ -71,13 +70,14 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SendData { pub struct SendData {
r#type: i32, r#type: i32,
key: String, pub key: String,
password: Option<String>, password: Option<String>,
max_access_count: Option<NumberOrString>, max_access_count: Option<NumberOrString>,
expiration_date: Option<DateTime<Utc>>, expiration_date: Option<DateTime<Utc>>,
deletion_date: DateTime<Utc>, deletion_date: DateTime<Utc>,
disabled: bool, disabled: bool,
hide_email: Option<bool>, hide_email: Option<bool>,
emails: Option<String>,
// Data field // Data field
name: String, name: String,
@@ -148,6 +148,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
); );
} }
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
send.user_uuid = Some(user_id); send.user_uuid = Some(user_id);
send.notes = data.notes; send.notes = data.notes;
@@ -168,7 +172,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
#[get("/sends")] #[get("/sends")]
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> { async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
let sends = Send::find_by_user(&headers.user.uuid, &conn); let sends = Send::find_by_user(&headers.user.uuid, &conn);
let sends_json: Vec<Value> = sends.await.iter().map(|s| s.to_json()).collect(); let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect();
Json(json!({ Json(json!({
"data": sends_json, "data": sends_json,
@@ -179,9 +183,10 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
#[get("/sends/<send_id>")] #[get("/sends/<send_id>")]
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
Some(send) => Ok(Json(send.to_json())), Ok(Json(send.to_json()))
None => err!("Send not found", "Invalid send uuid or does not belong to user"), } else {
err!("Send not found", "Invalid send uuid or does not belong to user")
} }
} }
@@ -193,7 +198,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
enforce_disable_hide_email_policy(&data, &headers, &conn).await?; enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
if data.r#type == SendType::File as i32 { if data.r#type == SendType::File as i32 {
err!("File sends should use /api/sends/file") err!("File sends should use /api/sends/file/v2")
} }
let mut send = create_send(data, headers.user.uuid)?; let mut send = create_send(data, headers.user.uuid)?;
@@ -210,93 +215,11 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} }
#[derive(FromForm)]
struct UploadData<'f> {
model: Json<SendData>,
data: TempFile<'f>,
}
#[derive(FromForm)] #[derive(FromForm)]
struct UploadDataV2<'f> { struct UploadDataV2<'f> {
data: TempFile<'f>, data: TempFile<'f>,
} }
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2).
// This method still exists to support older clients, probably need to remove it sometime.
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167
// 2025: This endpoint doesn't seem to exists anymore in the latest version
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
enforce_disable_send_policy(&headers, &conn).await?;
let UploadData {
model,
data,
} = data.into_inner();
let model = model.into_inner();
let Some(size) = data.len().to_i64() else {
err!("Invalid send size");
};
if size < 0 {
err!("Send size can't be negative")
}
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
let size_limit = match CONFIG.user_send_limit() {
Some(0) => err!("File uploads are disabled"),
Some(limit_kb) => {
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
err!("Existing sends overflow")
};
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
err!("Send size overflow");
};
if left <= 0 {
err!("Send storage limit reached! Delete some sends to free up space")
}
i64::clamp(left, 0, SIZE_525_MB)
}
None => SIZE_525_MB,
};
if size > size_limit {
err!("Send storage limit exceeded with this file");
}
let mut send = create_send(model, headers.user.uuid)?;
if send.atype != SendType::File as i32 {
err!("Send content is not a file");
}
let file_id = crate::crypto::generate_send_file_id();
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("id"), Value::String(file_id));
o.insert(String::from("size"), Value::Number(size.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size)));
}
send.data = serde_json::to_string(&data_value)?;
// Save the changes in the database
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendCreate,
&send,
&send.update_users_revision(&conn).await,
&headers.device,
&conn,
)
.await;
Ok(Json(send.to_json()))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")] #[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
@@ -310,9 +233,10 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
enforce_disable_hide_email_policy(&data, &headers, &conn).await?; enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
let file_length = match &data.file_length { let file_length = if let Some(m) = &data.file_length {
Some(m) => m.into_i64()?, m.into_i64()?
_ => err!("Invalid send length"), } else {
err!("Invalid send length")
}; };
if file_length < 0 { if file_length < 0 {
err!("Send size can't be negative") err!("Send size can't be negative")
@@ -369,7 +293,7 @@ pub struct SendFileData {
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")] #[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
async fn post_send_file_v2_data( async fn post_send_file_v2_data(
send_id: SendId, send_id: SendId,
file_id: SendFileId, file_id: SendFileId,
@@ -404,8 +328,8 @@ async fn post_send_file_v2_data(
"Send file name does not match.", "Send file name does not match.",
format!( format!(
"Expected file name '{}' got '{}'", "Expected file name '{}' got '{}'",
send_data.fileName, send_data.fileName.escape_debug(),
raw_file_name.dangerous_unsafe_unsanitized_raw() raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
) )
), ),
_ => err!("Send file name does not match or is not provided."), _ => err!("Send file name does not match or is not provided."),
@@ -439,59 +363,22 @@ async fn post_send_file_v2_data(
Ok(()) Ok(())
} }
#[derive(Deserialize)] #[post("/sends/access")]
#[serde(rename_all = "camelCase")] async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
pub struct SendAccessData { let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
pub password: Option<String>,
}
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() {
if let Some(max_access_count) = send.max_access_count {
if send.access_count >= max_access_count {
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
}
if let Some(expiration) = send.expiration_date {
if Utc::now().naive_utc() >= expiration {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)),
None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401),
}
}
// Files are incremented during the download // Files are incremented during the download
if send.atype == SendType::Text as i32 { if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
send.access_count += 1; err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access(send, conn, nt).await
}
send.save(&conn).await?; async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
@@ -504,51 +391,38 @@ async fn post_access(
Ok(Json(send.to_json_access(&conn).await)) Ok(Json(send.to_json_access(&conn).await))
} }
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")] #[post("/sends/access/file/<file_id>", rank = 1)]
async fn post_access_file( async fn post_access_file(
send_id: SendId,
file_id: SendFileId, file_id: SendFileId,
data: Json<SendAccessData>, headers: SendHeaders,
host: Host, host: Host,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() {
if let Some(max_access_count) = send.max_access_count {
if send.access_count >= max_access_count {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if let Some(expiration) = send.expiration_date {
if Utc::now().naive_utc() >= expiration {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
check_send_file_id(&send, &file_id)?;
if send.disabled { if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access_file(send, file_id, host, conn, nt).await
}
if send.password_hash.is_some() { fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
match data.into_inner().password { if send.atype != SendType::File as i32 {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } err!("Send is not a file type send.");
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
} }
match serde_json::from_str::<SendFileData>(&send.data) {
Ok(data) if &data.id == file_id => Ok(()),
_ => err_code!(SEND_INACCESSIBLE_MSG, 404),
}
}
send.access_count += 1; async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
send.save(&conn).await?;
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
@@ -561,29 +435,29 @@ async fn post_access_file(
Ok(Json(json!({ Ok(Json(json!({
"object": "send-fileDownload", "object": "send-fileDownload",
"id": file_id, "id": file_id,
"url": download_url(&host, &send_id, &file_id).await?, "url": download_url(&host, &send.uuid, &file_id).await?,
}))) })))
} }
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> { async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { if crate::storage::is_fs_operator(&operator) {
let token_claims = crate::auth::generate_send_claims(send_id, file_id); let token_claims = crate::auth::generate_send_claims(send_id, file_id);
let token = crate::auth::encode_jwt(&token_claims); let token = crate::auth::encode_jwt(&token_claims);
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host)) Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", host.host))
} else { } else {
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_secs(5 * 60)).await?.uri().to_string()) Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
} }
} }
#[get("/sends/<send_id>/<file_id>?<t>")] #[get("/sends/<send_id>/<file_id>?<t>")]
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> { async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
if let Ok(claims) = crate::auth::decode_send(t) { if let Ok(claims) = crate::auth::decode_send(t)
if claims.sub == format!("{send_id}/{file_id}") { && claims.sub == format!("{send_id}/{file_id}")
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok(); {
} return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
} }
None None
} }
@@ -599,12 +473,16 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
err!("Send not found", "Send send_id is invalid or does not belong to user") err!("Send not found", "Send send_id is invalid or does not belong to user")
}; };
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} }
pub async fn update_send_from_data( async fn update_send_from_data(
send: &mut Send, send: &mut Send,
data: SendData, data: SendData,
headers: &Headers, headers: &Headers,
+12 -11
View File
@@ -1,14 +1,13 @@
use data_encoding::BASE32; use data_encoding::BASE32;
use rocket::serde::json::Json; use rocket::{Route, serde::json::Json};
use rocket::Route;
use crate::{ use crate::{
api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData}, api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
auth::{ClientIp, Headers}, auth::{ClientIp, Headers},
crypto, crypto,
db::{ db::{
models::{EventType, TwoFactor, TwoFactorType, UserId},
DbConn, DbConn,
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
}, },
util::NumberOrString, util::NumberOrString,
}; };
@@ -70,9 +69,10 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
.await?; .await?;
// Validate key as base32 and 20 bytes length // Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) { let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
Ok(decoded) => decoded, decoded
_ => err!("Invalid totp secret"), } else {
err!("Invalid totp secret")
}; };
if decoded_key.len() != 20 { if decoded_key.len() != 20 {
@@ -82,7 +82,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
// Validate the token provided with the key, and save new twofactor // Validate the token provided with the key, and save new twofactor
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?; validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
_generate_recover_code(&mut user, &conn).await; generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
@@ -119,7 +119,7 @@ pub async fn validate_totp_code(
ip: &ClientIp, ip: &ClientIp,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
use totp_lite::{totp_custom, Sha1}; use totp_lite::{Sha1, totp_custom};
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else { let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
err!("Invalid TOTP secret") err!("Invalid TOTP secret")
@@ -128,7 +128,7 @@ pub async fn validate_totp_code(
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
{ {
Some(tf) => tf, Some(tf) => tf,
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()), _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
}; };
// The amount of steps back and forward in time // The amount of steps back and forward in time
@@ -145,7 +145,7 @@ pub async fn validate_totp_code(
// We need to calculate the time offsite and cast it as an u64. // We need to calculate the time offsite and cast it as an u64.
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64. // Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
let time = (current_timestamp + step * 30i64) as u64; let time: u64 = (current_timestamp + step * 30i64).cast_unsigned();
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time); let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
// Check the given code equals the generated and if the time_step is larger then the one last used. // Check the given code equals the generated and if the time_step is larger then the one last used.
@@ -200,6 +200,7 @@ async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: He
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
if twofactor.data == data.key { if twofactor.data == data.key {
twofactor.delete(&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) log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await; .await;
} else { } else {
+15 -129
View File
@@ -1,22 +1,20 @@
use chrono::Utc; use chrono::Utc;
use data_encoding::BASE64; use rocket::{Route, serde::json::Json};
use rocket::serde::json::Json;
use rocket::Route;
use crate::{ use crate::{
CONFIG,
api::{ api::{
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
PasswordOrOtpData, core::two_factor::generate_recover_code,
}, },
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
DbConn, DbConn,
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
}, },
error::MapResult, error::MapResult,
http_client::make_http_request, http_client::make_http_request,
CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -82,8 +80,7 @@ enum DuoStatus {
impl DuoStatus { impl DuoStatus {
fn data(self) -> Option<DuoData> { fn data(self) -> Option<DuoData> {
match self { match self {
DuoStatus::Global(data) => Some(data), DuoStatus::Global(data) | DuoStatus::User(data) => Some(data),
DuoStatus::User(data) => Some(data),
DuoStatus::Disabled(_) => None, DuoStatus::Disabled(_) => None,
} }
} }
@@ -182,7 +179,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str); let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
twofactor.save(&conn).await?; twofactor.save(&conn).await?;
_generate_recover_code(&mut user, &conn).await; generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
@@ -201,14 +198,14 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
} }
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
use reqwest::{header, Method}; use reqwest::{Method, header};
use std::str::FromStr; use std::str::FromStr;
// https://duo.com/docs/authapi#api-details // https://duo.com/docs/authapi#api-details
let url = format!("https://{}{path}", &data.host); let url = format!("https://{}{path}", data.host);
let date = Utc::now().to_rfc2822(); let dt = Utc::now().to_rfc2822();
let username = &data.ik; let username = &data.ik;
let fields = [&date, method, &data.host, path, params]; let fields = [&dt, method, &data.host, path, params];
let password = crypto::hmac_sign(&data.sk, &fields.join("\n")); let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
let m = Method::from_str(method).unwrap_or_default(); let m = Method::from_str(method).unwrap_or_default();
@@ -216,7 +213,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
make_http_request(m, &url)? make_http_request(m, &url)?
.basic_auth(username, Some(password)) .basic_auth(username, Some(password))
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)") .header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
.header(header::DATE, date) .header(header::DATE, dt)
.send() .send()
.await? .await?
.error_for_status()?; .error_for_status()?;
@@ -224,13 +221,6 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
Ok(()) Ok(())
} }
const DUO_EXPIRE: i64 = 300;
const APP_EXPIRE: i64 = 3600;
const AUTH_PREFIX: &str = "AUTH";
const DUO_PREFIX: &str = "TX";
const APP_PREFIX: &str = "APP";
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
let type_ = TwoFactorType::Duo as i32; let type_ = TwoFactorType::Duo as i32;
@@ -253,117 +243,13 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
DuoStatus::Disabled(false) DuoStatus::Disabled(false)
} }
// let (ik, sk, ak, host) = get_duo_keys(); // let (ik, sk, host) = get_duo_keys_email();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> {
let data = match User::find_by_mail(email, conn).await { let data = match User::find_by_mail(email, conn).await {
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
_ => DuoData::global(), _ => DuoData::global(),
} }
.map_res("Can't fetch Duo Keys")?; .map_res("Can't fetch Duo Keys")?;
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host)) Ok((data.ik, data.sk, data.host))
}
pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> {
let now = Utc::now().timestamp();
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE);
let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE);
Ok((format!("{duo_sign}:{app_sign}"), host))
}
fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String {
let val = format!("{email}|{ikey}|{expire}");
let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes()));
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
}
pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
let split: Vec<&str> = response.split(':').collect();
if split.len() != 2 {
err!(
"Invalid response length",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
);
}
let auth_sig = split[0];
let app_sig = split[1];
let now = Utc::now().timestamp();
let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?;
let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?;
let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?;
if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) {
err!(
"Error validating duo authentication",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
}
Ok(())
}
fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult<String> {
let split: Vec<&str> = val.split('|').collect();
if split.len() != 3 {
err!("Invalid value length")
}
let u_prefix = split[0];
let u_b64 = split[1];
let u_sig = split[2];
let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}"));
if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) {
err!("Duo signatures don't match")
}
if u_prefix != prefix {
err!("Prefixes don't match")
}
let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else {
err!("Invalid Duo cookie encoding")
};
let Ok(cookie) = String::from_utf8(cookie_vec) else {
err!("Invalid Duo cookie encoding")
};
let cookie_split: Vec<&str> = cookie.split('|').collect();
if cookie_split.len() != 3 {
err!("Invalid cookie length")
}
let username = cookie_split[0];
let u_ikey = cookie_split[1];
let expire = cookie_split[2];
if !crypto::ct_eq(ikey, u_ikey) {
err!("Invalid ikey")
}
let expire: i64 = match expire.parse() {
Ok(e) => e,
Err(_) => err!("Invalid expire time"),
};
if time >= expire {
err!("Expired authorization")
}
Ok(username.into())
} }
+23 -25
View File
@@ -1,23 +1,24 @@
use std::collections::HashMap;
use chrono::Utc; use chrono::Utc;
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use reqwest::{header, StatusCode}; use reqwest::{StatusCode, header};
use ring::digest::{digest, Digest, SHA512_256}; use ring::digest::{Digest, SHA512_256, digest};
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap; use url::Url;
use crate::{ use crate::{
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, CONFIG,
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
crypto, crypto,
db::{ db::{
models::{DeviceId, EventType, TwoFactorDuoContext},
DbConn, DbPool, DbConn, DbPool,
models::{DeviceId, EventType, TwoFactorDuoContext},
}, },
error::Error, error::Error,
http_client::make_http_request, http_client::make_http_request,
CONFIG,
}; };
use url::Url;
// The location on this service that Duo should redirect users to. For us, this is a bridge // The location on this service that Duo should redirect users to. For us, this is a bridge
// built in to the Bitwarden clients. // built in to the Bitwarden clients.
@@ -124,7 +125,7 @@ impl DuoClient {
ClientAssertion { ClientAssertion {
iss: self.client_id.clone(), iss: self.client_id.clone(),
sub: self.client_id.clone(), sub: self.client_id.clone(),
aud: url.to_string(), aud: url.to_owned(),
exp: now + JWT_VALIDITY_SECS, exp: now + JWT_VALIDITY_SECS,
jti: jwt_id, jti: jwt_id,
iat: now, iat: now,
@@ -302,7 +303,7 @@ impl DuoClient {
if !(matching_nonces && matching_usernames) { if !(matching_nonces && matching_usernames) {
err!("Error validating Duo authorization, nonce or username mismatch.") err!("Error validating Duo authorization, nonce or username mismatch.")
}; }
Ok(()) Ok(())
} }
@@ -347,7 +348,7 @@ pub async fn purge_duo_contexts(pool: DbPool) {
if let Ok(conn) = pool.get().await { if let Ok(conn) = pool.get().await {
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await; TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
} else { } else {
error!("Failed to get DB connection while purging expired Duo authentications") error!("Failed to get DB connection while purging expired Duo authentications");
} }
} }
@@ -382,7 +383,7 @@ pub async fn get_duo_auth_url(
device_identifier: &DeviceId, device_identifier: &DeviceId,
conn: &DbConn, conn: &DbConn,
) -> Result<String, Error> { ) -> Result<String, Error> {
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
let callback_url = match make_callback_url(client_id) { let callback_url = match make_callback_url(client_id) {
Ok(url) => url, Ok(url) => url,
@@ -394,7 +395,7 @@ pub async fn get_duo_auth_url(
match client.health_check().await { match client.health_check().await {
Ok(()) => {} Ok(()) => {}
Err(e) => return Err(e), Err(e) => return Err(e),
}; }
// Generate random OAuth2 state and OIDC Nonce // Generate random OAuth2 state and OIDC Nonce
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH); let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
@@ -434,20 +435,17 @@ pub async fn validate_duo_login(
let code = split[0]; let code = split[0];
let state = split[1]; let state = split[1];
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
// Get the context by the state reported by the client. If we don't have one, // Get the context by the state reported by the client. If we don't have one,
// it means the context is either missing or expired. // it means the context is either missing or expired.
let ctx = match extract_context(state, conn).await { let Some(ctx) = extract_context(state, conn).await else {
Some(c) => c, err!(
None => { "Error validating duo authentication",
err!( ErrorEvent {
"Error validating duo authentication", event: EventType::UserFailedLogIn2fa
ErrorEvent { }
event: EventType::UserFailedLogIn2fa )
}
)
}
}; };
// Context validation steps // Context validation steps
@@ -476,13 +474,13 @@ pub async fn validate_duo_login(
match client.health_check().await { match client.health_check().await {
Ok(()) => {} Ok(()) => {}
Err(e) => return Err(e), Err(e) => return Err(e),
}; }
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes()); let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
let hash: String = HEXLOWER.encode(d.as_ref()); let hash: String = HEXLOWER.encode(d.as_ref());
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await { match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
Ok(_) => Ok(()), Ok(()) => Ok(()),
Err(_) => { Err(_) => {
err!( err!(
"Error validating duo authentication", "Error validating duo authentication",
+42 -29
View File
@@ -1,20 +1,20 @@
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use rocket::serde::json::Json; use rocket::{Route, serde::json::Json};
use rocket::Route;
use crate::{ use crate::{
CONFIG,
api::{ api::{
core::{log_user_event, two_factor::_generate_recover_code},
EmptyResult, JsonResult, PasswordOrOtpData, EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code},
}, },
auth::{ClientHeaders, Headers}, auth::{ClientHeaders, Headers},
crypto, crypto,
db::{ db::{
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
DbConn, DbConn,
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
}, },
error::{Error, MapResult}, error::{Error, MapResult},
mail, CONFIG, mail,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -25,7 +25,7 @@ pub fn routes() -> Vec<Route> {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SendEmailLoginData { struct SendEmailLoginData {
#[serde(alias = "DeviceIdentifier")] #[serde(alias = "DeviceIdentifier")]
device_identifier: DeviceId, device_identifier: Option<DeviceId>,
#[serde(alias = "Email")] #[serde(alias = "Email")]
email: Option<String>, email: Option<String>,
#[serde(alias = "MasterPasswordHash")] #[serde(alias = "MasterPasswordHash")]
@@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email { let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else { 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: {}.", client_headers.ip.ip, email.escape_debug())
)
}; };
if let Some(master_password_hash) = master_password_hash { if let Some(master_password_hash) = master_password_hash {
// Check password // Check password
if !user.check_valid_password(master_password_hash) { 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: {}.", client_headers.ip.ip, email.escape_debug())
)
} }
} else if let Some(auth_request_id) = auth_request_id { } else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@@ -91,9 +97,15 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
user user
} else { } else {
let Some(device_identifier) = &data.device_identifier else {
err!("No device identifier has been submitted.")
};
// SSO login only sends device id, so we get the user by the most recently used device // SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else { 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 user
@@ -176,7 +188,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
} }
let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
let twofactor_data = EmailTokenData::new(data.email, generated_token); let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
// Uses EmailVerificationChallenge as type to show that it's not verified yet. // Uses EmailVerificationChallenge as type to show that it's not verified yet.
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
@@ -229,7 +241,7 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
twofactor.data = email_data.to_json(); twofactor.data = email_data.to_json();
twofactor.save(&conn).await?; twofactor.save(&conn).await?;
_generate_recover_code(&mut user, &conn).await; generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
@@ -281,9 +293,9 @@ pub async fn validate_email_code_str(
twofactor.data = email_data.to_json(); twofactor.data = email_data.to_json();
twofactor.save(conn).await?; twofactor.save(conn).await?;
let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
let max_time = CONFIG.email_expiration_time() as i64; let max_time = CONFIG.email_expiration_time().cast_signed();
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
err!( err!(
"Token has expired", "Token has expired",
ErrorEvent { ErrorEvent {
@@ -310,10 +322,10 @@ pub struct EmailTokenData {
} }
impl EmailTokenData { impl EmailTokenData {
pub fn new(email: String, token: String) -> EmailTokenData { pub fn new(email: String, token: Option<String>) -> EmailTokenData {
EmailTokenData { EmailTokenData {
email, email,
last_token: Some(token), last_token: token,
token_sent: Utc::now().timestamp(), token_sent: Utc::now().timestamp(),
attempts: 0, attempts: 0,
} }
@@ -339,9 +351,10 @@ impl EmailTokenData {
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> { pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string); let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
match res { if let Ok(x) = res {
Ok(x) => Ok(x), Ok(x)
Err(_) => err!("Could not decode EmailTokenData from string"), } else {
err!("Could not decode EmailTokenData from string")
} }
} }
} }
@@ -350,7 +363,8 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
if user.verified_at.is_none() { if user.verified_at.is_none() {
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!"); err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
} }
let twofactor_data = EmailTokenData::new(user.email.clone(), String::new()); // The token is set when the first code is sent
let twofactor_data = EmailTokenData::new(user.email.clone(), None);
let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json()); let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json());
twofactor.save(conn).await twofactor.save(conn).await
} }
@@ -359,18 +373,17 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
pub fn obscure_email(email: &str) -> String { pub fn obscure_email(email: &str) -> String {
let split: Vec<&str> = email.rsplitn(2, '@').collect(); let split: Vec<&str> = email.rsplitn(2, '@').collect();
let mut name = split[1].to_string(); let mut name = split[1].to_owned();
let domain = &split[0]; let domain = &split[0];
let name_size = name.chars().count(); let name_size = name.chars().count();
let new_name = match name_size { let new_name = if let 1..=3 = name_size {
1..=3 => "*".repeat(name_size), "*".repeat(name_size)
_ => { } else {
let stars = "*".repeat(name_size - 2); let stars = "*".repeat(name_size - 2);
name.truncate(2); name.truncate(2);
format!("{name}{stars}") format!("{name}{stars}")
}
}; };
format!("{new_name}@{domain}") format!("{new_name}@{domain}")
+23 -48
View File
@@ -1,28 +1,27 @@
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use data_encoding::BASE32; use data_encoding::BASE32;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::serde::json::Json; use rocket::{Route, serde::json::Json};
use rocket::Route;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
CONFIG,
api::{ api::{
core::{log_event, log_user_event},
EmptyResult, JsonResult, PasswordOrOtpData, EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_event, log_user_event},
}, },
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
},
DbConn, DbPool, DbConn, DbPool,
models::{
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
}, },
mail, mail,
util::NumberOrString, util::NumberOrString,
CONFIG,
}; };
pub mod authenticator; pub mod authenticator;
@@ -37,7 +36,7 @@ fn has_global_duo_credentials() -> bool {
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some() CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
} }
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool { pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool {
#[derive(Deserialize)] #[derive(Deserialize)]
struct DuoProviderData { struct DuoProviderData {
host: String, host: String,
@@ -46,7 +45,7 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
} }
match provider_type { match provider_type {
TwoFactorType::Authenticator => true, TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true,
TwoFactorType::Email => CONFIG._enable_email_2fa(), TwoFactorType::Email => CONFIG._enable_email_2fa(),
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => { TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
provider_data provider_data
@@ -59,7 +58,6 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
} }
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(), TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(), TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
TwoFactorType::RecoveryCode => true,
TwoFactorType::U2f TwoFactorType::U2f
| TwoFactorType::U2fRegisterChallenge | TwoFactorType::U2fRegisterChallenge
| TwoFactorType::U2fLoginChallenge | TwoFactorType::U2fLoginChallenge
@@ -71,13 +69,7 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
} }
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut routes = routes![ let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings];
get_twofactor,
get_recover,
disable_twofactor,
disable_twofactor_put,
get_device_verification_settings,
];
routes.append(&mut authenticator::routes()); routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes()); routes.append(&mut duo::routes());
@@ -96,7 +88,7 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
.iter() .iter()
.filter_map(|tf| { .filter_map(|tf| {
let provider_type = TwoFactorType::from_i32(tf.atype)?; let provider_type = TwoFactorType::from_i32(tf.atype)?;
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf)) is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
}) })
.collect(); .collect();
@@ -120,7 +112,7 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
}))) })))
} }
async fn _generate_recover_code(user: &mut User, conn: &DbConn) { async fn generate_recover_code(user: &mut User, conn: &DbConn) {
if user.totp_recover.is_none() { if user.totp_recover.is_none() {
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32); let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
user.totp_recover = Some(totp_recover); user.totp_recover = Some(totp_recover);
@@ -136,7 +128,7 @@ struct DisableTwoFactorData {
r#type: NumberOrString, r#type: NumberOrString,
} }
#[post("/two-factor/disable", data = "<data>")] #[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult { async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner(); let data: DisableTwoFactorData = data.into_inner();
let user = headers.user; let user = headers.user;
@@ -153,6 +145,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 { if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&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) log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await; .await;
} }
@@ -168,11 +161,6 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
}))) })))
} }
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor_put(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
disable_twofactor(data, headers, conn).await
}
pub async fn enforce_2fa_policy( pub async fn enforce_2fa_policy(
user: &User, user: &User,
act_user_id: &UserId, act_user_id: &UserId,
@@ -180,9 +168,7 @@ pub async fn enforce_2fa_policy(
ip: &std::net::IpAddr, ip: &std::net::IpAddr,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
for member in for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
{
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org // Policy only applies to non-Owner/non-Admin members who have accepted joining the org
if member.atype < MembershipType::Admin { if member.atype < MembershipType::Admin {
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
@@ -194,7 +180,7 @@ pub async fn enforce_2fa_policy(
member.save(conn).await?; member.save(conn).await?;
log_event( log_event(
EventType::OrganizationUserRevoked as i32, EventType::OrganizationUserRevoked,
&member.uuid, &member.uuid,
&member.org_uuid, &member.org_uuid,
act_user_id, act_user_id,
@@ -217,7 +203,7 @@ pub async fn enforce_2fa_policy_for_org(
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() { for member in Membership::find_confirmed_by_org(org_id, conn).await {
// Don't enforce the policy for Admins and Owners. // Don't enforce the policy for Admins and Owners.
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
@@ -228,16 +214,8 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke(); member.revoke();
member.save(conn).await?; member.save(conn).await?;
log_event( log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
EventType::OrganizationUserRevoked as i32, .await;
&member.uuid,
org_id,
act_user_id,
device_type,
ip,
conn,
)
.await;
} }
} }
@@ -251,12 +229,9 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
return; return;
} }
let conn = match pool.get().await { let Ok(conn) = pool.get().await else {
Ok(conn) => conn, error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
_ => { return;
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
return;
}
}; };
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
@@ -278,7 +253,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
) )
.await .await
{ {
Ok(_) => { Ok(()) => {
if let Err(e) = login.delete(&conn).await { if let Err(e) = login.delete(&conn).await {
error!("Error deleting incomplete 2FA record: {e:#?}"); error!("Error deleting incomplete 2FA record: {e:#?}");
} }
+16 -10
View File
@@ -1,16 +1,17 @@
use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds};
use rocket::{serde::json::Json, Route}; use rocket::{Route, serde::json::Json};
use crate::{ use crate::{
CONFIG,
api::EmptyResult, api::EmptyResult,
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
models::{TwoFactor, TwoFactorType, UserId},
DbConn, DbConn,
models::{TwoFactor, TwoFactorType, UserId},
}, },
error::{Error, MapResult}, error::{Error, MapResult},
mail, CONFIG, mail,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -44,9 +45,10 @@ impl ProtectedActionData {
pub fn from_json(string: &str) -> Result<Self, Error> { pub fn from_json(string: &str) -> Result<Self, Error> {
let res: Result<Self, serde_json::Error> = serde_json::from_str(string); let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
match res { if let Ok(x) = res {
Ok(x) => Ok(x), Ok(x)
Err(_) => err!("Could not decode ProtectedActionData from string"), } else {
err!("Could not decode ProtectedActionData from string")
} }
} }
@@ -62,7 +64,9 @@ impl ProtectedActionData {
#[post("/accounts/request-otp")] #[post("/accounts/request-otp")]
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
if !CONFIG.mail_enabled() { if !CONFIG.mail_enabled() {
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); err!(
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
);
} }
let user = headers.user; let user = headers.user;
@@ -102,7 +106,9 @@ struct ProtectedActionVerify {
#[post("/accounts/verify-otp", data = "<data>")] #[post("/accounts/verify-otp", data = "<data>")]
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult { async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
if !CONFIG.mail_enabled() { if !CONFIG.mail_enabled() {
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); err!(
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
);
} }
let user = headers.user; let user = headers.user;
@@ -133,7 +139,7 @@ pub async fn validate_protected_action_otp(
} }
// Check if the token has expired (Using the email 2fa expiration time) // Check if the token has expired (Using the email 2fa expiration time)
let max_time = CONFIG.email_expiration_time() as i64; let max_time = CONFIG.email_expiration_time().cast_signed();
if pa_data.time_since_sent().num_seconds() > max_time { if pa_data.time_since_sent().num_seconds() > max_time {
pa.delete(conn).await?; pa.delete(conn).await?;
err!("Token has expired") err!("Token has expired")
+46 -45
View File
@@ -1,34 +1,35 @@
use crate::{ use std::{str::FromStr, sync::LazyLock, time::Duration};
api::{
core::{log_user_event, two_factor::_generate_recover_code}, use rocket::{Route, serde::json::Json};
EmptyResult, JsonResult, PasswordOrOtpData,
},
auth::Headers,
crypto::ct_eq,
db::{
models::{EventType, TwoFactor, TwoFactorType, UserId},
DbConn,
},
error::Error,
util::NumberOrString,
CONFIG,
};
use rocket::serde::json::Json;
use rocket::Route;
use serde_json::Value; use serde_json::Value;
use std::str::FromStr;
use std::sync::LazyLock;
use std::time::Duration;
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; use webauthn_rs::{
use webauthn_rs::{Webauthn, WebauthnBuilder}; Webauthn, WebauthnBuilder,
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
};
use webauthn_rs_proto::{ use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
RequestAuthenticationExtensions, UserVerificationPolicy, RequestAuthenticationExtensions, UserVerificationPolicy,
}; };
use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code},
},
auth::Headers,
crypto::ct_eq,
db::{
DbConn,
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
},
error::Error,
util::NumberOrString,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| { static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let domain = CONFIG.domain(); let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin(); let domain_origin = CONFIG.domain_origin();
@@ -38,7 +39,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed") .expect("Creating WebauthnBuilder failed")
.rp_name(&domain) .rp_name(&domain)
.timeout(Duration::from_millis(60000)); .timeout(Duration::from_mins(1));
webauthn.build().expect("Building Webauthn failed") webauthn.build().expect("Building Webauthn failed")
}); });
@@ -149,7 +150,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
)?; )?;
let mut state = serde_json::to_value(&state)?; let mut state = serde_json::to_value(&state)?;
state["rs"]["policy"] = Value::String("discouraged".to_string()); state["rs"]["policy"] = Value::String("discouraged".to_owned());
state["rs"]["extensions"].as_object_mut().unwrap().clear(); state["rs"]["extensions"].as_object_mut().unwrap().clear();
let type_ = TwoFactorType::WebauthnRegisterChallenge; let type_ = TwoFactorType::WebauthnRegisterChallenge;
@@ -265,13 +266,12 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
// Retrieve and delete the saved challenge state // Retrieve and delete the saved challenge state
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
Some(tf) => { let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?;
tf.delete(&conn).await?; state
state } else {
} err!("Can't recover challenge")
None => err!("Can't recover challenge"),
}; };
// Verify the credentials with the saved state // Verify the credentials with the saved state
@@ -291,7 +291,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?) TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?)
.save(&conn) .save(&conn)
.await?; .await?;
_generate_recover_code(&mut user, &conn).await; generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
@@ -338,13 +338,15 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo
tf.data = serde_json::to_string(&data)?; tf.data = serde_json::to_string(&data)?;
tf.save(&conn).await?; tf.save(&conn).await?;
drop(tf); drop(tf);
Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?;
// If entry is migrated from u2f, delete the u2f entry as well // If entry is migrated from u2f, delete the u2f entry as well
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
{ {
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) { let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
Ok(d) => d, d
Err(_) => err!("Error parsing U2F data"), } else {
err!("Error parsing U2F data")
}; };
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
@@ -388,10 +390,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
// Modify to discourage user verification // Modify to discourage user verification
let mut state = serde_json::to_value(&state)?; let mut state = serde_json::to_value(&state)?;
state["ast"]["policy"] = Value::String("discouraged".to_string()); state["ast"]["policy"] = Value::String("discouraged".to_owned());
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well // Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
let app_id = format!("{}/app-id.json", &CONFIG.domain()); let app_id = format!("{}/app-id.json", CONFIG.domain());
state["ast"]["appid"] = Value::String(app_id.clone()); state["ast"]["appid"] = Value::String(app_id.clone());
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
@@ -416,18 +418,17 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::WebauthnLoginChallenge as i32; let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
Some(tf) => { let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?;
tf.delete(conn).await?; state
state } else {
} err!(
None => err!(
"Can't recover login challenge", "Can't recover login challenge",
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn2fa event: EventType::UserFailedLogIn2fa
} }
), )
}; };
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?; let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
+56 -23
View File
@@ -1,26 +1,56 @@
use rocket::serde::json::Json; use rocket::{Route, serde::json::Json};
use rocket::Route;
use serde_json::Value; use serde_json::Value;
use yubico::{config::Config, verify_async}; use yubico_ng::{
Verifier, YubicoError,
config::Config,
transport::{AsyncTransport, Response},
};
use crate::{ use crate::{
CONFIG,
api::{ api::{
core::{log_user_event, two_factor::_generate_recover_code},
EmptyResult, JsonResult, PasswordOrOtpData, EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code},
}, },
auth::Headers, auth::Headers,
db::{ db::{
models::{EventType, TwoFactor, TwoFactorType},
DbConn, DbConn,
models::{EventType, TwoFactor, TwoFactorType},
}, },
error::{Error, MapResult}, error::{Error, MapResult},
CONFIG, http_client,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] 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)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EnableYubikeyData { struct EnableYubikeyData {
@@ -45,8 +75,7 @@ pub struct YubikeyMetadata {
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> { fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5]; let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
} }
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value { fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
@@ -64,22 +93,25 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
err!("Yubico support is disabled"); err!("Yubico support is disabled");
} }
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) { if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
(Some(id), Some(secret)) => Ok((id, secret)), Ok((id, secret))
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"), } else {
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
} }
} }
async fn verify_yubikey_otp(otp: String) -> EmptyResult { async fn verify_yubikey_otp(otp: String) -> EmptyResult {
let (yubico_id, yubico_secret) = get_yubico_credentials()?; let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret); let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
if let Some(yubico_server) = CONFIG.yubico_server() {
match CONFIG.yubico_server() { config = config.set_api_host(yubico_server);
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
None => verify_async(otp, config).await,
} }
.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>")] #[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); let yubikeys = parse_yubikeys(&data);
if yubikeys.is_empty() { if yubikeys.is_empty() {
return Ok(Json(json!({ // Return an error to prevent saving empty keys which would cause users not being able to login anymore.
"enabled": false, // To remove all keys users should click the `Deactivate all keys` button
"object": "twoFactorU2f", err!("A key is required.");
})));
} }
// Ensure they are valid OTPs // Ensure they are valid OTPs
@@ -162,7 +193,7 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap(); yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
yubikey_data.save(&conn).await?; yubikey_data.save(&conn).await?;
_generate_recover_code(&mut user, &conn).await; generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
@@ -186,7 +217,9 @@ pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> Emp
} }
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
let response_id = &response[..12]; let Some(response_id) = response.get(..12) else {
err!("Invalid Yubikey OTP");
};
if !yubikey_metadata.keys.contains(&response_id.to_owned()) { if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
err!("Given Yubikey is not registered"); err!("Given Yubikey is not registered");
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more