Compare commits

...
19 Commits
Author SHA1 Message Date
Mathijs van Veluw b71cb7a832 Misc fixes regarding jobs and config (#7858)
* Fix several scheduled job issues

Several jobs could cause a panic, or stop without running through all
items because of this panic. This could cause missed items until that
panic somehow resolved it self, and probably would never work fully
again.

This could cause emergency access for example to never work again.
Besides this, emergency access was also able to be panic if the
wait-time was out-of-bounds and date/time calculation might fail.
This is also fixed now by using `checked` functions and continue upon
those issues so all awaiting items will be handled.

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

* Fix several possible panics and overflows

Adjust several integer types to be a lower or unsigned type.
Most types were too large, only because the function which used this
value needed that specific type. But, this allowed too large values
which either caused panics or might cause a DoS.

This does however might possible cause configurations which have larger
values, which are probably way too large anyway to be not loaded or use
their defaults. During the config loading, either via env or json, it
will show error logs regarding these items.

Also fixed an issue with protected actions via OTP, it increased the
attempt before it validated if the attempt limit was reached. This could
cause an issue when set to `1`, that way it was never allowed, and `3`
times would actually be `2`.

Also added several config validation checks which otherwise would have
prevent Vaultwarden to start, or login, so these will probably not cause
issues, else users would already have issues.

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

* Show config errors on diagnostics page

With the previous commits it is now possible to add a warning if their
rare issues with loading of the config, either environment variables
which we were not able to parse correctly, or if loading the
`config.json` failed instead of just showing it wasn't used, while it
does exists, which might be confusing.

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-22 14:20:03 +02:00
Mathijs van Veluw f4696877b9 Update dependencies (#7882)
- Updated all the crates
- Updated JavaScript libraries
- Updated xx to v1.10.0
- Updated Rust to v1.99.1
- Updated GHA & Pre-Commit

Resolves #7877

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-22 12:47:30 +02:00
ump45nose 296599003c docs: fix connect-src description grammar in config.rs and .env.template (#7866) 2026-10-22 12:46:26 +02:00
ump45nose 970cc49182 api/icons: fix typos in the minimum PNG size comment (#7867) 2026-10-22 12:46:00 +02:00
Mathijs van Veluw 58a980f850 Add unsupported riscv64 architecture (#7859)
This PR makes it easy for users to build a Debian container images which
is able to run on a riscv64 architecture using a Debian Container, or if
needed, you can extract the binary from this container, but the user
needs to be sure the libraries are available and supported if using
Docker is not an option.

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-16 11:51:32 +02:00
Cassian433 64895524e4 Delete auth requests when deleting a user (#7837) 2026-10-10 19:58:40 +02:00
Pier Carlo Cadoppi df2cd3c869 Add biometrics-sdk-ipc feature flag (#7813) 2026-10-07 19:20:23 +02:00
Tom 0e93d15b73 Add Windows desktop autotype support (#7808) 2026-10-07 19:20:04 +02:00
will-lottkowitz-prismandDaniel García d1cbd027cd Add revision_date to org policies (#7571)
* Add revision_date to org policies

Org policy JSON responses never included a revisionDate field, unlike
every other synced entity. The official Bitwarden server always sets
it, and current official clients (recently migrated to the WASM SDK's
typed PolicyView) now assume it is always a valid date string. Its
absence deserializes to undefined client-side, which then throws
(RangeError: Invalid time value) when the client formats it, breaking
the whole policy sync pipeline and silently emptying the vault view in
the browser and desktop apps.

Adds a revision_date column (mirroring the existing pattern used by
Send), stamped on creation and bumped on every save.

* Fix migration correctness for org policy revision_date

Caught by review: SQLite rejects non-constant defaults in ALTER TABLE
ADD COLUMN, so the sqlite migration would fail outright on every
existing installation (vaultwarden's default backend) rather than the
postgresql setup this was developed against.

- sqlite/mysql: add the column with a constant placeholder default,
  then backfill via UPDATE, since neither allows a volatile default
  in ALTER TABLE ADD COLUMN (mysql does allow it, but a constant
  keeps the three backends' migrations symmetric).
- mysql: use DATETIME instead of TIMESTAMP, matching this repo's
  existing revision_date columns, and backfill with UTC_TIMESTAMP()
  instead of a session-timezone-dependent CURRENT_TIMESTAMP.
- postgresql: backfill with `now() AT TIME ZONE 'utc'` instead of a
  DEFAULT now(), since assigning timestamptz now() into this naive
  TIMESTAMP column would otherwise cast through the server's TimeZone
  GUC and store local wall-clock instead of UTC on non-UTC servers.
- mysql/postgresql: add a real down.sql (DROP COLUMN) instead of
  leaving it empty.

* Move org policy revision_date migration after the latest one

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-07 19:19:26 +02:00
Daniel García 1f802f8e6a Update web-vault to v2026.8.0 (#7828) 2026-10-07 00:56:01 +02:00
a2efadc650 Fix email 2FA for SSO logins on iOS and Android (#7827)
* Allow email-only /api/two-factor/send-email-login for mobile clients

Mobile clients (iOS) may call /api/two-factor/send-email-login with only the user's email and without a MasterPasswordHash or an AuthRequest. Permit email-only requests so the server will send the email 2FA token in that flow.\n\nModified: src/api/core/two_factor/email.rs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(email-2fa): use device-identifier fallback when no password hash submitted

iOS clients call /api/two-factor/send-email-login with an email and
DeviceIdentifier but without masterPasswordHash or authRequestId after
receiving a 2FA-required response from the token endpoint. The previous
empty else-block allowed any caller to trigger a 2FA email for any
account knowing only the email address.

Replace the empty block with a device-identifier-based fallback:
- Look up the most-recently-active device via find_by_device_for_email2fa.
- Verify the device's associated user email matches the submitted email.
- Log (debug) when the fallback path is exercised for operator visibility.
- Reject with the original error when no device identifier is provided or
  when the device maps to a different account.

Fixes #7568

* Check the pending 2FA login for this user and device

Look up the pending 2FA login for the user and device instead of the
latest one on the device, answer a failed check with the same error and
IP/username log detail as a wrong password, and escape the device id in
the logs.

---------

Co-authored-by: Arunabha-Mukhopadhyay <dkarunabha2006@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-07 00:16:01 +02:00
acbf49018f [web-v2026.7.1] 2FA userVerificationToken for Authenticator, Email and Duo (#7563)
* 2FA using userVerificationToken

* Fix 2FA Yubikeys

* TwoFactor.find_by_user_and_type take enum parameter not i32

* log_user_event take enum parameter not i32

* Fix 2fa webauthn

* Prevent 2FA claims reuse

* 2fa webauthn return keys when deleting

* 2fa reset twofactor_remember

* Reject 2FA verification tokens issued for another provider

The Email and Duo claims only have optional fields, so a userVerificationToken minted for another
provider deserialized into them and was accepted. Each provider's claims now reject unknown fields,
with a test that every token only parses as its own provider.

Also fix the email 2FA login in the Playwright user setup, which used an undefined `mailBuffer`.

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 23:45:55 +02:00
TomandDaniel García b8089e31c2 [Web 2026.9.0] Support new account recovery password payload (#7747)
* Support new account recovery password payload

* Address account recovery review feedback

* Check the account recovery password before sending the recovery email

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 20:45:12 +02:00
Tom 8647af8f89 [Web 2026.9.0] Support new Emergency Access password payload (#7746)
* Support new emergency access password payload

* Apply PR 7747 review changes to emergency access

* Add Playwright test for emergency access takeover
2026-10-06 20:28:51 +02:00
JogiusandDaniel García c687cacb6b Fix group import to update groups on data change (#7671)
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:29:58 +02:00
Wu ShuwenandDaniel García 8b56926077 docs: fix Playwright test env link (#7739)
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:26:09 +02:00
ump45noseandDaniel García de6d2066b3 docs: fix "runnning" typo in playwright README (#7787)
Signed-off-by: yuwk <1729065730@qq.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:25:51 +02:00
IainandDaniel García 415df400f4 Validate the Duo and SSO purge schedules on startup (#7819)
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:21:46 +02:00
42aa3ee1c7 Close WebSocket connections that stop responding (#7807)
Both notification hubs send a Ping every 15 seconds but ignore the
Pong, so the loop only ends on a Close frame, EOF or a socket error.
When a client disappears without the TCP connection being closed, for
example behind a reverse proxy or CDN that keeps the upstream
connection open, none of these ever happens. The connection, its file
descriptor and its map entry are then kept forever, until the process
runs out of file descriptors and stops accepting connections and
opening the database.

Keep track of when something was last received from the client, Pongs
included, and close the connection when nothing was received for 30
seconds. This is the default ClientTimeoutInterval of ASP.NET Core
SignalR, which the official server uses.

Fixes #7805

Co-authored-by: Cassian433 <262026629+Cassian433@users.noreply.github.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:10:49 +02:00
68 changed files with 1863 additions and 962 deletions

No files matched your search

+8 -4
View File
@@ -78,7 +78,7 @@
# DB_CONNECTION_RETRIES=15 # DB_CONNECTION_RETRIES=15
## Database timeout ## Database timeout
## Timeout when acquiring database connection ## Timeout in seconds when acquiring database connection
# DATABASE_TIMEOUT=30 # DATABASE_TIMEOUT=30
## Database idle timeout ## Database idle timeout
@@ -173,7 +173,7 @@
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start. ## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *" # EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
## Number of days to retain events stored in the database. ## Number of days to retain events stored in the database.
## If unset (the default), events are kept indefinitely and the scheduled job is disabled! ## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
# EVENTS_DAYS_RETAIN= # EVENTS_DAYS_RETAIN=
## ##
## Cron schedule of the job that cleans old auth requests from the auth request. ## Cron schedule of the job that cleans old auth requests from the auth request.
@@ -233,7 +233,7 @@
# USER_SEND_LIMIT= # USER_SEND_LIMIT=
## Number of days to wait before auto-deleting a trashed item. ## Number of days to wait before auto-deleting a trashed item.
## If unset (the default), trashed items are not auto-deleted. ## If unset (the default) or 0, trashed items are not auto-deleted.
## This setting applies globally, so make sure to inform all users of any changes to this setting. ## This setting applies globally, so make sure to inform all users of any changes to this setting.
# TRASH_AUTO_DELETE_DAYS= # TRASH_AUTO_DELETE_DAYS=
@@ -394,11 +394,15 @@
## - "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)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.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)
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
## - "pm-30529-webauthn-related-origins": ## - "pm-30529-webauthn-related-origins":
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0) ## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
# 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,7 +463,7 @@
## Allowed connect-src (Know the risks!) ## Allowed connect-src (Know the risks!)
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src ## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature ## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value. ## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed. ## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld" ## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching # Enable Rust Caching
- name: Rust Caching - name: Rust Caching
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
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.
+4 -4
View File
@@ -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@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.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 }}"
@@ -222,7 +222,7 @@ jobs:
touch "${RUNNER_TEMP}/digests/${digest#sha256:}" touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -242,7 +242,7 @@ jobs:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
- name: Upload binaries as artifacts - name: Upload binaries as artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: vaultwarden-${{ env.NORMALIZED_ARCH }} path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -264,7 +264,7 @@ jobs:
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-*-${{ matrix.base_image }} pattern: digests-*-${{ matrix.base_image }}
+1 -1
View File
@@ -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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too # 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@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.
+1 -1
View File
@@ -18,7 +18,7 @@ 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: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 rev: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
hooks: hooks:
- id: typos - id: typos
always_run: true always_run: true
Generated
+247 -238
View File
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -91,7 +91,7 @@ dashmap = "6.2.1"
# Async futures # Async futures
futures = "0.3.34" futures = "0.3.34"
tokio = { version = "1.53.2", features = [ tokio = { version = "1.54.1", features = [
"fs", "fs",
"io-util", "io-util",
"net", "net",
@@ -100,14 +100,14 @@ tokio = { version = "1.53.2", features = [
"signal", "signal",
"time", "time",
] } ] }
tokio-util = { version = "0.7.19", features = ["compat"] } tokio-util = { version = "0.7.20", features = ["compat"] }
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.154"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = [
@@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.3" rand = "0.10.3"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false } rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.27.0", features = ["v4"] } uuid = { version = "1.28.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
@@ -215,7 +215,7 @@ bytes = "1.12.1"
svg-hush = "0.9.7" 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 = "4.0.1", features = ["async"] } cached = { version = "4.0.3", 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.2" cookie = "0.18.2"
@@ -257,18 +257,18 @@ rpassword = "7.5.4"
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.59.4", default-features = false, features = ["services-fs"] } opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true } opendal-http-transport-reqwest = { version = "0.59.5", 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
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
"behavior-version-latest", "behavior-version-latest",
"credentials-process", "credentials-process",
"rt-tokio", "rt-tokio",
"sso", "sso",
] } ] }
aws-credential-types = { version = "1.3.0", optional = true } aws-credential-types = { version = "1.3.1", optional = true }
aws-smithy-runtime-api = { version = "1.19.0", optional = true } aws-smithy-runtime-api = { version = "1.19.1", optional = true }
http = { version = "1.5.0", optional = true } http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true } reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true } reqsign-core = { version = "3.3.2", optional = true }
+4 -4
View File
@@ -1,10 +1,10 @@
--- ---
vault_version: "v2026.7.0" vault_version: "v2026.8.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c" vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
# Cross Compile Docker Helper Scripts v1.9.0 # Cross Compile Docker Helper Scripts v1.10.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:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
# The `rust_version` variable is extracted from `rust-toolchain.toml` # The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used # 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
+10 -10
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.7.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] # [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# #
# - 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:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.7.0] # [docker.io/vaultwarden/web-vault:v2026.8.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a 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.99.0 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
+8 -8
View File
@@ -19,24 +19,24 @@
# - 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.7.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] # [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# #
# - 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:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.7.0] # [docker.io/vaultwarden/web-vault:v2026.8.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a 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
## And these bash scripts do not have any significant difference if at all ## And these bash scripts do not have any significant difference if at all
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
# hadolint ignore=DL3067 # hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
+1
View File
@@ -15,6 +15,7 @@ Some unsupported platforms for Debian based images. These are not built and test
- 386 (linux/386) - 386 (linux/386)
- ppc64le (linux/ppc64le) - ppc64le (linux/ppc64le)
- s390x (linux/s390x) - s390x (linux/s390x)
- riscv64 (linux/riscv64)
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br> To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
This ensures the container build process can run binaries from other architectures.<br> This ensures the container build process can run binaries from other architectures.<br>
+6
View File
@@ -148,6 +148,12 @@ target "debian-s390x" {
platforms = ["linux/s390x"] platforms = ["linux/s390x"]
tags = generate_tags("", "-s390x") tags = generate_tags("", "-s390x")
} }
target "debian-riscv64" {
inherits = ["debian"]
platforms = ["linux/riscv64"]
tags = generate_tags("", "-riscv64")
}
// ==== End of unsupported Debian architecture targets === // ==== End of unsupported Debian architecture targets ===
// A Group to build all platforms individually for local testing // A Group to build all platforms individually for local testing
+1 -1
View File
@@ -14,7 +14,7 @@ proc-macro = true
[dependencies] [dependencies]
quote = "1.0.47" quote = "1.0.47"
syn = "3.0.6" syn = "3.0.9"
[lints] [lints]
workspace = true workspace = true
@@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -0,0 +1,7 @@
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date
-- columns elsewhere, and to avoid MySQL's implicit session-timezone
-- conversion and 2038 range limit on TIMESTAMP.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = UTC_TIMESTAMP();
@@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -0,0 +1,9 @@
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now():
-- assigning timestamptz now() into a naive TIMESTAMP column casts through
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp
-- column in this schema.
ALTER TABLE org_policies
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc');
Whitespace-only changes.
@@ -0,0 +1,6 @@
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add
-- the column with a constant placeholder and backfill separately.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP;
+2 -2
View File
@@ -2,7 +2,7 @@
This allows running integration tests using [Playwright](https://playwright.dev/). This allows running integration tests using [Playwright](https://playwright.dev/).
\ \
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance.
## Install ## Install
@@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
### Keep services running ### Keep services running
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 running (states are not impacted by the tests):
```bash ```bash
PW_KEEP_SERVICE_RUNNING=true npx playwright test PW_KEEP_SERVICE_RUNNING=true npx playwright test
+131
View File
@@ -0,0 +1,131 @@
import { test, expect, type Page, type TestInfo, Test } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv();
let mailserver;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailserver = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailserver.listen();
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
if( mailserver ){
await mailserver.close();
}
});
async function emergencyAccess(test: Test, page: Page, user: { name: string }) {
await test.step('Navigate', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Emergency access' }).click();
await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible();
});
}
test('Emergency access', async ({ browser, page }) => {
const context2 = await browser.newContext();
const page2 = await context2.newPage();
const mailBuffer = mailserver.buffer(users.user1.email);
const mailBuffer2 = mailserver.buffer(users.user2.email);
await createAccount(test, page, users.user1);
await createAccount(test, page2, users.user2);
await test.step('Add test2', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Add emergency contact' }).click();
await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email);
await page.getByRole('radio', { name: 'Takeover Can reset your' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
await test.step('Accept', async () => {
const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name);
const pageE = await context2.newPage();
await pageE.setContent(email.html);
const link = await pageE.getByTestId("emergency").getAttribute("href");
await pageE.close();
await page2.goto(link);
await utils.checkNotification(page2, 'Invitation accepted');
});
await test.step('Confirm', async () => {
await emergencyAccess(test, page, users.user1);
await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible();
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await utils.checkNotification(page, users.user2.name + ' confirmed');
await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed");
});
await test.step('Request', async () => {
await emergencyAccess(test, page2, users.user2);
await page2.getByRole('button', { name: 'Options' }).click();
await page2.getByRole('menuitem', { name: 'Request Access' }).click();
await page2.getByRole('button', { name: 'Request Access' }).click();
await utils.checkNotification(page2, 'Emergency access requested');
await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated");
});
await test.step('Approved', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Approve' }).click();
await page.getByRole('button', { name: 'Approve' }).click();
await utils.checkNotification(page, 'Emergency access approved');
await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved");
});
await activateTOTP(test, page, users.user1);
let newPassword = "TotoNewPassword";
await test.step('Access', async () => {
await emergencyAccess(test, page2, users.user2);
await page2.getByRole('button', { name: 'Options' }).click();
await page2.getByRole('menuitem', { name: 'Takeover' }).click();
await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword);
await page2.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page2, 'Password reset for ' + users.user1.name);
});
await test.step('Changed no 2fa', async () => {
users.user1.password = newPassword;
await logUser(test, page, users.user1);
});
await test.step('Reject', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Reject' }).click();
await utils.checkNotification(page, 'Emergency access rejected');
await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected");
});
await test.step('Remove', async () => {
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Removed user ' + users.user2.name);
await expect(page.getByText('You have not added any emergency contacts')).toBeVisible();
});
});
+29 -8
View File
@@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth";
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user'; import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP } from './setups/2fa'; import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa';
let users = utils.loadEnv(); let users = utils.loadEnv();
let totp; let totp;
@@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => {
await utils.logout(test, page, users.user1); await utils.logout(test, page, users.user1);
await test.step('login', async () => { await logUser(test, page, users.user1, { totp });
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
await disableTOTP(test, page, users.user1);
});
test('Recovery codes', async ({ context, page }) => {
await logUser(test, page, users.user1);
await activateTOTP(test, page, users.user1);
let recovery = await recoveryCodes(test, page, users.user1);
await utils.logout(test, page, users.user1);
await test.step('login', async () => {
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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).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', exact: true }).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.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
});
await disableTOTP(test, page, users.user1); const newPagePromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Use your recovery code' }).click();
const newPage = await newPagePromise;
const tabs = context.pages();
await tabs[1].bringToFront();
await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible();
await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email);
await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery);
await tabs[1].getByRole('button', { name: 'Submit' }).click();
await expect(tabs[1]).toHaveTitle(/Two-step login/);
});
}); });
+18 -1
View File
@@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string {
return await test.step('Recovery code', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.getByRole('button', { name: 'View recovery code' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
const recovery = await page.getByRole('code').innerText();
await page.getByLabel('Close').click();
return recovery;
})
}
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
return await test.step('Activate TOTP 2FA', async () => { return await test.step('Activate TOTP 2FA', async () => {
await page.getByRole('button', { name: user.name }).click(); await page.getByRole('button', { name: user.name }).click();
@@ -21,7 +39,6 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByLabel(/Verification code/).fill(totp.generate());
await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('button', { name: 'Turn on' }).click();
await page.getByRole('heading', { name: 'Turned on', exact: true }); await page.getByRole('heading', { name: 'Turned on', exact: true });
await page.getByLabel('Close').click();
return totp; return totp;
}) })
+16 -2
View File
@@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev'; import { type MailBuffer } from 'maildev';
import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa'; import { retrieveEmailCode } from './2fa';
@@ -43,6 +44,7 @@ export async function logUser(
mailBuffer ?: MailBuffer, mailBuffer ?: MailBuffer,
mail2fa?: boolean, mail2fa?: boolean,
notNewDevice?: boolean, notNewDevice?: boolean,
totp?: OTPAuth.TOTP,
} = {} } = {}
) { ) {
await test.step(`Log user ${user.email}`, async () => { await test.step(`Log user ${user.email}`, async () => {
@@ -55,11 +57,23 @@ export async function logUser(
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
if( options.mail2fa ){ if( options.mail2fa || options.totp ){
let code;
await test.step('2FA check', async () => { await test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
let code = await retrieveEmailCode(test, page, options.mailBuffer);
if( options.totp ) {
const totp = options.totp;
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
code = totp.generate({timestamp});
} else if( options.mail2fa ){
code = await retrieveEmailCode(test, page, options.mailBuffer);
}
await page.getByLabel(/Verification code/).fill(code); await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
}); });
} }
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.99.0" channel = "1.99.1"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"
+9 -2
View File
@@ -201,12 +201,15 @@ 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) {
// 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 = match generate_admin_claims() {
Ok(claims) => claims,
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
};
let jwt = encode_jwt(&claims); let jwt = encode_jwt(&claims);
let cookie = Cookie::build((COOKIE_NAME, jwt)) let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path()) .path(admin_path())
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime())) .max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.http_only(true) .http_only(true)
.secure(secure.https); .secure(secure.https);
@@ -779,6 +782,8 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
.into_keys() .into_keys()
.collect(); .collect();
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
let diagnostics_json = json!({ let diagnostics_json = json!({
"dns_resolved": dns_resolved, "dns_resolved": dns_resolved,
"current_release": VERSION, "current_release": VERSION,
@@ -801,7 +806,9 @@ 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(", "),
"config_file_status": &CONFIG.get_config_file_status(),
"template_overrides": check_template_overrides().join(", "), "template_overrides": check_template_overrides().join(", "),
"invalid_env_vars": invalid_env_vars,
"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,
+34 -19
View File
@@ -93,6 +93,15 @@ pub struct KDFData {
kdf_parallelism: Option<i32>, kdf_parallelism: Option<i32>,
} }
impl KDFData {
pub(super) fn matches_user(&self, user: &User) -> bool {
self.kdf == user.client_kdf_type
&& self.kdf_iterations == user.client_kdf_iter
&& self.kdf_memory == user.client_kdf_memory
&& self.kdf_parallelism == user.client_kdf_parallelism
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RegisterData { pub struct RegisterData {
@@ -484,8 +493,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
Membership::accept_user_invitations(&user.uuid, &conn).await?; Membership::accept_user_invitations(&user.uuid, &conn).await?;
} }
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
.await;
user.save(&conn).await?; user.save(&conn).await?;
@@ -631,8 +639,7 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid password") err!("Invalid password")
} }
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
.await;
let (new_master_password_hash, new_key) = let (new_master_password_hash, new_key) =
if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) { if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) {
@@ -717,21 +724,35 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AuthenticationData { pub(super) struct AuthenticationData {
#[serde(alias = "Salt")] #[serde(alias = "Salt")]
salt: String, salt: String,
#[serde(alias = "Kdf")] #[serde(alias = "Kdf")]
kdf: KDFData, pub(super) kdf: KDFData,
#[serde(alias = "MasterPasswordAuthenticationHash")] #[serde(alias = "MasterPasswordAuthenticationHash")]
master_password_authentication_hash: String, pub(super) master_password_authentication_hash: String,
}
impl AuthenticationData {
pub(super) fn check(&self, user: &User, unlock: &UnlockData) -> EmptyResult {
if self.kdf != unlock.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
if self.salt != user.master_password_salt() || self.salt != unlock.salt {
err!("Invalid master password salt")
}
Ok(())
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct UnlockData { pub(super) struct UnlockData {
salt: String, salt: String,
kdf: KDFData, kdf: KDFData,
master_key_wrapped_user_key: String, pub(super) master_key_wrapped_user_key: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -750,13 +771,7 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
err!("Invalid password") err!("Invalid password")
} }
if data.authentication_data.kdf != data.unlock_data.kdf { data.authentication_data.check(&headers.user, &data.unlock_data)?;
err!("KDF settings must be equal for authentication and unlock")
}
if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt {
err!("Invalid master password salt")
}
let mut user = headers.user; let mut user = headers.user;
@@ -1679,7 +1694,7 @@ async fn post_auth_request(
nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await;
log_user_event( log_user_event(
EventType::UserRequestedDeviceApproval as i32, EventType::UserRequestedDeviceApproval,
&user.uuid, &user.uuid,
client_headers.device_type, client_headers.device_type,
&client_headers.ip.ip, &client_headers.ip.ip,
@@ -1773,7 +1788,7 @@ async fn put_auth_request(
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await;
log_user_event( log_user_event(
EventType::OrganizationUserApprovedAuthRequest as i32, EventType::OrganizationUserApprovedAuthRequest,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@@ -1784,7 +1799,7 @@ async fn put_auth_request(
// If denied, there's no reason to keep the request // If denied, there's no reason to keep the request
auth_request.delete(&conn).await?; auth_request.delete(&conn).await?;
log_user_event( log_user_event(
EventType::OrganizationUserRejectedAuthRequest as i32, EventType::OrganizationUserRejectedAuthRequest,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
+205 -111
View File
@@ -1,4 +1,4 @@
use chrono::{TimeDelta, Utc}; use chrono::Utc;
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@@ -6,14 +6,17 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, EmptyResult, JsonResult,
core::{CipherSyncData, CipherSyncType}, core::{
CipherSyncData, CipherSyncType,
accounts::{AuthenticationData, UnlockData},
},
}, },
auth::{Headers, decode_emergency_access_invite}, auth::{Headers, decode_emergency_access_invite},
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
}, },
}, },
mail, mail,
@@ -133,19 +136,21 @@ async fn post_emergency_access(
let data: EmergencyAccessUpdateData = data.into_inner(); let data: EmergencyAccessUpdateData = data.into_inner();
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
err!("Invalid emergency access type.")
};
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let Some(mut emergency_access) = let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else { else {
err!("Emergency access not valid.") err!("Emergency access not valid.")
}; };
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { emergency_access.atype = new_type as i32;
new_type as i32
} else {
err!("Invalid emergency access type.")
};
emergency_access.atype = new_type;
emergency_access.wait_time_days = data.wait_time_days; emergency_access.wait_time_days = data.wait_time_days;
if data.key_encrypted.is_some() { if data.key_encrypted.is_some() {
emergency_access.key_encrypted = data.key_encrypted; emergency_access.key_encrypted = data.key_encrypted;
@@ -207,11 +212,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let email = data.email.to_lowercase(); let email = data.email.to_lowercase();
let wait_time_days = data.wait_time_days; let wait_time_days = data.wait_time_days;
let emergency_access_status = EmergencyAccessStatus::Invited as i32; if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
new_type as i32
} else {
err!("Invalid emergency access type.") err!("Invalid emergency access type.")
}; };
@@ -257,6 +262,8 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
err!(format!("Grantee user already invited: {}", &grantee_user.email)) err!(format!("Grantee user already invited: {}", &grantee_user.email))
} }
let new_type = new_type as i32;
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let mut new_emergency_access = let mut new_emergency_access =
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days); EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
new_emergency_access.save(&conn).await?; new_emergency_access.save(&conn).await?;
@@ -289,7 +296,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
}; };
if emergency_access.status != EmergencyAccessStatus::Invited as i32 { if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
err!("The grantee user is already accepted or confirmed to the organization"); err!("The grantee user is already accepted or confirmed for emergency access");
} }
let Some(email) = emergency_access.email.clone() else { let Some(email) = emergency_access.email.clone() else {
@@ -450,11 +457,24 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) = let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
else { else {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
)
}; };
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
)
}
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
)
} }
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
@@ -487,11 +507,17 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) = let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else { else {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
)
}; };
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
)
} }
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
@@ -615,6 +641,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
"kdfMemory": grantor_user.client_kdf_memory, "kdfMemory": grantor_user.client_kdf_memory,
"kdfParallelism": grantor_user.client_kdf_parallelism, "kdfParallelism": grantor_user.client_kdf_parallelism,
"keyEncrypted": &emergency_access.key_encrypted, "keyEncrypted": &emergency_access.key_encrypted,
"salt": grantor_user.master_password_salt(),
"object": "emergencyAccessTakeover", "object": "emergencyAccessTakeover",
}); });
@@ -624,8 +651,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EmergencyAccessPasswordData { struct EmergencyAccessPasswordData {
new_master_password_hash: String, // Legacy payload
key: String, new_master_password_hash: Option<String>,
key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
} }
#[post("/emergency-access/<emer_id>/password", data = "<data>")] #[post("/emergency-access/<emer_id>/password", data = "<data>")]
@@ -638,8 +670,6 @@ async fn password_emergency_access(
check_emergency_access_enabled()?; check_emergency_access_enabled()?;
let data: EmergencyAccessPasswordData = data.into_inner(); let data: EmergencyAccessPasswordData = data.into_inner();
let new_master_password_hash = &data.new_master_password_hash;
//let key = &data.Key;
let requesting_user = headers.user; let requesting_user = headers.user;
let Some(emergency_access) = let Some(emergency_access) =
@@ -656,8 +686,23 @@ async fn password_emergency_access(
err!("Grantor user not found.") err!("Grantor user not found.")
}; };
let (new_master_password_hash, new_key) =
if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) {
authentication_data.check(&grantor_user, &unlock_data)?;
if !authentication_data.kdf.matches_user(&grantor_user) {
err!("KDF settings do not match the grantor account")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key)
} else {
err!("Invalid request!")
};
// change grantor_user password // change grantor_user password
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
grantor_user.save(&conn).await?; grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins // Disable TwoFactor providers since they will otherwise block logins
@@ -725,52 +770,71 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
return; return;
} }
if let Ok(conn) = pool.get().await { let Ok(conn) = pool.get().await else {
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; error!("Failed to get DB connection for emergency time-outs!");
return;
};
if emergency_access_list.is_empty() { let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
debug!("No emergency request timeout to approve");
}
let now = Utc::now().naive_utc(); if emergency_access_list.is_empty() {
for mut emer in emergency_access_list { debug!("No emergency request timeout to approve");
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) return;
let recovery_allowed_at = }
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
if recovery_allowed_at.le(&now) {
// Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
.await
.expect("Unable to update emergency access status");
if CONFIG.mail_enabled() { let now = Utc::now().naive_utc();
// get grantor user to send Accepted email for mut emer in emergency_access_list {
let grantor_user = match emer.is_recovery_allowed(&now) {
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); Ok(true) => {} // Recovery is allowed
Ok(false) => continue,
// get grantee user to send Accepted email Err(e) => {
let grantee_user = error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) continue;
.await }
.expect("Grantee user not found"); }
mail::send_emergency_access_recovery_timed_out( // Get grantor user to send Accepted email
&grantor_user.email, let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
&grantee_user.name, error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
emer.get_type_as_str(), continue;
) };
.await
.expect("Error on sending email"); let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name) continue;
.await };
.expect("Error on sending email");
} // Get grantee user to send Accepted email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
continue;
}
if CONFIG.mail_enabled() {
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
)
.await
{
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
}
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
{
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
} }
} }
} else {
error!("Failed to get DB connection while searching emergency request timed out");
} }
} }
@@ -780,55 +844,85 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
return; return;
} }
if let Ok(conn) = pool.get().await { let Ok(conn) = pool.get().await else {
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; error!("Failed to get DB connection for emergency reminders!");
return;
};
if emergency_access_list.is_empty() { let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
debug!("No emergency request reminder notification to send");
if emergency_access_list.is_empty() {
debug!("No emergency request reminder notification to send");
return;
}
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
let Ok(deadline_at) = emer.deadline_at() else {
error!("Invalid deadline for emergency access '{}'", emer.uuid);
continue;
};
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
continue;
};
// Only send reminders during the last 24 hours, else continue
if now < final_reminder_at || now >= deadline_at {
continue;
} }
let now = Utc::now().naive_utc(); // Check if we did not sent a recovery mail in the past 24h already, else skip it.
for mut emer in emergency_access_list { // In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) // This is to prevent possible missed reminders, this might cause an extra reminder,
// Calculate the day before the recovery will become active // but that is better then no reminder at all.
let final_recovery_reminder_at = let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap(); last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
// Calculate if a day has passed since the previous notification, else no notification has been sent before } else {
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { now
last_notification_at + TimeDelta::try_days(1).unwrap() };
} else {
now
};
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
// Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
emer.update_last_notification_date_and_save(&now, &conn)
.await
.expect("Unable to update emergency access notification date");
if CONFIG.mail_enabled() { // Only send a reminder if the `next_reminder_at` is less then or equal to `now`
// get grantor user to send Accepted email // Else we can skip the current emergency access this run.
let grantor_user = if next_reminder_at.gt(&now) {
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); continue;
}
// get grantee user to send Accepted email
let grantee_user = // Get grantor user to send Reminder email
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
.await error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
.expect("Grantee user not found"); continue;
};
mail::send_emergency_access_recovery_reminder(
&grantor_user.email, let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
&grantee_user.name, error!("Missing grantee for emergency access '{}'", emer.uuid);
emer.get_type_as_str(), continue;
"1", // This notification is only triggered one day before the activation };
)
.await // Get grantee user to send Reminder email
.expect("Error on sending email"); let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
} error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
} continue;
};
// Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
continue;
}
if CONFIG.mail_enabled()
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
"1", // This notification is only triggered one day before the activation
)
.await
{
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
} }
} else {
error!("Failed to get DB connection while searching emergency notification reminder");
} }
} }
+10 -7
View File
@@ -242,11 +242,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
Ok(()) Ok(())
} }
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
if !CONFIG.org_events_enabled() { if !CONFIG.org_events_enabled() {
return; return;
} }
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await; log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await;
} }
async fn log_user_event_impl( async fn log_user_event_impl(
@@ -347,14 +347,17 @@ async fn log_event_impl(
pub async fn event_cleanup_job(pool: DbPool) { pub async fn event_cleanup_job(pool: DbPool) {
debug!("Start events cleanup job"); debug!("Start events cleanup job");
if CONFIG.events_days_retain().is_none() { if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
debug!("events_days_retain is not configured, abort"); debug!("events_days_retain is not configured or set to 0, skipping");
return; return;
} }
if let Ok(conn) = pool.get().await { let Ok(conn) = pool.get().await else {
Event::clean_events(&conn).await.ok();
} 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");
return;
};
if let Err(e) = Event::clean_events(&conn).await {
error!("Failed to cleanup the events table: {e}");
} }
} }
+34 -9
View File
@@ -26,6 +26,8 @@ use crate::{
util::{NumberOrString, convert_json_key_lcase_first}, util::{NumberOrString, convert_json_key_lcase_first},
}; };
use super::accounts::{AuthenticationData, UnlockData};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![
get_organization, get_organization,
@@ -2751,9 +2753,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest { struct OrganizationUserRecoverAccountRequest {
// Legacy payload
new_master_password_hash: Option<String>, new_master_password_hash: Option<String>,
key: Option<String>, key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
#[serde(default)] #[serde(default)]
reset_master_password: bool, reset_master_password: bool,
#[serde(default)] #[serde(default)]
@@ -2833,11 +2840,34 @@ async fn recover_account(
let fallback_2fa_email = let fallback_2fa_email =
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() { if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
} else { } else {
false false
}; };
// Check the new password before the email below, so that a rejected request doesn't tell the user
// their password was reset
let new_password = if req.reset_master_password {
let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) =
(req.authentication_data, req.unlock_data)
{
authentication_data.check(&user, &unlock_data)?;
if !authentication_data.kdf.matches_user(&user) {
err!("KDF settings do not match the user account")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (req.new_master_password_hash, req.key) {
(new_master_password_hash, new_key)
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
};
Some((new_master_password_hash, new_key))
} else {
None
};
// Sending email first ensure working email configuration and the resulting user notification. // Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse // Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery( if let Err(e) = mail::send_admin_account_recovery(
@@ -2853,14 +2883,8 @@ async fn recover_account(
err!(format!("Error sending user reset password email: {e:#?}")); err!(format!("Error sending user reset password email: {e:#?}"));
} }
if req.reset_master_password { if let Some((new_master_password_hash, new_key)) = new_password {
if let Some(key) = req.key user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
} }
if req.reset_two_factor { if req.reset_two_factor {
@@ -2919,6 +2943,7 @@ async fn get_reset_password_details(
"kdfIterations": user.client_kdf_iter, "kdfIterations": user.client_kdf_iter,
"kdfMemory": user.client_kdf_memory, "kdfMemory": user.client_kdf_memory,
"kdfParallelism": user.client_kdf_parallelism, "kdfParallelism": user.client_kdf_parallelism,
"masterPasswordSalt": user.master_password_salt(),
"resetPasswordKey": member.reset_password_key, "resetPasswordKey": member.reset_password_key,
"encryptedPrivateKey": org.private_key, "encryptedPrivateKey": org.private_key,
}))) })))
+5 -1
View File
@@ -137,9 +137,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
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 = if let Some(group) = let group_uuid = if let Some(mut group) =
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
{ {
if group.name != group_data.name {
group.name = group_data.name.clone();
group.save(&conn).await?;
}
group.uuid group.uuid
} else { } else {
let mut group = let mut group =
+23 -45
View File
@@ -3,11 +3,11 @@ use rocket::{Route, serde::json::Json};
use crate::{ use crate::{
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
auth::{ClientIp, Headers}, auth::{ClientIp, Headers, two_factor},
crypto, crypto,
db::{ db::{
DbConn, DbConn,
models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, models::{EventType, TwoFactor, TwoFactorType, UserId},
}, },
util::NumberOrString, util::NumberOrString,
}; };
@@ -20,27 +20,23 @@ pub fn routes() -> Vec<Route> {
#[post("/two-factor/get-authenticator", data = "<data>")] #[post("/two-factor/get-authenticator", data = "<data>")]
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let type_ = TwoFactorType::Authenticator as i32; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
let (enabled, key) = match twofactor { let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data), Some(tf) => (true, tf.data),
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)), _ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
}; };
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
// It should help prevent TOTP disclosure if someone keeps their vault unlocked.
// Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response.
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94
Ok(Json(json!({ Ok(Json(json!({
"enabled": enabled, "authenticator": json!({
"key": key, "enabled": enabled,
"object": "twoFactorAuthenticator" "key": key,
}),
"userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled),
}))) })))
} }
@@ -49,8 +45,7 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
struct EnableAuthenticatorData { struct EnableAuthenticatorData {
key: String, key: String,
token: NumberOrString, token: NumberOrString,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
#[post("/two-factor/authenticator", data = "<data>")] #[post("/two-factor/authenticator", data = "<data>")]
@@ -61,12 +56,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
let mut user = headers.user; let mut user = headers.user;
PasswordOrOtpData { two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
// Validate key as base32 and 20 bytes length // Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
@@ -84,12 +74,13 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({ Ok(Json(json!({
"enabled": true, "authenticator": json!({
"key": key, "enabled": true,
"object": "twoFactorAuthenticator" "key": key,
}),
}))) })))
} }
@@ -125,8 +116,7 @@ pub async fn validate_totp_code(
err!("Invalid TOTP secret") err!("Invalid TOTP secret")
}; };
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, conn).await {
{
Some(tf) => tf, Some(tf) => tf,
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
}; };
@@ -184,37 +174,25 @@ pub async fn validate_totp_code(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DisableAuthenticatorData { struct DisableAuthenticatorData {
key: String, key: String,
master_password_hash: String, user_verification_token: String,
r#type: NumberOrString,
} }
#[delete("/two-factor/authenticator", data = "<data>")] #[delete("/two-factor/authenticator", data = "<data>")]
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult { async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user; let user = headers.user;
let type_ = data.r#type.into_i32()?;
if !user.check_valid_password(&data.master_password_hash) { two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
err!("Invalid password");
}
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, TwoFactorType::Authenticator, &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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
} else { } else {
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
} }
} }
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
Ok(Json(json!({ Ok(())
"enabled": false,
"keys": type_,
"object": "twoFactorProvider"
})))
} }
+48 -77
View File
@@ -2,12 +2,12 @@ use chrono::Utc;
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use crate::{ use crate::{
CONFIG,
api::{ api::{
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
core::two_factor::generate_recover_code, core::log_user_event,
core::two_factor::{VerificationTokenData, generate_recover_code},
}, },
auth::Headers, auth::{Headers, two_factor, two_factor::DuoData},
crypto, crypto,
db::{ db::{
DbConn, DbConn,
@@ -18,55 +18,7 @@ use crate::{
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![get_duo, activate_duo, activate_duo_put,] routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
}
#[derive(Serialize, Deserialize)]
struct DuoData {
host: String, // Duo API hostname
ik: String, // client id
sk: String, // client secret
}
impl DuoData {
fn global() -> Option<Self> {
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
(true, Some(host)) => Some(Self {
host,
ik: CONFIG.duo_ikey().unwrap(),
sk: CONFIG.duo_skey().unwrap(),
}),
_ => None,
}
}
fn msg(s: &str) -> Self {
Self {
host: s.into(),
ik: s.into(),
sk: s.into(),
}
}
fn secret() -> Self {
Self::msg("<global_secret>")
}
fn obscure(self) -> Self {
let mut host = self.host;
let mut ik = self.ik;
let mut sk = self.sk;
let digits = 4;
let replaced = "************";
host.replace_range(digits.., replaced);
ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced);
Self {
host,
ik,
sk,
}
}
} }
enum DuoStatus { enum DuoStatus {
@@ -95,22 +47,19 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let data = get_user_duo_data(&user.uuid, &conn).await; let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
let (enabled, data) = match data {
DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::Global(_) => (true, Some(DuoData::secret())),
DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::User(data) => (true, Some(data.obscure())),
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
DuoStatus::Disabled(false) => (false, None), DuoStatus::Disabled(false) => (false, None),
}; };
let json = if let Some(data) = data { let duo_json = if let Some(data) = duo.as_ref() {
json!({ json!({
"enabled": enabled, "enabled": enabled,
"host": data.host, "host": data.host,
"clientSecret": data.sk, "clientSecret": data.sk,
"clientId": data.ik, "clientId": data.ik,
"object": "twoFactorDuo"
}) })
} else { } else {
json!({ json!({
@@ -118,11 +67,13 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
"host": null, "host": null,
"clientSecret": null, "clientSecret": null,
"clientId": null, "clientId": null,
"object": "twoFactorDuo"
}) })
}; };
Ok(Json(json)) Ok(Json(rocket::serde::json::json!({
"duo": duo_json,
"userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled),
})))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -131,8 +82,7 @@ struct EnableDuoData {
host: String, host: String,
client_secret: String, client_secret: String,
client_id: String, client_id: String,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
impl From<EnableDuoData> for DuoData { impl From<EnableDuoData> for DuoData {
@@ -159,12 +109,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
let data: EnableDuoData = data.into_inner(); let data: EnableDuoData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
PasswordOrOtpData { two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
let (data, data_str) = if check_duo_fields_custom(&data) { let (data, data_str) = if check_duo_fields_custom(&data) {
let data_req: DuoData = data.into(); let data_req: DuoData = data.into();
@@ -181,14 +126,15 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({ Ok(Json(json!({
"enabled": true, "duo": json!({
"host": data.host, "enabled": true,
"clientSecret": data.sk, "host": data.host,
"clientId": data.ik, "clientSecret": data.sk,
"object": "twoFactorDuo" "clientId": data.ik,
}),
}))) })))
} }
@@ -197,6 +143,29 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
activate_duo(data, headers, conn).await activate_duo(data, headers, conn).await
} }
#[delete("/two-factor/duo", data = "<data>")]
async fn disable_duo(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await {
// Apply the same transformation than in `get_duo` to check we are disabling the correct one
let duo = match to_user_duo_data(&twofactor) {
DuoStatus::Global(_) => Some(DuoData::secret()),
DuoStatus::User(data) => Some(data.obscure()),
DuoStatus::Disabled(_) => None,
};
two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?;
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
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::{Method, header}; use reqwest::{Method, header};
use std::str::FromStr; use std::str::FromStr;
@@ -222,13 +191,15 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
} }
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;
// If the user doesn't have an entry, disabled // If the user doesn't have an entry, disabled
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else {
return DuoStatus::Disabled(DuoData::global().is_some()); return DuoStatus::Disabled(DuoData::global().is_some());
}; };
to_user_duo_data(&twofactor)
}
fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus {
// If the user has the required values, we use those // If the user has the required values, we use those
if let Ok(data) = serde_json::from_str(&twofactor.data) { if let Ok(data) = serde_json::from_str(&twofactor.data) {
return DuoStatus::User(data); return DuoStatus::User(data);
+73 -54
View File
@@ -5,20 +5,26 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, PasswordOrOtpData, EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code}, core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
}, },
auth::{ClientHeaders, Headers}, auth::{ClientHeaders, Headers, two_factor},
crypto, crypto,
db::{ db::{
DbConn, DbConn,
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, models::{
AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
},
}, },
error::{Error, MapResult}, error::{Error, MapResult},
mail, mail,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![get_email, send_email_login, send_email, email,] routes![get_email, send_email_login, send_email, email, disable_email]
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -91,6 +97,20 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
{ {
err!("AuthRequest doesn't exist", "Invalid device, IP or code") err!("AuthRequest doesn't exist", "Invalid device, IP or code")
} }
} else if let Some(device_identifier) = &data.device_identifier {
// iOS/Android SSO logins send the email and device id but no password hash,
// so accept a device that has a pending 2FA login for this user
if TwoFactorIncomplete::find_by_user_and_device(&user.uuid, device_identifier, &conn).await.is_none() {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
}
debug!(
"Email 2FA fallback: pending login. Username: {}. Device: {}.",
user.email,
device_identifier.to_string().escape_debug()
);
} else { } else {
err!("No password hash has been submitted.") err!("No password hash has been submitted.")
} }
@@ -104,7 +124,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else { let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!( err!(
"Username or password is incorrect. Try again", "Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip) format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug())
) )
}; };
@@ -116,8 +136,8 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
/// Generate the token, save the data for later verification and send email to user /// Generate the token, save the data for later verification and send email to user
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::Email as i32; let mut twofactor =
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
@@ -140,18 +160,19 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let (enabled, mfa_email) = let (enabled, mfa_email) =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
Some(x) => { let twofactor_data = EmailTokenData::from_json(&x.data)?;
let twofactor_data = EmailTokenData::from_json(&x.data)?; (true, Some(twofactor_data.email))
(true, json!(twofactor_data.email)) } else {
} (false, None)
_ => (false, serde_json::value::Value::Null),
}; };
Ok(Json(json!({ Ok(Json(rocket::serde::json::json!({
"email": mfa_email, "email": rocket::serde::json::json!({
"enabled": enabled, "enabled": enabled,
"object": "twoFactorEmail" "email": mfa_email,
}),
"userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled),
}))) })))
} }
@@ -160,30 +181,22 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
struct SendEmailData { struct SendEmailData {
/// Email where 2FA codes will be sent to, can be different than user email account. /// Email where 2FA codes will be sent to, can be different than user email account.
email: String, email: String,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
/// Send a verification email to the specified email address to check whether it exists/belongs to user. /// Send a verification email to the specified email address to check whether it exists/belongs to user.
#[post("/two-factor/send-email", data = "<data>")] #[post("/two-factor/send-email", data = "<data>")]
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult { async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SendEmailData = data.into_inner(); let data: SendEmailData = data.into_inner();
let user = headers.user; let user = headers.user;
PasswordOrOtpData { two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, false, &conn)
.await?;
if !CONFIG._enable_email_2fa() { if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled") err!("Email 2FA is disabled")
} }
let type_ = TwoFactorType::Email as i32; if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
tf.delete(&conn).await?; tf.delete(&conn).await?;
} }
@@ -191,12 +204,13 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
let twofactor_data = EmailTokenData::new(data.email, Some(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.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
twofactor.save(&conn).await?; twofactor.save(&conn).await?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
Ok(()) Ok(Json(json!({})))
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@@ -204,8 +218,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
struct EmailData { struct EmailData {
email: String, email: String,
token: String, token: String,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
/// Verify email belongs to user and can be used for 2FA email codes. /// Verify email belongs to user and can be used for 2FA email codes.
@@ -214,17 +227,11 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
let data: EmailData = data.into_inner(); let data: EmailData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
// This is the last step in the verification process, delete the otp directly afterwards two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
let type_ = TwoFactorType::EmailVerificationChallenge as i32; let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
let mut twofactor = .await
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; .map_res("Two factor not found")?;
let mut email_data = EmailTokenData::from_json(&twofactor.data)?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
@@ -243,13 +250,26 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({ Ok(Json(json!({})))
"email": email_data.email, }
"enabled": "true",
"object": "twoFactorEmail" #[delete("/two-factor/email", data = "<data>")]
}))) async fn disable_email(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
let twofactor_data = EmailTokenData::from_json(&twofactor.data)?;
two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?;
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
} }
/// Validate the email code when used as TwoFactor token mechanism /// Validate the email code when used as TwoFactor token mechanism
@@ -261,9 +281,8 @@ pub async fn validate_email_code_str(
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
let mut email_data = EmailTokenData::from_json(data)?; let mut email_data = EmailTokenData::from_json(data)?;
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) let mut twofactor =
.await TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
.map_res("Two factor not found")?;
let Some(issued_token) = &email_data.last_token else { let Some(issued_token) = &email_data.last_token else {
err!( err!(
format!("No token available! IP: {ip}"), format!("No token available! IP: {ip}"),
@@ -275,7 +294,7 @@ pub async fn validate_email_code_str(
if !crypto::ct_eq(issued_token, token) { if !crypto::ct_eq(issued_token, token) {
email_data.add_attempt(); email_data.add_attempt();
if email_data.attempts >= CONFIG.email_attempts_limit() { if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
email_data.reset_token(); email_data.reset_token();
} }
twofactor.data = email_data.to_json(); twofactor.data = email_data.to_json();
@@ -294,7 +313,7 @@ pub async fn validate_email_code_str(
twofactor.save(conn).await?; twofactor.save(conn).await?;
let dt = 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().cast_signed(); let max_time = i64::from(CONFIG.email_expiration_time());
if dt + 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",
+29 -48
View File
@@ -7,10 +7,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_event, log_user_event},
},
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
@@ -21,7 +18,6 @@ use crate::{
}, },
}, },
mail, mail,
util::NumberOrString,
}; };
pub mod authenticator; pub mod authenticator;
@@ -69,7 +65,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![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings]; let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
routes.append(&mut authenticator::routes()); routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes()); routes.append(&mut duo::routes());
@@ -81,6 +77,12 @@ pub fn routes() -> Vec<Route> {
routes routes
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerificationTokenData {
user_verification_token: String,
}
#[get("/two-factor")] #[get("/two-factor")]
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> { async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
@@ -120,45 +122,14 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
} }
} }
#[derive(Deserialize)] /// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
#[serde(rename_all = "camelCase")] /// the providers that are left, and once every provider is gone the 2FA policy applies.
struct DisableTwoFactorData { pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
master_password_hash: Option<String>, Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
otp: Option<String>, if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
r#type: NumberOrString, enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
}
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner();
let user = headers.user;
// Delete directly after a valid token has been provided
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
} }
.validate(&user, true, &conn) Ok(())
.await?;
let type_ = data.r#type.into_i32()?;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
Ok(Json(json!({
"enabled": false,
"type": type_,
"object": "twoFactorProvider"
})))
} }
pub async fn enforce_2fa_policy( pub async fn enforce_2fa_policy(
@@ -225,7 +196,7 @@ pub async fn enforce_2fa_policy_for_org(
pub async fn send_incomplete_2fa_notifications(pool: DbPool) { pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
debug!("Sending notifications for incomplete 2FA logins"); debug!("Sending notifications for incomplete 2FA logins");
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
return; return;
} }
@@ -235,11 +206,21 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
}; };
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap(); let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
let time_before = now - time_limit; error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
return;
};
let Some(time_before) = now.checked_sub_signed(time_limit) else {
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
return;
};
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await; let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
for login in incomplete_logins { for login in incomplete_logins {
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
error!("User '{}', not found!", login.user_uuid);
continue;
};
info!( info!(
"User {} did not complete a 2FA login within the configured time limit. IP: {}", "User {} did not complete a 2FA login within the configured time limit. IP: {}",
user.email, login.ip_address user.email, login.ip_address
+7 -7
View File
@@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user; let user = headers.user;
// Only one Protected Action per user is allowed to take place, delete the previous one // Only one Protected Action per user is allowed to take place, delete the previous one
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await {
{
let pa_data = ProtectedActionData::from_json(&pa.data)?; let pa_data = ProtectedActionData::from_json(&pa.data)?;
let elapsed = pa_data.time_since_sent().num_seconds(); let elapsed = pa_data.time_since_sent().num_seconds();
let delay = 30; let delay = 30;
@@ -125,27 +124,28 @@ pub async fn validate_protected_action_otp(
delete_if_valid: bool, delete_if_valid: bool,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn)
.await .await
.map_res("Protected action token not found, try sending the code again or restart the process")?; .map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
pa_data.add_attempt();
pa.data = pa_data.to_json();
// Fail after x attempts if the token has been used too many times. // Fail after x attempts if the token has been used too many times.
// Don't delete it, as we use it to keep track of attempts. // Don't delete it, as we use it to keep track of attempts.
if pa_data.attempts >= CONFIG.email_attempts_limit() { if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
err!("Token has expired") err!("Token has expired")
} }
// 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().cast_signed(); let max_time = i64::from(CONFIG.email_expiration_time());
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")
} }
if !crypto::ct_eq(&pa_data.token, otp) { if !crypto::ct_eq(&pa_data.token, otp) {
pa_data.add_attempt();
pa.data = pa_data.to_json();
pa.save(conn).await?; pa.save(conn).await?;
err!("Token is invalid") err!("Token is invalid")
} }
+111 -72
View File
@@ -1,4 +1,4 @@
use std::{str::FromStr, sync::LazyLock, time::Duration}; use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration};
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@@ -18,16 +18,18 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, PasswordOrOtpData, EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code}, core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
}, },
auth::Headers, auth::{Headers, two_factor},
crypto::ct_eq, crypto::ct_eq,
db::{ db::{
DbConn, DbConn,
models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, models::{EventType, TwoFactor, TwoFactorType, UserId},
}, },
error::Error, error::Error,
util::NumberOrString,
}; };
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| { static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
@@ -45,7 +47,14 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
}); });
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] routes![
get_webauthn,
generate_webauthn_challenge,
activate_webauthn,
activate_webauthn_put,
delete_webauthn,
delete_webauthns
]
} }
// Some old u2f structs still needed for migrating from u2f to WebAuthn // Some old u2f structs still needed for migrating from u2f to WebAuthn
@@ -119,34 +128,36 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbC
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect(); let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({ Ok(Json(json!({
"enabled": enabled, "webAuthn": json!({
"keys": registrations_json, "enabled": enabled,
"object": "twoFactorWebAuthn" "keys": registrations_json,
}),
"userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled),
}))) })))
} }
#[post("/two-factor/get-webauthn-challenge", data = "<data>")] #[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
data.validate(&user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
let registrations = get_webauthn_registrations(&user.uuid, &conn) let creds = registrations
.await?
.1
.into_iter() .into_iter()
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
.collect(); .collect();
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?;
let (mut challenge, state) = WEBAUTHN.start_passkey_registration( let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
&user.email, &user.email,
user.display_name(), user.display_name(),
Some(registrations), Some(creds),
)?; )?;
let mut state = serde_json::to_value(&state)?; let mut state = serde_json::to_value(&state)?;
@@ -166,17 +177,19 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
let mut challenge_value = serde_json::to_value(challenge.public_key)?; let mut challenge_value = serde_json::to_value(challenge.public_key)?;
challenge_value["status"] = "ok".into(); challenge_value["status"] = "ok".into();
challenge_value["errorMessage"] = "".into(); challenge_value["errorMessage"] = "".into();
Ok(Json(challenge_value))
Ok(Json(json!({
"options": challenge_value
})))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EnableWebauthnData { struct EnableWebauthnData {
id: NumberOrString, // 1..5 id: i32,
name: String, name: String,
device_response: RegisterPublicKeyCredentialCopy, device_response: RegisterPublicKeyCredentialCopy,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -257,16 +270,14 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
let data: EnableWebauthnData = data.into_inner(); let data: EnableWebauthnData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
PasswordOrOtpData { let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
master_password_hash: data.master_password_hash, let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
otp: data.otp, two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
}
.validate(&user, true, &conn)
.await?;
// Retrieve and delete the saved challenge state // Retrieve and delete the saved challenge state
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let state = if let Some(tf) =
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
{
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
@@ -277,10 +288,9 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
// Verify the credentials with the saved state // Verify the credentials with the saved state
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
// TODO: Check for repeated ID's // TODO: Check for repeated ID's
registrations.push(WebauthnRegistration { registrations.push(WebauthnRegistration {
id: data.id.into_i32()?, id: data.id,
name: data.name, name: data.name,
migrated: false, migrated: false,
@@ -293,13 +303,13 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
.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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({ Ok(Json(json!({
"enabled": true, "webAuthn": json!({
"keys": keys_json, "enabled": true,
"object": "twoFactorU2f" "keys": registrations.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
}))) })))
} }
@@ -310,58 +320,87 @@ async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers,
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DeleteU2FData { struct DeleteWebauthnData {
id: NumberOrString, id: i32,
master_password_hash: String, user_verification_token: String,
} }
#[delete("/two-factor/webauthn", data = "<data>")] #[delete("/two-factor/webauthn", data = "<data>")]
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult { async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
let id = data.id.into_i32()?; inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
if !headers.user.check_valid_password(&data.master_password_hash) { }
err!("Invalid password");
}
let Some(mut tf) = #[delete("/two-factor/webauthn/all", data = "<data>")]
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await async fn delete_webauthns(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
else { inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await
}
async fn inner_delete_webauthns(
token: &str,
retain: impl Fn(&WebauthnRegistration) -> bool,
headers: Headers,
conn: &DbConn,
) -> JsonResult {
let user = headers.user;
let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else {
err!("Webauthn data not found!") err!("Webauthn data not found!")
}; };
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?; let mut keys: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
let keys_id: Vec<i32> = keys.iter().map(|r| r.id).collect();
let Some(item_pos) = data.iter().position(|r| r.id == id) else { two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
let mut removed: HashSet<Vec<u8>> = HashSet::new();
let mut migrated = false;
keys.retain(|key| {
let retained = retain(key);
if !retained {
removed.insert(key.credential.cred_id().to_vec());
migrated = migrated || key.migrated;
}
retained
});
if removed.is_empty() {
err!("Webauthn entry not found") err!("Webauthn entry not found")
}; }
let removed_item = data.remove(item_pos); if keys.is_empty() {
tf.data = serde_json::to_string(&data)?; tf.delete(conn).await?;
tf.save(&conn).await?; log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
drop(tf); } else {
Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?; tf.data = serde_json::to_string(&keys)?;
tf.save(conn).await?;
drop(tf);
}
// 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 migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await {
{ let Ok(mut data) = serde_json::from_str::<Vec<U2FRegistration>>(&u2f.data) else {
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
d
} else {
err!("Error parsing U2F data") err!("Error parsing U2F data")
}; };
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); data.retain(|old| !removed.contains(&old.reg.key_handle));
let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str; if data.is_empty() {
u2f.save(&conn).await?; u2f.delete(conn).await?;
} else {
let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str;
u2f.save(conn).await?;
}
} }
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect(); super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?;
Ok(Json(json!({ Ok(Json(json!({
"enabled": true, "webAuthn": json!({
"keys": keys_json, "enabled": !keys.is_empty(),
"object": "twoFactorU2f" "keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
}))) })))
} }
@@ -369,8 +408,7 @@ pub async fn get_webauthn_registrations(
user_id: &UserId, user_id: &UserId,
conn: &DbConn, conn: &DbConn,
) -> Result<(bool, Vec<WebauthnRegistration>), Error> { ) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
let type_ = TwoFactorType::Webauthn as i32; match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
None => Ok((false, Vec::new())), // If no data, return empty list None => Ok((false, Vec::new())), // If no data, return empty list
} }
@@ -417,8 +455,9 @@ 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 mut state = if let Some(tf) =
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await { TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
{
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
+53 -45
View File
@@ -10,9 +10,12 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, PasswordOrOtpData, EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code}, core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
}, },
auth::Headers, auth::{Headers, two_factor},
db::{ db::{
DbConn, DbConn,
models::{EventType, TwoFactor, TwoFactorType}, models::{EventType, TwoFactor, TwoFactorType},
@@ -22,7 +25,7 @@ use crate::{
}; };
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, delete_yubikeys,]
} }
struct HttpClientTransport { struct HttpClientTransport {
@@ -60,8 +63,7 @@ struct EnableYubikeyData {
key4: Option<String>, key4: Option<String>,
key5: Option<String>, key5: Option<String>,
nfc: bool, nfc: bool,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
@@ -125,48 +127,29 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn:
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let user_id = &user.uuid; let user_id = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32;
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; let (enabled, keys, yubikey_json) =
if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await {
if let Some(r) = r { let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; let enabled = !yubikey_metadata.keys.is_empty();
let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone());
let mut result = jsonify_yubikeys(yubikey_metadata.keys); result["enabled"] = Value::Bool(enabled);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["enabled"] = Value::Bool(true); (enabled, yubikey_metadata.keys, result)
result["nfc"] = Value::Bool(yubikey_metadata.nfc); } else {
result["object"] = Value::String("twoFactorU2f".to_owned()); (false, Vec::new(), json!({"enabled": false}))
};
Ok(Json(result)) Ok(Json(json!({
} else { "yubiKey": yubikey_json,
Ok(Json(json!({ "userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
"enabled": false, })))
"object": "twoFactorU2f",
})))
}
} }
#[post("/two-factor/yubikey", data = "<data>")] #[post("/two-factor/yubikey", data = "<data>")]
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult { async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableYubikeyData = data.into_inner(); let data: EnableYubikeyData = data.into_inner();
let mut user = headers.user;
PasswordOrOtpData {
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
// Check if we already have some data
let mut yubikey_data =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await {
Some(data) => data,
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
};
let yubikeys = parse_yubikeys(&data); let yubikeys = parse_yubikeys(&data);
let mut user = headers.user;
if yubikeys.is_empty() { if yubikeys.is_empty() {
// Return an error to prevent saving empty keys which would cause users not being able to login anymore. // Return an error to prevent saving empty keys which would cause users not being able to login anymore.
@@ -174,6 +157,17 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
err!("A key is required."); err!("A key is required.");
} }
// Check if we already have some data
let mut yubikey_data =
if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?;
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?;
yd
} else {
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?;
TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new())
};
// Ensure they are valid OTPs // Ensure they are valid OTPs
for yubikey in &yubikeys { for yubikey in &yubikeys {
if yubikey.is_empty() || yubikey.len() == 12 { if yubikey.is_empty() || yubikey.len() == 12 {
@@ -195,15 +189,12 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let mut result = jsonify_yubikeys(yubikey_metadata.keys); let mut result = jsonify_yubikeys(yubikey_metadata.keys);
result["enabled"] = Value::Bool(true); result["enabled"] = Value::Bool(true);
result["nfc"] = Value::Bool(yubikey_metadata.nfc); result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["object"] = Value::String("twoFactorU2f".to_owned()); Ok(Json(json!({"yubiKey": result})))
Ok(Json(result))
} }
#[put("/two-factor/yubikey", data = "<data>")] #[put("/two-factor/yubikey", data = "<data>")]
@@ -211,6 +202,23 @@ async fn activate_yubikey_put(data: Json<EnableYubikeyData>, headers: Headers, c
activate_yubikey(data, headers, conn).await activate_yubikey(data, headers, conn).await
} }
#[delete("/two-factor/yubikey", data = "<data>")]
async fn delete_yubikeys(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?;
r.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
if response.len() != 44 { if response.len() != 44 {
err!("Invalid Yubikey OTP length"); err!("Invalid Yubikey OTP length");
+4 -4
View File
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
// Generate the cookie store // Generate the cookie store
let cookie_store = Arc::new(Jar::default()); let cookie_store = Arc::new(Jar::default());
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
let pool_idle_timeout = Duration::from_secs(10); let pool_idle_timeout = Duration::from_secs(10);
// Reuse the client between requests // Reuse the client between requests
get_reqwest_client_builder(true) get_reqwest_client_builder(true)
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None None
} }
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> { async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
let meta = operator.stat(path).await?; let meta = operator.stat(path).await?;
let modified = let modified =
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
let age = SystemTime::now().duration_since(modified.into())?; let age = SystemTime::now().duration_since(modified.into())?;
Ok(ttl > 0 && ttl <= age.as_secs()) Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
} }
async fn icon_is_negcached(path: &str) -> bool { async fn icon_is_negcached(path: &str) -> bool {
@@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
Ok(()) Ok(())
}) { }) {
Ok(_) => { Ok(_) => {
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create // Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
if body.len() >= 67 { if body.len() >= 67 {
// Check if the icon type is allowed, else try an icon from the list. // Check if the icon type is allowed, else try an icon from the list.
icon_type = get_icon_type(&body); icon_type = get_icon_type(&body);
+7 -8
View File
@@ -118,7 +118,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
match &login_result { match &login_result {
Ok(_) => { Ok(_) => {
log_user_event( log_user_event(
EventType::UserLoggedIn as i32, EventType::UserLoggedIn,
&user_id, &user_id,
client_header.device_type, client_header.device_type,
&client_header.ip.ip, &client_header.ip.ip,
@@ -128,8 +128,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
} }
Err(e) => { Err(e) => {
if let Some(ev) = e.get_event() { if let Some(ev) = e.get_event() {
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
.await;
} }
} }
} }
@@ -448,9 +447,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
if user.last_verifying_at.is_none() if user.last_verifying_at.is_none()
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
> CONFIG.signups_verify_resend_time().cast_signed() > i64::from(CONFIG.signups_verify_resend_time())
{ {
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed(); let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
if resend_limit == 0 || user.login_verify_count < resend_limit { if resend_limit == 0 || user.login_verify_count < resend_limit {
// We want to send another email verification if we require signups to verify // We want to send another email verification if we require signups to verify
// their email address, and we haven't sent them a reminder in a while... // their email address, and we haven't sent them a reminder in a while...
@@ -904,7 +903,7 @@ async fn twofactor_auth(
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await;
if CONFIG.mail_enabled() if CONFIG.mail_enabled()
&& let Err(e) = && let Err(e) =
@@ -974,7 +973,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
} }
Some(tf_type @ TwoFactorType::YubiKey) => { Some(tf_type @ TwoFactorType::YubiKey) => {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
err!("No YubiKey devices registered") err!("No YubiKey devices registered")
}; };
@@ -986,7 +985,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
} }
Some(tf_type @ TwoFactorType::Email) => { Some(tf_type @ TwoFactorType::Email) => {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
err!("No twofactor email registered") err!("No twofactor email registered")
}; };
+1 -1
View File
@@ -46,7 +46,7 @@ pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>; pub type EmptyResult = ApiResult<()>;
// Common structs representing JSON data received // Common structs representing JSON data received
#[derive(Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct PasswordOrOtpData { struct PasswordOrOtpData {
#[serde(alias = "MasterPasswordHash")] #[serde(alias = "MasterPasswordHash")]
+33 -5
View File
@@ -1,7 +1,7 @@
use std::{ use std::{
net::IpAddr, net::IpAddr,
sync::{Arc, LazyLock}, sync::{Arc, LazyLock},
time::Duration, time::{Duration, Instant},
}; };
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
@@ -41,6 +41,14 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscripti
/// One connection is needed per pending login request, several at once are only expected behind NAT. /// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25; const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
/// How often a Ping is sent to the client.
const WS_PING_INTERVAL: Duration = Duration::from_secs(15);
/// Close the connection if nothing, not even a Pong, was received from the client for this long.
/// Otherwise half-open connections (client gone without a FIN, e.g. behind a proxy or NAT) are kept forever.
/// Same as the default `ClientTimeoutInterval` of ASP.NET Core SignalR, which the official server uses.
const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -156,12 +164,16 @@ fn websockets_hub<'r>(
rocket_ws::Stream! { ws => { rocket_ws::Stream! { ws => {
let mut ws = ws; let mut ws = ws;
let _guard = guard; let _guard = guard;
let mut interval = tokio::time::interval(Duration::from_secs(15)); let mut interval = tokio::time::interval(WS_PING_INTERVAL);
let mut last_received = Instant::now();
loop { loop {
tokio::select! { tokio::select! {
res = ws.next() => { res = ws.next() => {
match res { match res {
Some(Ok(message)) => { Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message { match message {
// Respond to any pings // Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping), Message::Ping(ping) => yield Message::Pong(ping),
@@ -195,7 +207,13 @@ fn websockets_hub<'r>(
} }
} }
_ = interval.tick() => yield Message::Ping(create_ping()) _ = interval.tick() => {
// The client stopped responding without closing the connection, drop it
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
break;
}
yield Message::Ping(create_ping());
}
} }
} }
}} }}
@@ -229,12 +247,16 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
rocket_ws::Stream! { ws => { rocket_ws::Stream! { ws => {
let mut ws = ws; let mut ws = ws;
let _guard = guard; let _guard = guard;
let mut interval = tokio::time::interval(Duration::from_secs(15)); let mut interval = tokio::time::interval(WS_PING_INTERVAL);
let mut last_received = Instant::now();
loop { loop {
tokio::select! { tokio::select! {
res = ws.next() => { res = ws.next() => {
match res { match res {
Some(Ok(message)) => { Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message { match message {
// Respond to any pings // Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping), Message::Ping(ping) => yield Message::Pong(ping),
@@ -268,7 +290,13 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
} }
} }
_ = interval.tick() => yield Message::Ping(create_ping()) _ = interval.tick() => {
// The client stopped responding without closing the connection, drop it
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
break;
}
yield Message::Ping(create_ping());
}
} }
} }
}} }}
+45 -24
View File
@@ -1,3 +1,6 @@
#[path = "auth/two_factor.rs"]
pub mod two_factor;
#[path = "auth/send.rs"] #[path = "auth/send.rs"]
pub mod send; pub mod send;
pub type SendTokens = send::SendTokens; pub type SendTokens = send::SendTokens;
@@ -311,6 +314,17 @@ impl LoginJwtClaims {
} }
} }
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
};
let Some(exp) = now.checked_add_signed(hours_delta) else {
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
};
Ok(exp)
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct InviteJwtClaims { pub struct InviteJwtClaims {
// Not before // Not before
@@ -334,19 +348,19 @@ pub fn generate_invite_claims(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
invited_by_email: Option<String>, invited_by_email: Option<String>,
) -> InviteJwtClaims { ) -> Result<InviteJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
InviteJwtClaims { Ok(InviteJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_INVITE_ISSUER.to_string(), iss: JWT_INVITE_ISSUER.to_string(),
sub: user_id, sub: user_id,
email, email,
org_id, org_id,
member_id, member_id,
invited_by_email, invited_by_email,
} })
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -372,19 +386,19 @@ pub fn generate_emergency_access_invite_claims(
emer_id: EmergencyAccessId, emer_id: EmergencyAccessId,
grantor_name: String, grantor_name: String,
grantor_email: String, grantor_email: String,
) -> EmergencyAccessInviteJwtClaims { ) -> Result<EmergencyAccessInviteJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
EmergencyAccessInviteJwtClaims { Ok(EmergencyAccessInviteJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
sub: user_id, sub: user_id,
email, email,
emer_id, emer_id,
grantor_name, grantor_name,
grantor_email, grantor_email,
} })
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -518,36 +532,43 @@ impl BasicJwtClaims {
} }
} }
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
BasicJwtClaims { Ok(BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_DELETE_ISSUER.to_string(), iss: JWT_DELETE_ISSUER.to_string(),
sub: uuid, sub: uuid,
} })
} }
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
BasicJwtClaims { Ok(BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_VERIFYEMAIL_ISSUER.to_string(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
sub: user_id.to_string(), sub: user_id.to_string(),
} })
} }
pub fn generate_admin_claims() -> BasicJwtClaims { pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
BasicJwtClaims { let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
Ok(BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_ADMIN_ISSUER.to_string(), iss: JWT_ADMIN_ISSUER.to_string(),
sub: "admin_panel".to_owned(), sub: "admin_panel".to_owned(),
} })
} }
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
+281
View File
@@ -0,0 +1,281 @@
use chrono::{TimeDelta, Utc};
use serde::{de::DeserializeOwned, ser::Serialize};
use std::sync::LazyLock;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult},
auth::{decode_jwt, encode_jwt},
db::models::UserId,
};
static JWT_2FA_AUTH_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin()));
#[derive(Serialize, Deserialize)]
pub struct TwoFactorClaims<T> {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: UserId,
pub enabled: bool,
pub claims: T,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthenticatorClaims {
#[serde(rename = "authenticator_key")]
pub key: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DuoClaims {
#[serde(rename = "duo_data")]
pub data: Option<DuoData>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebauthnClaims {
#[serde(rename = "webauthn_keys")]
pub keys: Vec<i32>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct YubikeyClaims {
#[serde(rename = "yubi_keys")]
pub keys: Vec<String>,
}
#[derive(Serialize, Deserialize, PartialEq)]
pub struct DuoData {
pub host: String, // Duo API hostname
pub ik: String, // client id
pub sk: String, // client secret
}
impl DuoData {
pub fn global() -> Option<Self> {
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
(true, Some(host)) => Some(Self {
host,
ik: CONFIG.duo_ikey().unwrap(),
sk: CONFIG.duo_skey().unwrap(),
}),
_ => None,
}
}
pub fn msg(s: &str) -> Self {
Self {
host: s.into(),
ik: s.into(),
sk: s.into(),
}
}
pub fn secret() -> Self {
Self::msg("<global_secret>")
}
pub fn obscure(self) -> Self {
let mut host = self.host;
let mut ik = self.ik;
let mut sk = self.sk;
let digits = 4;
let replaced = "************";
host.replace_range(digits.., replaced);
ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced);
Self {
host,
ik,
sk,
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmailClaims {
pub email: Option<String>,
}
fn token<T: Serialize>(user_id: UserId, enabled: bool, claims: T) -> String {
let time_now = Utc::now();
let claims = TwoFactorClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
iss: JWT_2FA_AUTH_ISSUER.to_string(),
sub: user_id,
enabled,
claims,
};
encode_jwt(&claims)
}
fn validate<T: DeserializeOwned>(token: &str, user_id: &UserId, enabled: bool) -> ApiResult<T> {
match decode_jwt::<TwoFactorClaims<T>>(token, JWT_2FA_AUTH_ISSUER.to_string()) {
Ok(claims) => {
if claims.sub != *user_id {
err!("Invalid verification token: Invalid user");
}
if claims.enabled != enabled {
err!("Invalid verification token: Invalid state");
}
Ok(claims.claims)
}
Err(err) => err!(format!("Failed to decode verification token: {err}")),
}
}
pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String {
token(
user_id,
enabled,
AuthenticatorClaims {
key,
},
)
}
pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult {
let claims = validate::<AuthenticatorClaims>(token, user_id, enabled)?;
if claims.key != key {
err!("Invalid verification token: Invalid key");
}
Ok(())
}
pub fn duo_token(user_id: UserId, data: Option<DuoData>, enabled: bool) -> String {
token(
user_id,
enabled,
DuoClaims {
data,
},
)
}
// When disabling we check that it's the correct data
pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult {
let claims = validate::<DuoClaims>(token, user_id, enabled)?;
if enabled && claims.data.as_ref() != data {
err!("Invalid verification token: Invalid duo data");
}
Ok(())
}
pub fn email_token(user_id: UserId, email: Option<String>, enabled: bool) -> String {
token(
user_id,
enabled,
EmailClaims {
email,
},
)
}
// When disabling we check that it's the correct `email`
pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult {
let claims = validate::<EmailClaims>(token, user_id, enabled)?;
if enabled && claims.email != Some(email) {
err!("Invalid verification token: Invalid email");
}
Ok(())
}
pub fn webauthn_token(user_id: UserId, keys: Vec<i32>, enabled: bool) -> String {
token(
user_id,
enabled,
WebauthnClaims {
keys,
},
)
}
pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult {
let claims = validate::<WebauthnClaims>(token, user_id, enabled)?;
if keys != claims.keys {
err!("Invalid verification token: Invalid keys");
}
Ok(())
}
pub fn yubikey_token(user_id: UserId, keys: Vec<String>, enabled: bool) -> String {
token(
user_id,
enabled,
YubikeyClaims {
keys,
},
)
}
pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec<String>, enabled: bool) -> EmptyResult {
let claims = validate::<YubikeyClaims>(token, user_id, enabled)?;
if *keys != claims.keys {
err!("Invalid verification token: Invalid keys");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, from_value, to_value};
fn token<T: Serialize>(claims: T) -> Value {
to_value(TwoFactorClaims {
nbf: 0,
exp: 0,
iss: String::new(),
sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")),
enabled: false,
claims,
})
.unwrap()
}
#[test]
fn claims_only_parse_as_their_own_provider() {
let tokens = [
token(AuthenticatorClaims {
key: String::from("JBSWY3DPEHPK3PXP"),
}),
token(DuoClaims {
data: None,
}),
token(WebauthnClaims {
keys: vec![1],
}),
token(YubikeyClaims {
keys: Vec::new(),
}),
token(EmailClaims {
email: None,
}),
];
for (issued, token) in tokens.iter().enumerate() {
let parsed = [
from_value::<TwoFactorClaims<AuthenticatorClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<DuoClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<WebauthnClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<YubikeyClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<EmailClaims>>(token.clone()).is_ok(),
];
for (checked, ok) in parsed.into_iter().enumerate() {
assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}");
}
}
}
}
+121 -36
View File
@@ -111,6 +111,7 @@ macro_rules! make_config {
_usr: ConfigBuilder, _usr: ConfigBuilder,
_overrides: Vec<&'static str>, _overrides: Vec<&'static str>,
_config_file_status: ConfigFileStatus,
} }
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
@@ -259,11 +260,18 @@ macro_rules! make_config {
builder builder
} }
async fn from_file() -> Result<Self, Error> { async fn from_file() -> Result<Option<Self>, Error> {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = operator.read(&CONFIG_FILENAME).await?; let config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes,
// No config file is fine, should not return any error
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
// Any other error might be an issue and should be passed back to the caller
Err(e) => return Err(e.into()),
};
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) Ok(Some(builder))
} }
fn clear_non_editable(&mut self) { fn clear_non_editable(&mut self) {
@@ -440,6 +448,8 @@ macro_rules! make_config {
"domain_origin", "domain_origin",
"domain_path", "domain_path",
"domain", "domain",
"duo_ikey",
"duo_host",
"helo_name", "helo_name",
"org_creation_users", "org_creation_users",
"signups_domains_whitelist", "signups_domains_whitelist",
@@ -451,6 +461,8 @@ macro_rules! make_config {
"sso_authority", "sso_authority",
"sso_callback_path", "sso_callback_path",
"sso_client_id", "sso_client_id",
"yubico_client_id",
"yubico_server",
]; ];
let cfg = { let cfg = {
@@ -483,6 +495,10 @@ macro_rules! make_config {
}; };
overrides overrides
} }
pub fn get_config_file_status(&self) -> ConfigFileStatus {
self.inner.read().unwrap()._config_file_status
}
} }
}; };
} }
@@ -539,7 +555,7 @@ make_config! {
jobs { jobs {
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
/// Set to 0 to globally disable scheduled jobs. /// Set to 0 to globally disable scheduled jobs.
job_poll_interval_ms: u64, false, def, 30_000; job_poll_interval_ms: u32, false, def, 30_000;
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date. /// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
/// Defaults to hourly. Set blank to disable this job. /// Defaults to hourly. Set blank to disable this job.
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned(); send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
@@ -595,19 +611,19 @@ make_config! {
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org. /// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
org_attachment_limit: i64, true, option; org_attachment_limit: i64, true, option;
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends. /// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
user_send_limit: i64, true, option; user_send_limit: i64, true, option;
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item. /// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make /// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
/// sure to inform all users of any changes to this setting. /// sure to inform all users of any changes to this setting.
trash_auto_delete_days: i64, true, option; trash_auto_delete_days: u16, true, option;
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
/// where the correct master password was provided but the required 2FA step was not completed, /// where the correct master password was provided but the required 2FA step was not completed,
/// which potentially indicates a master password compromise. Set to 0 to disable this check. /// which potentially indicates a master password compromise. Set to 0 to disable this check.
/// This setting applies globally to all users. /// This setting applies globally to all users.
incomplete_2fa_time_limit: i64, true, def, 3; incomplete_2fa_time_limit: u16, true, def, 3;
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
@@ -620,9 +636,9 @@ make_config! {
/// this will prevent logins from succeeding until the address has been verified /// this will prevent logins from succeeding until the address has been verified
signups_verify: bool, true, def, false; signups_verify: bool, true, def, false;
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
signups_verify_resend_time: u64, true, def, 3_600; signups_verify_resend_time: u32, true, def, 3_600;
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit) /// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
signups_verify_resend_limit: u32, true, def, 6; signups_verify_resend_limit: u16, true, def, 6;
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled /// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
signups_domains_whitelist: String, true, def, String::new(); signups_domains_whitelist: String, true, def, String::new();
/// Enable event logging |> Enables event logging for organizations. /// Enable event logging |> Enables event logging for organizations.
@@ -634,7 +650,7 @@ make_config! {
invitations_allowed: bool, true, def, true; invitations_allowed: bool, true, def, true;
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token, /// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
/// email verification token and deletion request token will expire (must be at least 1) /// email verification token and deletion request token will expire (must be at least 1)
invitation_expiration_hours: u32, false, def, 120; invitation_expiration_hours: u16, false, def, 120;
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. /// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
emergency_access_allowed: bool, true, def, true; emergency_access_allowed: bool, true, def, true;
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users. /// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
@@ -655,8 +671,8 @@ make_config! {
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
invitation_org_name: String, true, def, "Vaultwarden".to_owned(); invitation_org_name: String, true, def, "Vaultwarden".to_owned();
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely. /// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
events_days_retain: i64, false, option; events_days_retain: u16, false, option;
}, },
client { client {
@@ -694,13 +710,13 @@ make_config! {
/// Temporary redirects are useful while testing different icon services, but once a service /// Temporary redirects are useful while testing different icon services, but once a service
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes /// has been decided on, consider using permanent redirects for cacheability. The legacy codes
/// are currently better supported by the Bitwarden clients. /// are currently better supported by the Bitwarden clients.
icon_redirect_code: u32, true, def, 302; icon_redirect_code: u16, true, def, 302;
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed /// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
icon_cache_ttl: u64, true, def, 2_592_000; icon_cache_ttl: u32, true, def, 2_592_000;
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again. /// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
icon_cache_negttl: u64, true, def, 259_200; icon_cache_negttl: u32, true, def, 259_200;
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon. /// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
icon_download_timeout: u64, true, def, 10; icon_download_timeout: u16, true, def, 10;
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
icon_blacklist_regex: String, false, option; icon_blacklist_regex: String, false, option;
@@ -751,11 +767,11 @@ make_config! {
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
db_connection_retries: u32, false, def, 15; db_connection_retries: u32, false, def, 15;
/// Timeout when acquiring database connection /// Timeout in seconds when acquiring database connection
database_timeout: u64, false, def, 30; database_timeout: u32, false, def, 30;
/// Timeout in seconds before idle connections to the database are closed /// Timeout in seconds before idle connections to the database are closed
database_idle_timeout: u64, false, def, 600; database_idle_timeout: u32, false, def, 600;
/// Database connection max pool size /// Database connection max pool size
database_max_conns: u32, false, def, 10; database_max_conns: u32, false, def, 10;
@@ -772,26 +788,26 @@ make_config! {
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
allowed_iframe_ancestors: String, true, def, String::new(); allowed_iframe_ancestors: String, true, def, String::new();
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature /// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
allowed_connect_src: String, true, def, String::new(); allowed_connect_src: String, true, def, String::new();
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
login_ratelimit_seconds: u64, false, def, 60; login_ratelimit_seconds: u16, false, def, 60;
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u32, false, def, 10; login_ratelimit_max_burst: u16, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints /// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u64, false, def, 60; unauthenticated_ratelimit_seconds: u16, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one /// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u32, false, def, 50; unauthenticated_ratelimit_max_burst: u16, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u64, false, def, 300; admin_ratelimit_seconds: u16, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
admin_ratelimit_max_burst: u32, false, def, 3; admin_ratelimit_max_burst: u16, false, def, 3;
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes). /// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
admin_session_lifetime: i64, true, def, 20; admin_session_lifetime: u32, true, def, 20;
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false; org_groups_enabled: bool, false, def, false;
@@ -844,8 +860,8 @@ make_config! {
sso_master_password_policy: String, true, option; sso_master_password_policy: String, true, option;
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
sso_auth_only_not_session: bool, true, def, false; sso_auth_only_not_session: bool, true, def, false;
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache /// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u64, true, def, 0; sso_client_cache_expiration: u32, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false; sso_debug_tokens: bool, true, def, false;
}, },
@@ -903,11 +919,11 @@ make_config! {
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
smtp_auth_mechanism: String, true, option; smtp_auth_mechanism: String, true, option;
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u64, true, def, 15; smtp_timeout: u16, true, def, 15;
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
helo_name: String, true, option; helo_name: String, true, option;
/// Embed images as email attachments. /// Embed images as email attachments.
smtp_embed_images: bool, true, def, true; smtp_embed_images: bool, true, def, true;
/// _smtp_img_src /// _smtp_img_src
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); _smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
@@ -925,9 +941,9 @@ make_config! {
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
email_token_size: u8, true, def, 6; email_token_size: u8, true, def, 6;
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
email_expiration_time: u64, true, def, 600; email_expiration_time: u32, true, def, 600;
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
email_attempts_limit: u64, true, def, 3; email_attempts_limit: u8, true, def, 3;
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
email_2fa_enforce_on_verified_invite: bool, true, def, false; email_2fa_enforce_on_verified_invite: bool, true, def, false;
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
@@ -983,7 +999,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
} }
if cfg.database_min_conns > cfg.database_max_conns { if cfg.database_min_conns > cfg.database_max_conns {
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`."); err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
}
if cfg.database_timeout < 1 {
err!("`DATABASE_TIMEOUT` must be at least 1 second");
}
if cfg.database_idle_timeout < 1 {
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
} }
if let Some(log_file) = &cfg.log_file if let Some(log_file) = &cfg.log_file
@@ -1266,6 +1290,38 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression")
} }
if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::<Schedule>().is_err() {
err!("`DUO_CONTEXT_PURGE_SCHEDULE` is not a valid cron expression")
}
if !cfg.purge_incomplete_sso_auth.is_empty() && cfg.purge_incomplete_sso_auth.parse::<Schedule>().is_err() {
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
}
if cfg.login_ratelimit_seconds < 1 {
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.login_ratelimit_max_burst < 1 {
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.admin_ratelimit_seconds < 1 {
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.admin_ratelimit_max_burst < 1 {
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.unauthenticated_ratelimit_seconds < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.unauthenticated_ratelimit_max_burst < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
}
if !cfg.disable_admin_token { if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() { match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => { Some(t) if t.starts_with("$argon2") => {
@@ -1422,7 +1478,10 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"undetermined-cipher-scenario-logic", "undetermined-cipher-scenario-logic",
"enable-basic-auth-response", "enable-basic-auth-response",
"ssh-agent-v2", "ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
// Key Management Team // Key Management Team
"biometrics-sdk-ipc",
"windows-native-credential-sync", "windows-native-credential-sync",
// Mobile Team // Mobile Team
"pm-34171-card-scanner", "pm-34171-card-scanner",
@@ -1432,11 +1491,34 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"pm-32009-new-item-types", "pm-32009-new-item-types",
]; ];
#[derive(Clone, Copy, Serialize)]
pub enum ConfigFileStatus {
NotFound,
Loaded,
Error,
}
impl Config { impl Config {
pub async fn load() -> Result<Self, Error> { pub async fn load() -> Result<Self, Error> {
// Loading from env and file // Loading from env and file
let env = ConfigBuilder::from_env(); let env = ConfigBuilder::from_env();
let usr = ConfigBuilder::from_file().await.unwrap_or_default(); let (usr, config_file_status) = match ConfigBuilder::from_file().await {
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
Err(e) => {
println!(
"[ERROR] Failed to load configuration from `{}`\n\
#####################################################################################\n\
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
# This file is not used until those errors are resolved. #\n\
#####################################################################################\n\
{e:?}\n",
*CONFIG_FILE
);
(ConfigBuilder::default(), ConfigFileStatus::Error)
}
};
// Create merged config, config file overwrites env // Create merged config, config file overwrites env
let mut overrides = Vec::new(); let mut overrides = Vec::new();
@@ -1456,6 +1538,7 @@ impl Config {
_env: env, _env: env,
_usr: usr, _usr: usr,
_overrides: overrides, _overrides: overrides,
_config_file_status: config_file_status,
}), }),
}) })
} }
@@ -1489,6 +1572,7 @@ impl Config {
writer.config = config; writer.config = config;
writer._usr = builder; writer._usr = builder;
writer._overrides = overrides; writer._overrides = overrides;
writer._config_file_status = ConfigFileStatus::Loaded;
} }
//Save to file //Save to file
@@ -1580,6 +1664,7 @@ impl Config {
writer.config = config; writer.config = config;
writer._usr = usr; writer._usr = usr;
writer._overrides = Vec::new(); writer._overrides = Vec::new();
writer._config_file_status = ConfigFileStatus::NotFound;
} }
// Invalidate CSS Cache because several config items might have impact on the rendered CSS // Invalidate CSS Cache because several config items might have impact on the rendered CSS
+3 -3
View File
@@ -212,8 +212,8 @@ impl DbPool {
let pool = Pool::builder() let pool = Pool::builder()
.max_size(max_conns) .max_size(max_conns)
.min_idle(Some(CONFIG.database_min_conns())) .min_idle(Some(CONFIG.database_min_conns()))
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout()))) .idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
.connection_timeout(Duration::from_secs(CONFIG.database_timeout())) .connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
.connection_customizer(Box::new(DbConnOptions { .connection_customizer(Box::new(DbConnOptions {
init_stmts: conn_type.get_init_stmts(), init_stmts: conn_type.get_init_stmts(),
})) }))
@@ -233,7 +233,7 @@ impl DbPool {
// Get a connection from the pool // Get a connection from the pool
pub async fn get(&self) -> Result<DbConn, Error> { pub async fn get(&self) -> Result<DbConn, Error> {
let duration = Duration::from_secs(CONFIG.database_timeout()); let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await { let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
Ok(p) => p.expect("Semaphore should be open"), Ok(p) => p.expect("Semaphore should be open"),
Err(_) => { Err(_) => {
+10 -1
View File
@@ -166,6 +166,15 @@ impl AuthRequest {
.await .await
} }
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting auth requests for user")
})
.await
}
/// Marks the request as used to log in. Returns false when it already was, so it only works once. /// Marks the request as used to log in. Returns false when it already was, so it only works once.
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> { pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
@@ -188,7 +197,7 @@ impl AuthRequest {
pub async fn purge_expired_auth_requests(conn: &DbConn) { pub async fn purge_expired_auth_requests(conn: &DbConn) {
// delete auth requests older than 15 minutes which is functionally equivalent to upstream: // delete auth requests older than 15 minutes which is functionally equivalent to upstream:
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap(); let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
for auth_request in Self::find_created_before(&expiry_time, conn).await { for auth_request in Self::find_created_before(&expiry_time, conn).await {
auth_request.delete(conn).await.ok(); auth_request.delete(conn).await.ok();
} }
+16 -6
View File
@@ -494,12 +494,22 @@ impl Cipher {
/// Purge all ciphers that are old enough to be auto-deleted. /// Purge all ciphers that are old enough to be auto-deleted.
pub async fn purge_trash(conn: &DbConn) { pub async fn purge_trash(conn: &DbConn) {
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() { let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
let now = Utc::now().naive_utc(); debug!("trash_auto_delete_days is not configured or set to 0, skipping");
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap(); return;
for cipher in Self::find_deleted_before(&dt, conn).await { };
cipher.delete(conn).await.ok();
} let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
error!("Unable to calculate auto delete days!");
return;
};
let now = Utc::now().naive_utc();
let Some(dt) = now.checked_sub_signed(days) else {
error!("Unable to calculate trash purge date!");
return;
};
for cipher in Self::find_deleted_before(&dt, conn).await {
cipher.delete(conn).await.ok();
} }
} }
+50 -17
View File
@@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
use serde_json::Value; use serde_json::Value;
@@ -112,8 +112,26 @@ impl EmergencyAccess {
"object": "emergencyAccessGranteeDetails", "object": "emergencyAccessGranteeDetails",
})) }))
} }
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
TimeDelta::try_days(i64::from(self.wait_time_days))
.and_then(|w| initiated_at.checked_add_signed(w))
.map_res("Emergency access deadline is invalid")
}
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
Ok(self.deadline_at()?.le(now))
}
} }
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub enum EmergencyAccessType { pub enum EmergencyAccessType {
View = 0, View = 0,
@@ -169,27 +187,42 @@ impl EmergencyAccess {
pub async fn update_access_status_and_save( pub async fn update_access_status_and_save(
&mut self, &mut self,
status: i32, from_status: i32,
to_status: i32,
date: &NaiveDateTime, date: &NaiveDateTime,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
let uuid = self.uuid.clone();
let updated_at = *date;
let res = conn
.run(move |conn| {
crate::util::retry(
|| {
diesel::update(
emergency_access::table
.filter(emergency_access::uuid.eq(&uuid))
.filter(emergency_access::status.eq(from_status)),
)
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
.execute(conn)
},
10,
)
})
.await;
match res {
Ok(1) => {} // One record updated, which is valid
Ok(_) => err!("Emergency access status was changed already"),
Err(e) => err!("Error updating emergency access status", format!("{e}")),
}
self.status = to_status;
self.updated_at = updated_at;
// Update the grantee so that it will refresh it's status. // Update the grantee so that it will refresh it's status.
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await; User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
self.status = status; Ok(())
date.clone_into(&mut self.updated_at);
conn.run(move |conn| {
crate::util::retry(
|| {
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
.execute(conn)
},
10,
)
.map_res("Error updating emergency access status")
})
.await
} }
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
+17 -11
View File
@@ -347,17 +347,23 @@ impl Event {
} }
pub async fn clean_events(conn: &DbConn) -> EmptyResult { pub async fn clean_events(conn: &DbConn) -> EmptyResult {
if let Some(days_to_retain) = CONFIG.events_days_retain() { let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); return Ok(());
conn.run(move |conn| { };
diesel::delete(event::table.filter(event::event_date.lt(dt)))
.execute(conn) let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
.map_res("Error cleaning old events") err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
}) };
.await
} else { let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
Ok(()) err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
} };
conn.run(move |conn| {
diesel::delete(event::table.filter(event::event_date.lt(dt)))
.execute(conn)
.map_res("Error cleaning old events")
})
.await
} }
} }
+3 -1
View File
@@ -24,7 +24,9 @@ pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::emergency_access::{
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
};
pub use self::event::{Event, EventType}; pub use self::event::{Event, EventType};
pub use self::favorite::Favorite; pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
+13 -7
View File
@@ -1,3 +1,4 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, From}; use derive_more::{AsRef, From};
use diesel::prelude::*; use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
@@ -11,6 +12,7 @@ use crate::{
schema::{org_policies, users_organizations}, schema::{org_policies, users_organizations},
}, },
error::MapResult, error::MapResult,
util::format_date,
}; };
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
@@ -24,6 +26,7 @@ pub struct OrgPolicy {
pub atype: i32, pub atype: i32,
pub enabled: bool, pub enabled: bool,
pub data: String, pub data: String,
pub revision_date: NaiveDateTime,
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
@@ -46,7 +49,7 @@ pub enum OrgPolicyType {
RemoveUnlockWithPin = 14, RemoveUnlockWithPin = 14,
RestrictedItemTypes = 15, RestrictedItemTypes = 15,
UriMatchDefaults = 16, UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet AutotypeDefaultSetting = 17,
// AutoConfirm = 18, // Not supported (not implemented yet) // AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20, OrganizationUserNotification = 20,
@@ -77,6 +80,7 @@ impl OrgPolicy {
atype: atype as i32, atype: atype as i32,
enabled, enabled,
data, data,
revision_date: Utc::now().naive_utc(),
} }
} }
@@ -92,7 +96,7 @@ impl OrgPolicy {
"type": self.atype, "type": self.atype,
"data": data_json, "data": data_json,
"enabled": self.enabled, "enabled": self.enabled,
"revisionDate": null, "revisionDate": format_date(&self.revision_date),
"object": "policy", "object": "policy",
}); });
@@ -110,11 +114,13 @@ impl OrgPolicy {
/// Database methods /// Database methods
impl OrgPolicy { impl OrgPolicy {
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.revision_date = Utc::now().naive_utc();
db_run! { conn: db_run! { conn:
sqlite, mysql { sqlite, mysql {
match diesel::replace_into(org_policies::table) match diesel::replace_into(org_policies::table)
.values(self) .values(&*self)
.execute(conn) .execute(conn)
{ {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@@ -122,7 +128,7 @@ impl OrgPolicy {
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(org_policies::table) diesel::update(org_policies::table)
.filter(org_policies::uuid.eq(&self.uuid)) .filter(org_policies::uuid.eq(&self.uuid))
.set(self) .set(&*self)
.execute(conn) .execute(conn)
.map_res("Error saving org_policy") .map_res("Error saving org_policy")
} }
@@ -142,10 +148,10 @@ impl OrgPolicy {
.map_res("Error deleting org_policy for insert")?; .map_res("Error deleting org_policy for insert")?;
diesel::insert_into(org_policies::table) diesel::insert_into(org_policies::table)
.values(self) .values(&*self)
.on_conflict(org_policies::uuid) .on_conflict(org_policies::uuid)
.do_update() .do_update()
.set(self) .set(&*self)
.execute(conn) .execute(conn)
.map_res("Error saving org_policy") .map_res("Error saving org_policy")
} }
+2 -2
View File
@@ -137,11 +137,11 @@ impl TwoFactor {
.await .await
} }
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option<Self> { pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
twofactor::table twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::atype.eq(atype)) .filter(twofactor::atype.eq(atype as i32))
.first::<Self>(conn) .first::<Self>(conn)
.ok() .ok()
}) })
+2 -2
View File
@@ -37,7 +37,7 @@ impl TwoFactorIncomplete {
ip: &ClientIp, ip: &ClientIp,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
return Ok(()); return Ok(());
} }
@@ -66,7 +66,7 @@ impl TwoFactorIncomplete {
} }
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
return Ok(()); return Ok(());
} }
+7 -1
View File
@@ -19,7 +19,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -170,6 +171,10 @@ impl User {
) )
} }
pub fn master_password_salt(&self) -> String {
self.email.trim().to_lowercase()
}
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
if let Some(ref totp_recover) = self.totp_recover { if let Some(ref totp_recover) = self.totp_recover {
crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
@@ -373,6 +378,7 @@ impl User {
Cipher::delete_all_by_user(&self.uuid, conn).await?; Cipher::delete_all_by_user(&self.uuid, conn).await?;
Favorite::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?;
Folder::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?;
AuthRequest::delete_all_by_user(&self.uuid, conn).await?;
Device::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
+1
View File
@@ -116,6 +116,7 @@ table! {
atype -> Integer, atype -> Integer,
enabled -> Bool, enabled -> Bool,
data -> Text, data -> Text,
revision_date -> Timestamp,
} }
} }
+6 -6
View File
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str()) let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
.port(CONFIG.smtp_port()) .port(CONFIG.smtp_port())
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); .timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
// Determine security // Determine security
let smtp_client = if CONFIG.smtp_security() == *"off" { let smtp_client = if CONFIG.smtp_security() == *"off" {
@@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
} }
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_delete_claims(user_id.to_string()); let claims = generate_delete_claims(user_id.to_string())?;
let delete_token = encode_jwt(&claims); let delete_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
} }
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id); let claims = generate_verify_email_claims(user_id)?;
let verify_email_token = encode_jwt(&claims); let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
} }
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id); let claims = generate_verify_email_claims(user_id)?;
let verify_email_token = encode_jwt(&claims); let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -292,7 +292,7 @@ pub async fn send_invite(
org_id.clone(), org_id.clone(),
member_id.clone(), member_id.clone(),
invited_by_email, invited_by_email,
); )?;
let invite_token = encode_jwt(&claims); let invite_token = encode_jwt(&claims);
let mut query = url::Url::parse("https://query.builder").unwrap(); let mut query = url::Url::parse("https://query.builder").unwrap();
{ {
@@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite(
emer_id.clone(), emer_id.clone(),
String::from(grantor_name), String::from(grantor_name),
String::from(grantor_email), String::from(grantor_email),
); )?;
// Build the query here to ensure proper escaping // Build the query here to ensure proper escaping
let mut query = url::Url::parse("https://query.builder").unwrap(); let mut query = url::Url::parse("https://query.builder").unwrap();
+4 -2
View File
@@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) {
// Cleanup the event table of records x days old. // Cleanup the event table of records x days old.
if CONFIG.org_events_enabled() if CONFIG.org_events_enabled()
&& !CONFIG.event_cleanup_schedule().is_empty() && !CONFIG.event_cleanup_schedule().is_empty()
&& CONFIG.events_days_retain().is_some() && CONFIG.events_days_retain().is_some_and(|d| d != 0)
{ {
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || { sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
runtime.spawn(api::event_cleanup_job(pool.clone())); runtime.spawn(api::event_cleanup_job(pool.clone()));
@@ -754,7 +754,9 @@ fn schedule_jobs(pool: db::DbPool) {
// tick, the one that was added earlier will run first. // tick, the one that was added earlier will run first.
loop { loop {
sched.tick(); sched.tick();
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms()))); runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
CONFIG.job_poll_interval_ms(),
))));
} }
}) })
.expect("Error spawning job scheduler thread"); .expect("Error spawning job scheduler thread");
+6 -6
View File
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>; type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| { static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds()));
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| { static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds()));
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| { static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds()); let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst()) let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
.expect("Non-zero unauthenticated ratelimit burst"); .expect("Non-zero unauthenticated ratelimit burst");
RateLimiter::keyed( RateLimiter::keyed(
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst), Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
+1 -1
View File
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| { static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
moka::sync::Cache::builder() moka::sync::Cache::builder()
.max_capacity(1) .max_capacity(1)
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) .time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration())))
.build() .build()
}); });
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> = static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
+9 -2
View File
@@ -93,7 +93,9 @@ async function generateSupportString(event, dj) {
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`; supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`; supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`; supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`;
supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n";
supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`;
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`; supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`; supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
if (dj.ip_header_exists) { if (dj.ip_header_exists) {
@@ -116,7 +118,7 @@ async function generateSupportString(event, dj) {
} }
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`; supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
if (dj.invalid_feature_flags != "") { if (dj.invalid_feature_flags != "") {
supportString += "* Invalid feature flags: true\n"; supportString += "* Invalid feature flags: true :x:\n";
} }
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
@@ -132,6 +134,11 @@ async function generateSupportString(event, dj) {
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
supportString += "<details><summary>Show Config & Details</summary>\n"; supportString += "<details><summary>Show Config & Details</summary>\n";
// Show invalid environment variables if they exists
if (dj.invalid_env_vars != "") {
supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`;
}
// Add overrides if they exists // Add overrides if they exists
if (dj.overrides != "") { if (dj.overrides != "") {
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
+2 -2
View File
@@ -4,10 +4,10 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-3.1.3 * https://datatables.net/download/#bs5/dt-3.1.4
* *
* Included libraries: * Included libraries:
* DataTables 3.1.3 * DataTables 3.1.4
*/ */
/*! DataTables Bootstrap 5 integration /*! DataTables Bootstrap 5 integration
+13 -7
View File
@@ -4,13 +4,13 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-3.1.3 * https://datatables.net/download/#bs5/dt-3.1.4
* *
* Included libraries: * Included libraries:
* DataTables 3.1.3 * DataTables 3.1.4
*/ */
/*! DataTables 3.1.3 /*! DataTables 3.1.4
* Copyright (c) SpryMedia Ltd - datatables.net/license * Copyright (c) SpryMedia Ltd - datatables.net/license
*/ */
@@ -453,6 +453,9 @@ function assignDeep(out, ...inputs) {
continue; continue;
} }
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(input)) {
if (key === '__proto__') {
continue;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (!Array.isArray(out[key])) { if (!Array.isArray(out[key])) {
out[key] = []; out[key] = [];
@@ -489,6 +492,9 @@ function assignDeep(out, ...inputs) {
function assignDeepObjects(out, extender, breakRefs = false) { function assignDeepObjects(out, extender, breakRefs = false) {
let val; let val;
for (let prop in extender) { for (let prop in extender) {
if (prop === '__proto__') {
continue;
}
if (Object.prototype.hasOwnProperty.call(extender, prop)) { if (Object.prototype.hasOwnProperty.call(extender, prop)) {
val = extender[prop]; val = extender[prop];
if (plainObject(val)) { if (plainObject(val)) {
@@ -2694,7 +2700,7 @@ class Dom {
*/ */
prepend(content) { prepend(content) {
return this.each(el => { return this.each(el => {
if (content instanceof Dom) { if (arrayLike(content) || dom(content)) {
// Reverse the array, so if there are multiple elements, they // Reverse the array, so if there are multiple elements, they
// end up being added sequentially, just like jQuery // end up being added sequentially, just like jQuery
let itemsReversed = Array.from(content).reverse(); let itemsReversed = Array.from(content).reverse();
@@ -8714,7 +8720,7 @@ const ext = {
/** /**
* DataTables build type (expanded by the download builder) * DataTables build type (expanded by the download builder)
*/ */
builder: 'bs5/dt-3.1.3', builder: 'bs5/dt-3.1.4',
/** /**
* Buttons. For use with the Buttons extension for DataTables. This is * Buttons. For use with the Buttons extension for DataTables. This is
* defined here so other extensions can define buttons regardless of load * defined here so other extensions can define buttons regardless of load
@@ -8860,7 +8866,7 @@ const ext = {
* Software version * Software version
* @type string * @type string
*/ */
version: '3.1.3' version: '3.1.4'
}; };
// //
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts // Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
@@ -9559,7 +9565,7 @@ register('destroy()', function (remove) {
callbackFire(settings, 'destroy', 'destroy', [settings], true); callbackFire(settings, 'destroy', 'destroy', [settings], true);
// If not being removed from the document, make all columns visible // If not being removed from the document, make all columns visible
if (!remove) { if (!remove) {
new Api(settings).columns().visible(); new Api(settings).columns().visible(true);
} }
// Container width change listener // Container width change listener
if (settings.resizeObserver) { if (settings.resizeObserver) {
+17 -3
View File
@@ -69,12 +69,26 @@
</dd> </dd>
<dt class="col-sm-5">Uses config.json</dt> <dt class="col-sm-5">Uses config.json</dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
{{#if page_data.overrides}} {{#if (eq page_data.config_file_status "Loaded")}}
<span class="d-inline"><b>Yes</b></span> <span class="d-inline"><b>Yes</b></span>
{{#if page_data.overrides}}
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.&#013;&#010;{{page_data.overrides}}">Details</span> <span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.&#013;&#010;{{page_data.overrides}}">Details</span>
{{/if}}
{{~else if (eq page_data.config_file_status "Error")}}
<span class="d-inline"><b>Errors</b></span>
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
{{~else}}
<span class="d-inline"><b>No</b></span>
{{/if}} {{/if}}
{{#unless page_data.overrides}} </dd>
<span class="d-block"><b>No</b></span> <dt class="col-sm-5">Environment variables are valid</dt>
<dd class="col-sm-7">
{{#if page_data.invalid_env_vars}}
<span class="d-inline"><b>No</b></span>
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.&#013;&#010;{{page_data.invalid_env_vars}}">Details</span>
{{/if}}
{{#unless page_data.invalid_env_vars}}
<span class="d-block"><b>Yes</b></span>
{{/unless}} {{/unless}}
</dd> </dd>
<dt class="col-sm-5">Uses custom templates</dt> <dt class="col-sm-5">Uses custom templates</dt>
@@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}}
</tr> </tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center"> <td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{url}}}" <a data-testid="emergency" href="{{{url}}}"
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Become emergency contact Become emergency contact
</a> </a>
+20 -6
View File
@@ -210,7 +210,7 @@ impl Fairing for Cors {
pub struct Cached<R> { pub struct Cached<R> {
response: R, response: R,
is_immutable: bool, is_immutable: bool,
ttl: u64, ttl: u32,
} }
impl<R> Cached<R> { impl<R> Cached<R> {
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
} }
} }
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> { pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
Self { Self {
response, response,
is_immutable, is_immutable,
@@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
res.set_raw_header("Cache-Control", cache_control_header); res.set_raw_header("Cache-Control", cache_control_header);
let time_now = Local::now(); let time_now = Local::now();
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap(); let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap();
res.set_raw_header("Expires", format_datetime_http(&expiry_time)); res.set_raw_header("Expires", format_datetime_http(&expiry_time));
Ok(res) Ok(res)
} }
@@ -447,20 +447,34 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
} }
} }
pub fn get_env<V>(key: &str) -> Option<V> pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
pub fn get_env<V>(key: &'static str) -> Option<V>
where where
V: FromStr, V: FromStr,
{ {
try_parse_string(get_env_str_value(key)) let value = get_env_str_value(key);
let value_is_some = value.is_some();
let parsed: Option<V> = try_parse_string(value);
if parsed.is_none() && value_is_some {
println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead.");
INVALID_ENV_VARS.lock().unwrap().push(key);
}
parsed
} }
pub fn get_env_bool(key: &str) -> Option<bool> { pub fn get_env_bool(key: &'static str) -> Option<bool> {
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"]; const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"]; const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
match get_env_str_value(key) { match get_env_str_value(key) {
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true), Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false), Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
Some(_) => {
println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead.");
INVALID_ENV_VARS.lock().unwrap().push(key);
None
}
_ => None, _ => None,
} }
} }