mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-02 19:26:47 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fe3a3452 | ||
|
|
24428a8aba |
No files matched your search
+6
-20
@@ -78,7 +78,7 @@
|
|||||||
# DB_CONNECTION_RETRIES=15
|
# DB_CONNECTION_RETRIES=15
|
||||||
|
|
||||||
## Database timeout
|
## Database timeout
|
||||||
## Timeout in seconds when acquiring database connection
|
## Timeout 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) or 0, events are kept indefinitely and the scheduled job is disabled!
|
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
|
||||||
# EVENTS_DAYS_RETAIN=
|
# 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) or 0, trashed items are not auto-deleted.
|
## If unset (the default), trashed items are not auto-deleted.
|
||||||
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
## 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=
|
||||||
|
|
||||||
@@ -386,11 +386,6 @@
|
|||||||
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
||||||
|
|
||||||
## Client Settings
|
## Client Settings
|
||||||
## Server-wide Fill Assist rules feed base directory URL, not a JSON file.
|
|
||||||
## Defaults to https://fillassist.bitwarden.com.
|
|
||||||
## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace.
|
|
||||||
# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com
|
|
||||||
|
|
||||||
## Enable experimental feature flags for clients.
|
## Enable experimental feature flags for clients.
|
||||||
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
||||||
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||||
@@ -399,17 +394,11 @@
|
|||||||
## - "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)
|
## - "pm-30529-webauthn-related-origins":
|
||||||
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
|
||||||
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
|
||||||
## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0)
|
|
||||||
## - "pm-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)
|
|
||||||
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
|
|
||||||
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
|
|
||||||
# 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.
|
||||||
@@ -470,7 +459,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 for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||||
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
## 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"
|
||||||
@@ -613,9 +602,6 @@
|
|||||||
## Note that the checkbox would still be present, but ignored.
|
## Note that the checkbox would still be present, but ignored.
|
||||||
# DISABLE_2FA_REMEMBER=false
|
# DISABLE_2FA_REMEMBER=false
|
||||||
##
|
##
|
||||||
## Number of days before a remembered 2FA login expires (min: 1, max: 90).
|
|
||||||
# TWO_FACTOR_REMEMBER_DAYS=30
|
|
||||||
##
|
|
||||||
## Authenticator Settings
|
## Authenticator Settings
|
||||||
## Disable authenticator time drifted codes to be valid.
|
## Disable authenticator time drifted codes to be valid.
|
||||||
## TOTP codes of the previous and next 30 seconds will be invalid
|
## TOTP codes of the previous and next 30 seconds will be invalid
|
||||||
@@ -624,7 +610,7 @@
|
|||||||
## we allow by default the TOTP code which was valid one step back and one in the future.
|
## we allow by default the TOTP code which was valid one step back and one in the future.
|
||||||
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
||||||
## You can disable this, so that only the current TOTP Code is allowed.
|
## You can disable this, so that only the current TOTP Code is allowed.
|
||||||
## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid.
|
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
|
||||||
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
||||||
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
# Enable Rust Caching
|
# Enable Rust Caching
|
||||||
- name: Rust Caching
|
- name: Rust Caching
|
||||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
|
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
@@ -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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*-${{ matrix.base_image }}
|
pattern: digests-*-${{ matrix.base_image }}
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -303,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
@@ -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@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
# 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@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
@@ -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: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: typos
|
- id: typos
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
Generated
+499
-790
File diff suppressed because it is too large.
Load diff
+16
-16
@@ -66,7 +66,7 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.35"
|
log = "0.4.34"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||||
tracing = { version = "0.1.44", features = ["log"] }
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
@@ -80,7 +80,7 @@ num-derive = "0.5.1"
|
|||||||
bigdecimal = "0.4.11"
|
bigdecimal = "0.4.11"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] }
|
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||||
rocket_ws = { version = "0.1.1" }
|
rocket_ws = { version = "0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
@@ -91,7 +91,7 @@ dashmap = "6.2.1"
|
|||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
tokio = { version = "1.54.1", features = [
|
tokio = { version = "1.53.2", features = [
|
||||||
"fs",
|
"fs",
|
||||||
"io-util",
|
"io-util",
|
||||||
"net",
|
"net",
|
||||||
@@ -100,14 +100,14 @@ tokio = { version = "1.54.1", features = [
|
|||||||
"signal",
|
"signal",
|
||||||
"time",
|
"time",
|
||||||
] }
|
] }
|
||||||
tokio-util = { version = "0.7.20", features = ["compat"] }
|
tokio-util = { version = "0.7.19", 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.154"
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
||||||
diesel_migrations = "2.3.2"
|
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.46", features = ["ring", "std"], default-features = false }
|
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.29.0", features = ["v4"] }
|
uuid = { version = "1.27.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"] }
|
||||||
@@ -204,7 +204,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [
|
|||||||
hickory-resolver = "0.26.3"
|
hickory-resolver = "0.26.3"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.9.0"
|
html5gum = "0.8.4"
|
||||||
regex = { version = "1.13.1", default-features = false, features = [
|
regex = { version = "1.13.1", default-features = false, features = [
|
||||||
"perf",
|
"perf",
|
||||||
"std",
|
"std",
|
||||||
@@ -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.1.0", features = ["async"] }
|
cached = { version = "4.0.1", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.2"
|
cookie = "0.18.2"
|
||||||
@@ -235,7 +235,7 @@ governor = "0.10.4"
|
|||||||
ipnet = "2.12.2"
|
ipnet = "2.12.2"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.1.0", default-features = false }
|
openidconnect = { version = "4.0.1", default-features = false }
|
||||||
moka = { version = "0.12.16", features = ["future"] }
|
moka = { version = "0.12.16", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
@@ -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.5", default-features = false, features = ["services-fs"] }
|
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
|
||||||
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
|
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
|
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
||||||
"behavior-version-latest",
|
"behavior-version-latest",
|
||||||
"credentials-process",
|
"credentials-process",
|
||||||
"rt-tokio",
|
"rt-tokio",
|
||||||
"sso",
|
"sso",
|
||||||
] }
|
] }
|
||||||
aws-credential-types = { version = "1.3.1", optional = true }
|
aws-credential-types = { version = "1.3.0", optional = true }
|
||||||
aws-smithy-runtime-api = { version = "1.19.2", optional = true }
|
aws-smithy-runtime-api = { version = "1.19.0", 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 }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2026.10.1"
|
vault_version: "v2026.7.0"
|
||||||
vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
|
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
|
||||||
# Cross Compile Docker Helper Scripts v1.10.0
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
# 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
@@ -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.10.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
|
|||||||
@@ -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.10.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
## 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:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
|
||||||
# hadolint ignore=DL3067
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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>
|
||||||
|
|||||||
@@ -148,12 +148,6 @@ 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
@@ -14,7 +14,7 @@ proc-macro = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.47"
|
quote = "1.0.47"
|
||||||
syn = "3.0.9"
|
syn = "3.0.6"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE org_policies DROP COLUMN revision_date;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- 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();
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE org_policies DROP COLUMN revision_date;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- 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');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- TEXT has no size limit here, only MySQL needed the change.
|
|
||||||
-- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op.
|
|
||||||
SELECT 1;
|
|
||||||
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
-- TEXT has no size limit here, only MySQL needed the change
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
Whitespace-only changes.
@@ -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 uses its own [test.env](./test.env) with different ports to not collide with a running dev instance.
|
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -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 running (states are not impacted by the tests):
|
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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, recoveryCodes } from './setups/2fa';
|
import { activateTOTP, disableTOTP } from './setups/2fa';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
let totp;
|
let totp;
|
||||||
@@ -31,42 +31,21 @@ test('Authenticator 2fa', async ({ page }) => {
|
|||||||
|
|
||||||
await utils.logout(test, page, users.user1);
|
await utils.logout(test, page, users.user1);
|
||||||
|
|
||||||
await logUser(test, page, users.user1, { totp });
|
|
||||||
|
|
||||||
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 test.step('login', async () => {
|
||||||
|
let timestamp = Date.now(); // Needed to use the next token
|
||||||
|
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
|
||||||
|
|
||||||
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/);
|
||||||
|
|
||||||
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/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await disableTOTP(test, page, users.user1);
|
||||||
|
});
|
||||||
@@ -4,24 +4,6 @@ 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();
|
||||||
@@ -39,6 +21,7 @@ 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;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ 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 () => {
|
||||||
@@ -57,23 +55,11 @@ 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 || options.totp ){
|
if( options.mail2fa ){
|
||||||
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
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.99.1"
|
channel = "1.99.0"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
+5
-20
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
api::{
|
api::{
|
||||||
ApiResult, EmptyResult, JsonResult, Notify,
|
ApiResult, EmptyResult, JsonResult, Notify,
|
||||||
core::{log_event, two_factor},
|
core::{log_event, two_factor},
|
||||||
push_logout_to_relay, unregister_push_device,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||||
config::ConfigBuilder,
|
config::ConfigBuilder,
|
||||||
@@ -201,15 +201,12 @@ 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 = match generate_admin_claims() {
|
let claims = 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(i64::from(CONFIG.admin_session_lifetime())))
|
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.secure(secure.https);
|
.secure(secure.https);
|
||||||
@@ -419,21 +416,13 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/delete", format = "application/json")]
|
#[post("/users/<user_id>/delete", format = "application/json")]
|
||||||
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let user = get_user_or_404(&user_id, &conn).await?;
|
let user = get_user_or_404(&user_id, &conn).await?;
|
||||||
|
|
||||||
// Get the membership records and push devices before deleting the actual user
|
// Get the membership records before deleting the actual user
|
||||||
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
||||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await;
|
|
||||||
let res = user.delete(&conn).await;
|
let res = user.delete(&conn).await;
|
||||||
|
|
||||||
if res.is_ok() {
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
|
||||||
if has_push_device {
|
|
||||||
push_logout_to_relay(&user, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserDeleted,
|
EventType::OrganizationUserDeleted,
|
||||||
@@ -790,8 +779,6 @@ 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,
|
||||||
@@ -814,9 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
"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,
|
||||||
|
|||||||
+28
-69
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
api::{
|
api::{
|
||||||
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||||
master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device,
|
master_password_policy, register_push_device, unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||||
crypto,
|
crypto,
|
||||||
@@ -94,15 +94,6 @@ 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 {
|
||||||
@@ -759,7 +750,8 @@ async fn post_set_password(data: Json<Value>, headers: Headers, conn: DbConn) ->
|
|||||||
Membership::accept_user_invitations(&user.uuid, &conn).await?;
|
Membership::accept_user_invitations(&user.uuid, &conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
|
.await;
|
||||||
|
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
@@ -957,7 +949,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
|||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
|
.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) {
|
||||||
@@ -1044,35 +1037,21 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(super) struct AuthenticationData {
|
struct AuthenticationData {
|
||||||
#[serde(alias = "Salt")]
|
#[serde(alias = "Salt")]
|
||||||
salt: String,
|
salt: String,
|
||||||
#[serde(alias = "Kdf")]
|
#[serde(alias = "Kdf")]
|
||||||
pub(super) kdf: KDFData,
|
kdf: KDFData,
|
||||||
#[serde(alias = "MasterPasswordAuthenticationHash")]
|
#[serde(alias = "MasterPasswordAuthenticationHash")]
|
||||||
pub(super) master_password_authentication_hash: String,
|
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")]
|
||||||
pub(super) struct UnlockData {
|
struct UnlockData {
|
||||||
salt: String,
|
salt: String,
|
||||||
kdf: KDFData,
|
kdf: KDFData,
|
||||||
pub(super) master_key_wrapped_user_key: String,
|
master_key_wrapped_user_key: String,
|
||||||
contained_key_id: Option<KeyId>,
|
contained_key_id: Option<KeyId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,7 +1083,13 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
|
|||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
data.authentication_data.check(&headers.user, &data.unlock_data)?;
|
if data.authentication_data.kdf != data.unlock_data.kdf {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
validate_key_id_unchanged(&headers.user, &data.unlock_data)?;
|
validate_key_id_unchanged(&headers.user, &data.unlock_data)?;
|
||||||
|
|
||||||
@@ -1492,11 +1477,7 @@ async fn post_email_token(data: Json<EmailTokenData>, headers: Headers, conn: Db
|
|||||||
error!("Error sending change-email email: {e:#?}");
|
error!("Error sending change-email email: {e:#?}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email);
|
||||||
"Email change request for user ({}) to email ({}) with token ({token})",
|
|
||||||
user.uuid,
|
|
||||||
data.new_email.escape_debug()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.email_new = Some(data.new_email);
|
user.email_new = Some(data.new_email);
|
||||||
@@ -1649,7 +1630,7 @@ struct DeleteRecoverTokenData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete-recover-token", data = "<data>")]
|
#[post("/accounts/delete-recover-token", data = "<data>")]
|
||||||
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn) -> EmptyResult {
|
||||||
let data: DeleteRecoverTokenData = data.into_inner();
|
let data: DeleteRecoverTokenData = data.into_inner();
|
||||||
|
|
||||||
let Ok(claims) = decode_delete(&data.token) else {
|
let Ok(claims) = decode_delete(&data.token) else {
|
||||||
@@ -1663,44 +1644,22 @@ async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbC
|
|||||||
if claims.sub != *user.uuid {
|
if claims.sub != *user.uuid {
|
||||||
err!("Invalid claim");
|
err!("Invalid claim");
|
||||||
}
|
}
|
||||||
|
user.delete(&conn).await
|
||||||
// The devices are deleted together with the user, so check for push devices beforehand
|
|
||||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
|
|
||||||
user.delete(&conn).await?;
|
|
||||||
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
|
||||||
if has_push_device {
|
|
||||||
push_logout_to_relay(&user, None);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete", data = "<data>")]
|
#[post("/accounts/delete", data = "<data>")]
|
||||||
async fn post_delete_account(
|
async fn post_delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
data: Json<PasswordOrOtpData>,
|
delete_account(data, headers, conn).await
|
||||||
headers: Headers,
|
|
||||||
conn: DbConn,
|
|
||||||
nt: Notify<'_>,
|
|
||||||
) -> EmptyResult {
|
|
||||||
delete_account(data, headers, conn, nt).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/accounts", data = "<data>")]
|
#[delete("/accounts", data = "<data>")]
|
||||||
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, true, &conn).await?;
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
// The devices are deleted together with the user, so check for push devices beforehand
|
user.delete(&conn).await
|
||||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
|
|
||||||
user.delete(&conn).await?;
|
|
||||||
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
|
||||||
if has_push_device {
|
|
||||||
push_logout_to_relay(&user, None);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
@@ -1751,7 +1710,7 @@ async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn)
|
|||||||
mail::send_password_hint(email, hint).await?;
|
mail::send_password_hint(email, hint).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else if let Some(hint) = hint {
|
} else if let Some(hint) = hint {
|
||||||
err_silent!(format!("Your password hint is: {hint}"));
|
err!(format!("Your password hint is: {hint}"));
|
||||||
} else {
|
} else {
|
||||||
err!(NO_HINT);
|
err!(NO_HINT);
|
||||||
}
|
}
|
||||||
@@ -2064,7 +2023,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,
|
EventType::UserRequestedDeviceApproval as i32,
|
||||||
&user.uuid,
|
&user.uuid,
|
||||||
client_headers.device_type,
|
client_headers.device_type,
|
||||||
&client_headers.ip.ip,
|
&client_headers.ip.ip,
|
||||||
@@ -2158,7 +2117,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,
|
EventType::OrganizationUserApprovedAuthRequest as i32,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
@@ -2169,7 +2128,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,
|
EventType::OrganizationUserRejectedAuthRequest as i32,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
|
|||||||
+69
-173
@@ -13,7 +13,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||||
auth::ClientVersion,
|
auth::ClientVersion,
|
||||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
@@ -376,25 +376,6 @@ impl CipherData {
|
|||||||
self.data.as_deref().is_some_and(is_data_blob_encrypted)
|
self.data.as_deref().is_some_and(is_data_blob_encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saving's last checks: a known type, and its data unless it's a blob.
|
|
||||||
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
|
|
||||||
let type_data = match self.r#type {
|
|
||||||
1 => &self.login,
|
|
||||||
2 => &self.secure_note,
|
|
||||||
3 => &self.card,
|
|
||||||
4 => &self.identity,
|
|
||||||
5 => &self.ssh_key,
|
|
||||||
6 => &self.bank_account,
|
|
||||||
7 => &self.drivers_license,
|
|
||||||
8 => &self.passport,
|
|
||||||
_ => err!("Invalid type"),
|
|
||||||
};
|
|
||||||
if !is_blob && type_data.is_none() {
|
|
||||||
err!("Data missing")
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
|
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
|
||||||
///
|
///
|
||||||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
|
||||||
@@ -532,7 +513,7 @@ async fn validate_cipher_update(
|
|||||||
{
|
{
|
||||||
match NaiveDateTime::parse_from_str(dt, "%+") {
|
match NaiveDateTime::parse_from_str(dt, "%+") {
|
||||||
// ISO 8601 format
|
// ISO 8601 format
|
||||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
|
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
||||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
||||||
}
|
}
|
||||||
@@ -582,7 +563,6 @@ pub async fn update_cipher_from_data(
|
|||||||
|
|
||||||
let is_blob = data.is_blob();
|
let is_blob = data.is_blob();
|
||||||
data.validate_content(is_blob)?;
|
data.validate_content(is_blob)?;
|
||||||
data.validate_type_data(is_blob)?;
|
|
||||||
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
|
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
|
||||||
|
|
||||||
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
||||||
@@ -627,7 +607,7 @@ pub async fn update_cipher_from_data(
|
|||||||
// A missing attachment means it was removed via an other client.
|
// A missing attachment means it was removed via an other client.
|
||||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||||
// Bitwarden it self ignores these mismatches server side.
|
// Bitwarden it self ignores these mismatches server side.
|
||||||
warn!("Attachment {} doesn't exist", id.escape_debug());
|
warn!("Attachment {id} doesn't exist");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,7 +676,7 @@ pub async fn update_cipher_from_data(
|
|||||||
match data.archived_date {
|
match data.archived_date {
|
||||||
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||||
Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()),
|
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
||||||
},
|
},
|
||||||
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||||
}
|
}
|
||||||
@@ -782,9 +762,6 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||||
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||||
cipher_data.folder_id = folder_id;
|
cipher_data.folder_id = folder_id;
|
||||||
// A personal import always creates personal ciphers, the organization ID is ignored instead of rejected
|
|
||||||
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
|
|
||||||
cipher_data.organization_id = None;
|
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type);
|
let mut cipher = Cipher::new(cipher_data.r#type);
|
||||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||||
@@ -806,11 +783,6 @@ async fn put_cipher_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
// Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's
|
|
||||||
if data.organization_id.is_none() {
|
|
||||||
err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
put_cipher(cipher_id, data, headers, conn, nt).await
|
put_cipher(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +794,7 @@ async fn post_cipher_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
put_cipher_admin(cipher_id, data, headers, conn, nt).await
|
post_cipher(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
||||||
@@ -862,11 +834,6 @@ async fn put_cipher(
|
|||||||
// Against the cipher we hold rather than the organization the client claims, like upstream
|
// Against the cipher we hold rather than the organization the client claims, like upstream
|
||||||
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
|
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
|
||||||
|
|
||||||
// Moving a cipher into an organization is only possible through the share endpoints
|
|
||||||
if cipher.organization_uuid != data.organization_id {
|
|
||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
|
||||||
}
|
|
||||||
|
|
||||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
||||||
|
|
||||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
||||||
@@ -1163,53 +1130,14 @@ struct ShareSelectedCipherData {
|
|||||||
collection_ids: Vec<CollectionId>,
|
collection_ids: Vec<CollectionId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A bulk share returns cipherMini models, not the cipherDetails models used by
|
|
||||||
// the single-share route. Keep the encrypted legacy fields and a future v2
|
|
||||||
// `data` blob, but do not expose details-only fields in this response.
|
|
||||||
fn cipher_mini_from_json(mut cipher: Value) -> Value {
|
|
||||||
const FIELDS: &[&str] = &[
|
|
||||||
"id",
|
|
||||||
"organizationId",
|
|
||||||
"type",
|
|
||||||
"data",
|
|
||||||
"partialData",
|
|
||||||
"name",
|
|
||||||
"notes",
|
|
||||||
"login",
|
|
||||||
"card",
|
|
||||||
"identity",
|
|
||||||
"secureNote",
|
|
||||||
"sshKey",
|
|
||||||
"bankAccount",
|
|
||||||
"driversLicense",
|
|
||||||
"passport",
|
|
||||||
"fields",
|
|
||||||
"passwordHistory",
|
|
||||||
"attachments",
|
|
||||||
"organizationUseTotp",
|
|
||||||
"revisionDate",
|
|
||||||
"creationDate",
|
|
||||||
"deletedDate",
|
|
||||||
"reprompt",
|
|
||||||
"key",
|
|
||||||
];
|
|
||||||
|
|
||||||
let Some(object) = cipher.as_object_mut() else {
|
|
||||||
return json!({ "object": "cipherMini" });
|
|
||||||
};
|
|
||||||
object.retain(|field, _| FIELDS.contains(&field.as_str()));
|
|
||||||
object.insert("object".into(), "cipherMini".into());
|
|
||||||
cipher
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/ciphers/share", data = "<data>")]
|
#[put("/ciphers/share", data = "<data>")]
|
||||||
async fn put_cipher_share_selected(
|
async fn put_cipher_share_selected(
|
||||||
data: Json<ShareSelectedCipherData>,
|
data: Json<ShareSelectedCipherData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> EmptyResult {
|
||||||
let data: ShareSelectedCipherData = data.into_inner();
|
let mut data: ShareSelectedCipherData = data.into_inner();
|
||||||
|
|
||||||
// Upstream's `PutShareMany` checks, before anything is written.
|
// Upstream's `PutShareMany` checks, before anything is written.
|
||||||
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
|
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
|
||||||
@@ -1227,9 +1155,7 @@ async fn put_cipher_share_selected(
|
|||||||
err!("You must select at least one collection.")
|
err!("You must select at least one collection.")
|
||||||
}
|
}
|
||||||
for cipher in &data.ciphers {
|
for cipher in &data.ciphers {
|
||||||
let is_blob = cipher.is_blob();
|
cipher.validate_content(cipher.is_blob())?;
|
||||||
cipher.validate_content(is_blob)?;
|
|
||||||
cipher.validate_type_data(is_blob)?;
|
|
||||||
}
|
}
|
||||||
for cipher in &data.ciphers {
|
for cipher in &data.ciphers {
|
||||||
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
|
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
|
||||||
@@ -1242,38 +1168,23 @@ async fn put_cipher_share_selected(
|
|||||||
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
|
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut responses = Vec::with_capacity(data.ciphers.len());
|
while let Some(cipher) = data.ciphers.pop() {
|
||||||
for cipher in data.ciphers {
|
|
||||||
let mut shared_cipher_data = ShareCipherData {
|
let mut shared_cipher_data = ShareCipherData {
|
||||||
cipher,
|
cipher,
|
||||||
collection_ids: data.collection_ids.clone(),
|
collection_ids: data.collection_ids.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||||
let response =
|
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?;
|
|
||||||
|
|
||||||
// Like upstream's VisibleToClient, withhold a cipher if sharing it
|
|
||||||
// removed the user's access to it. Only then include its encrypted
|
|
||||||
// mini response; the full single-share response stays internal.
|
|
||||||
if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await
|
|
||||||
&& shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
|
||||||
{
|
|
||||||
responses.push(cipher_mini_from_json(response.into_inner()));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
err!("Request missing ids field")
|
err!("Request missing ids field")
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
||||||
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(())
|
||||||
"data": responses,
|
|
||||||
"object": "list",
|
|
||||||
"continuationToken": null,
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn share_cipher_by_uuid(
|
async fn share_cipher_by_uuid(
|
||||||
@@ -1312,9 +1223,7 @@ async fn share_cipher_by_uuid(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// For the same reason, the other checks of saving
|
// For the same reason, the other checks of saving
|
||||||
let is_blob = data.cipher.is_blob();
|
data.cipher.validate_content(data.cipher.is_blob())?;
|
||||||
data.cipher.validate_content(is_blob)?;
|
|
||||||
data.cipher.validate_type_data(is_blob)?;
|
|
||||||
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
|
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
|
||||||
|
|
||||||
let mut shared_to_collections = vec![];
|
let mut shared_to_collections = vec![];
|
||||||
@@ -1382,40 +1291,6 @@ enum FileUploadType {
|
|||||||
// Azure = 1, // only used upstream
|
// Azure = 1, // only used upstream
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited.
|
|
||||||
/// `size_adjust` credits a v2 attachment whose record already counts as used.
|
|
||||||
async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult<Option<i64>> {
|
|
||||||
let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid {
|
|
||||||
let Some(limit_kb) = CONFIG.user_attachment_limit() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
(limit_kb, Attachment::size_by_user(user_id, conn).await)
|
|
||||||
} else if let Some(ref org_id) = cipher.organization_uuid {
|
|
||||||
let Some(limit_kb) = CONFIG.org_attachment_limit() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
(limit_kb, Attachment::size_by_org(org_id, conn).await)
|
|
||||||
} else {
|
|
||||||
err!("Cipher is neither owned by a user nor an organization");
|
|
||||||
};
|
|
||||||
|
|
||||||
if limit_kb == 0 {
|
|
||||||
err!("Attachments are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(left) =
|
|
||||||
limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust))
|
|
||||||
else {
|
|
||||||
err!("Attachment size overflow");
|
|
||||||
};
|
|
||||||
|
|
||||||
if left <= 0 {
|
|
||||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(left))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v2 API for creating an attachment associated with a cipher.
|
/// v2 API for creating an attachment associated with a cipher.
|
||||||
/// This redirects the client to the API it should use to upload the attachment.
|
/// This redirects the client to the API it should use to upload the attachment.
|
||||||
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
||||||
@@ -1426,7 +1301,6 @@ async fn post_attachment_v2(
|
|||||||
data: Json<AttachmentRequestData>,
|
data: Json<AttachmentRequestData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
err!("Cipher doesn't exist")
|
err!("Cipher doesn't exist")
|
||||||
@@ -1446,18 +1320,10 @@ async fn post_attachment_v2(
|
|||||||
if file_size > SIZE_525_MB {
|
if file_size > SIZE_525_MB {
|
||||||
err!("Max file size is 525 MB.")
|
err!("Max file size is 525 MB.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await?
|
|
||||||
&& file_size > size_limit
|
|
||||||
{
|
|
||||||
err!("Attachment storage limit exceeded with this file");
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachment_id = crypto::generate_attachment_id();
|
let attachment_id = crypto::generate_attachment_id();
|
||||||
let attachment =
|
let attachment =
|
||||||
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
||||||
attachment.save(&conn).await.expect("Error saving attachment");
|
attachment.save(&conn).await.expect("Error saving attachment");
|
||||||
notify_attachment_created(&cipher, &headers, &conn, &nt).await;
|
|
||||||
|
|
||||||
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
||||||
let response_key = match data.admin_request {
|
let response_key = match data.admin_request {
|
||||||
@@ -1520,7 +1386,55 @@ async fn save_attachment(
|
|||||||
Some(a) => a.file_size, // v2 API
|
Some(a) => a.file_size, // v2 API
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await?
|
let size_limit = if let Some(ref user_id) = cipher.user_uuid {
|
||||||
|
match CONFIG.user_attachment_limit() {
|
||||||
|
Some(0) => err!("Attachments are disabled"),
|
||||||
|
Some(limit_kb) => {
|
||||||
|
let already_used = Attachment::size_by_user(user_id, &conn).await;
|
||||||
|
let left = limit_kb
|
||||||
|
.checked_mul(1024)
|
||||||
|
.and_then(|l| l.checked_sub(already_used))
|
||||||
|
.and_then(|l| l.checked_add(size_adjust));
|
||||||
|
|
||||||
|
let Some(left) = left else {
|
||||||
|
err!("Attachment size overflow");
|
||||||
|
};
|
||||||
|
|
||||||
|
if left <= 0 {
|
||||||
|
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(left)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else if let Some(ref org_id) = cipher.organization_uuid {
|
||||||
|
match CONFIG.org_attachment_limit() {
|
||||||
|
Some(0) => err!("Attachments are disabled"),
|
||||||
|
Some(limit_kb) => {
|
||||||
|
let already_used = Attachment::size_by_org(org_id, &conn).await;
|
||||||
|
let left = limit_kb
|
||||||
|
.checked_mul(1024)
|
||||||
|
.and_then(|l| l.checked_sub(already_used))
|
||||||
|
.and_then(|l| l.checked_add(size_adjust));
|
||||||
|
|
||||||
|
let Some(left) = left else {
|
||||||
|
err!("Attachment size overflow");
|
||||||
|
};
|
||||||
|
|
||||||
|
if left <= 0 {
|
||||||
|
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(left)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err!("Cipher is neither owned by a user nor an organization");
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(size_limit) = size_limit
|
||||||
&& size > size_limit
|
&& size > size_limit
|
||||||
{
|
{
|
||||||
err!("Attachment storage limit exceeded with this file");
|
err!("Attachment storage limit exceeded with this file");
|
||||||
@@ -1577,23 +1491,13 @@ async fn save_attachment(
|
|||||||
|
|
||||||
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
||||||
|
|
||||||
// With the v2 API, post_attachment_v2() already did this when it created the attachment record
|
|
||||||
if attachment.is_none() {
|
|
||||||
notify_attachment_created(&cipher, headers, &conn, &nt).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((cipher, conn))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends the cipher update to the clients and logs the attachment event
|
|
||||||
async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) {
|
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
UpdateType::SyncCipherUpdate,
|
UpdateType::SyncCipherUpdate,
|
||||||
cipher,
|
&cipher,
|
||||||
&cipher.update_users_revision(conn).await,
|
&cipher.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
None,
|
None,
|
||||||
conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1605,10 +1509,12 @@ async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &Db
|
|||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok((cipher, conn))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v2 API for uploading the actual data content of an attachment.
|
/// v2 API for uploading the actual data content of an attachment.
|
||||||
@@ -1884,22 +1790,12 @@ async fn move_cipher_selected(
|
|||||||
// Find all ciphers a user has access to, all others will be ignored
|
// Find all ciphers a user has access to, all others will be ignored
|
||||||
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
||||||
let accessible_ciphers_count = accessible_ciphers.len();
|
let accessible_ciphers_count = accessible_ciphers.len();
|
||||||
let mut move_result = Ok(());
|
|
||||||
for cipher in accessible_ciphers {
|
for cipher in accessible_ciphers {
|
||||||
move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await;
|
cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?;
|
||||||
if move_result.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if cipher_count == 1 {
|
if cipher_count == 1 {
|
||||||
single_cipher = Some(cipher);
|
single_cipher = Some(cipher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update the revision once for the whole batch. Also when a move failed halfway,
|
|
||||||
// since the ciphers moved before it are already changed.
|
|
||||||
if accessible_ciphers_count > 0 {
|
|
||||||
User::update_uuid_revision(user_id, &conn).await;
|
|
||||||
}
|
|
||||||
move_result?;
|
|
||||||
|
|
||||||
if let Some(cipher) = single_cipher {
|
if let Some(cipher) = single_cipher {
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::Utc;
|
use chrono::{TimeDelta, Utc};
|
||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -6,17 +6,14 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult,
|
EmptyResult, JsonResult,
|
||||||
core::{
|
core::{CipherSyncData, CipherSyncType},
|
||||||
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, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||||
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -136,21 +133,19 @@ 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.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.atype = new_type as i32;
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
|
new_type as i32
|
||||||
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
|
};
|
||||||
|
|
||||||
|
emergency_access.atype = new_type;
|
||||||
emergency_access.wait_time_days = data.wait_time_days;
|
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;
|
||||||
@@ -212,11 +207,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;
|
||||||
|
|
||||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
err!("Emergency access wait time is out-of-bounds!")
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
|
new_type as i32
|
||||||
|
} else {
|
||||||
err!("Invalid emergency access type.")
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,8 +257,6 @@ 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?;
|
||||||
@@ -296,7 +289,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 for emergency access");
|
err!("The grantee user is already accepted or confirmed to the organization");
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(email) = emergency_access.email.clone() else {
|
let Some(email) = emergency_access.email.clone() else {
|
||||||
@@ -457,24 +450,11 @@ 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!(
|
err!("Emergency access not valid.")
|
||||||
"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!(
|
err!("Emergency access not valid.")
|
||||||
"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 {
|
||||||
@@ -507,17 +487,11 @@ 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!(
|
err!("Emergency access not valid.")
|
||||||
"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!(
|
err!("Emergency access not valid.")
|
||||||
"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 {
|
||||||
@@ -641,7 +615,6 @@ 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",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -651,13 +624,8 @@ 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 {
|
||||||
// Legacy payload
|
new_master_password_hash: String,
|
||||||
new_master_password_hash: Option<String>,
|
key: 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>")]
|
||||||
@@ -670,6 +638,8 @@ 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) =
|
||||||
@@ -686,23 +656,8 @@ 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(new_key), true, None, &conn).await?;
|
grantor_user.set_password(new_master_password_hash, Some(data.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
|
||||||
@@ -770,72 +725,53 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(conn) = pool.get().await else {
|
if let Ok(conn) = pool.get().await {
|
||||||
error!("Failed to get DB connection for emergency time-outs!");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
if emergency_access_list.is_empty() {
|
||||||
debug!("No emergency request timeout to approve");
|
debug!("No emergency request timeout to approve");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
for mut emer in emergency_access_list {
|
for mut emer in emergency_access_list {
|
||||||
match emer.is_recovery_allowed(&now) {
|
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
||||||
Ok(true) => {} // Recovery is allowed
|
let recovery_allowed_at =
|
||||||
Ok(false) => continue,
|
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
|
||||||
Err(e) => {
|
if recovery_allowed_at.le(&now) {
|
||||||
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get grantor user to send Accepted email
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
|
||||||
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
|
||||||
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get grantee user to send Accepted email
|
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
|
||||||
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only update the access status
|
// Only update the access status
|
||||||
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
||||||
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
|
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
|
||||||
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
|
.await
|
||||||
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
|
.expect("Unable to update emergency access status");
|
||||||
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
|
// get grantor user to send Accepted email
|
||||||
|
let grantor_user =
|
||||||
|
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||||
|
|
||||||
|
// get grantee user to send Accepted email
|
||||||
|
let grantee_user =
|
||||||
|
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||||
|
.await
|
||||||
|
.expect("Grantee user not found");
|
||||||
|
|
||||||
|
mail::send_emergency_access_recovery_timed_out(
|
||||||
&grantor_user.email,
|
&grantor_user.email,
|
||||||
&grantee_user.name,
|
&grantee_user.name,
|
||||||
emer.get_type_as_str(),
|
emer.get_type_as_str(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
.expect("Error on sending email");
|
||||||
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
|
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);
|
.expect("Error on sending email");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Failed to get DB connection while searching emergency request timed out");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
||||||
@@ -844,85 +780,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(conn) = pool.get().await else {
|
if let Ok(conn) = pool.get().await {
|
||||||
error!("Failed to get DB connection for emergency reminders!");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
if emergency_access_list.is_empty() {
|
||||||
debug!("No emergency request reminder notification to send");
|
debug!("No emergency request reminder notification to send");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
for mut emer in emergency_access_list {
|
for mut emer in emergency_access_list {
|
||||||
let Ok(deadline_at) = emer.deadline_at() else {
|
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
||||||
error!("Invalid deadline for emergency access '{}'", emer.uuid);
|
// Calculate the day before the recovery will become active
|
||||||
continue;
|
let final_recovery_reminder_at =
|
||||||
};
|
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
|
||||||
|
// Calculate if a day has passed since the previous notification, else no notification has been sent before
|
||||||
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
|
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
||||||
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
|
last_notification_at + TimeDelta::try_days(1).unwrap()
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only send reminders during the last 24 hours, else continue
|
|
||||||
if now < final_reminder_at || now >= deadline_at {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
|
|
||||||
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
|
|
||||||
// This is to prevent possible missed reminders, this might cause an extra reminder,
|
|
||||||
// but that is better then no reminder at all.
|
|
||||||
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
|
||||||
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
|
|
||||||
} else {
|
} else {
|
||||||
now
|
now
|
||||||
};
|
};
|
||||||
|
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
||||||
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
|
|
||||||
// Else we can skip the current emergency access this run.
|
|
||||||
if next_reminder_at.gt(&now) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get grantor user to send Reminder email
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
|
||||||
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
|
||||||
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get grantee user to send Reminder email
|
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
|
||||||
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only update the last notification date
|
// Only update the last notification date
|
||||||
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
// 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 {
|
emer.update_last_notification_date_and_save(&now, &conn)
|
||||||
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
|
.await
|
||||||
continue;
|
.expect("Unable to update emergency access notification date");
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled()
|
if CONFIG.mail_enabled() {
|
||||||
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
|
// get grantor user to send Accepted email
|
||||||
|
let grantor_user =
|
||||||
|
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||||
|
|
||||||
|
// get grantee user to send Accepted email
|
||||||
|
let grantee_user =
|
||||||
|
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||||
|
.await
|
||||||
|
.expect("Grantee user not found");
|
||||||
|
|
||||||
|
mail::send_emergency_access_recovery_reminder(
|
||||||
&grantor_user.email,
|
&grantor_user.email,
|
||||||
&grantee_user.name,
|
&grantee_user.name,
|
||||||
emer.get_type_as_str(),
|
emer.get_type_as_str(),
|
||||||
"1", // This notification is only triggered one day before the activation
|
"1", // This notification is only triggered one day before the activation
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
.expect("Error on sending email");
|
||||||
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-12
@@ -214,8 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
|
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
||||||
1107..=1114 | 1117 | 1119..=1133 => {
|
1107..=1114 | 1117 | 1119..=1132 => {
|
||||||
// The cipher determines the organization the event is logged to, so make sure the
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
// user can actually access it instead of trusting the provided cipher uuid.
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(cipher_uuid) = &event.cipher_id
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
@@ -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: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
pub async fn log_user_event(event_type: i32, 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 as i32, user_id, device_type, None, ip, conn).await;
|
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_user_event_impl(
|
async fn log_user_event_impl(
|
||||||
@@ -347,17 +347,14 @@ 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_or(|d| d == 0) {
|
if CONFIG.events_days_retain().is_none() {
|
||||||
debug!("events_days_retain is not configured or set to 0, skipping");
|
debug!("events_days_retain is not configured, abort");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(conn) = pool.get().await else {
|
if let Ok(conn) = pool.get().await {
|
||||||
|
Event::clean_events(&conn).await.ok();
|
||||||
|
} else {
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = Event::clean_events(&conn).await {
|
|
||||||
error!("Failed to cleanup the events table: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,6 @@ fn config() -> Json<Value> {
|
|||||||
"notifications": format!("{domain}/notifications"),
|
"notifications": format!("{domain}/notifications"),
|
||||||
"sso": "",
|
"sso": "",
|
||||||
"cloudRegion": null,
|
"cloudRegion": null,
|
||||||
"fillAssistRules": CONFIG.fill_assist_rules_url(),
|
|
||||||
},
|
},
|
||||||
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
||||||
"push": {
|
"push": {
|
||||||
|
|||||||
+29
-131
@@ -16,9 +16,9 @@ use crate::{
|
|||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
||||||
EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
|
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
|
||||||
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
|
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
|
||||||
OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -26,8 +26,6 @@ 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,
|
||||||
@@ -131,16 +129,13 @@ impl FullCollectionData {
|
|||||||
let org_groups = Group::find_by_organization(org_id, conn).await;
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
||||||
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
||||||
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
||||||
err!(
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
|
||||||
"Invalid member",
|
|
||||||
format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -555,7 +550,7 @@ async fn post_bulk_access_collections(
|
|||||||
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
||||||
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
for col_id in data.collection_ids {
|
for col_id in data.collection_ids {
|
||||||
@@ -978,16 +973,13 @@ impl InviteData {
|
|||||||
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
||||||
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
|
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
|
||||||
err!(
|
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
||||||
"Invalid collection",
|
|
||||||
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_groups = Group::find_by_organization(org_id, conn).await;
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1162,10 +1154,7 @@ async fn bulk_reinvite_members(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1338,10 +1327,7 @@ async fn bulk_confirm_invite(
|
|||||||
let user_key = invite.key.unwrap_or_default();
|
let user_key = invite.key.unwrap_or_default();
|
||||||
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1622,10 +1608,7 @@ async fn bulk_delete_member(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to delete member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2054,29 +2037,6 @@ async fn put_policy(
|
|||||||
err!("Invalid or unsupported policy type")
|
err!("Invalid or unsupported policy type")
|
||||||
};
|
};
|
||||||
|
|
||||||
let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await;
|
|
||||||
let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled);
|
|
||||||
|
|
||||||
// Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the
|
|
||||||
// policy changes state.
|
|
||||||
if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled {
|
|
||||||
let single_org_policy_enabled =
|
|
||||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled);
|
|
||||||
|
|
||||||
if !single_org_policy_enabled {
|
|
||||||
err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled {
|
|
||||||
let fill_assist_policy_enabled =
|
|
||||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled);
|
|
||||||
|
|
||||||
if fill_assist_policy_enabled {
|
|
||||||
err!("Turn off the Activate fill assist policy because it requires the Single organization policy.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
||||||
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
||||||
// Now that groups are available we can enforce this option when wanted.
|
// Now that groups are available we can enforce this option when wanted.
|
||||||
@@ -2109,37 +2069,6 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pol_type_enum == OrgPolicyType::FillAssist {
|
|
||||||
let fill_assist_data = match data.data.as_ref() {
|
|
||||||
None | Some(Value::Null) => FillAssistPolicyData {
|
|
||||||
rules_url: None,
|
|
||||||
},
|
|
||||||
Some(data) => {
|
|
||||||
let Ok(data) = serde_json::from_value::<FillAssistPolicyData>(data.clone()) else {
|
|
||||||
err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.")
|
|
||||||
};
|
|
||||||
data
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if data.enabled {
|
|
||||||
let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else {
|
|
||||||
err!("The RulesUrl field is required.")
|
|
||||||
};
|
|
||||||
let Ok(rules_url) = url::Url::parse(&rules_url) else {
|
|
||||||
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
|
||||||
};
|
|
||||||
|
|
||||||
if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") {
|
|
||||||
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rules_url.scheme() != "https" {
|
|
||||||
err!("RulesUrl must use HTTPS.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
||||||
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
||||||
two_factor::enforce_2fa_policy_for_org(
|
two_factor::enforce_2fa_policy_for_org(
|
||||||
@@ -2171,7 +2100,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
|
EventType::OrganizationUserRemoved,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2187,7 +2116,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut policy = match current_policy {
|
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
||||||
};
|
};
|
||||||
@@ -2291,10 +2220,7 @@ async fn bulk_revoke_members(
|
|||||||
for member_id in members {
|
for member_id in members {
|
||||||
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2397,10 +2323,7 @@ async fn bulk_restore_members(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to restore member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2553,16 +2476,13 @@ impl GroupRequest {
|
|||||||
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
||||||
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
|
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
|
||||||
err!(
|
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
||||||
"Invalid collection",
|
|
||||||
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
||||||
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
||||||
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
|
||||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id))
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2831,14 +2751,9 @@ 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)]
|
||||||
@@ -2953,33 +2868,11 @@ async fn recover_account(
|
|||||||
err!("Organization user must be confirmed for password reset functionality");
|
err!("Organization user must be confirmed for password reset functionality");
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallback_2fa_email = req.reset_two_factor
|
let fallback_2fa_email =
|
||||||
&& CONFIG.mail_enabled()
|
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
|
||||||
&& CONFIG.email_2fa_auto_fallback()
|
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
|
||||||
&& user.verified()
|
|
||||||
&& TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none();
|
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
|
false
|
||||||
};
|
|
||||||
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.
|
||||||
@@ -2997,8 +2890,14 @@ async fn recover_account(
|
|||||||
err!(format!("Error sending user reset password email: {e:#?}"));
|
err!(format!("Error sending user reset password email: {e:#?}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((new_master_password_hash, new_key)) = new_password {
|
if req.reset_master_password {
|
||||||
user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
|
if let Some(key) = req.key
|
||||||
|
&& let Some(hash) = req.new_master_password_hash
|
||||||
|
{
|
||||||
|
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
|
||||||
|
} else {
|
||||||
|
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.reset_two_factor {
|
if req.reset_two_factor {
|
||||||
@@ -3057,7 +2956,6 @@ 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,
|
||||||
})))
|
})))
|
||||||
|
|||||||
@@ -137,13 +137,9 @@ 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(mut group) =
|
let group_uuid = if let Some(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 =
|
||||||
|
|||||||
@@ -268,12 +268,11 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
|||||||
let file_id = crate::crypto::generate_send_file_id();
|
let file_id = crate::crypto::generate_send_file_id();
|
||||||
|
|
||||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||||
let Some(o) = data_value.as_object_mut() else {
|
if let Some(o) = data_value.as_object_mut() {
|
||||||
err!("Invalid Send file data")
|
|
||||||
};
|
|
||||||
o.insert(String::from("id"), Value::String(file_id.clone()));
|
o.insert(String::from("id"), Value::String(file_id.clone()));
|
||||||
o.insert(String::from("size"), Value::Number(file_length.into()));
|
o.insert(String::from("size"), Value::Number(file_length.into()));
|
||||||
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
||||||
|
}
|
||||||
send.data = serde_json::to_string(&data_value)?;
|
send.data = serde_json::to_string(&data_value)?;
|
||||||
send.save(&conn).await?;
|
send.save(&conn).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -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, two_factor},
|
auth::{ClientIp, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
@@ -20,23 +20,27 @@ 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 twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
|
let type_ = TwoFactorType::Authenticator as i32;
|
||||||
|
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!({
|
||||||
"authenticator": json!({
|
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"key": key,
|
"key": key,
|
||||||
}),
|
"object": "twoFactorAuthenticator"
|
||||||
"userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled),
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +49,8 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
|
|||||||
struct EnableAuthenticatorData {
|
struct EnableAuthenticatorData {
|
||||||
key: String,
|
key: String,
|
||||||
token: NumberOrString,
|
token: NumberOrString,
|
||||||
user_verification_token: String,
|
master_password_hash: Option<String>,
|
||||||
|
otp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/authenticator", data = "<data>")]
|
#[post("/two-factor/authenticator", data = "<data>")]
|
||||||
@@ -56,7 +61,12 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
|
PasswordOrOtpData {
|
||||||
|
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()) {
|
||||||
@@ -74,13 +84,12 @@ 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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"authenticator": json!({
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"key": key,
|
"key": key,
|
||||||
}),
|
"object": "twoFactorAuthenticator"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +125,8 @@ 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, conn).await {
|
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||||
|
{
|
||||||
Some(tf) => tf,
|
Some(tf) => tf,
|
||||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
||||||
};
|
};
|
||||||
@@ -174,25 +184,37 @@ pub async fn validate_totp_code(
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DisableAuthenticatorData {
|
struct DisableAuthenticatorData {
|
||||||
key: String,
|
key: String,
|
||||||
user_verification_token: String,
|
master_password_hash: 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) -> EmptyResult {
|
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
let type_ = data.r#type.into_i32()?;
|
||||||
|
|
||||||
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
|
if !user.check_valid_password(&data.master_password_hash) {
|
||||||
|
err!("Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await {
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
if twofactor.data == data.key {
|
if twofactor.data == data.key {
|
||||||
twofactor.delete(&conn).await?;
|
twofactor.delete(&conn).await?;
|
||||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &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;
|
||||||
} 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
||||||
|
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"enabled": false,
|
||||||
|
"keys": type_,
|
||||||
|
"object": "twoFactorProvider"
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
@@ -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,
|
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
|
||||||
core::log_user_event,
|
core::two_factor::generate_recover_code,
|
||||||
core::two_factor::{VerificationTokenData, generate_recover_code},
|
|
||||||
},
|
},
|
||||||
auth::{Headers, two_factor, two_factor::DuoData},
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
@@ -18,7 +18,55 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
|
routes![get_duo, activate_duo, activate_duo_put,]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
@@ -47,19 +95,22 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
|
|||||||
|
|
||||||
data.validate(&user, false, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
|
let data = 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 duo_json = if let Some(data) = duo.as_ref() {
|
let json = if let Some(data) = data {
|
||||||
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!({
|
||||||
@@ -67,13 +118,11 @@ 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(rocket::serde::json::json!({
|
Ok(Json(json))
|
||||||
"duo": duo_json,
|
|
||||||
"userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled),
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -82,7 +131,8 @@ struct EnableDuoData {
|
|||||||
host: String,
|
host: String,
|
||||||
client_secret: String,
|
client_secret: String,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
user_verification_token: String,
|
master_password_hash: Option<String>,
|
||||||
|
otp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<EnableDuoData> for DuoData {
|
impl From<EnableDuoData> for DuoData {
|
||||||
@@ -109,7 +159,12 @@ 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;
|
||||||
|
|
||||||
two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
|
PasswordOrOtpData {
|
||||||
|
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();
|
||||||
@@ -126,15 +181,14 @@ 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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"duo": json!({
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"host": data.host,
|
"host": data.host,
|
||||||
"clientSecret": data.sk,
|
"clientSecret": data.sk,
|
||||||
"clientId": data.ik,
|
"clientId": data.ik,
|
||||||
}),
|
"object": "twoFactorDuo"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,29 +197,6 @@ 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;
|
||||||
@@ -191,15 +222,13 @@ 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, TwoFactorType::Duo, conn).await else {
|
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, 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);
|
||||||
|
|||||||
@@ -5,26 +5,20 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::{
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
log_user_event,
|
|
||||||
two_factor::{VerificationTokenData, generate_recover_code},
|
|
||||||
},
|
},
|
||||||
},
|
auth::{ClientHeaders, Headers},
|
||||||
auth::{ClientHeaders, Headers, two_factor},
|
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
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, disable_email]
|
routes![get_email, send_email_login, send_email, email,]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -84,41 +78,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if let Some(auth_request_id) = auth_request_id {
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
|
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||||
|
err!("AuthRequest doesn't exist", "User not found")
|
||||||
|
};
|
||||||
let Some(code) = &data.auth_request_access_code else {
|
let Some(code) = &data.auth_request_access_code else {
|
||||||
err!("no auth request access code")
|
err!("no auth request access code")
|
||||||
};
|
};
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await
|
|
||||||
else {
|
|
||||||
err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user")
|
|
||||||
};
|
|
||||||
|
|
||||||
let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5);
|
if auth_request.device_type != client_headers.device_type
|
||||||
if !auth_request.approved.unwrap_or(false)
|
|
||||||
|| auth_request.authentication_date.is_some()
|
|
||||||
|| request_expired
|
|
||||||
|| auth_request.device_type != client_headers.device_type
|
|
||||||
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||||
|| !auth_request.check_access_code(code)
|
|| !auth_request.check_access_code(code)
|
||||||
{
|
{
|
||||||
err!(
|
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||||
"AuthRequest doesn't exist",
|
|
||||||
"Auth request not approved, already used or expired, or 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.")
|
||||||
}
|
}
|
||||||
@@ -132,7 +104,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: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug())
|
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,8 +116,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 mut twofactor =
|
let type_ = TwoFactorType::Email as i32;
|
||||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
|
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, 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());
|
||||||
|
|
||||||
@@ -168,19 +140,18 @@ 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) =
|
||||||
if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &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(rocket::serde::json::json!({
|
Ok(Json(json!({
|
||||||
"email": rocket::serde::json::json!({
|
|
||||||
"enabled": enabled,
|
|
||||||
"email": mfa_email,
|
"email": mfa_email,
|
||||||
}),
|
"enabled": enabled,
|
||||||
"userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled),
|
"object": "twoFactorEmail"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,22 +160,30 @@ 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,
|
||||||
user_verification_token: String,
|
master_password_hash: Option<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) -> JsonResult {
|
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: SendEmailData = data.into_inner();
|
let data: SendEmailData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
|
PasswordOrOtpData {
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
let type_ = TwoFactorType::Email as i32;
|
||||||
|
|
||||||
|
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
tf.delete(&conn).await?;
|
tf.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,13 +191,12 @@ 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 =
|
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
||||||
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(Json(json!({})))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
@@ -226,7 +204,8 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
|
|||||||
struct EmailData {
|
struct EmailData {
|
||||||
email: String,
|
email: String,
|
||||||
token: String,
|
token: String,
|
||||||
user_verification_token: String,
|
master_password_hash: Option<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.
|
||||||
@@ -235,11 +214,17 @@ 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;
|
||||||
|
|
||||||
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
|
// This is the last step in the verification process, delete the otp directly afterwards
|
||||||
|
PasswordOrOtpData {
|
||||||
|
master_password_hash: data.master_password_hash,
|
||||||
|
otp: data.otp,
|
||||||
|
}
|
||||||
|
.validate(&user, true, &conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
|
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
|
||||||
.await
|
let mut twofactor =
|
||||||
.map_res("Two factor not found")?;
|
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?;
|
||||||
|
|
||||||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||||
|
|
||||||
@@ -258,26 +243,13 @@ 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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({})))
|
Ok(Json(json!({
|
||||||
}
|
"email": email_data.email,
|
||||||
|
"enabled": "true",
|
||||||
#[delete("/two-factor/email", data = "<data>")]
|
"object": "twoFactorEmail"
|
||||||
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
|
||||||
@@ -289,8 +261,9 @@ 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 =
|
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
|
||||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
|
.await
|
||||||
|
.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}"),
|
||||||
@@ -302,7 +275,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 >= u64::from(CONFIG.email_attempts_limit()) {
|
if email_data.attempts >= CONFIG.email_attempts_limit() {
|
||||||
email_data.reset_token();
|
email_data.reset_token();
|
||||||
}
|
}
|
||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
@@ -321,7 +294,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 = i64::from(CONFIG.email_expiration_time());
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
|
api::{
|
||||||
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_event, log_user_event},
|
||||||
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
@@ -18,6 +21,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod authenticator;
|
pub mod authenticator;
|
||||||
@@ -65,7 +69,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
|
let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings];
|
||||||
|
|
||||||
routes.append(&mut authenticator::routes());
|
routes.append(&mut authenticator::routes());
|
||||||
routes.append(&mut duo::routes());
|
routes.append(&mut duo::routes());
|
||||||
@@ -77,12 +81,6 @@ 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;
|
||||||
@@ -122,14 +120,45 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
|
#[derive(Deserialize)]
|
||||||
/// the providers that are left, and once every provider is gone the 2FA policy applies.
|
#[serde(rename_all = "camelCase")]
|
||||||
pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
|
struct DisableTwoFactorData {
|
||||||
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
|
master_password_hash: Option<String>,
|
||||||
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
|
otp: Option<String>,
|
||||||
enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
|
r#type: NumberOrString,
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
|
#[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)
|
||||||
|
.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(
|
||||||
@@ -151,7 +180,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
EventType::OrganizationUserRevoked,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&member.org_uuid,
|
&member.org_uuid,
|
||||||
act_user_id,
|
act_user_id,
|
||||||
@@ -185,15 +214,7 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
member.revoke();
|
member.revoke();
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
||||||
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
|
||||||
&member.uuid,
|
|
||||||
org_id,
|
|
||||||
act_user_id,
|
|
||||||
device_type,
|
|
||||||
ip,
|
|
||||||
conn,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +225,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,21 +235,11 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
|
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
||||||
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
|
let time_before = now - time_limit;
|
||||||
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 Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
|
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
|
||||||
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
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ 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, &conn).await {
|
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &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;
|
||||||
@@ -124,28 +125,27 @@ 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, conn)
|
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, 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 >= u64::from(CONFIG.email_attempts_limit()) {
|
if pa_data.attempts >= 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 = i64::from(CONFIG.email_expiration_time());
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration};
|
use std::{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,18 +18,16 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::{
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
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::{EventType, TwoFactor, TwoFactorType, UserId},
|
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
|
|
||||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||||
@@ -47,14 +45,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![
|
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
|
||||||
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
|
||||||
@@ -128,36 +119,34 @@ 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!({
|
||||||
"webAuthn": json!({
|
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"keys": registrations_json,
|
"keys": registrations_json,
|
||||||
}),
|
"object": "twoFactorWebAuthn"
|
||||||
"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<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
|
||||||
let creds = registrations
|
let registrations = get_webauthn_registrations(&user.uuid, &conn)
|
||||||
|
.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(creds),
|
Some(registrations),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
@@ -177,19 +166,17 @@ async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers:
|
|||||||
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: i32,
|
id: NumberOrString, // 1..5
|
||||||
name: String,
|
name: String,
|
||||||
device_response: RegisterPublicKeyCredentialCopy,
|
device_response: RegisterPublicKeyCredentialCopy,
|
||||||
user_verification_token: String,
|
master_password_hash: Option<String>,
|
||||||
|
otp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -270,14 +257,16 @@ 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;
|
||||||
|
|
||||||
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
|
PasswordOrOtpData {
|
||||||
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
master_password_hash: data.master_password_hash,
|
||||||
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
|
otp: data.otp,
|
||||||
|
}
|
||||||
|
.validate(&user, true, &conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Retrieve and delete the saved challenge state
|
// Retrieve and delete the saved challenge state
|
||||||
let state = if let Some(tf) =
|
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
||||||
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
|
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &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
|
||||||
@@ -288,9 +277,10 @@ 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,
|
id: data.id.into_i32()?,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
migrated: false,
|
migrated: false,
|
||||||
|
|
||||||
@@ -303,13 +293,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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
|
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"webAuthn": json!({
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"keys": registrations.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
|
"keys": keys_json,
|
||||||
}),
|
"object": "twoFactorU2f"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,87 +310,58 @@ 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 DeleteWebauthnData {
|
struct DeleteU2FData {
|
||||||
id: i32,
|
id: NumberOrString,
|
||||||
user_verification_token: String,
|
master_password_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/two-factor/webauthn", data = "<data>")]
|
#[delete("/two-factor/webauthn", data = "<data>")]
|
||||||
async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
|
let id = data.id.into_i32()?;
|
||||||
|
if !headers.user.check_valid_password(&data.master_password_hash) {
|
||||||
|
err!("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/two-factor/webauthn/all", data = "<data>")]
|
let Some(mut tf) =
|
||||||
async fn delete_webauthns(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
|
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
|
||||||
inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await
|
else {
|
||||||
}
|
|
||||||
|
|
||||||
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 keys: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||||
let keys_id: Vec<i32> = keys.iter().map(|r| r.id).collect();
|
|
||||||
|
|
||||||
two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
|
let Some(item_pos) = data.iter().position(|r| r.id == id) else {
|
||||||
|
|
||||||
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")
|
||||||
}
|
};
|
||||||
|
|
||||||
if keys.is_empty() {
|
let removed_item = data.remove(item_pos);
|
||||||
tf.delete(conn).await?;
|
tf.data = serde_json::to_string(&data)?;
|
||||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
|
tf.save(&conn).await?;
|
||||||
} else {
|
|
||||||
tf.data = serde_json::to_string(&keys)?;
|
|
||||||
tf.save(conn).await?;
|
|
||||||
drop(tf);
|
drop(tf);
|
||||||
}
|
Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?;
|
||||||
|
|
||||||
// If entry is migrated from u2f, delete the u2f entry as well
|
// If entry is migrated from u2f, delete the u2f entry as well
|
||||||
if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await {
|
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &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(|old| !removed.contains(&old.reg.key_handle));
|
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||||
|
|
||||||
if data.is_empty() {
|
|
||||||
u2f.delete(conn).await?;
|
|
||||||
} else {
|
|
||||||
let new_data_str = serde_json::to_string(&data)?;
|
let new_data_str = serde_json::to_string(&data)?;
|
||||||
|
|
||||||
u2f.data = new_data_str;
|
u2f.data = new_data_str;
|
||||||
u2f.save(conn).await?;
|
u2f.save(&conn).await?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?;
|
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"webAuthn": json!({
|
"enabled": true,
|
||||||
"enabled": !keys.is_empty(),
|
"keys": keys_json,
|
||||||
"keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
|
"object": "twoFactorU2f"
|
||||||
}),
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +369,8 @@ 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> {
|
||||||
match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
|
let type_ = TwoFactorType::Webauthn as i32;
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -455,9 +417,8 @@ 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 mut state = if let Some(tf) =
|
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
|
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, 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
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::{
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
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},
|
||||||
@@ -25,7 +22,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,]
|
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HttpClientTransport {
|
struct HttpClientTransport {
|
||||||
@@ -63,7 +60,8 @@ struct EnableYubikeyData {
|
|||||||
key4: Option<String>,
|
key4: Option<String>,
|
||||||
key5: Option<String>,
|
key5: Option<String>,
|
||||||
nfc: bool,
|
nfc: bool,
|
||||||
user_verification_token: String,
|
master_password_hash: Option<String>,
|
||||||
|
otp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
@@ -127,47 +125,55 @@ 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 (enabled, keys, yubikey_json) =
|
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await;
|
||||||
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["enabled"] = Value::Bool(true);
|
||||||
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
||||||
(enabled, yubikey_metadata.keys, result)
|
result["object"] = Value::String("twoFactorU2f".to_owned());
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
} else {
|
} else {
|
||||||
(false, Vec::new(), json!({"enabled": false}))
|
|
||||||
};
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"yubiKey": yubikey_json,
|
"enabled": false,
|
||||||
"userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
|
"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 yubikeys = parse_yubikeys(&data);
|
|
||||||
let mut user = headers.user;
|
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);
|
||||||
|
|
||||||
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.
|
||||||
// To remove all keys users should click the `Deactivate all keys` button
|
// To remove all keys users should click the `Deactivate all keys` button
|
||||||
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 {
|
||||||
@@ -189,12 +195,15 @@ 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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
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);
|
||||||
Ok(Json(json!({"yubiKey": result})))
|
result["object"] = Value::String("twoFactorU2f".to_owned());
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/two-factor/yubikey", data = "<data>")]
|
#[put("/two-factor/yubikey", data = "<data>")]
|
||||||
@@ -202,23 +211,6 @@ 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");
|
||||||
|
|||||||
+5
-16
@@ -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(u64::from(CONFIG.icon_download_timeout()));
|
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
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: u32) -> Result<bool, Error> {
|
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
let 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 && u64::from(ttl) <= age.as_secs())
|
Ok(ttl > 0 && ttl <= age.as_secs())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn icon_is_negcached(path: &str) -> bool {
|
async fn icon_is_negcached(path: &str) -> bool {
|
||||||
@@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
TAG_BASE => {
|
TAG_BASE => {
|
||||||
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
||||||
let href = std::str::from_utf8(href).unwrap_or_default();
|
let href = std::str::from_utf8(href).unwrap_or_default();
|
||||||
debug!("Found base href: {}", href.escape_debug());
|
debug!("Found base href: {href}");
|
||||||
match base_url.join(href) {
|
match base_url.join(href) {
|
||||||
Ok(inner_url) => inner_url,
|
Ok(inner_url) => inner_url,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -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 at least 67 bytes, which seems to be the smallest png I could create
|
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create
|
||||||
if body.len() >= 67 {
|
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);
|
||||||
@@ -663,17 +663,6 @@ impl reqwest::cookie::CookieStore for Jar {
|
|||||||
use time::Duration;
|
use time::Duration;
|
||||||
|
|
||||||
let mut cookie_store = self.0.write().unwrap();
|
let mut cookie_store = self.0.write().unwrap();
|
||||||
|
|
||||||
// The store only skips expired cookies when reading them, so remove them here or they are kept forever
|
|
||||||
let expired: Vec<_> = cookie_store
|
|
||||||
.iter_any()
|
|
||||||
.filter(|c| c.is_expired())
|
|
||||||
.map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned()))
|
|
||||||
.collect();
|
|
||||||
for (domain, path, name) in expired {
|
|
||||||
cookie_store.remove(&domain, &path, &name);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cookies = cookie_headers.filter_map(|val| {
|
let cookies = cookie_headers.filter_map(|val| {
|
||||||
std::str::from_utf8(val.as_bytes())
|
std::str::from_utf8(val.as_bytes())
|
||||||
.map_err(RawCookieParseError::from)
|
.map_err(RawCookieParseError::from)
|
||||||
|
|||||||
+16
-132
@@ -46,7 +46,6 @@ pub fn routes() -> Vec<Route> {
|
|||||||
post_prelogin,
|
post_prelogin,
|
||||||
prelogin_password,
|
prelogin_password,
|
||||||
register_verification_email,
|
register_verification_email,
|
||||||
register_verification_email_clicked,
|
|
||||||
register_finish,
|
register_finish,
|
||||||
prevalidate,
|
prevalidate,
|
||||||
authorize,
|
authorize,
|
||||||
@@ -77,14 +76,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
password_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await
|
password_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"client_credentials" => {
|
"client_credentials" => {
|
||||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||||
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||||
|
|
||||||
api_key_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await
|
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"authorization_code" if CONFIG.sso_enabled() => {
|
"authorization_code" if CONFIG.sso_enabled() => {
|
||||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
@@ -95,7 +94,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
sso_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await
|
sso_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"authorization_code" => err!("SSO sign-in is not available"),
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
"send_access" => {
|
"send_access" => {
|
||||||
@@ -112,14 +111,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(tokens.to_json()))
|
Ok(Json(tokens.to_json()))
|
||||||
}
|
}
|
||||||
t => err!("Invalid type", t.escape_debug().to_string()),
|
t => err!("Invalid type", t),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(user_id) = user_id {
|
if let Some(user_id) = user_id {
|
||||||
match &login_result {
|
match &login_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log_user_event(
|
log_user_event(
|
||||||
EventType::UserLoggedIn,
|
EventType::UserLoggedIn as i32,
|
||||||
&user_id,
|
&user_id,
|
||||||
client_header.device_type,
|
client_header.device_type,
|
||||||
&client_header.ip.ip,
|
&client_header.ip.ip,
|
||||||
@@ -129,7 +128,8 @@ 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, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
|
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,13 +179,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||||
async fn sso_login(
|
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
data: ConnectData,
|
|
||||||
user_id: &mut Option<UserId>,
|
|
||||||
conn: &DbConn,
|
|
||||||
ip: &ClientIp,
|
|
||||||
client_version: Option<&semver::Version>,
|
|
||||||
) -> JsonResult {
|
|
||||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||||
|
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
@@ -331,8 +325,6 @@ async fn sso_login(
|
|||||||
(user, device, None, None)
|
(user, device, None, None)
|
||||||
}
|
}
|
||||||
Some((user, _)) if !user.enabled => {
|
Some((user, _)) if !user.enabled => {
|
||||||
// Set in both arms, not only after the match, so that their failed logins are logged
|
|
||||||
*user_id = Some(user.uuid.clone());
|
|
||||||
err!(
|
err!(
|
||||||
"This user has been disabled",
|
"This user has been disabled",
|
||||||
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||||
@@ -342,9 +334,6 @@ async fn sso_login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Some((mut user, sso_user)) => {
|
Some((mut user, sso_user)) => {
|
||||||
*user_id = Some(user.uuid.clone());
|
|
||||||
check_client_version(&user, client_version, ip)?;
|
|
||||||
|
|
||||||
let mut device = get_device(&data, conn, &user).await?;
|
let mut device = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
||||||
@@ -379,13 +368,7 @@ async fn sso_login(
|
|||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn password_login(
|
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
data: ConnectData,
|
|
||||||
user_id: &mut Option<UserId>,
|
|
||||||
conn: &DbConn,
|
|
||||||
ip: &ClientIp,
|
|
||||||
client_version: Option<&semver::Version>,
|
|
||||||
) -> JsonResult {
|
|
||||||
// Validate scope
|
// Validate scope
|
||||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||||
|
|
||||||
@@ -455,8 +438,6 @@ async fn password_login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
check_client_version(&user, client_version, ip)?;
|
|
||||||
|
|
||||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||||
if data.auth_request.is_none() {
|
if data.auth_request.is_none() {
|
||||||
kdf_upgrade(&mut user, password, conn).await?;
|
kdf_upgrade(&mut user, password, conn).await?;
|
||||||
@@ -467,9 +448,9 @@ async fn password_login(
|
|||||||
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()
|
||||||
> i64::from(CONFIG.signups_verify_resend_time())
|
> CONFIG.signups_verify_resend_time().cast_signed()
|
||||||
{
|
{
|
||||||
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
|
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
||||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
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...
|
||||||
@@ -518,47 +499,6 @@ async fn password_login(
|
|||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like upstream's `ClientVersionValidator`, a V2 account logs in only from a client that sends its version and is at
|
|
||||||
/// least 2025.11.0 (upstream also refuses a missing header for V1 accounts). Called right after the credentials are
|
|
||||||
/// checked, like upstream, so that it doesn't tell anyone else which accounts are V2.
|
|
||||||
///
|
|
||||||
/// Ref: <https://github.com/bitwarden/server/blob/2028f0a5cc9d8ab8ff8475029085f29f81483598/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L42-L69>
|
|
||||||
fn check_client_version(user: &User, client_version: Option<&semver::Version>, ip: &ClientIp) -> EmptyResult {
|
|
||||||
let Some((error, description)) = client_version_error(user.is_v2(), client_version) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
Err(crate::error::Error::from((
|
|
||||||
format!("IP: {}. Username: {}. {error}", ip.ip, user.email),
|
|
||||||
json!({
|
|
||||||
"error": error,
|
|
||||||
"error_description": description,
|
|
||||||
"ErrorModel": { "Message": description, "Object": "error" },
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
.with_event(crate::error::ErrorEvent {
|
|
||||||
event: EventType::UserFailedLogIn,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The error id and message of [`check_client_version`], `None` when the client may log in.
|
|
||||||
fn client_version_error(is_v2: bool, client_version: Option<&semver::Version>) -> Option<(&'static str, &'static str)> {
|
|
||||||
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2025, 11, 0);
|
|
||||||
|
|
||||||
if !is_v2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match client_version {
|
|
||||||
None => Some((
|
|
||||||
"version_header_missing",
|
|
||||||
"No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"",
|
|
||||||
)),
|
|
||||||
Some(version) if *version < MIN_V2_CLIENT => {
|
|
||||||
Some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
|
|
||||||
}
|
|
||||||
Some(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn authenticated_response(
|
async fn authenticated_response(
|
||||||
user: &User,
|
user: &User,
|
||||||
device: &mut Device,
|
device: &mut Device,
|
||||||
@@ -647,13 +587,7 @@ async fn authenticated_response(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_key_login(
|
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
data: ConnectData,
|
|
||||||
user_id: &mut Option<UserId>,
|
|
||||||
conn: &DbConn,
|
|
||||||
ip: &ClientIp,
|
|
||||||
client_version: Option<&semver::Version>,
|
|
||||||
) -> JsonResult {
|
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
@@ -665,7 +599,7 @@ async fn api_key_login(
|
|||||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
user_api_key_login(data, user_id, conn, ip, client_version).await
|
user_api_key_login(data, user_id, conn, ip).await
|
||||||
}
|
}
|
||||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||||
_ => err!("Scope not supported"),
|
_ => err!("Scope not supported"),
|
||||||
@@ -677,7 +611,6 @@ async fn user_api_key_login(
|
|||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
client_version: Option<&semver::Version>,
|
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
// Get the user via the client_id
|
// Get the user via the client_id
|
||||||
let client_id = data.client_id.as_ref().unwrap();
|
let client_id = data.client_id.as_ref().unwrap();
|
||||||
@@ -715,8 +648,6 @@ async fn user_api_key_login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
check_client_version(&user, client_version, ip)?;
|
|
||||||
|
|
||||||
let mut device = get_device(&data, conn, &user).await?;
|
let mut device = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() && device.is_new() {
|
if CONFIG.mail_enabled() && device.is_new() {
|
||||||
@@ -951,7 +882,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, &user.uuid, device.atype, &ip.ip, conn).await;
|
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||||
|
|
||||||
if CONFIG.mail_enabled()
|
if CONFIG.mail_enabled()
|
||||||
&& let Err(e) =
|
&& let Err(e) =
|
||||||
@@ -1021,7 +952,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, conn).await else {
|
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
|
||||||
err!("No YubiKey devices registered")
|
err!("No YubiKey devices registered")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1033,7 +964,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, conn).await else {
|
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
|
||||||
err!("No twofactor email registered")
|
err!("No twofactor email registered")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1082,13 +1013,6 @@ struct RegisterVerificationData {
|
|||||||
// receiveMarketingEmails: bool,
|
// receiveMarketingEmails: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct RegisterVerificationEmailClickedData {
|
|
||||||
email: String,
|
|
||||||
email_verification_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(rocket::Responder)]
|
#[derive(rocket::Responder)]
|
||||||
enum RegisterVerificationResponse {
|
enum RegisterVerificationResponse {
|
||||||
#[response(status = 204)]
|
#[response(status = 204)]
|
||||||
@@ -1151,25 +1075,6 @@ async fn register_verification_email(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register/verification-email-clicked", data = "<data>")]
|
|
||||||
async fn register_verification_email_clicked(
|
|
||||||
data: Json<RegisterVerificationEmailClickedData>,
|
|
||||||
conn: DbConn,
|
|
||||||
) -> EmptyResult {
|
|
||||||
let data = data.into_inner();
|
|
||||||
let token_valid = auth::decode_register_verify(&data.email_verification_token)
|
|
||||||
.is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email));
|
|
||||||
let user_exists = User::find_by_mail(&data.email, &conn)
|
|
||||||
.await
|
|
||||||
.is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some());
|
|
||||||
|
|
||||||
if !token_valid || user_exists {
|
|
||||||
err!("Expired link. Please restart registration or try logging in. You may already have an account")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/accounts/register/finish", data = "<data>")]
|
#[post("/accounts/register/finish", data = "<data>")]
|
||||||
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
@@ -1398,24 +1303,3 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
|
|||||||
|
|
||||||
Ok(Redirect::temporary(String::from(auth_url)))
|
Ok(Redirect::temporary(String::from(auth_url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_version_error() {
|
|
||||||
let version = |v| semver::Version::parse(v).unwrap();
|
|
||||||
// V1 accounts aren't checked, with or without a version
|
|
||||||
assert_eq!(client_version_error(false, None), None);
|
|
||||||
assert_eq!(client_version_error(false, Some(&version("2024.1.0"))), None);
|
|
||||||
// V2 accounts need the header and 2025.11.0 or newer
|
|
||||||
assert_eq!(client_version_error(true, None).map(|e| e.0), Some("version_header_missing"));
|
|
||||||
assert_eq!(
|
|
||||||
client_version_error(true, Some(&version("2025.10.3"))).map(|e| e.0),
|
|
||||||
Some("invalid_client_version")
|
|
||||||
);
|
|
||||||
assert_eq!(client_version_error(true, Some(&version("2025.11.0"))), None);
|
|
||||||
assert_eq!(client_version_error(true, Some(&version("2026.10.1"))), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-4
@@ -25,12 +25,12 @@ pub use crate::api::{
|
|||||||
notifications::routes as notifications_routes,
|
notifications::routes as notifications_routes,
|
||||||
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||||
push::{
|
push::{
|
||||||
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
|
||||||
register_push_device, unregister_push_device,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
web::catchers as web_catchers,
|
web::catchers as web_catchers,
|
||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::{STATIC_FILES, invalidate_css_cache},
|
web::{invalidate_css_cache, static_files},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
@@ -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(Debug, Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct PasswordOrOtpData {
|
struct PasswordOrOtpData {
|
||||||
#[serde(alias = "MasterPasswordHash")]
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
sync::{Arc, LazyLock},
|
sync::{Arc, LazyLock},
|
||||||
time::{Duration, Instant},
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
@@ -41,14 +41,6 @@ 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> {
|
||||||
@@ -164,16 +156,12 @@ 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(WS_PING_INTERVAL);
|
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
||||||
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),
|
||||||
@@ -207,13 +195,7 @@ fn websockets_hub<'r>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => yield Message::Ping(create_ping())
|
||||||
// The client stopped responding without closing the connection, drop it
|
|
||||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
yield Message::Ping(PING.to_vec());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -247,16 +229,12 @@ 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(WS_PING_INTERVAL);
|
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
||||||
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),
|
||||||
@@ -290,13 +268,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => yield Message::Ping(create_ping())
|
||||||
// The client stopped responding without closing the connection, drop it
|
|
||||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
yield Message::Ping(PING.to_vec());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -359,7 +331,6 @@ fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
|
|||||||
|
|
||||||
const RECORD_SEPARATOR: u8 = 0x1e;
|
const RECORD_SEPARATOR: u8 = 0x1e;
|
||||||
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
||||||
const PING: [u8; 3] = [0x02, 0x91, 0x06];
|
|
||||||
|
|
||||||
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
||||||
struct InitialMessage<'a> {
|
struct InitialMessage<'a> {
|
||||||
@@ -692,6 +663,10 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
serialize(&value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_ping() -> Vec<u8> {
|
||||||
|
serialize(&Value::Array(vec![6.into()]))
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
pub enum UpdateType {
|
pub enum UpdateType {
|
||||||
|
|||||||
+10
-20
@@ -139,25 +139,21 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
|
|||||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let data = json!({
|
|
||||||
"id": push_id.as_ref().unwrap(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let auth_api_token = get_auth_api_token().await?;
|
let auth_api_token = get_auth_api_token().await?;
|
||||||
|
|
||||||
let auth_header = format!("Bearer {auth_api_token}");
|
let auth_header = format!("Bearer {auth_api_token}");
|
||||||
|
|
||||||
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
|
match make_http_request(
|
||||||
.header(CONTENT_TYPE, "application/json")
|
Method::POST,
|
||||||
.header(ACCEPT, "application/json")
|
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
|
||||||
|
)?
|
||||||
.header(AUTHORIZATION, auth_header)
|
.header(AUTHORIZATION, auth_header)
|
||||||
.json(&data)
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await
|
||||||
.error_for_status()
|
|
||||||
{
|
{
|
||||||
err!(format!("An error occurred during device unregistration: {e}"));
|
Ok(r) => r,
|
||||||
}
|
Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
|
||||||
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,13 +190,6 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
|
|
||||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
push_logout_to_relay(user, acting_device);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sends the logout without checking for push devices first.
|
|
||||||
// Needed after deleting a user, when the devices are already gone from the database.
|
|
||||||
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
|
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"organizationId": (),
|
"organizationId": (),
|
||||||
@@ -215,6 +204,7 @@ pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
|
|||||||
"installationId": null
|
"installationId": null
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
|
|||||||
+32
-62
@@ -1,14 +1,13 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, LazyLock, RwLock},
|
sync::{Arc, RwLock},
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Catcher, Route,
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{Redirect, content::RawHtml as Html},
|
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -80,12 +79,12 @@ pub fn invalidate_css_cache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/css/vaultwarden.css")]
|
#[get("/css/vaultwarden.css")]
|
||||||
fn vaultwarden_css() -> EtagCached<String> {
|
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
||||||
// If reload_templates is false, and we already have the CSS Cached, return this
|
// If reload_templates is false, and we already have the CSS Cached, return this
|
||||||
if !CONFIG.reload_templates()
|
if !CONFIG.reload_templates()
|
||||||
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
||||||
{
|
{
|
||||||
return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag);
|
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
||||||
@@ -148,7 +147,7 @@ fn vaultwarden_css() -> EtagCached<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||||
EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag)
|
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -257,7 +256,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
|||||||
// This endpoint/function is used during development and development only.
|
// This endpoint/function is used during development and development only.
|
||||||
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
||||||
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
||||||
// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below!
|
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
#[get("/vw_static/<filename>", rank = 1)]
|
#[get("/vw_static/<filename>", rank = 1)]
|
||||||
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||||
@@ -277,61 +276,32 @@ pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StaticFile {
|
|
||||||
pub content_type: ContentType,
|
|
||||||
pub bytes: &'static [u8],
|
|
||||||
pub etag: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static STATIC_FILES: LazyLock<HashMap<&'static str, StaticFile>> = LazyLock::new(|| {
|
|
||||||
[
|
|
||||||
("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]),
|
|
||||||
("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]),
|
|
||||||
("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]),
|
|
||||||
("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]),
|
|
||||||
("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]),
|
|
||||||
("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]),
|
|
||||||
("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]),
|
|
||||||
("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]),
|
|
||||||
("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]),
|
|
||||||
("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]),
|
|
||||||
("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]),
|
|
||||||
("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]),
|
|
||||||
(
|
|
||||||
"admin_organizations.js",
|
|
||||||
ContentType::JavaScript,
|
|
||||||
&include_bytes!("../static/scripts/admin_organizations.js")[..],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"admin_diagnostics.js",
|
|
||||||
ContentType::JavaScript,
|
|
||||||
&include_bytes!("../static/scripts/admin_diagnostics.js")[..],
|
|
||||||
),
|
|
||||||
("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]),
|
|
||||||
("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]),
|
|
||||||
("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]),
|
|
||||||
("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]),
|
|
||||||
("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, content_type, bytes)| {
|
|
||||||
(
|
|
||||||
name,
|
|
||||||
StaticFile {
|
|
||||||
content_type,
|
|
||||||
bytes,
|
|
||||||
etag: sha256_hex(bytes),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
});
|
|
||||||
|
|
||||||
#[get("/vw_static/<filename>", rank = 2)]
|
#[get("/vw_static/<filename>", rank = 2)]
|
||||||
pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error> {
|
pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> {
|
||||||
if let Some(file) = STATIC_FILES.get(filename) {
|
match filename {
|
||||||
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
|
"404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))),
|
||||||
} else {
|
"mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
||||||
err!(format!("Static file not found: {}", filename.escape_debug()))
|
"logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
|
||||||
|
"error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
|
||||||
|
"hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
|
||||||
|
"vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))),
|
||||||
|
"vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))),
|
||||||
|
"404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))),
|
||||||
|
"admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))),
|
||||||
|
"admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))),
|
||||||
|
"admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))),
|
||||||
|
"admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))),
|
||||||
|
"admin_organizations.js" => {
|
||||||
|
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js")))
|
||||||
|
}
|
||||||
|
"admin_diagnostics.js" => {
|
||||||
|
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js")))
|
||||||
|
}
|
||||||
|
"bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))),
|
||||||
|
"bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))),
|
||||||
|
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||||
|
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||||
|
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||||
|
_ => err!(format!("Static file not found: {filename}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-56
@@ -1,6 +1,3 @@
|
|||||||
#[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;
|
||||||
@@ -314,17 +311,6 @@ impl LoginJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
|
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
|
||||||
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
|
|
||||||
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
|
|
||||||
};
|
|
||||||
let Some(exp) = now.checked_add_signed(hours_delta) else {
|
|
||||||
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
|
|
||||||
};
|
|
||||||
Ok(exp)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct InviteJwtClaims {
|
pub struct InviteJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -348,19 +334,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>,
|
||||||
) -> Result<InviteJwtClaims, Error> {
|
) -> InviteJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(InviteJwtClaims {
|
InviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).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)]
|
||||||
@@ -386,19 +372,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,
|
||||||
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
) -> EmergencyAccessInviteJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(EmergencyAccessInviteJwtClaims {
|
EmergencyAccessInviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
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)]
|
||||||
@@ -503,17 +489,13 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) ->
|
|||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
TwoFactorRememberClaims {
|
TwoFactorRememberClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()),
|
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
||||||
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||||
sub: device_uuid,
|
sub: device_uuid,
|
||||||
user_uuid,
|
user_uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn two_factor_remember_expiration(time_now: DateTime<Utc>, remember_days: i64) -> i64 {
|
|
||||||
(time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BasicJwtClaims {
|
pub struct BasicJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -536,43 +518,36 @@ impl BasicJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
|
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(BasicJwtClaims {
|
BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).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) -> Result<BasicJwtClaims, Error> {
|
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(BasicJwtClaims {
|
BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).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() -> Result<BasicJwtClaims, Error> {
|
pub fn generate_admin_claims() -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
|
BasicJwtClaims {
|
||||||
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: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).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 {
|
||||||
@@ -632,9 +607,6 @@ impl<'r> FromRequest<'r> for Host {
|
|||||||
pub struct ClientHeaders {
|
pub struct ClientHeaders {
|
||||||
pub device_type: i32,
|
pub device_type: i32,
|
||||||
pub ip: ClientIp,
|
pub ip: ClientIp,
|
||||||
/// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this
|
|
||||||
/// doesn't log an error, as not every client sends it.
|
|
||||||
pub client_version: Option<semver::Version>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
@@ -651,13 +623,10 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
|||||||
.get_one("device-type")
|
.get_one("device-type")
|
||||||
.and_then(|d| d.parse().ok())
|
.and_then(|d| d.parse().ok())
|
||||||
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||||
let client_version =
|
|
||||||
request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok());
|
|
||||||
|
|
||||||
Outcome::Success(ClientHeaders {
|
Outcome::Success(ClientHeaders {
|
||||||
device_type,
|
device_type,
|
||||||
ip,
|
ip,
|
||||||
client_version,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -72,11 +72,7 @@ impl SendTokens {
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> ApiResult<SendTokens> {
|
) -> ApiResult<SendTokens> {
|
||||||
let Some(send_id) = Self::as_send_id(access_id) else {
|
let Some(send_id) = Self::as_send_id(access_id) else {
|
||||||
return Self::invalid_error(
|
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
||||||
&format!("Can't convert {}", access_id.escape_debug()),
|
|
||||||
"send_id_invalid",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+35
-145
@@ -111,7 +111,6 @@ 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
|
||||||
@@ -260,18 +259,11 @@ macro_rules! make_config {
|
|||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_file() -> Result<Option<Self>, Error> {
|
async fn from_file() -> Result<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 = match operator.read(&CONFIG_FILENAME).await {
|
let config_bytes = 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);
|
||||||
Ok(Some(builder))
|
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_non_editable(&mut self) {
|
fn clear_non_editable(&mut self) {
|
||||||
@@ -448,8 +440,6 @@ 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",
|
||||||
@@ -461,8 +451,6 @@ 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 = {
|
||||||
@@ -495,10 +483,6 @@ macro_rules! make_config {
|
|||||||
};
|
};
|
||||||
overrides
|
overrides
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_config_file_status(&self) -> ConfigFileStatus {
|
|
||||||
self.inner.read().unwrap()._config_file_status
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -555,7 +539,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: u32, false, def, 30_000;
|
job_poll_interval_ms: u64, false, def, 30_000;
|
||||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
/// 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();
|
||||||
@@ -603,9 +587,6 @@ make_config! {
|
|||||||
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
||||||
sends_allowed: bool, true, def, true;
|
sends_allowed: bool, true, def, true;
|
||||||
|
|
||||||
/// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory.
|
|
||||||
fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned();
|
|
||||||
|
|
||||||
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
||||||
hibp_api_key: Pass, true, option;
|
hibp_api_key: Pass, true, option;
|
||||||
|
|
||||||
@@ -617,16 +598,16 @@ make_config! {
|
|||||||
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 or 0, trashed items are not auto-deleted. This setting applies globally, so make
|
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
|
||||||
/// sure to inform all users of any changes to this setting.
|
/// sure to inform all users of any changes to this setting.
|
||||||
trash_auto_delete_days: u16, true, option;
|
trash_auto_delete_days: i64, true, option;
|
||||||
|
|
||||||
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
/// 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: u16, true, def, 3;
|
incomplete_2fa_time_limit: i64, true, def, 3;
|
||||||
|
|
||||||
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
/// 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
|
||||||
@@ -639,9 +620,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: u32, true, def, 3_600;
|
signups_verify_resend_time: u64, true, def, 3_600;
|
||||||
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
||||||
signups_verify_resend_limit: u16, true, def, 6;
|
signups_verify_resend_limit: u32, true, def, 6;
|
||||||
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
/// 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.
|
||||||
@@ -653,7 +634,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: u16, false, def, 120;
|
invitation_expiration_hours: u32, false, def, 120;
|
||||||
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
/// 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.
|
||||||
@@ -674,8 +655,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 or 0, events are kept indefinitely.
|
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
||||||
events_days_retain: u16, false, option;
|
events_days_retain: i64, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
client {
|
client {
|
||||||
@@ -713,13 +694,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: u16, true, def, 302;
|
icon_redirect_code: u32, true, def, 302;
|
||||||
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
||||||
icon_cache_ttl: u32, true, def, 2_592_000;
|
icon_cache_ttl: u64, true, def, 2_592_000;
|
||||||
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
||||||
icon_cache_negttl: u32, true, def, 259_200;
|
icon_cache_negttl: u64, true, def, 259_200;
|
||||||
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
||||||
icon_download_timeout: u16, true, def, 10;
|
icon_download_timeout: u64, true, def, 10;
|
||||||
|
|
||||||
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
||||||
icon_blacklist_regex: String, false, option;
|
icon_blacklist_regex: String, false, option;
|
||||||
@@ -736,8 +717,6 @@ make_config! {
|
|||||||
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
||||||
/// Note that the checkbox would still be present, but ignored.
|
/// Note that the checkbox would still be present, but ignored.
|
||||||
disable_2fa_remember: bool, true, def, false;
|
disable_2fa_remember: bool, true, def, false;
|
||||||
/// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90).
|
|
||||||
two_factor_remember_days: i64, true, def, 30;
|
|
||||||
|
|
||||||
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
||||||
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
||||||
@@ -772,11 +751,11 @@ make_config! {
|
|||||||
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
/// 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 in seconds when acquiring database connection
|
/// Timeout when acquiring database connection
|
||||||
database_timeout: u32, false, def, 30;
|
database_timeout: u64, 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: u32, false, def, 600;
|
database_idle_timeout: u64, 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;
|
||||||
@@ -793,26 +772,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 for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||||
allowed_connect_src: String, true, def, String::new();
|
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: u16, false, def, 60;
|
login_ratelimit_seconds: u64, false, def, 60;
|
||||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||||
login_ratelimit_max_burst: u16, false, def, 10;
|
login_ratelimit_max_burst: u32, false, def, 10;
|
||||||
|
|
||||||
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
unauthenticated_ratelimit_seconds: u16, false, def, 60;
|
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
||||||
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
|
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u16, false, def, 300;
|
admin_ratelimit_seconds: u64, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
admin_ratelimit_max_burst: u16, false, def, 3;
|
admin_ratelimit_max_burst: u32, false, def, 3;
|
||||||
|
|
||||||
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
||||||
admin_session_lifetime: u32, true, def, 20;
|
admin_session_lifetime: i64, true, def, 20;
|
||||||
|
|
||||||
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
/// 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;
|
||||||
@@ -865,8 +844,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 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 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
||||||
sso_client_cache_expiration: u32, true, def, 0;
|
sso_client_cache_expiration: u64, 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;
|
||||||
},
|
},
|
||||||
@@ -924,7 +903,7 @@ make_config! {
|
|||||||
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
/// SMTP Auth mechanism |> 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: u16, true, def, 15;
|
smtp_timeout: u64, true, def, 15;
|
||||||
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
/// 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.
|
||||||
@@ -946,9 +925,9 @@ make_config! {
|
|||||||
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
/// Email token size |> 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: u32, true, def, 600;
|
email_expiration_time: u64, true, def, 600;
|
||||||
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
||||||
email_attempts_limit: u8, true, def, 3;
|
email_attempts_limit: u64, true, def, 3;
|
||||||
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
/// 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
|
||||||
@@ -1004,15 +983,7 @@ 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 less than or equal to `DATABASE_MAX_CONNS`.");
|
err!("`DATABASE_MIN_CONNS` must be smaller 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
|
||||||
@@ -1028,21 +999,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| {
|
|
||||||
url.scheme() == "https"
|
|
||||||
&& url.has_host()
|
|
||||||
&& url.username().is_empty()
|
|
||||||
&& url.password().is_none()
|
|
||||||
&& url.query().is_none()
|
|
||||||
&& url.fragment().is_none()
|
|
||||||
&& cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url
|
|
||||||
});
|
|
||||||
if !valid_fill_assist_rules_url {
|
|
||||||
err!(
|
|
||||||
"`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||||
for url in connect_src.split_whitespace() {
|
for url in connect_src.split_whitespace() {
|
||||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||||
@@ -1277,10 +1233,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !(1..=90).contains(&cfg.two_factor_remember_days) {
|
|
||||||
err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate schedule crontab format
|
// Validate schedule crontab format
|
||||||
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
||||||
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
||||||
@@ -1314,38 +1266,6 @@ 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") => {
|
||||||
@@ -1501,12 +1421,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
// Autofill Team
|
// Autofill Team
|
||||||
"undetermined-cipher-scenario-logic",
|
"undetermined-cipher-scenario-logic",
|
||||||
"enable-basic-auth-response",
|
"enable-basic-auth-response",
|
||||||
"fill-assist-targeting-rules",
|
|
||||||
"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",
|
||||||
"enable-account-encryption-v2-jit-password-registration",
|
"enable-account-encryption-v2-jit-password-registration",
|
||||||
"pm-27278-v2-password-registration",
|
"pm-27278-v2-password-registration",
|
||||||
@@ -1518,34 +1434,11 @@ 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, config_file_status) = match ConfigBuilder::from_file().await {
|
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||||
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();
|
||||||
@@ -1565,7 +1458,6 @@ impl Config {
|
|||||||
_env: env,
|
_env: env,
|
||||||
_usr: usr,
|
_usr: usr,
|
||||||
_overrides: overrides,
|
_overrides: overrides,
|
||||||
_config_file_status: config_file_status,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1599,7 +1491,6 @@ 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
|
||||||
@@ -1618,7 +1509,7 @@ impl Config {
|
|||||||
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
||||||
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
||||||
warn!("Failed to parse email address '{}'", email.escape_debug());
|
warn!("Failed to parse email address '{email}'");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let email_domain = e[0].to_lowercase();
|
let email_domain = e[0].to_lowercase();
|
||||||
@@ -1691,7 +1582,6 @@ 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
@@ -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(u64::from(CONFIG.database_idle_timeout()))))
|
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
|
||||||
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
|
.connection_timeout(Duration::from_secs(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(u64::from(CONFIG.database_timeout()));
|
let duration = Duration::from_secs(CONFIG.database_timeout());
|
||||||
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
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(_) => {
|
||||||
|
|||||||
@@ -166,15 +166,6 @@ 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();
|
||||||
@@ -197,7 +188,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::minutes(15);
|
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
|
||||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||||
auth_request.delete(conn).await.ok();
|
auth_request.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-28
@@ -123,15 +123,10 @@ impl Cipher {
|
|||||||
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_blob = cipher.is_blob();
|
if let Err(e) = cipher.validate_content(cipher.is_blob()) {
|
||||||
if let Err(e) = cipher.validate_content(is_blob) {
|
|
||||||
validation_errors
|
validation_errors
|
||||||
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
|
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
|
||||||
}
|
}
|
||||||
if let Err(e) = cipher.validate_type_data(is_blob) {
|
|
||||||
validation_errors
|
|
||||||
.insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the password history if it contains `null` values and if so, return a warning
|
// Validate the password history if it contains `null` values and if so, return a warning
|
||||||
if let Some(Value::Array(password_history)) = &cipher.password_history {
|
if let Some(Value::Array(password_history)) = &cipher.password_history {
|
||||||
@@ -541,24 +536,14 @@ impl Cipher {
|
|||||||
|
|
||||||
/// Purge all ciphers that are old enough to be auto-deleted.
|
/// 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) {
|
||||||
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
|
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
|
||||||
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
|
|
||||||
error!("Unable to calculate auto delete days!");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let Some(dt) = now.checked_sub_signed(days) else {
|
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
|
||||||
error!("Unable to calculate trash purge date!");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for cipher in Self::find_deleted_before(&dt, conn).await {
|
for cipher in Self::find_deleted_before(&dt, conn).await {
|
||||||
cipher.delete(conn).await.ok();
|
cipher.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn move_to_folder(
|
pub async fn move_to_folder(
|
||||||
&self,
|
&self,
|
||||||
@@ -567,16 +552,7 @@ impl Cipher {
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as `move_to_folder`, but the caller is responsible for updating the user's revision.
|
|
||||||
pub async fn move_to_folder_without_revision_update(
|
|
||||||
&self,
|
|
||||||
folder_uuid: Option<FolderId>,
|
|
||||||
user_uuid: &UserId,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> EmptyResult {
|
|
||||||
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
||||||
// No changes
|
// No changes
|
||||||
(None, None) => Ok(()),
|
(None, None) => Ok(()),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, 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,26 +112,8 @@ 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,
|
||||||
@@ -187,42 +169,27 @@ impl EmergencyAccess {
|
|||||||
|
|
||||||
pub async fn update_access_status_and_save(
|
pub async fn update_access_status_and_save(
|
||||||
&mut self,
|
&mut self,
|
||||||
from_status: i32,
|
status: i32,
|
||||||
to_status: i32,
|
|
||||||
date: &NaiveDateTime,
|
date: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let uuid = self.uuid.clone();
|
// Update the grantee so that it will refresh it's status.
|
||||||
let updated_at = *date;
|
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
||||||
let res = conn
|
self.status = status;
|
||||||
.run(move |conn| {
|
date.clone_into(&mut self.updated_at);
|
||||||
|
|
||||||
|
conn.run(move |conn| {
|
||||||
crate::util::retry(
|
crate::util::retry(
|
||||||
|| {
|
|| {
|
||||||
diesel::update(
|
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||||
emergency_access::table
|
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
||||||
.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)
|
.execute(conn)
|
||||||
},
|
},
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
|
.map_res("Error updating emergency access status")
|
||||||
})
|
})
|
||||||
.await;
|
.await
|
||||||
|
|
||||||
match res {
|
|
||||||
Ok(1) => {} // One record updated, which is valid
|
|
||||||
Ok(_) => err!("Emergency access status was changed already"),
|
|
||||||
Err(e) => err!("Error updating emergency access status", format!("{e}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
self.status = to_status;
|
|
||||||
self.updated_at = updated_at;
|
|
||||||
|
|
||||||
// Update the grantee so that it will refresh it's status.
|
|
||||||
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
+7
-14
@@ -79,7 +79,6 @@ pub enum EventType {
|
|||||||
CipherSoftDeleted = 1115,
|
CipherSoftDeleted = 1115,
|
||||||
CipherRestored = 1116,
|
CipherRestored = 1116,
|
||||||
CipherClientToggledCardNumberVisible = 1117,
|
CipherClientToggledCardNumberVisible = 1117,
|
||||||
CipherClientHttpAuthReleased = 1133,
|
|
||||||
|
|
||||||
// Collection
|
// Collection
|
||||||
CollectionCreated = 1300,
|
CollectionCreated = 1300,
|
||||||
@@ -112,8 +111,8 @@ pub enum EventType {
|
|||||||
// OrganizationUserAutomaticallyConfirmed = 1517,
|
// OrganizationUserAutomaticallyConfirmed = 1517,
|
||||||
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
||||||
OrganizationUserAdminResetTwoFactor = 1519,
|
OrganizationUserAdminResetTwoFactor = 1519,
|
||||||
OrganizationUserRevokedTwoFactorNonCompliance = 1520,
|
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
|
||||||
OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
|
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
|
||||||
OrganizationUserNotificationBannerActionClicked = 1522,
|
OrganizationUserNotificationBannerActionClicked = 1522,
|
||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
@@ -348,23 +347,17 @@ impl Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||||
let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
|
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
||||||
return Ok(());
|
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
||||||
};
|
|
||||||
|
|
||||||
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
|
|
||||||
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
|
|
||||||
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
|
||||||
};
|
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error cleaning old events")
|
.map_res("Error cleaning old events")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,15 +137,15 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json_details_for_group(&self) -> Value {
|
pub fn to_json_details_for_group(&self) -> Value {
|
||||||
// Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group
|
// If both read_only and hide_passwords are false, then manage should be true
|
||||||
// permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage"
|
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
||||||
// in GET /organizations/<org>/collections/details, while GET /groups/<id>/details reported the stored
|
// Or an entry with everything to false
|
||||||
// value. Saving the collection access dialog then persisted manage=true.
|
// For backwards compatibility and migration proposes we keep checking read_only and hide_password
|
||||||
json!({
|
json!({
|
||||||
"id": self.groups_uuid,
|
"id": self.groups_uuid,
|
||||||
"readOnly": self.read_only,
|
"readOnly": self.read_only,
|
||||||
"hidePasswords": self.hide_passwords,
|
"hidePasswords": self.hide_passwords,
|
||||||
"manage": self.manage,
|
"manage": self.manage || (!self.read_only && !self.hide_passwords),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,12 @@ pub use self::auth_request::{AuthRequest, AuthRequestId};
|
|||||||
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
|
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
|
||||||
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::{
|
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
||||||
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};
|
||||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||||
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
|
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||||
pub use self::organization::{
|
pub use self::organization::{
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||||
OrganizationId,
|
OrganizationId,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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;
|
||||||
@@ -12,7 +11,6 @@ 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};
|
||||||
@@ -26,7 +24,6 @@ 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
|
||||||
@@ -49,12 +46,10 @@ pub enum OrgPolicyType {
|
|||||||
RemoveUnlockWithPin = 14,
|
RemoveUnlockWithPin = 14,
|
||||||
RestrictedItemTypes = 15,
|
RestrictedItemTypes = 15,
|
||||||
UriMatchDefaults = 16,
|
UriMatchDefaults = 16,
|
||||||
AutotypeDefaultSetting = 17,
|
// AutotypeDefaultSetting = 17, // Not supported yet
|
||||||
// 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,
|
||||||
// SendControls = 21, // Not supported yet
|
|
||||||
FillAssist = 22,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
||||||
@@ -73,14 +68,6 @@ pub struct ResetPasswordDataModel {
|
|||||||
pub auto_enroll_enabled: bool,
|
pub auto_enroll_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct FillAssistPolicyData {
|
|
||||||
#[serde(alias = "RulesUrl")]
|
|
||||||
pub rules_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl OrgPolicy {
|
impl OrgPolicy {
|
||||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
||||||
@@ -90,7 +77,6 @@ impl OrgPolicy {
|
|||||||
atype: atype as i32,
|
atype: atype as i32,
|
||||||
enabled,
|
enabled,
|
||||||
data,
|
data,
|
||||||
revision_date: Utc::now().naive_utc(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +92,7 @@ impl OrgPolicy {
|
|||||||
"type": self.atype,
|
"type": self.atype,
|
||||||
"data": data_json,
|
"data": data_json,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"revisionDate": format_date(&self.revision_date),
|
"revisionDate": null,
|
||||||
"object": "policy",
|
"object": "policy",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,13 +110,11 @@ impl OrgPolicy {
|
|||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl OrgPolicy {
|
impl OrgPolicy {
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&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(()),
|
||||||
@@ -138,7 +122,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")
|
||||||
}
|
}
|
||||||
@@ -158,10 +142,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -523,8 +523,7 @@ impl Membership {
|
|||||||
|
|
||||||
// These are per user
|
// These are per user
|
||||||
"userId": self.user_uuid,
|
"userId": self.user_uuid,
|
||||||
// Accepted (not yet confirmed) members have no key yet; send null like upstream, not ""
|
"key": self.akey,
|
||||||
"key": (!self.akey.is_empty()).then_some(&self.akey),
|
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"type": membership_type,
|
"type": membership_type,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ impl Send {
|
|||||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct FileData {
|
struct FileData {
|
||||||
|
#[serde(rename = "size", alias = "Size")]
|
||||||
size: NumberOrString,
|
size: NumberOrString,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,11 +137,11 @@ impl TwoFactor {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, 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 as i32))
|
.filter(twofactor::atype.eq(atype))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use crate::{
|
|||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
|
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
||||||
TwoFactorIncomplete, UserSignatureKeyPair,
|
UserSignatureKeyPair,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
@@ -184,10 +184,6 @@ 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())
|
||||||
@@ -408,7 +404,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||||
if member.atype == MembershipType::Owner
|
if member.atype == MembershipType::Owner
|
||||||
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
||||||
@@ -424,7 +420,6 @@ 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?;
|
||||||
@@ -432,7 +427,7 @@ impl User {
|
|||||||
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
||||||
|
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user")
|
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ table! {
|
|||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
enabled -> Bool,
|
enabled -> Bool,
|
||||||
data -> Text,
|
data -> Text,
|
||||||
revision_date -> Timestamp,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -425,12 +425,12 @@ macro_rules! err_json {
|
|||||||
macro_rules! err_handler {
|
macro_rules! err_handler {
|
||||||
($expr:expr) => {{
|
($expr:expr) => {{
|
||||||
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
||||||
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr));
|
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr) => {{
|
($usr_msg:expr, $log_value:expr) => {{
|
||||||
let usr_msg = $usr_msg;
|
let usr_msg = $usr_msg;
|
||||||
let log_value = $log_value;
|
let log_value = $log_value;
|
||||||
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
||||||
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg));
|
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg));
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
+9
-9
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
|
|||||||
|
|
||||||
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
||||||
.port(CONFIG.smtp_port())
|
.port(CONFIG.smtp_port())
|
||||||
.timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
|
.timeout(Some(Duration::from_secs(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(
|
||||||
@@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
|||||||
|
|
||||||
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true");
|
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
|
||||||
let Some(query_string) = query.query() else {
|
let Some(query_string) = query.query() else {
|
||||||
err!("Failed to build verify URL query parameters")
|
err!("Failed to build verify URL query parameters")
|
||||||
};
|
};
|
||||||
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
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();
|
||||||
@@ -738,8 +738,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text:
|
|||||||
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
||||||
|
|
||||||
let body = if CONFIG.smtp_embed_images() {
|
let body = if CONFIG.smtp_embed_images() {
|
||||||
let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec());
|
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
|
||||||
let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec());
|
let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec());
|
||||||
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
||||||
MultiPart::related()
|
MultiPart::related()
|
||||||
.singlepart(SinglePart::html(body_html))
|
.singlepart(SinglePart::html(body_html))
|
||||||
|
|||||||
+3
-5
@@ -595,7 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
||||||
.attach(util::AppHeaders())
|
.attach(util::AppHeaders())
|
||||||
.attach(util::Cors())
|
.attach(util::Cors())
|
||||||
.attach(util::BetterLogging::new(extra_debug, basepath))
|
.attach(util::BetterLogging(extra_debug))
|
||||||
.ignite()
|
.ignite()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -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_and(|d| d != 0)
|
&& CONFIG.events_days_retain().is_some()
|
||||||
{
|
{
|
||||||
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,9 +754,7 @@ 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(u64::from(
|
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms())));
|
||||||
CONFIG.job_poll_interval_ms(),
|
|
||||||
))));
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("Error spawning job scheduler thread");
|
.expect("Error spawning job scheduler thread");
|
||||||
|
|||||||
+6
-6
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
|
|||||||
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
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(u64::from(CONFIG.login_ratelimit_seconds()));
|
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
|
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
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(u64::from(CONFIG.admin_ratelimit_seconds()));
|
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
|
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
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(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
|
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
|
let burst = NonZeroU32::new(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),
|
||||||
|
|||||||
+2
-6
@@ -266,8 +266,8 @@ pub async fn exchange_code(
|
|||||||
(_, Some(re)) => {
|
(_, Some(re)) => {
|
||||||
let error_msg = format!(
|
let error_msg = format!(
|
||||||
"SSO authorization failed: {}, {}",
|
"SSO authorization failed: {}, {}",
|
||||||
re.error.escape_debug(),
|
re.error,
|
||||||
re.error_description.as_deref().unwrap_or_default().escape_debug()
|
re.error_description.as_ref().unwrap_or(&String::new())
|
||||||
);
|
);
|
||||||
sso_auth.delete(conn).await?;
|
sso_auth.delete(conn).await?;
|
||||||
err!(error_msg);
|
err!(error_msg);
|
||||||
@@ -309,11 +309,7 @@ pub async fn exchange_code(
|
|||||||
user_name: user_name.clone(),
|
user_name: user_name.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.sso_debug_tokens() {
|
|
||||||
debug!("Authenticated user {authenticated_user:?}");
|
debug!("Authenticated user {authenticated_user:?}");
|
||||||
} else {
|
|
||||||
debug!("Authenticated user {identifier}");
|
|
||||||
}
|
|
||||||
sso_auth.auth_response = Some(authenticated_user.clone());
|
sso_auth.auth_response = Some(authenticated_user.clone());
|
||||||
sso_auth.updated_at = Utc::now().naive_utc();
|
sso_auth.updated_at = Utc::now().naive_utc();
|
||||||
sso_auth.save(conn).await?;
|
sso_auth.save(conn).await?;
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
|
|||||||
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
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(u64::from(CONFIG.sso_client_cache_expiration())))
|
.time_to_live(Duration::from_secs(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>>> =
|
||||||
@@ -260,7 +260,7 @@ impl Client {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.sso_debug_tokens() {
|
if CONFIG.sso_debug_tokens() {
|
||||||
debug!("Id token: {id_token}");
|
debug!("Id token: {}", id_token.to_string());
|
||||||
debug!("Access token: {}", token_response.access_token().secret());
|
debug!("Access token: {}", token_response.access_token().secret());
|
||||||
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
||||||
debug!("Expiration time: {:?}", token_response.expires_in());
|
debug!("Expiration time: {:?}", token_response.expires_in());
|
||||||
|
|||||||
+2
-9
@@ -93,9 +93,7 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
supportString += `* 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.config_file_status !== "NotFound")}`;
|
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
|
||||||
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) {
|
||||||
@@ -118,7 +116,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 :x:\n";
|
supportString += "* Invalid feature flags: true\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||||
@@ -134,11 +132,6 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
supportString += "\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`;
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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.4
|
* https://datatables.net/download/#bs5/dt-3.1.3
|
||||||
*
|
*
|
||||||
* Included libraries:
|
* Included libraries:
|
||||||
* DataTables 3.1.4
|
* DataTables 3.1.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*! DataTables Bootstrap 5 integration
|
/*! DataTables Bootstrap 5 integration
|
||||||
|
|||||||
Vendored
+7
-13
@@ -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.4
|
* https://datatables.net/download/#bs5/dt-3.1.3
|
||||||
*
|
*
|
||||||
* Included libraries:
|
* Included libraries:
|
||||||
* DataTables 3.1.4
|
* DataTables 3.1.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*! DataTables 3.1.4
|
/*! DataTables 3.1.3
|
||||||
* Copyright (c) SpryMedia Ltd - datatables.net/license
|
* Copyright (c) SpryMedia Ltd - datatables.net/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -453,9 +453,6 @@ 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] = [];
|
||||||
@@ -492,9 +489,6 @@ 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)) {
|
||||||
@@ -2700,7 +2694,7 @@ class Dom {
|
|||||||
*/
|
*/
|
||||||
prepend(content) {
|
prepend(content) {
|
||||||
return this.each(el => {
|
return this.each(el => {
|
||||||
if (arrayLike(content) || dom(content)) {
|
if (content instanceof Dom) {
|
||||||
// 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();
|
||||||
@@ -8720,7 +8714,7 @@ const ext = {
|
|||||||
/**
|
/**
|
||||||
* DataTables build type (expanded by the download builder)
|
* DataTables build type (expanded by the download builder)
|
||||||
*/
|
*/
|
||||||
builder: 'bs5/dt-3.1.4',
|
builder: 'bs5/dt-3.1.3',
|
||||||
/**
|
/**
|
||||||
* Buttons. For use with the Buttons extension for DataTables. This is
|
* 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
|
||||||
@@ -8866,7 +8860,7 @@ const ext = {
|
|||||||
* Software version
|
* Software version
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
version: '3.1.4'
|
version: '3.1.3'
|
||||||
};
|
};
|
||||||
//
|
//
|
||||||
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
|
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
|
||||||
@@ -9565,7 +9559,7 @@ register('destroy()', function (remove) {
|
|||||||
callbackFire(settings, 'destroy', 'destroy', [settings], true);
|
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(true);
|
new Api(settings).columns().visible();
|
||||||
}
|
}
|
||||||
// Container width change listener
|
// Container width change listener
|
||||||
if (settings.resizeObserver) {
|
if (settings.resizeObserver) {
|
||||||
|
|||||||
@@ -69,26 +69,12 @@
|
|||||||
</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 (eq page_data.config_file_status "Loaded")}}
|
|
||||||
<span class="d-inline"><b>Yes</b></span>
|
|
||||||
{{#if page_data.overrides}}
|
{{#if page_data.overrides}}
|
||||||
|
<span class="d-inline"><b>Yes</b></span>
|
||||||
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{~else if (eq page_data.config_file_status "Error")}}
|
{{#unless page_data.overrides}}
|
||||||
<span class="d-inline"><b>Errors</b></span>
|
<span class="d-block"><b>No</b></span>
|
||||||
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
|
|
||||||
{{~else}}
|
|
||||||
<span class="d-inline"><b>No</b></span>
|
|
||||||
{{/if}}
|
|
||||||
</dd>
|
|
||||||
<dt class="col-sm-5">Environment variables are valid</dt>
|
|
||||||
<dd class="col-sm-7">
|
|
||||||
{{#if page_data.invalid_env_vars}}
|
|
||||||
<span class="d-inline"><b>No</b></span>
|
|
||||||
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.
{{page_data.invalid_env_vars}}">Details</span>
|
|
||||||
{{/if}}
|
|
||||||
{{#unless page_data.invalid_env_vars}}
|
|
||||||
<span class="d-block"><b>Yes</b></span>
|
|
||||||
{{/unless}}
|
{{/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 data-testid="emergency" href="{{{url}}}"
|
<a 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>
|
||||||
|
|||||||
+32
-77
@@ -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: u32,
|
ttl: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> Cached<R> {
|
impl<R> Cached<R> {
|
||||||
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
|
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> {
|
||||||
Self {
|
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.into()).unwrap();
|
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap();
|
||||||
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
@@ -259,16 +259,14 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
|||||||
|
|
||||||
pub struct EtagCached<R> {
|
pub struct EtagCached<R> {
|
||||||
response: R,
|
response: R,
|
||||||
content_type: ContentType,
|
|
||||||
etag: String,
|
etag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> EtagCached<R> {
|
impl<R> EtagCached<R> {
|
||||||
/// An `etag` response should always be quoted
|
/// An `etag` response should always be quoted
|
||||||
pub fn new(response: R, content_type: ContentType, etag: &str) -> Self {
|
pub fn new(response: R, etag: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
response,
|
response,
|
||||||
content_type,
|
|
||||||
etag: format!("\"{etag}\""),
|
etag: format!("\"{etag}\""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,8 +288,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
|||||||
self.response.respond_to(request)?
|
self.response.respond_to(request)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control
|
// Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control
|
||||||
res.set_header(self.content_type);
|
|
||||||
res.set_raw_header("Etag", self.etag);
|
res.set_raw_header("Etag", self.etag);
|
||||||
res.set_raw_header("Cache-Control", "public, no-cache");
|
res.set_raw_header("Cache-Control", "public, no-cache");
|
||||||
Ok(res)
|
Ok(res)
|
||||||
@@ -302,55 +299,19 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
|||||||
// Effectively ignores, any static file route, and the alive endpoint
|
// Effectively ignores, any static file route, and the alive endpoint
|
||||||
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
||||||
|
|
||||||
// When extra is true, we ignore the whitelist above and also print the mounts
|
// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts
|
||||||
pub struct BetterLogging {
|
pub struct BetterLogging(pub bool);
|
||||||
extra: bool,
|
|
||||||
log_requests: bool,
|
|
||||||
log_response: bool,
|
|
||||||
domain_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BetterLogging {
|
|
||||||
pub fn new(extra: bool, domain_path: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
extra,
|
|
||||||
log_requests: log_enabled!(target: "request", log::Level::Info),
|
|
||||||
log_response: log_enabled!(target: "response", log::Level::Info),
|
|
||||||
domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option<std::borrow::Cow<'p, str>> {
|
|
||||||
if !self.extra && method == Method::Options {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let path = path.percent_decode_lossy();
|
|
||||||
if self.extra {
|
|
||||||
return Some(path);
|
|
||||||
}
|
|
||||||
let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path);
|
|
||||||
LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl Fairing for BetterLogging {
|
impl Fairing for BetterLogging {
|
||||||
fn info(&self) -> Info {
|
fn info(&self) -> Info {
|
||||||
let mut kind = Kind::Liftoff;
|
|
||||||
if self.log_requests {
|
|
||||||
kind = kind | Kind::Request;
|
|
||||||
}
|
|
||||||
if self.log_response {
|
|
||||||
kind = kind | Kind::Response;
|
|
||||||
}
|
|
||||||
Info {
|
Info {
|
||||||
name: "Better Logging",
|
name: "Better Logging",
|
||||||
kind,
|
kind: Kind::Liftoff | Kind::Request | Kind::Response,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
||||||
if self.extra {
|
if self.0 {
|
||||||
info!(target: "routes", "Routes loaded:");
|
info!(target: "routes", "Routes loaded:");
|
||||||
let mut routes: Vec<_> = rocket.routes().collect();
|
let mut routes: Vec<_> = rocket.routes().collect();
|
||||||
routes.sort_by_key(|r| r.uri.path());
|
routes.sort_by_key(|r| r.uri.path());
|
||||||
@@ -374,28 +335,36 @@ impl Fairing for BetterLogging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
||||||
let uri = request.uri();
|
|
||||||
let method = request.method();
|
let method = request.method();
|
||||||
let Some(path) = self.log_path(method, uri.path().raw()) else {
|
if !self.0 && method == Method::Options {
|
||||||
return;
|
return;
|
||||||
};
|
|
||||||
match uri.query() {
|
|
||||||
Some(q) => {
|
|
||||||
let q = q.as_str();
|
|
||||||
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug());
|
|
||||||
}
|
}
|
||||||
None => info!(target: "request", "{method} {}", path.escape_debug()),
|
let uri = request.uri();
|
||||||
|
let uri_path = uri.path();
|
||||||
|
let uri_path_str = uri_path.url_decode_lossy();
|
||||||
|
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
||||||
|
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
||||||
|
match uri.query() {
|
||||||
|
Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]),
|
||||||
|
None => info!(target: "request", "{method} {uri_path_str}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
||||||
if self.log_path(request.method(), request.uri().path().raw()).is_none() {
|
if !self.0 && request.method() == Method::Options {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let uri_path = request.uri().path();
|
||||||
|
let uri_path_str = uri_path.url_decode_lossy();
|
||||||
|
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
||||||
|
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
match request.route() {
|
if let Some(route) = request.route() {
|
||||||
Some(r) => info!(target: "response", "{r} => {status}"),
|
info!(target: "response", "{route} => {status}");
|
||||||
None => info!(target: "response", "{status}"),
|
} else {
|
||||||
|
info!(target: "response", "{status}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,34 +447,20 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
pub fn get_env<V>(key: &str) -> Option<V>
|
||||||
|
|
||||||
pub fn get_env<V>(key: &'static str) -> Option<V>
|
|
||||||
where
|
where
|
||||||
V: FromStr,
|
V: FromStr,
|
||||||
{
|
{
|
||||||
let value = get_env_str_value(key);
|
try_parse_string(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: &'static str) -> Option<bool> {
|
pub fn get_env_bool(key: &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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user