mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 17:58:36 +02:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975f316110 | ||
|
|
1430a64547 | ||
|
|
7c82fbed0c | ||
|
|
d28c8339fb | ||
|
|
5ad0c6d41a | ||
|
|
17b159beff | ||
|
|
904b80115d | ||
|
|
82fc47e83e | ||
|
|
286499781a | ||
|
|
6ed6921cc2 | ||
|
|
5d870b796f | ||
|
|
79ee29d501 | ||
|
|
acb574b768 | ||
|
|
e056359a42 | ||
|
|
f1c7eccd4a | ||
|
|
0dbc92f2b7 | ||
|
|
42660af757 | ||
|
|
863395f5e2 | ||
|
|
be5d3ad907 | ||
|
|
80d1859452 | ||
|
|
b723afe08a | ||
|
|
64ae55ceb2 | ||
|
|
a96912bf21 | ||
|
|
e2d63cfca3 | ||
|
|
0f44744ef8 | ||
|
|
44c4853d37 | ||
|
|
e5a90968f2 | ||
|
|
7ce705be56 | ||
|
|
c925c04de0 | ||
|
|
b71cb7a832 | ||
|
|
f4696877b9 | ||
|
|
296599003c | ||
|
|
970cc49182 | ||
|
|
58a980f850 | ||
|
|
64895524e4 | ||
|
|
df2cd3c869 | ||
|
|
0e93d15b73 | ||
|
|
d1cbd027cd | ||
|
|
1f802f8e6a | ||
|
|
a2efadc650 | ||
|
|
acbf49018f | ||
|
|
b8089e31c2 | ||
|
|
8647af8f89 | ||
|
|
c687cacb6b | ||
|
|
8b56926077 | ||
|
|
de6d2066b3 | ||
|
|
415df400f4 | ||
|
|
42aa3ee1c7 |
No files matched your search
+33
-6
@@ -78,7 +78,7 @@
|
|||||||
# DB_CONNECTION_RETRIES=15
|
# DB_CONNECTION_RETRIES=15
|
||||||
|
|
||||||
## Database timeout
|
## Database timeout
|
||||||
## Timeout when acquiring database connection
|
## Timeout in seconds when acquiring database connection
|
||||||
# DATABASE_TIMEOUT=30
|
# DATABASE_TIMEOUT=30
|
||||||
|
|
||||||
## Database idle timeout
|
## Database idle timeout
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
||||||
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
||||||
## Number of days to retain events stored in the database.
|
## Number of days to retain events stored in the database.
|
||||||
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
|
## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
|
||||||
# EVENTS_DAYS_RETAIN=
|
# EVENTS_DAYS_RETAIN=
|
||||||
##
|
##
|
||||||
## Cron schedule of the job that cleans old auth requests from the auth request.
|
## Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
# USER_SEND_LIMIT=
|
# USER_SEND_LIMIT=
|
||||||
|
|
||||||
## Number of days to wait before auto-deleting a trashed item.
|
## Number of days to wait before auto-deleting a trashed item.
|
||||||
## If unset (the default), trashed items are not auto-deleted.
|
## If unset (the default) or 0, trashed items are not auto-deleted.
|
||||||
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
||||||
# TRASH_AUTO_DELETE_DAYS=
|
# TRASH_AUTO_DELETE_DAYS=
|
||||||
|
|
||||||
@@ -386,19 +386,34 @@
|
|||||||
# 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!
|
||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
|
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
|
||||||
## - "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)
|
||||||
## - "pm-30529-webauthn-related-origins":
|
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
||||||
|
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
||||||
|
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
||||||
|
## - "pm-30529-webauthn-related-origins": 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.
|
||||||
|
## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1)
|
||||||
|
## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0)
|
||||||
|
## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one.
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||||
|
|
||||||
## Require new device emails. When a user logs in an email is required to be sent.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
@@ -459,7 +474,7 @@
|
|||||||
|
|
||||||
## Allowed connect-src (Know the risks!)
|
## Allowed connect-src (Know the risks!)
|
||||||
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
|
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
|
||||||
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||||
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
||||||
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
||||||
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
||||||
@@ -485,6 +500,15 @@
|
|||||||
## KNOW WHAT YOU ARE DOING!
|
## KNOW WHAT YOU ARE DOING!
|
||||||
# ORG_GROUPS_ENABLED=false
|
# ORG_GROUPS_ENABLED=false
|
||||||
|
|
||||||
|
## EXPERIMENTAL FEATURE: Custom roles
|
||||||
|
## Enables the Custom member role with granular permissions for organizations.
|
||||||
|
## This is still being developed: only the permissions this version enforces can be granted.
|
||||||
|
## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS.
|
||||||
|
## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled.
|
||||||
|
## Disabled by default because this is an experimental feature!
|
||||||
|
## KNOW WHAT YOU ARE DOING!
|
||||||
|
# EXPERIMENTAL_CUSTOM_ROLES=false
|
||||||
|
|
||||||
## Increase secure note size limit (Know the risks!)
|
## Increase secure note size limit (Know the risks!)
|
||||||
## Sets the secure note size limit to 100_000 instead of the default 10_000.
|
## Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||||
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||||
@@ -602,6 +626,9 @@
|
|||||||
## 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
|
||||||
@@ -610,7 +637,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 sever drifts out of time, valid codes could be marked as invalid.
|
## Keep in mind that when a server 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
|
|||||||
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
@@ -222,7 +222,7 @@ jobs:
|
|||||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -242,7 +242,7 @@ jobs:
|
|||||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
- name: Upload binaries as artifacts
|
- name: Upload binaries as artifacts
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
@@ -264,7 +264,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*-${{ matrix.base_image }}
|
pattern: digests-*-${{ matrix.base_image }}
|
||||||
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||||
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: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
rev: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: typos
|
- id: typos
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
+2
-3
@@ -14,9 +14,8 @@ extend-ignore-re = [
|
|||||||
# In SMTP it's called HELO, so ignore it
|
# In SMTP it's called HELO, so ignore it
|
||||||
"(?i)helo_name",
|
"(?i)helo_name",
|
||||||
"Server name sent during.+HELO",
|
"Server name sent during.+HELO",
|
||||||
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
# COSE Is short for CBOR Object Signing and Encryption
|
||||||
"COSEKey",
|
"(?i)cose",
|
||||||
"COSEAlgorithm",
|
|
||||||
# Ignore this specific string as it's valid
|
# Ignore this specific string as it's valid
|
||||||
"Ensure they are valid OTPs",
|
"Ensure they are valid OTPs",
|
||||||
# This word is misspelled upstream
|
# This word is misspelled upstream
|
||||||
|
|||||||
Generated
+792
-501
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.34"
|
log = "0.4.35"
|
||||||
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.1", default-features = false, features = ["json", "tls"] }
|
rocket = { version = "0.5.2", 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.53.2", features = [
|
tokio = { version = "1.54.1", features = [
|
||||||
"fs",
|
"fs",
|
||||||
"io-util",
|
"io-util",
|
||||||
"net",
|
"net",
|
||||||
@@ -100,14 +100,14 @@ tokio = { version = "1.53.2", features = [
|
|||||||
"signal",
|
"signal",
|
||||||
"time",
|
"time",
|
||||||
] }
|
] }
|
||||||
tokio-util = { version = "0.7.19", features = ["compat"] }
|
tokio-util = { version = "0.7.20", features = ["compat"] }
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.154"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
|
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "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.45", features = ["ring", "std"], default-features = false }
|
rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.27.0", features = ["v4"] }
|
uuid = { version = "1.29.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.8.4"
|
html5gum = "0.9.0"
|
||||||
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.0.1", features = ["async"] }
|
cached = { version = "4.1.0", 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.0.1", default-features = false }
|
openidconnect = { version = "4.1.0", 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.4", default-features = false, features = ["services-fs"] }
|
opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] }
|
||||||
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
|
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
|
||||||
"behavior-version-latest",
|
"behavior-version-latest",
|
||||||
"credentials-process",
|
"credentials-process",
|
||||||
"rt-tokio",
|
"rt-tokio",
|
||||||
"sso",
|
"sso",
|
||||||
] }
|
] }
|
||||||
aws-credential-types = { version = "1.3.0", optional = true }
|
aws-credential-types = { version = "1.3.1", optional = true }
|
||||||
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
|
aws-smithy-runtime-api = { version = "1.19.2", 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.7.0"
|
vault_version: "v2026.10.1"
|
||||||
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
|
vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
|
||||||
# Cross Compile Docker Helper Scripts v1.9.0
|
# Cross Compile Docker Helper Scripts v1.10.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
|
||||||
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
||||||
# rust_version: x.yy.z # Rust version to be used
|
# rust_version: x.yy.z # Rust version to be used
|
||||||
debian_version: trixie # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
|
|||||||
+10
-10
@@ -19,23 +19,23 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
|
|||||||
@@ -19,24 +19,24 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
## And these bash scripts do not have any significant difference if at all
|
## And these bash scripts do not have any significant difference if at all
|
||||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
|
||||||
# hadolint ignore=DL3067
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Some unsupported platforms for Debian based images. These are not built and test
|
|||||||
- 386 (linux/386)
|
- 386 (linux/386)
|
||||||
- ppc64le (linux/ppc64le)
|
- ppc64le (linux/ppc64le)
|
||||||
- s390x (linux/s390x)
|
- s390x (linux/s390x)
|
||||||
|
- riscv64 (linux/riscv64)
|
||||||
|
|
||||||
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
|
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
|
||||||
This ensures the container build process can run binaries from other architectures.<br>
|
This ensures the container build process can run binaries from other architectures.<br>
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ target "debian-s390x" {
|
|||||||
platforms = ["linux/s390x"]
|
platforms = ["linux/s390x"]
|
||||||
tags = generate_tags("", "-s390x")
|
tags = generate_tags("", "-s390x")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target "debian-riscv64" {
|
||||||
|
inherits = ["debian"]
|
||||||
|
platforms = ["linux/riscv64"]
|
||||||
|
tags = generate_tags("", "-riscv64")
|
||||||
|
}
|
||||||
// ==== End of unsupported Debian architecture targets ===
|
// ==== End of unsupported Debian architecture targets ===
|
||||||
|
|
||||||
// A Group to build all platforms individually for local testing
|
// A Group to build all platforms individually for local testing
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ proc-macro = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.47"
|
quote = "1.0.47"
|
||||||
syn = "3.0.6"
|
syn = "3.0.9"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE org_policies DROP COLUMN revision_date;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date
|
||||||
|
-- columns elsewhere, and to avoid MySQL's implicit session-timezone
|
||||||
|
-- conversion and 2038 range limit on TIMESTAMP.
|
||||||
|
ALTER TABLE org_policies
|
||||||
|
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||||
|
|
||||||
|
UPDATE org_policies SET revision_date = UTC_TIMESTAMP();
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
|
||||||
|
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
|
||||||
|
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN signed_public_key;
|
||||||
|
ALTER TABLE users DROP COLUMN security_state;
|
||||||
|
ALTER TABLE users DROP COLUMN security_version;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_state TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_version INTEGER;
|
||||||
|
|
||||||
|
CREATE TABLE user_signature_key_pairs (
|
||||||
|
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
user_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
|
||||||
|
signing_key TEXT NOT NULL,
|
||||||
|
verifying_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
||||||
|
-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything
|
||||||
|
-- is dropped: change their role first, or restore the backup taken before the upgrade.
|
||||||
|
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
|
||||||
|
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||||
|
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
||||||
|
INSERT INTO custom_role_downgrade
|
||||||
|
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
||||||
|
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
||||||
|
DROP TEMPORARY TABLE custom_role_downgrade;
|
||||||
|
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
DROP COLUMN manage_users,
|
||||||
|
DROP COLUMN manage_groups,
|
||||||
|
DROP COLUMN manage_policies,
|
||||||
|
DROP COLUMN create_new_collections,
|
||||||
|
DROP COLUMN edit_any_collection,
|
||||||
|
DROP COLUMN delete_any_collection,
|
||||||
|
DROP COLUMN access_event_logs,
|
||||||
|
DROP COLUMN access_import_export,
|
||||||
|
DROP COLUMN access_reports;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
||||||
|
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
||||||
|
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
-- Key rotations before this kept the id of the old user key, so every new
|
||||||
|
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||||
|
-- all of them; clients record their current key's id again when it's empty.
|
||||||
|
UPDATE users SET key_id = NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE org_policies DROP COLUMN revision_date;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now():
|
||||||
|
-- assigning timestamptz now() into a naive TIMESTAMP column casts through
|
||||||
|
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock
|
||||||
|
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp
|
||||||
|
-- column in this schema.
|
||||||
|
ALTER TABLE org_policies
|
||||||
|
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||||
|
|
||||||
|
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc');
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN signed_public_key;
|
||||||
|
ALTER TABLE users DROP COLUMN security_state;
|
||||||
|
ALTER TABLE users DROP COLUMN security_version;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_state TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_version INTEGER;
|
||||||
|
|
||||||
|
CREATE TABLE user_signature_key_pairs (
|
||||||
|
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
|
||||||
|
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
|
||||||
|
signing_key TEXT NOT NULL,
|
||||||
|
verifying_key TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
||||||
|
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
|
||||||
|
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
|
||||||
|
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||||
|
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
||||||
|
INSERT INTO custom_role_downgrade
|
||||||
|
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
||||||
|
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
||||||
|
DROP TABLE custom_role_downgrade;
|
||||||
|
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
DROP COLUMN manage_users,
|
||||||
|
DROP COLUMN manage_groups,
|
||||||
|
DROP COLUMN manage_policies,
|
||||||
|
DROP COLUMN create_new_collections,
|
||||||
|
DROP COLUMN edit_any_collection,
|
||||||
|
DROP COLUMN delete_any_collection,
|
||||||
|
DROP COLUMN access_event_logs,
|
||||||
|
DROP COLUMN access_import_export,
|
||||||
|
DROP COLUMN access_reports;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
||||||
|
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
||||||
|
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
-- Key rotations before this kept the id of the old user key, so every new
|
||||||
|
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||||
|
-- all of them; clients record their current key's id again when it's empty.
|
||||||
|
UPDATE users SET key_id = NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1,6 @@
|
|||||||
|
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add
|
||||||
|
-- the column with a constant placeholder and backfill separately.
|
||||||
|
ALTER TABLE org_policies
|
||||||
|
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||||
|
|
||||||
|
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
-- TEXT has no size limit here, only MySQL needed the change
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN signed_public_key;
|
||||||
|
ALTER TABLE users DROP COLUMN security_state;
|
||||||
|
ALTER TABLE users DROP COLUMN security_version;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_state TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_version INTEGER;
|
||||||
|
|
||||||
|
CREATE TABLE user_signature_key_pairs (
|
||||||
|
uuid TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
|
||||||
|
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
|
||||||
|
signing_key TEXT NOT NULL,
|
||||||
|
verifying_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
||||||
|
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
|
||||||
|
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
|
||||||
|
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||||
|
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
||||||
|
INSERT INTO custom_role_downgrade
|
||||||
|
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
||||||
|
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
||||||
|
DROP TABLE custom_role_downgrade;
|
||||||
|
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN manage_users;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN manage_groups;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN manage_policies;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN access_import_export;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN access_reports;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
||||||
|
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
-- Key rotations before this kept the id of the old user key, so every new
|
||||||
|
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||||
|
-- all of them; clients record their current key's id again when it's empty.
|
||||||
|
UPDATE users SET key_id = NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This allows running integration tests using [Playwright](https://playwright.dev/).
|
This allows running integration tests using [Playwright](https://playwright.dev/).
|
||||||
\
|
\
|
||||||
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
|
It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
|
|
||||||
### Keep services running
|
### Keep services running
|
||||||
|
|
||||||
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
If you want you can keep the DB and Keycloak running (states are not impacted by the tests):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo, Test } from '@playwright/test';
|
||||||
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP } from './setups/2fa';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let mailserver;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
mailserver = new MailDev({
|
||||||
|
port: process.env.MAILDEV_SMTP_PORT,
|
||||||
|
web: { port: process.env.MAILDEV_HTTP_PORT },
|
||||||
|
})
|
||||||
|
|
||||||
|
await mailserver.listen();
|
||||||
|
|
||||||
|
await utils.startVault(browser, testInfo, {
|
||||||
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
if( mailserver ){
|
||||||
|
await mailserver.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function emergencyAccess(test: Test, page: Page, user: { name: string }) {
|
||||||
|
await test.step('Navigate', async () => {
|
||||||
|
await page.getByRole('button', { name: user.name }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Emergency access' }).click();
|
||||||
|
await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Emergency access', async ({ browser, page }) => {
|
||||||
|
const context2 = await browser.newContext();
|
||||||
|
const page2 = await context2.newPage();
|
||||||
|
|
||||||
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
const mailBuffer2 = mailserver.buffer(users.user2.email);
|
||||||
|
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
await createAccount(test, page2, users.user2);
|
||||||
|
|
||||||
|
await test.step('Add test2', async () => {
|
||||||
|
await emergencyAccess(test, page, users.user1);
|
||||||
|
await page.getByRole('button', { name: 'Add emergency contact' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email);
|
||||||
|
await page.getByRole('radio', { name: 'Takeover Can reset your' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'User(s) invited');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Accept', async () => {
|
||||||
|
const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name);
|
||||||
|
const pageE = await context2.newPage();
|
||||||
|
await pageE.setContent(email.html);
|
||||||
|
const link = await pageE.getByTestId("emergency").getAttribute("href");
|
||||||
|
await pageE.close();
|
||||||
|
|
||||||
|
await page2.goto(link);
|
||||||
|
await utils.checkNotification(page2, 'Invitation accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Confirm', async () => {
|
||||||
|
await emergencyAccess(test, page, users.user1);
|
||||||
|
await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Options' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Confirm' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
await utils.checkNotification(page, users.user2.name + ' confirmed');
|
||||||
|
await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Request', async () => {
|
||||||
|
await emergencyAccess(test, page2, users.user2);
|
||||||
|
await page2.getByRole('button', { name: 'Options' }).click();
|
||||||
|
await page2.getByRole('menuitem', { name: 'Request Access' }).click();
|
||||||
|
await page2.getByRole('button', { name: 'Request Access' }).click();
|
||||||
|
await utils.checkNotification(page2, 'Emergency access requested');
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Approved', async () => {
|
||||||
|
await emergencyAccess(test, page, users.user1);
|
||||||
|
await page.getByRole('button', { name: 'Options' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Approve' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Approve' }).click();
|
||||||
|
await utils.checkNotification(page, 'Emergency access approved');
|
||||||
|
await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved");
|
||||||
|
});
|
||||||
|
await activateTOTP(test, page, users.user1);
|
||||||
|
|
||||||
|
let newPassword = "TotoNewPassword";
|
||||||
|
await test.step('Access', async () => {
|
||||||
|
await emergencyAccess(test, page2, users.user2);
|
||||||
|
await page2.getByRole('button', { name: 'Options' }).click();
|
||||||
|
await page2.getByRole('menuitem', { name: 'Takeover' }).click();
|
||||||
|
await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
|
||||||
|
await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword);
|
||||||
|
await page2.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page2, 'Password reset for ' + users.user1.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Changed no 2fa', async () => {
|
||||||
|
users.user1.password = newPassword;
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Reject', async () => {
|
||||||
|
await emergencyAccess(test, page, users.user1);
|
||||||
|
await page.getByRole('button', { name: 'Options' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Reject' }).click();
|
||||||
|
await utils.checkNotification(page, 'Emergency access rejected');
|
||||||
|
await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Remove', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Options' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Removed user ' + users.user2.name);
|
||||||
|
await expect(page.getByText('You have not added any emergency contacts')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth";
|
|||||||
|
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
import { createAccount, logUser } from './setups/user';
|
import { createAccount, logUser } from './setups/user';
|
||||||
import { activateTOTP, disableTOTP } from './setups/2fa';
|
import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
let totp;
|
let totp;
|
||||||
@@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => {
|
|||||||
|
|
||||||
await utils.logout(test, page, users.user1);
|
await utils.logout(test, page, users.user1);
|
||||||
|
|
||||||
await test.step('login', async () => {
|
await logUser(test, page, users.user1, { totp });
|
||||||
let timestamp = Date.now(); // Needed to use the next token
|
|
||||||
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
|
|
||||||
|
|
||||||
|
await disableTOTP(test, page, users.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Recovery codes', async ({ context, page }) => {
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await activateTOTP(test, page, users.user1);
|
||||||
|
let recovery = await recoveryCodes(test, page, users.user1);
|
||||||
|
|
||||||
|
await utils.logout(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('login', async () => {
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
});
|
|
||||||
|
|
||||||
await disableTOTP(test, page, users.user1);
|
const newPagePromise = context.waitForEvent('page');
|
||||||
|
await page.getByRole('button', { name: 'Use your recovery code' }).click();
|
||||||
|
const newPage = await newPagePromise;
|
||||||
|
|
||||||
|
const tabs = context.pages();
|
||||||
|
await tabs[1].bringToFront();
|
||||||
|
await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible();
|
||||||
|
await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email);
|
||||||
|
await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery);
|
||||||
|
await tabs[1].getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
await expect(tabs[1]).toHaveTitle(/Two-step login/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth";
|
|||||||
|
|
||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
|
export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string {
|
||||||
|
return await test.step('Recovery code', async () => {
|
||||||
|
await page.getByRole('button', { name: user.name }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'View recovery code' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
const recovery = await page.getByRole('code').innerText();
|
||||||
|
await page.getByLabel('Close').click();
|
||||||
|
|
||||||
|
return recovery;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
|
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
|
||||||
return await test.step('Activate TOTP 2FA', async () => {
|
return await test.step('Activate TOTP 2FA', async () => {
|
||||||
await page.getByRole('button', { name: user.name }).click();
|
await page.getByRole('button', { name: user.name }).click();
|
||||||
@@ -21,7 +39,6 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
|
|||||||
await page.getByLabel(/Verification code/).fill(totp.generate());
|
await page.getByLabel(/Verification code/).fill(totp.generate());
|
||||||
await page.getByRole('button', { name: 'Turn on' }).click();
|
await page.getByRole('button', { name: 'Turn on' }).click();
|
||||||
await page.getByRole('heading', { name: 'Turned on', exact: true });
|
await page.getByRole('heading', { name: 'Turned on', exact: true });
|
||||||
await page.getByLabel('Close').click();
|
|
||||||
|
|
||||||
return totp;
|
return totp;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test';
|
|||||||
|
|
||||||
import { type MailBuffer } from 'maildev';
|
import { type MailBuffer } from 'maildev';
|
||||||
|
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
import { retrieveEmailCode } from './2fa';
|
import { retrieveEmailCode } from './2fa';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export async function logUser(
|
|||||||
mailBuffer ?: MailBuffer,
|
mailBuffer ?: MailBuffer,
|
||||||
mail2fa?: boolean,
|
mail2fa?: boolean,
|
||||||
notNewDevice?: boolean,
|
notNewDevice?: boolean,
|
||||||
|
totp?: OTPAuth.TOTP,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
@@ -55,11 +57,23 @@ export async function logUser(
|
|||||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
if( options.mail2fa ){
|
if( options.mail2fa || options.totp ){
|
||||||
|
let code;
|
||||||
|
|
||||||
await test.step('2FA check', async () => {
|
await test.step('2FA check', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
let code = await retrieveEmailCode(test, page, options.mailBuffer);
|
|
||||||
|
if( options.totp ) {
|
||||||
|
const totp = options.totp;
|
||||||
|
let timestamp = Date.now(); // Needed to use the next token
|
||||||
|
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
|
||||||
|
code = totp.generate({timestamp});
|
||||||
|
} else if( options.mail2fa ){
|
||||||
|
code = await retrieveEmailCode(test, page, options.mailBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByLabel(/Verification code/).fill(code);
|
await page.getByLabel(/Verification code/).fill(code);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.99.0"
|
channel = "1.99.1"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
+52
-11
@@ -1,4 +1,4 @@
|
|||||||
use std::{env, sync::LazyLock};
|
use std::{collections::HashMap, env, sync::LazyLock};
|
||||||
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
@@ -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},
|
||||||
unregister_push_device,
|
push_logout_to_relay, 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,
|
||||||
@@ -26,6 +26,7 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||||
|
custom_roles_enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
@@ -201,12 +202,15 @@ fn post_admin_login(
|
|||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if validate_token(&data.token) {
|
if validate_token(&data.token) {
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = generate_admin_claims();
|
let claims = match generate_admin_claims() {
|
||||||
|
Ok(claims) => claims,
|
||||||
|
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
|
||||||
|
};
|
||||||
let jwt = encode_jwt(&claims);
|
let jwt = encode_jwt(&claims);
|
||||||
|
|
||||||
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
||||||
.path(admin_path())
|
.path(admin_path())
|
||||||
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
.max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.secure(secure.https);
|
.secure(secure.https);
|
||||||
@@ -253,6 +257,7 @@ struct AdminTemplateData {
|
|||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
urlpath: String,
|
urlpath: String,
|
||||||
sso_enabled: bool,
|
sso_enabled: bool,
|
||||||
|
custom_roles_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminTemplateData {
|
impl AdminTemplateData {
|
||||||
@@ -263,6 +268,7 @@ impl AdminTemplateData {
|
|||||||
logged_in: true,
|
logged_in: true,
|
||||||
urlpath: CONFIG.domain_path(),
|
urlpath: CONFIG.domain_path(),
|
||||||
sso_enabled: CONFIG.sso_enabled(),
|
sso_enabled: CONFIG.sso_enabled(),
|
||||||
|
custom_roles_enabled: custom_roles_enabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
|
|||||||
|
|
||||||
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||||
|
|
||||||
|
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
|
||||||
|
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|member| (member.org_uuid.to_string(), member.atype))
|
||||||
|
.collect();
|
||||||
|
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
|
||||||
|
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
|
||||||
|
org["type"] = json!(atype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
users_json.push(usr);
|
users_json.push(usr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,13 +434,21 @@ 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) -> EmptyResult {
|
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> 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 before deleting the actual user
|
// Get the membership records and push devices 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, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserDeleted,
|
EventType::OrganizationUserDeleted,
|
||||||
@@ -554,10 +580,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
err!("The specified user isn't member of the organization")
|
err!("The specified user isn't member of the organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
||||||
new_type as i32
|
// Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
|
||||||
} else {
|
// are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
|
||||||
err!("Invalid type")
|
Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
|
||||||
|
MembershipType::Manager
|
||||||
|
}
|
||||||
|
Some(new_type) => new_type,
|
||||||
|
None => err!("Invalid type"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||||
@@ -567,7 +597,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
member_to_edit.atype = new_type;
|
// A Manager only becomes a Custom member if that changes nothing they may do
|
||||||
|
if new_type == MembershipType::Custom
|
||||||
|
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await
|
||||||
|
{
|
||||||
|
err!(loss)
|
||||||
|
}
|
||||||
|
|
||||||
|
member_to_edit.set_type(new_type);
|
||||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||||
|
|
||||||
@@ -779,6 +816,8 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
.into_keys()
|
.into_keys()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
|
||||||
|
|
||||||
let diagnostics_json = json!({
|
let diagnostics_json = json!({
|
||||||
"dns_resolved": dns_resolved,
|
"dns_resolved": dns_resolved,
|
||||||
"current_release": VERSION,
|
"current_release": VERSION,
|
||||||
@@ -801,7 +840,9 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"config_file_status": &CONFIG.get_config_file_status(),
|
||||||
"template_overrides": check_template_overrides().join(", "),
|
"template_overrides": check_template_overrides().join(", "),
|
||||||
|
"invalid_env_vars": invalid_env_vars,
|
||||||
"invalid_feature_flags": invalid_feature_flags,
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
|
|||||||
+1037
-198
File diff suppressed because it is too large.
Load diff
+1013
-379
File diff suppressed because it is too large.
Load diff
+205
-111
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::Utc;
|
||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -6,14 +6,17 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult,
|
EmptyResult, JsonResult,
|
||||||
core::{CipherSyncData, CipherSyncType},
|
core::{
|
||||||
|
CipherSyncData, CipherSyncType,
|
||||||
|
accounts::{AuthenticationData, UnlockData},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auth::{Headers, decode_emergency_access_invite},
|
auth::{Headers, decode_emergency_access_invite},
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
||||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -133,19 +136,21 @@ async fn post_emergency_access(
|
|||||||
|
|
||||||
let data: EmergencyAccessUpdateData = data.into_inner();
|
let data: EmergencyAccessUpdateData = data.into_inner();
|
||||||
|
|
||||||
|
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
|
||||||
|
err!("Emergency access wait time is out-of-bounds!")
|
||||||
|
}
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
emergency_access.atype = new_type as i32;
|
||||||
new_type as i32
|
|
||||||
} else {
|
|
||||||
err!("Invalid emergency access type.")
|
|
||||||
};
|
|
||||||
|
|
||||||
emergency_access.atype = new_type;
|
|
||||||
emergency_access.wait_time_days = data.wait_time_days;
|
emergency_access.wait_time_days = data.wait_time_days;
|
||||||
if data.key_encrypted.is_some() {
|
if data.key_encrypted.is_some() {
|
||||||
emergency_access.key_encrypted = data.key_encrypted;
|
emergency_access.key_encrypted = data.key_encrypted;
|
||||||
@@ -207,11 +212,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
let email = data.email.to_lowercase();
|
let email = data.email.to_lowercase();
|
||||||
let wait_time_days = data.wait_time_days;
|
let wait_time_days = data.wait_time_days;
|
||||||
|
|
||||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
|
||||||
|
err!("Emergency access wait time is out-of-bounds!")
|
||||||
|
}
|
||||||
|
|
||||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
||||||
new_type as i32
|
|
||||||
} else {
|
|
||||||
err!("Invalid emergency access type.")
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,6 +262,8 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let new_type = new_type as i32;
|
||||||
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
let mut new_emergency_access =
|
let mut new_emergency_access =
|
||||||
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
||||||
new_emergency_access.save(&conn).await?;
|
new_emergency_access.save(&conn).await?;
|
||||||
@@ -289,7 +296,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
|
|||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
||||||
err!("The grantee user is already accepted or confirmed to the organization");
|
err!("The grantee user is already accepted or confirmed for emergency access");
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(email) = emergency_access.email.clone() else {
|
let Some(email) = emergency_access.email.clone() else {
|
||||||
@@ -450,11 +457,24 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
|
||||||
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
@@ -487,11 +507,17 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
||||||
@@ -615,6 +641,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
"kdfMemory": grantor_user.client_kdf_memory,
|
"kdfMemory": grantor_user.client_kdf_memory,
|
||||||
"kdfParallelism": grantor_user.client_kdf_parallelism,
|
"kdfParallelism": grantor_user.client_kdf_parallelism,
|
||||||
"keyEncrypted": &emergency_access.key_encrypted,
|
"keyEncrypted": &emergency_access.key_encrypted,
|
||||||
|
"salt": grantor_user.master_password_salt(),
|
||||||
"object": "emergencyAccessTakeover",
|
"object": "emergencyAccessTakeover",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -624,8 +651,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EmergencyAccessPasswordData {
|
struct EmergencyAccessPasswordData {
|
||||||
new_master_password_hash: String,
|
// Legacy payload
|
||||||
key: String,
|
new_master_password_hash: Option<String>,
|
||||||
|
key: Option<String>,
|
||||||
|
|
||||||
|
// Current payload
|
||||||
|
authentication_data: Option<AuthenticationData>,
|
||||||
|
unlock_data: Option<UnlockData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
||||||
@@ -638,8 +670,6 @@ async fn password_emergency_access(
|
|||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let data: EmergencyAccessPasswordData = data.into_inner();
|
let data: EmergencyAccessPasswordData = data.into_inner();
|
||||||
let new_master_password_hash = &data.new_master_password_hash;
|
|
||||||
//let key = &data.Key;
|
|
||||||
|
|
||||||
let requesting_user = headers.user;
|
let requesting_user = headers.user;
|
||||||
let Some(emergency_access) =
|
let Some(emergency_access) =
|
||||||
@@ -656,8 +686,23 @@ async fn password_emergency_access(
|
|||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (new_master_password_hash, new_key) =
|
||||||
|
if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) {
|
||||||
|
authentication_data.check(&grantor_user, &unlock_data)?;
|
||||||
|
|
||||||
|
if !authentication_data.kdf.matches_user(&grantor_user) {
|
||||||
|
err!("KDF settings do not match the grantor account")
|
||||||
|
}
|
||||||
|
|
||||||
|
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
|
||||||
|
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
|
||||||
|
(new_master_password_hash, new_key)
|
||||||
|
} else {
|
||||||
|
err!("Invalid request!")
|
||||||
|
};
|
||||||
|
|
||||||
// change grantor_user password
|
// change grantor_user password
|
||||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
|
||||||
grantor_user.save(&conn).await?;
|
grantor_user.save(&conn).await?;
|
||||||
|
|
||||||
// Disable TwoFactor providers since they will otherwise block logins
|
// Disable TwoFactor providers since they will otherwise block logins
|
||||||
@@ -725,52 +770,71 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(conn) = pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
error!("Failed to get DB connection for emergency time-outs!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
debug!("No emergency request timeout to approve");
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
if emergency_access_list.is_empty() {
|
||||||
for mut emer in emergency_access_list {
|
debug!("No emergency request timeout to approve");
|
||||||
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
return;
|
||||||
let recovery_allowed_at =
|
}
|
||||||
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
|
|
||||||
if recovery_allowed_at.le(&now) {
|
|
||||||
// Only update the access status
|
|
||||||
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
|
||||||
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
|
|
||||||
.await
|
|
||||||
.expect("Unable to update emergency access status");
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
let now = Utc::now().naive_utc();
|
||||||
// get grantor user to send Accepted email
|
for mut emer in emergency_access_list {
|
||||||
let grantor_user =
|
match emer.is_recovery_allowed(&now) {
|
||||||
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
Ok(true) => {} // Recovery is allowed
|
||||||
|
Ok(false) => continue,
|
||||||
// get grantee user to send Accepted email
|
Err(e) => {
|
||||||
let grantee_user =
|
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
|
||||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
continue;
|
||||||
.await
|
}
|
||||||
.expect("Grantee user not found");
|
}
|
||||||
|
|
||||||
mail::send_emergency_access_recovery_timed_out(
|
// Get grantor user to send Accepted email
|
||||||
&grantor_user.email,
|
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
||||||
&grantee_user.name,
|
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
||||||
emer.get_type_as_str(),
|
continue;
|
||||||
)
|
};
|
||||||
.await
|
|
||||||
.expect("Error on sending email");
|
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
||||||
|
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
||||||
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)
|
continue;
|
||||||
.await
|
};
|
||||||
.expect("Error on sending email");
|
|
||||||
}
|
// Get grantee user to send Accepted email
|
||||||
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
|
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only update the access status
|
||||||
|
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
||||||
|
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
|
||||||
|
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||||
|
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
|
||||||
|
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if CONFIG.mail_enabled() {
|
||||||
|
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
|
||||||
|
&grantor_user.email,
|
||||||
|
&grantee_user.name,
|
||||||
|
emer.get_type_as_str(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
|
||||||
|
{
|
||||||
|
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
error!("Failed to get DB connection while searching emergency request timed out");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,55 +844,85 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(conn) = pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
error!("Failed to get DB connection for emergency reminders!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
debug!("No emergency request reminder notification to send");
|
|
||||||
|
if emergency_access_list.is_empty() {
|
||||||
|
debug!("No emergency request reminder notification to send");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
for mut emer in emergency_access_list {
|
||||||
|
let Ok(deadline_at) = emer.deadline_at() else {
|
||||||
|
error!("Invalid deadline for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
|
||||||
|
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only send reminders during the last 24 hours, else continue
|
||||||
|
if now < final_reminder_at || now >= deadline_at {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
|
||||||
for mut emer in emergency_access_list {
|
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
|
||||||
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
// This is to prevent possible missed reminders, this might cause an extra reminder,
|
||||||
// Calculate the day before the recovery will become active
|
// but that is better then no reminder at all.
|
||||||
let final_recovery_reminder_at =
|
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
||||||
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
|
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
|
||||||
// Calculate if a day has passed since the previous notification, else no notification has been sent before
|
} else {
|
||||||
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
now
|
||||||
last_notification_at + TimeDelta::try_days(1).unwrap()
|
};
|
||||||
} else {
|
|
||||||
now
|
|
||||||
};
|
|
||||||
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
|
||||||
// Only update the last notification date
|
|
||||||
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
|
||||||
emer.update_last_notification_date_and_save(&now, &conn)
|
|
||||||
.await
|
|
||||||
.expect("Unable to update emergency access notification date");
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
|
||||||
// get grantor user to send Accepted email
|
// Else we can skip the current emergency access this run.
|
||||||
let grantor_user =
|
if next_reminder_at.gt(&now) {
|
||||||
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
continue;
|
||||||
|
}
|
||||||
// get grantee user to send Accepted email
|
|
||||||
let grantee_user =
|
// Get grantor user to send Reminder email
|
||||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
||||||
.await
|
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
||||||
.expect("Grantee user not found");
|
continue;
|
||||||
|
};
|
||||||
mail::send_emergency_access_recovery_reminder(
|
|
||||||
&grantor_user.email,
|
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
||||||
&grantee_user.name,
|
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
||||||
emer.get_type_as_str(),
|
continue;
|
||||||
"1", // This notification is only triggered one day before the activation
|
};
|
||||||
)
|
|
||||||
.await
|
// Get grantee user to send Reminder email
|
||||||
.expect("Error on sending email");
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
}
|
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
||||||
}
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only update the last notification date
|
||||||
|
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
||||||
|
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
|
||||||
|
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if CONFIG.mail_enabled()
|
||||||
|
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
|
||||||
|
&grantor_user.email,
|
||||||
|
&grantee_user.name,
|
||||||
|
emer.get_type_as_str(),
|
||||||
|
"1", // This notification is only triggered one day before the activation
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
error!("Failed to get DB connection while searching emergency notification reminder");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-11
@@ -10,7 +10,9 @@ use crate::{
|
|||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
models::{
|
||||||
|
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
@@ -214,13 +216,13 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
// Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
|
||||||
1107..=1114 | 1117 | 1119..=1132 => {
|
1107..=1114 | 1117 | 1119..=1133 => {
|
||||||
// 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
|
||||||
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
&& cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await
|
||||||
&& let Some(org_id) = cipher.organization_uuid
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
{
|
{
|
||||||
log_event_impl(
|
log_event_impl(
|
||||||
@@ -242,11 +244,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
|
log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_user_event_impl(
|
async fn log_user_event_impl(
|
||||||
@@ -347,14 +349,17 @@ async fn log_event_impl(
|
|||||||
|
|
||||||
pub async fn event_cleanup_job(pool: DbPool) {
|
pub async fn event_cleanup_job(pool: DbPool) {
|
||||||
debug!("Start events cleanup job");
|
debug!("Start events cleanup job");
|
||||||
if CONFIG.events_days_retain().is_none() {
|
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
|
||||||
debug!("events_days_retain is not configured, abort");
|
debug!("events_days_retain is not configured or set to 0, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(conn) = pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
Event::clean_events(&conn).await.ok();
|
|
||||||
} else {
|
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = Event::clean_events(&conn).await {
|
||||||
|
error!("Failed to cleanup the events table: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +247,7 @@ 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": {
|
||||||
|
|||||||
+276
-68
@@ -8,17 +8,18 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::admin::FAKE_ADMIN_UUID,
|
api::admin::FAKE_ADMIN_UUID,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
||||||
},
|
},
|
||||||
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
||||||
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
|
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
||||||
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
|
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
||||||
TwoFactorType, User, UserId,
|
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
||||||
|
custom_roles_enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -26,6 +27,8 @@ use crate::{
|
|||||||
util::{NumberOrString, convert_json_key_lcase_first},
|
util::{NumberOrString, convert_json_key_lcase_first},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::accounts::{AuthenticationData, UnlockData};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![
|
routes![
|
||||||
get_organization,
|
get_organization,
|
||||||
@@ -45,6 +48,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
bulk_delete_organization_collections,
|
bulk_delete_organization_collections,
|
||||||
post_bulk_collections,
|
post_bulk_collections,
|
||||||
get_org_details,
|
get_org_details,
|
||||||
|
get_assigned_org_details,
|
||||||
get_org_domain_sso_verified,
|
get_org_domain_sso_verified,
|
||||||
get_members,
|
get_members,
|
||||||
send_invite,
|
send_invite,
|
||||||
@@ -129,13 +133,16 @@ 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, org_id))
|
err!("Invalid group", format!("Group {} 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.id)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
||||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
|
err!(
|
||||||
|
"Invalid member",
|
||||||
|
format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -550,7 +557,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, org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
for col_id in data.collection_ids {
|
for col_id in data.collection_ids {
|
||||||
@@ -836,7 +843,7 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db
|
|||||||
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !headers.membership.has_full_access() {
|
if !headers.membership.can_edit_any_collection() {
|
||||||
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,7 +861,12 @@ async fn get_org_details_impl(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
||||||
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
||||||
|
// Every cipher of the organization is listed, so list every collection of them too
|
||||||
|
cipher_sync_data.cipher_collections = HashMap::new();
|
||||||
|
for (cipher_id, collection_id) in Cipher::get_collections_with_cipher_by_organization(org_id, conn).await {
|
||||||
|
cipher_sync_data.cipher_collections.entry(cipher_id).or_default().push(collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
||||||
for c in ciphers {
|
for c in ciphers {
|
||||||
@@ -863,6 +875,39 @@ async fn get_org_details_impl(
|
|||||||
Ok(json!(ciphers_json))
|
Ok(json!(ciphers_json))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Admin Console lists these instead of every cipher of the organization for a member who may not edit every
|
||||||
|
// cipher, like a Custom member with Delete any collection only. As in Bitwarden, any confirmed member may list them.
|
||||||
|
#[get("/ciphers/organization-details/assigned?<data..>")]
|
||||||
|
async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
|
let Some(member) =
|
||||||
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await
|
||||||
|
else {
|
||||||
|
err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (assigned, unassigned) = Cipher::find_assigned_to_member(&member, &conn).await;
|
||||||
|
let mut ciphers_json = Vec::with_capacity(assigned.len() + unassigned.len());
|
||||||
|
// The ciphers of the member's own vault, as they are there
|
||||||
|
let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await;
|
||||||
|
for cipher in assigned {
|
||||||
|
ciphers_json.push(
|
||||||
|
cipher
|
||||||
|
.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The ciphers in no collection, as the member may use them in the Admin Console
|
||||||
|
for cipher in unassigned {
|
||||||
|
ciphers_json.push(cipher.to_json_org_admin(&headers.host, &headers.user.uuid, &conn).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"data": ciphers_json,
|
||||||
|
"object": "list",
|
||||||
|
"continuationToken": null,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||||
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
||||||
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
||||||
@@ -973,13 +1018,16 @@ 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!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
err!(
|
||||||
|
"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, org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1000,14 +1048,12 @@ async fn send_invite(
|
|||||||
data.validate(&org_id, &conn).await?;
|
data.validate(&org_id, &conn).await?;
|
||||||
|
|
||||||
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
||||||
// The from_str() will convert the custom role type into a manager role type
|
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
// Membership::from_str will convert custom (4) to manager (3)
|
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
||||||
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
|
|
||||||
new_type as i32
|
|
||||||
} else {
|
|
||||||
err!("Invalid type")
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
|
||||||
|
|
||||||
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
||||||
err!("Only Owners can invite Managers, Admins or Owners")
|
err!("Only Owners can invite Managers, Admins or Owners")
|
||||||
@@ -1017,11 +1063,19 @@ async fn send_invite(
|
|||||||
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
||||||
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
||||||
let access_all = new_type >= MembershipType::Admin
|
let access_all = new_type >= MembershipType::Admin
|
||||||
|| (raw_type.eq("4")
|
|| (new_type == MembershipType::Manager
|
||||||
|
&& raw_type.eq("4")
|
||||||
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
||||||
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
||||||
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
||||||
|
|
||||||
|
// Checked before anything is created, so a rejected permission leaves nothing behind
|
||||||
|
let custom_permissions = if new_type == MembershipType::Custom {
|
||||||
|
Some(CustomPermission::requested(&data.permissions)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let mut user_created: bool;
|
let mut user_created: bool;
|
||||||
for email in &data.emails {
|
for email in &data.emails {
|
||||||
let mut member_status = MembershipStatus::Invited as i32;
|
let mut member_status = MembershipStatus::Invited as i32;
|
||||||
@@ -1063,8 +1117,11 @@ async fn send_invite(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||||
|
new_member.set_type(new_type);
|
||||||
new_member.access_all = access_all;
|
new_member.access_all = access_all;
|
||||||
new_member.atype = new_type;
|
if let Some(permissions) = &custom_permissions {
|
||||||
|
new_member.set_custom_permissions(permissions);
|
||||||
|
}
|
||||||
new_member.status = member_status;
|
new_member.status = member_status;
|
||||||
new_member.save(&conn).await?;
|
new_member.save(&conn).await?;
|
||||||
|
|
||||||
@@ -1154,7 +1211,10 @@ 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) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1327,7 +1387,10 @@ 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) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1494,24 +1557,32 @@ async fn edit_member(
|
|||||||
let data: EditUserData = data.into_inner();
|
let data: EditUserData = data.into_inner();
|
||||||
|
|
||||||
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
||||||
// The from_str() will convert the custom role type into a manager role type
|
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
// MembershipType::from_str will convert custom (4) to manager (3)
|
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
||||||
let Some(new_type) = MembershipType::from_str(raw_type) else {
|
|
||||||
err!("Invalid type")
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
|
||||||
|
err!("The specified user isn't member of the organization")
|
||||||
|
};
|
||||||
|
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled());
|
||||||
|
|
||||||
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
|
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
|
||||||
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
||||||
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
||||||
let access_all = new_type >= MembershipType::Admin
|
let access_all = new_type >= MembershipType::Admin
|
||||||
|| (raw_type.eq("4")
|
|| (new_type == MembershipType::Manager
|
||||||
|
&& raw_type.eq("4")
|
||||||
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
||||||
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
||||||
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
||||||
|
|
||||||
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
|
// While Custom roles are disabled, a Custom member keeps their stored permissions untouched
|
||||||
err!("The specified user isn't member of the organization")
|
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
|
||||||
|
Some(CustomPermission::requested(&data.permissions)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_type != member_to_edit.atype
|
if new_type != member_to_edit.atype
|
||||||
@@ -1535,8 +1606,11 @@ async fn edit_member(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member_to_edit.set_type(new_type);
|
||||||
member_to_edit.access_all = access_all;
|
member_to_edit.access_all = access_all;
|
||||||
member_to_edit.atype = new_type as i32;
|
if let Some(permissions) = &custom_permissions {
|
||||||
|
member_to_edit.set_custom_permissions(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||||
// We need to perform the check after changing the type since `admin` is exempt.
|
// We need to perform the check after changing the type since `admin` is exempt.
|
||||||
@@ -1608,7 +1682,10 @@ 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) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to delete member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1829,7 +1906,7 @@ async fn post_org_import(
|
|||||||
cipher_data.folder_id = None;
|
cipher_data.folder_id = None;
|
||||||
// Replace the client-provided, unvalidated organizationId with the real target org
|
// Replace the client-provided, unvalidated organizationId with the real target org
|
||||||
cipher_data.organization_id = Some(org_id.clone());
|
cipher_data.organization_id = Some(org_id.clone());
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type);
|
||||||
update_cipher_from_data(
|
update_cipher_from_data(
|
||||||
&mut cipher,
|
&mut cipher,
|
||||||
cipher_data,
|
cipher_data,
|
||||||
@@ -1838,6 +1915,7 @@ async fn post_org_import(
|
|||||||
&conn,
|
&conn,
|
||||||
&nt,
|
&nt,
|
||||||
UpdateType::None,
|
UpdateType::None,
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
@@ -1871,29 +1949,37 @@ struct BulkCollectionsData {
|
|||||||
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: BulkCollectionsData = data.into_inner();
|
let data: BulkCollectionsData = data.into_inner();
|
||||||
|
|
||||||
if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() {
|
let Some(member) =
|
||||||
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await
|
||||||
|
else {
|
||||||
err!("You need to be a Member of the Organization to call this endpoint")
|
err!("You need to be a Member of the Organization to call this endpoint")
|
||||||
}
|
};
|
||||||
|
// A member who may edit any collection may use every collection of the organization
|
||||||
|
let can_edit_any_collection = member.can_edit_any_collection();
|
||||||
|
|
||||||
// Get all the collection available to the user in one query
|
// Get all the collection available to the user in one query
|
||||||
// Also filter based upon the provided collections
|
// Also filter based upon the provided collections
|
||||||
let user_collections: HashMap<CollectionId, Collection> =
|
let collections = if can_edit_any_collection {
|
||||||
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn)
|
Collection::find_by_organization(&data.organization_id, &conn).await
|
||||||
.await
|
} else {
|
||||||
.into_iter()
|
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await
|
||||||
.filter_map(|c| {
|
};
|
||||||
if data.collection_ids.contains(&c.uuid) {
|
let user_collections: HashMap<CollectionId, Collection> = collections
|
||||||
Some((c.uuid.clone(), c))
|
.into_iter()
|
||||||
} else {
|
.filter_map(|c| {
|
||||||
None
|
if data.collection_ids.contains(&c.uuid) {
|
||||||
}
|
Some((c.uuid.clone(), c))
|
||||||
})
|
} else {
|
||||||
.collect();
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Verify if all the collections requested exists and are writable for the user, else abort
|
// Verify if all the collections requested exists and are writable for the user, else abort
|
||||||
for collection_uuid in &data.collection_ids {
|
for collection_uuid in &data.collection_ids {
|
||||||
match user_collections.get(collection_uuid) {
|
match user_collections.get(collection_uuid) {
|
||||||
Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (),
|
Some(collection)
|
||||||
|
if can_edit_any_collection || collection.is_writable_by_user(&headers.user.uuid, &conn).await => {}
|
||||||
_ => err_code!("Resource not found", "User does not have access to a collection", 404),
|
_ => err_code!("Resource not found", "User does not have access to a collection", 404),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1902,7 +1988,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
|
|||||||
// Only act on existing cipher uuid's
|
// Only act on existing cipher uuid's
|
||||||
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
||||||
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
||||||
&& cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await
|
||||||
{
|
{
|
||||||
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
||||||
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
||||||
@@ -2037,6 +2123,29 @@ 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.
|
||||||
@@ -2069,6 +2178,37 @@ 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(
|
||||||
@@ -2100,7 +2240,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRemoved,
|
EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2116,7 +2256,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
let mut policy = match current_policy {
|
||||||
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()),
|
||||||
};
|
};
|
||||||
@@ -2220,7 +2360,10 @@ 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) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2323,7 +2466,10 @@ 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) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to restore member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2476,13 +2622,16 @@ 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!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
err!(
|
||||||
|
"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, org_id))
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2751,9 +2900,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct OrganizationUserRecoverAccountRequest {
|
struct OrganizationUserRecoverAccountRequest {
|
||||||
|
// Legacy payload
|
||||||
new_master_password_hash: Option<String>,
|
new_master_password_hash: Option<String>,
|
||||||
key: Option<String>,
|
key: Option<String>,
|
||||||
|
|
||||||
|
// Current payload
|
||||||
|
authentication_data: Option<AuthenticationData>,
|
||||||
|
unlock_data: Option<UnlockData>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
reset_master_password: bool,
|
reset_master_password: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -2764,8 +2918,26 @@ struct OrganizationUserRecoverAccountRequest {
|
|||||||
// But the clients do not seem to use this at all
|
// But the clients do not seem to use this at all
|
||||||
// Just add it here in case they will
|
// Just add it here in case they will
|
||||||
#[get("/organizations/<org_id>/public-key")]
|
#[get("/organizations/<org_id>/public-key")]
|
||||||
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
async fn get_organization_public_key(
|
||||||
if org_id != headers.membership.org_uuid {
|
org_id: OrganizationId,
|
||||||
|
headers: Headers,
|
||||||
|
member: Result<OrgMemberHeaders, &'static str>,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
|
||||||
|
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"object": "organizationPublicKey",
|
||||||
|
"publicKey": fake_sso_org_public_key().await?,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let member = match member {
|
||||||
|
Ok(member) => member,
|
||||||
|
// The guard already logged it
|
||||||
|
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if org_id != member.membership.org_uuid {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||||
@@ -2778,11 +2950,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org,
|
||||||
|
/// so it is made once and its private half dropped.
|
||||||
|
async fn fake_sso_org_public_key() -> ApiResult<String> {
|
||||||
|
static PUBLIC_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
|
||||||
|
let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?;
|
||||||
|
Some(data_encoding::BASE64.encode(&der))
|
||||||
|
});
|
||||||
|
|
||||||
|
let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else {
|
||||||
|
err!("Failed to generate the organization key")
|
||||||
|
};
|
||||||
|
Ok(public_key)
|
||||||
|
}
|
||||||
|
|
||||||
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
|
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
||||||
#[get("/organizations/<org_id>/keys")]
|
#[get("/organizations/<org_id>/keys")]
|
||||||
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
async fn get_organization_keys(
|
||||||
get_organization_public_key(org_id, headers, conn).await
|
org_id: OrganizationId,
|
||||||
|
headers: Headers,
|
||||||
|
member: Result<OrgMemberHeaders, &'static str>,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
get_organization_public_key(org_id, headers, member, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will allow to reset 2FA too
|
// Will allow to reset 2FA too
|
||||||
@@ -2831,12 +3022,34 @@ 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 =
|
let fallback_2fa_email = req.reset_two_factor
|
||||||
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
|
&& CONFIG.mail_enabled()
|
||||||
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
|
&& CONFIG.email_2fa_auto_fallback()
|
||||||
|
&& 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 {
|
||||||
false
|
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
|
||||||
};
|
};
|
||||||
|
Some((new_master_password_hash, new_key))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Sending email first ensure working email configuration and the resulting user notification.
|
// Sending email first ensure working email configuration and the resulting user notification.
|
||||||
// Also this might add some protection against security flaws and misuse
|
// Also this might add some protection against security flaws and misuse
|
||||||
@@ -2853,14 +3066,8 @@ async fn recover_account(
|
|||||||
err!(format!("Error sending user reset password email: {e:#?}"));
|
err!(format!("Error sending user reset password email: {e:#?}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.reset_master_password {
|
if let Some((new_master_password_hash, new_key)) = new_password {
|
||||||
if let Some(key) = req.key
|
user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
|
||||||
&& let Some(hash) = req.new_master_password_hash
|
|
||||||
{
|
|
||||||
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
|
|
||||||
} else {
|
|
||||||
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.reset_two_factor {
|
if req.reset_two_factor {
|
||||||
@@ -2919,6 +3126,7 @@ async fn get_reset_password_details(
|
|||||||
"kdfIterations": user.client_kdf_iter,
|
"kdfIterations": user.client_kdf_iter,
|
||||||
"kdfMemory": user.client_kdf_memory,
|
"kdfMemory": user.client_kdf_memory,
|
||||||
"kdfParallelism": user.client_kdf_parallelism,
|
"kdfParallelism": user.client_kdf_parallelism,
|
||||||
|
"masterPasswordSalt": user.master_password_salt(),
|
||||||
"resetPasswordKey": member.reset_password_key,
|
"resetPasswordKey": member.reset_password_key,
|
||||||
"encryptedPrivateKey": org.private_key,
|
"encryptedPrivateKey": org.private_key,
|
||||||
})))
|
})))
|
||||||
|
|||||||
@@ -137,9 +137,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
|
|
||||||
if CONFIG.org_groups_enabled() {
|
if CONFIG.org_groups_enabled() {
|
||||||
for group_data in &data.groups {
|
for group_data in &data.groups {
|
||||||
let group_uuid = if let Some(group) =
|
let group_uuid = if let Some(mut group) =
|
||||||
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
||||||
{
|
{
|
||||||
|
if group.name != group_data.name {
|
||||||
|
group.name = group_data.name.clone();
|
||||||
|
group.save(&conn).await?;
|
||||||
|
}
|
||||||
group.uuid
|
group.uuid
|
||||||
} else {
|
} else {
|
||||||
let mut group =
|
let mut group =
|
||||||
|
|||||||
@@ -268,11 +268,12 @@ 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)?;
|
||||||
if let Some(o) = data_value.as_object_mut() {
|
let Some(o) = data_value.as_object_mut() else {
|
||||||
o.insert(String::from("id"), Value::String(file_id.clone()));
|
err!("Invalid Send file data")
|
||||||
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("id"), Value::String(file_id.clone()));
|
||||||
}
|
o.insert(String::from("size"), Value::Number(file_length.into()));
|
||||||
|
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},
|
auth::{ClientIp, Headers, two_factor},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
@@ -20,27 +20,23 @@ pub fn routes() -> Vec<Route> {
|
|||||||
|
|
||||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||||
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let type_ = TwoFactorType::Authenticator as i32;
|
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
|
||||||
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
|
|
||||||
|
|
||||||
let (enabled, key) = match twofactor {
|
let (enabled, key) = match twofactor {
|
||||||
Some(tf) => (true, tf.data),
|
Some(tf) => (true, tf.data),
|
||||||
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
|
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
|
|
||||||
// It should help prevent TOTP disclosure if someone keeps their vault unlocked.
|
|
||||||
// Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response.
|
|
||||||
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": enabled,
|
"authenticator": json!({
|
||||||
"key": key,
|
"enabled": enabled,
|
||||||
"object": "twoFactorAuthenticator"
|
"key": key,
|
||||||
|
}),
|
||||||
|
"userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +45,7 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
|
|||||||
struct EnableAuthenticatorData {
|
struct EnableAuthenticatorData {
|
||||||
key: String,
|
key: String,
|
||||||
token: NumberOrString,
|
token: NumberOrString,
|
||||||
master_password_hash: Option<String>,
|
user_verification_token: String,
|
||||||
otp: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/authenticator", data = "<data>")]
|
#[post("/two-factor/authenticator", data = "<data>")]
|
||||||
@@ -61,12 +56,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
PasswordOrOtpData {
|
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
|
||||||
master_password_hash: data.master_password_hash,
|
|
||||||
otp: data.otp,
|
|
||||||
}
|
|
||||||
.validate(&user, true, &conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Validate key as base32 and 20 bytes length
|
// Validate key as base32 and 20 bytes length
|
||||||
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
||||||
@@ -84,12 +74,13 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
|
|
||||||
generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": true,
|
"authenticator": json!({
|
||||||
"key": key,
|
"enabled": true,
|
||||||
"object": "twoFactorAuthenticator"
|
"key": key,
|
||||||
|
}),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +116,7 @@ pub async fn validate_totp_code(
|
|||||||
err!("Invalid TOTP secret")
|
err!("Invalid TOTP secret")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await {
|
||||||
{
|
|
||||||
Some(tf) => tf,
|
Some(tf) => tf,
|
||||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
||||||
};
|
};
|
||||||
@@ -184,37 +174,25 @@ pub async fn validate_totp_code(
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DisableAuthenticatorData {
|
struct DisableAuthenticatorData {
|
||||||
key: String,
|
key: String,
|
||||||
master_password_hash: String,
|
user_verification_token: String,
|
||||||
r#type: NumberOrString,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/two-factor/authenticator", data = "<data>")]
|
#[delete("/two-factor/authenticator", data = "<data>")]
|
||||||
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
let type_ = data.r#type.into_i32()?;
|
|
||||||
|
|
||||||
if !user.check_valid_password(&data.master_password_hash) {
|
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
|
||||||
err!("Invalid password");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await {
|
||||||
if twofactor.data == data.key {
|
if twofactor.data == data.key {
|
||||||
twofactor.delete(&conn).await?;
|
twofactor.delete(&conn).await?;
|
||||||
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
|
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
|
||||||
.await;
|
|
||||||
} else {
|
} else {
|
||||||
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
|
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(())
|
||||||
"enabled": false,
|
|
||||||
"keys": type_,
|
|
||||||
"object": "twoFactorProvider"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,12 @@ use chrono::Utc;
|
|||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
|
||||||
api::{
|
api::{
|
||||||
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
|
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::two_factor::generate_recover_code,
|
core::log_user_event,
|
||||||
|
core::two_factor::{VerificationTokenData, generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::{Headers, two_factor, two_factor::DuoData},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
@@ -18,55 +18,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![get_duo, activate_duo, activate_duo_put,]
|
routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct DuoData {
|
|
||||||
host: String, // Duo API hostname
|
|
||||||
ik: String, // client id
|
|
||||||
sk: String, // client secret
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DuoData {
|
|
||||||
fn global() -> Option<Self> {
|
|
||||||
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
|
|
||||||
(true, Some(host)) => Some(Self {
|
|
||||||
host,
|
|
||||||
ik: CONFIG.duo_ikey().unwrap(),
|
|
||||||
sk: CONFIG.duo_skey().unwrap(),
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn msg(s: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
host: s.into(),
|
|
||||||
ik: s.into(),
|
|
||||||
sk: s.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn secret() -> Self {
|
|
||||||
Self::msg("<global_secret>")
|
|
||||||
}
|
|
||||||
fn obscure(self) -> Self {
|
|
||||||
let mut host = self.host;
|
|
||||||
let mut ik = self.ik;
|
|
||||||
let mut sk = self.sk;
|
|
||||||
|
|
||||||
let digits = 4;
|
|
||||||
let replaced = "************";
|
|
||||||
|
|
||||||
host.replace_range(digits.., replaced);
|
|
||||||
ik.replace_range(digits.., replaced);
|
|
||||||
sk.replace_range(digits.., replaced);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
host,
|
|
||||||
ik,
|
|
||||||
sk,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DuoStatus {
|
enum DuoStatus {
|
||||||
@@ -95,22 +47,19 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
|
|||||||
|
|
||||||
data.validate(&user, false, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let data = get_user_duo_data(&user.uuid, &conn).await;
|
let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
|
||||||
|
|
||||||
let (enabled, data) = match data {
|
|
||||||
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
|
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
|
||||||
DuoStatus::User(data) => (true, Some(data.obscure())),
|
DuoStatus::User(data) => (true, Some(data.obscure())),
|
||||||
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
|
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
|
||||||
DuoStatus::Disabled(false) => (false, None),
|
DuoStatus::Disabled(false) => (false, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = if let Some(data) = data {
|
let duo_json = if let Some(data) = duo.as_ref() {
|
||||||
json!({
|
json!({
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"host": data.host,
|
"host": data.host,
|
||||||
"clientSecret": data.sk,
|
"clientSecret": data.sk,
|
||||||
"clientId": data.ik,
|
"clientId": data.ik,
|
||||||
"object": "twoFactorDuo"
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
json!({
|
json!({
|
||||||
@@ -118,11 +67,13 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
|
|||||||
"host": null,
|
"host": null,
|
||||||
"clientSecret": null,
|
"clientSecret": null,
|
||||||
"clientId": null,
|
"clientId": null,
|
||||||
"object": "twoFactorDuo"
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json))
|
Ok(Json(rocket::serde::json::json!({
|
||||||
|
"duo": duo_json,
|
||||||
|
"userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled),
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -131,8 +82,7 @@ struct EnableDuoData {
|
|||||||
host: String,
|
host: String,
|
||||||
client_secret: String,
|
client_secret: String,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
master_password_hash: Option<String>,
|
user_verification_token: String,
|
||||||
otp: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<EnableDuoData> for DuoData {
|
impl From<EnableDuoData> for DuoData {
|
||||||
@@ -159,12 +109,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
|||||||
let data: EnableDuoData = data.into_inner();
|
let data: EnableDuoData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
PasswordOrOtpData {
|
two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
|
||||||
master_password_hash: data.master_password_hash.clone(),
|
|
||||||
otp: data.otp.clone(),
|
|
||||||
}
|
|
||||||
.validate(&user, true, &conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (data, data_str) = if check_duo_fields_custom(&data) {
|
let (data, data_str) = if check_duo_fields_custom(&data) {
|
||||||
let data_req: DuoData = data.into();
|
let data_req: DuoData = data.into();
|
||||||
@@ -181,14 +126,15 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
|||||||
|
|
||||||
generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": true,
|
"duo": json!({
|
||||||
"host": data.host,
|
"enabled": true,
|
||||||
"clientSecret": data.sk,
|
"host": data.host,
|
||||||
"clientId": data.ik,
|
"clientSecret": data.sk,
|
||||||
"object": "twoFactorDuo"
|
"clientId": data.ik,
|
||||||
|
}),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +143,29 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
|||||||
activate_duo(data, headers, conn).await
|
activate_duo(data, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[delete("/two-factor/duo", data = "<data>")]
|
||||||
|
async fn disable_duo(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
|
let user = headers.user;
|
||||||
|
|
||||||
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await {
|
||||||
|
// Apply the same transformation than in `get_duo` to check we are disabling the correct one
|
||||||
|
let duo = match to_user_duo_data(&twofactor) {
|
||||||
|
DuoStatus::Global(_) => Some(DuoData::secret()),
|
||||||
|
DuoStatus::User(data) => Some(data.obscure()),
|
||||||
|
DuoStatus::Disabled(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?;
|
||||||
|
|
||||||
|
twofactor.delete(&conn).await?;
|
||||||
|
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||||
use reqwest::{Method, header};
|
use reqwest::{Method, header};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -222,13 +191,15 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
|
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
|
||||||
let type_ = TwoFactorType::Duo as i32;
|
|
||||||
|
|
||||||
// If the user doesn't have an entry, disabled
|
// If the user doesn't have an entry, disabled
|
||||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else {
|
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else {
|
||||||
return DuoStatus::Disabled(DuoData::global().is_some());
|
return DuoStatus::Disabled(DuoData::global().is_some());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
to_user_duo_data(&twofactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus {
|
||||||
// If the user has the required values, we use those
|
// If the user has the required values, we use those
|
||||||
if let Ok(data) = serde_json::from_str(&twofactor.data) {
|
if let Ok(data) = serde_json::from_str(&twofactor.data) {
|
||||||
return DuoStatus::User(data);
|
return DuoStatus::User(data);
|
||||||
|
|||||||
@@ -5,20 +5,26 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::{log_user_event, two_factor::generate_recover_code},
|
core::{
|
||||||
|
log_user_event,
|
||||||
|
two_factor::{VerificationTokenData, generate_recover_code},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, Headers},
|
auth::{ClientHeaders, Headers, two_factor},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
models::{
|
||||||
|
AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
|
||||||
|
UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![get_email, send_email_login, send_email, email,]
|
routes![get_email, send_email_login, send_email, email, disable_email]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -78,19 +84,41 @@ 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")
|
||||||
|
};
|
||||||
|
|
||||||
if auth_request.device_type != client_headers.device_type
|
let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5);
|
||||||
|
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!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
err!(
|
||||||
|
"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.")
|
||||||
}
|
}
|
||||||
@@ -104,7 +132,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
||||||
err!(
|
err!(
|
||||||
"Username or password is incorrect. Try again",
|
"Username or password is incorrect. Try again",
|
||||||
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
|
format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug())
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,8 +144,8 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
|
|
||||||
/// Generate the token, save the data for later verification and send email to user
|
/// Generate the token, save the data for later verification and send email to user
|
||||||
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
let type_ = TwoFactorType::Email as i32;
|
let mut twofactor =
|
||||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
|
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
|
||||||
|
|
||||||
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
||||||
|
|
||||||
@@ -140,18 +168,19 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
|
|||||||
data.validate(&user, false, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let (enabled, mfa_email) =
|
let (enabled, mfa_email) =
|
||||||
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await {
|
if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
||||||
Some(x) => {
|
let twofactor_data = EmailTokenData::from_json(&x.data)?;
|
||||||
let twofactor_data = EmailTokenData::from_json(&x.data)?;
|
(true, Some(twofactor_data.email))
|
||||||
(true, json!(twofactor_data.email))
|
} else {
|
||||||
}
|
(false, None)
|
||||||
_ => (false, serde_json::value::Value::Null),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(rocket::serde::json::json!({
|
||||||
"email": mfa_email,
|
"email": rocket::serde::json::json!({
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"object": "twoFactorEmail"
|
"email": mfa_email,
|
||||||
|
}),
|
||||||
|
"userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,30 +189,22 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
|
|||||||
struct SendEmailData {
|
struct SendEmailData {
|
||||||
/// Email where 2FA codes will be sent to, can be different than user email account.
|
/// Email where 2FA codes will be sent to, can be different than user email account.
|
||||||
email: String,
|
email: String,
|
||||||
master_password_hash: Option<String>,
|
user_verification_token: String,
|
||||||
otp: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
|
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
|
||||||
#[post("/two-factor/send-email", data = "<data>")]
|
#[post("/two-factor/send-email", data = "<data>")]
|
||||||
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: SendEmailData = data.into_inner();
|
let data: SendEmailData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
PasswordOrOtpData {
|
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
|
||||||
master_password_hash: data.master_password_hash,
|
|
||||||
otp: data.otp,
|
|
||||||
}
|
|
||||||
.validate(&user, false, &conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !CONFIG._enable_email_2fa() {
|
if !CONFIG._enable_email_2fa() {
|
||||||
err!("Email 2FA is disabled")
|
err!("Email 2FA is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
let type_ = TwoFactorType::Email as i32;
|
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
||||||
|
|
||||||
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
|
||||||
tf.delete(&conn).await?;
|
tf.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +212,13 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
|
|||||||
let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
|
let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
|
||||||
|
|
||||||
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
||||||
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
let twofactor =
|
||||||
|
TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
||||||
twofactor.save(&conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
|
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(Json(json!({})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
@@ -204,8 +226,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
|
|||||||
struct EmailData {
|
struct EmailData {
|
||||||
email: String,
|
email: String,
|
||||||
token: String,
|
token: String,
|
||||||
master_password_hash: Option<String>,
|
user_verification_token: String,
|
||||||
otp: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify email belongs to user and can be used for 2FA email codes.
|
/// Verify email belongs to user and can be used for 2FA email codes.
|
||||||
@@ -214,17 +235,11 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
|||||||
let data: EmailData = data.into_inner();
|
let data: EmailData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
// This is the last step in the verification process, delete the otp directly afterwards
|
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
|
||||||
PasswordOrOtpData {
|
|
||||||
master_password_hash: data.master_password_hash,
|
|
||||||
otp: data.otp,
|
|
||||||
}
|
|
||||||
.validate(&user, true, &conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
|
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
|
||||||
let mut twofactor =
|
.await
|
||||||
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?;
|
.map_res("Two factor not found")?;
|
||||||
|
|
||||||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||||
|
|
||||||
@@ -243,13 +258,26 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
|||||||
|
|
||||||
generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({})))
|
||||||
"email": email_data.email,
|
}
|
||||||
"enabled": "true",
|
|
||||||
"object": "twoFactorEmail"
|
#[delete("/two-factor/email", data = "<data>")]
|
||||||
})))
|
async fn disable_email(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
|
let user = headers.user;
|
||||||
|
|
||||||
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
||||||
|
let twofactor_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||||
|
two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?;
|
||||||
|
|
||||||
|
twofactor.delete(&conn).await?;
|
||||||
|
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the email code when used as TwoFactor token mechanism
|
/// Validate the email code when used as TwoFactor token mechanism
|
||||||
@@ -261,9 +289,8 @@ pub async fn validate_email_code_str(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let mut email_data = EmailTokenData::from_json(data)?;
|
let mut email_data = EmailTokenData::from_json(data)?;
|
||||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
|
let mut twofactor =
|
||||||
.await
|
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
|
||||||
.map_res("Two factor not found")?;
|
|
||||||
let Some(issued_token) = &email_data.last_token else {
|
let Some(issued_token) = &email_data.last_token else {
|
||||||
err!(
|
err!(
|
||||||
format!("No token available! IP: {ip}"),
|
format!("No token available! IP: {ip}"),
|
||||||
@@ -275,7 +302,7 @@ pub async fn validate_email_code_str(
|
|||||||
|
|
||||||
if !crypto::ct_eq(issued_token, token) {
|
if !crypto::ct_eq(issued_token, token) {
|
||||||
email_data.add_attempt();
|
email_data.add_attempt();
|
||||||
if email_data.attempts >= CONFIG.email_attempts_limit() {
|
if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
||||||
email_data.reset_token();
|
email_data.reset_token();
|
||||||
}
|
}
|
||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
@@ -294,7 +321,7 @@ pub async fn validate_email_code_str(
|
|||||||
twofactor.save(conn).await?;
|
twofactor.save(conn).await?;
|
||||||
|
|
||||||
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
let max_time = i64::from(CONFIG.email_expiration_time());
|
||||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
|
||||||
core::{log_event, log_user_event},
|
|
||||||
},
|
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
@@ -21,7 +18,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod authenticator;
|
pub mod authenticator;
|
||||||
@@ -69,7 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings];
|
let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
|
||||||
|
|
||||||
routes.append(&mut authenticator::routes());
|
routes.append(&mut authenticator::routes());
|
||||||
routes.append(&mut duo::routes());
|
routes.append(&mut duo::routes());
|
||||||
@@ -81,6 +77,12 @@ pub fn routes() -> Vec<Route> {
|
|||||||
routes
|
routes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct VerificationTokenData {
|
||||||
|
user_verification_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/two-factor")]
|
#[get("/two-factor")]
|
||||||
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||||
@@ -120,45 +122,14 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
|
||||||
#[serde(rename_all = "camelCase")]
|
/// the providers that are left, and once every provider is gone the 2FA policy applies.
|
||||||
struct DisableTwoFactorData {
|
pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
|
||||||
master_password_hash: Option<String>,
|
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
|
||||||
otp: Option<String>,
|
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
|
||||||
r#type: NumberOrString,
|
enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/two-factor/disable", data = "<data>")]
|
|
||||||
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
|
||||||
let data: DisableTwoFactorData = data.into_inner();
|
|
||||||
let user = headers.user;
|
|
||||||
|
|
||||||
// Delete directly after a valid token has been provided
|
|
||||||
PasswordOrOtpData {
|
|
||||||
master_password_hash: data.master_password_hash,
|
|
||||||
otp: data.otp,
|
|
||||||
}
|
}
|
||||||
.validate(&user, true, &conn)
|
Ok(())
|
||||||
.await?;
|
|
||||||
|
|
||||||
let type_ = data.r#type.into_i32()?;
|
|
||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
|
||||||
twofactor.delete(&conn).await?;
|
|
||||||
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
|
|
||||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
|
||||||
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"enabled": false,
|
|
||||||
"type": type_,
|
|
||||||
"object": "twoFactorProvider"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn enforce_2fa_policy(
|
pub async fn enforce_2fa_policy(
|
||||||
@@ -180,7 +151,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevoked,
|
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&member.org_uuid,
|
&member.org_uuid,
|
||||||
act_user_id,
|
act_user_id,
|
||||||
@@ -214,8 +185,16 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
member.revoke();
|
member.revoke();
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
log_event(
|
||||||
.await;
|
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
||||||
|
&member.uuid,
|
||||||
|
org_id,
|
||||||
|
act_user_id,
|
||||||
|
device_type,
|
||||||
|
ip,
|
||||||
|
conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +204,7 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||||
debug!("Sending notifications for incomplete 2FA logins");
|
debug!("Sending notifications for incomplete 2FA logins");
|
||||||
|
|
||||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,11 +214,21 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
|
||||||
let time_before = now - time_limit;
|
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(time_before) = now.checked_sub_signed(time_limit) else {
|
||||||
|
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
||||||
for login in incomplete_logins {
|
for login in incomplete_logins {
|
||||||
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
|
let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
|
||||||
|
error!("User '{}', not found!", login.user_uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
info!(
|
info!(
|
||||||
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
||||||
user.email, login.ip_address
|
user.email, login.ip_address
|
||||||
|
|||||||
@@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
|||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
// Only one Protected Action per user is allowed to take place, delete the previous one
|
// Only one Protected Action per user is allowed to take place, delete the previous one
|
||||||
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await
|
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await {
|
||||||
{
|
|
||||||
let pa_data = ProtectedActionData::from_json(&pa.data)?;
|
let pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||||
let elapsed = pa_data.time_since_sent().num_seconds();
|
let elapsed = pa_data.time_since_sent().num_seconds();
|
||||||
let delay = 30;
|
let delay = 30;
|
||||||
@@ -125,27 +124,28 @@ pub async fn validate_protected_action_otp(
|
|||||||
delete_if_valid: bool,
|
delete_if_valid: bool,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
|
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn)
|
||||||
.await
|
.await
|
||||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||||
pa_data.add_attempt();
|
|
||||||
pa.data = pa_data.to_json();
|
|
||||||
|
|
||||||
// Fail after x attempts if the token has been used too many times.
|
// Fail after x attempts if the token has been used too many times.
|
||||||
// Don't delete it, as we use it to keep track of attempts.
|
// Don't delete it, as we use it to keep track of attempts.
|
||||||
if pa_data.attempts >= CONFIG.email_attempts_limit() {
|
if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token has expired (Using the email 2fa expiration time)
|
// Check if the token has expired (Using the email 2fa expiration time)
|
||||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
let max_time = i64::from(CONFIG.email_expiration_time());
|
||||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||||
pa.delete(conn).await?;
|
pa.delete(conn).await?;
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crypto::ct_eq(&pa_data.token, otp) {
|
if !crypto::ct_eq(&pa_data.token, otp) {
|
||||||
|
pa_data.add_attempt();
|
||||||
|
pa.data = pa_data.to_json();
|
||||||
|
|
||||||
pa.save(conn).await?;
|
pa.save(conn).await?;
|
||||||
err!("Token is invalid")
|
err!("Token is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{str::FromStr, sync::LazyLock, time::Duration};
|
use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration};
|
||||||
|
|
||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -18,16 +18,18 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::{log_user_event, two_factor::generate_recover_code},
|
core::{
|
||||||
|
log_user_event,
|
||||||
|
two_factor::{VerificationTokenData, generate_recover_code},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::{Headers, two_factor},
|
||||||
crypto::ct_eq,
|
crypto::ct_eq,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
util::NumberOrString,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||||
@@ -45,7 +47,14 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
|
routes![
|
||||||
|
get_webauthn,
|
||||||
|
generate_webauthn_challenge,
|
||||||
|
activate_webauthn,
|
||||||
|
activate_webauthn_put,
|
||||||
|
delete_webauthn,
|
||||||
|
delete_webauthns
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some old u2f structs still needed for migrating from u2f to WebAuthn
|
// Some old u2f structs still needed for migrating from u2f to WebAuthn
|
||||||
@@ -119,34 +128,36 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbC
|
|||||||
data.validate(&user, false, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
||||||
|
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
||||||
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": enabled,
|
"webAuthn": json!({
|
||||||
"keys": registrations_json,
|
"enabled": enabled,
|
||||||
"object": "twoFactorWebAuthn"
|
"keys": registrations_json,
|
||||||
|
}),
|
||||||
|
"userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
|
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
|
||||||
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &conn).await?;
|
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
||||||
|
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
||||||
let registrations = get_webauthn_registrations(&user.uuid, &conn)
|
let creds = registrations
|
||||||
.await?
|
|
||||||
.1
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
|
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?;
|
||||||
|
|
||||||
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||||
&user.email,
|
&user.email,
|
||||||
user.display_name(),
|
user.display_name(),
|
||||||
Some(registrations),
|
Some(creds),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
@@ -166,17 +177,19 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
|||||||
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
|
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
|
||||||
challenge_value["status"] = "ok".into();
|
challenge_value["status"] = "ok".into();
|
||||||
challenge_value["errorMessage"] = "".into();
|
challenge_value["errorMessage"] = "".into();
|
||||||
Ok(Json(challenge_value))
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"options": challenge_value
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EnableWebauthnData {
|
struct EnableWebauthnData {
|
||||||
id: NumberOrString, // 1..5
|
id: i32,
|
||||||
name: String,
|
name: String,
|
||||||
device_response: RegisterPublicKeyCredentialCopy,
|
device_response: RegisterPublicKeyCredentialCopy,
|
||||||
master_password_hash: Option<String>,
|
user_verification_token: String,
|
||||||
otp: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -257,16 +270,14 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
let data: EnableWebauthnData = data.into_inner();
|
let data: EnableWebauthnData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
PasswordOrOtpData {
|
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
|
||||||
master_password_hash: data.master_password_hash,
|
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
||||||
otp: data.otp,
|
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
|
||||||
}
|
|
||||||
.validate(&user, true, &conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Retrieve and delete the saved challenge state
|
// Retrieve and delete the saved challenge state
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
let state = if let Some(tf) =
|
||||||
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
|
||||||
|
{
|
||||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||||
tf.delete(&conn).await?;
|
tf.delete(&conn).await?;
|
||||||
state
|
state
|
||||||
@@ -277,10 +288,9 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
// Verify the credentials with the saved state
|
// Verify the credentials with the saved state
|
||||||
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
|
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
|
||||||
|
|
||||||
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
|
|
||||||
// TODO: Check for repeated ID's
|
// TODO: Check for repeated ID's
|
||||||
registrations.push(WebauthnRegistration {
|
registrations.push(WebauthnRegistration {
|
||||||
id: data.id.into_i32()?,
|
id: data.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
migrated: false,
|
migrated: false,
|
||||||
|
|
||||||
@@ -293,13 +303,13 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
.await?;
|
.await?;
|
||||||
generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": true,
|
"webAuthn": json!({
|
||||||
"keys": keys_json,
|
"enabled": true,
|
||||||
"object": "twoFactorU2f"
|
"keys": registrations.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
|
||||||
|
}),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,58 +320,87 @@ async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers,
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DeleteU2FData {
|
struct DeleteWebauthnData {
|
||||||
id: NumberOrString,
|
id: i32,
|
||||||
master_password_hash: String,
|
user_verification_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/two-factor/webauthn", data = "<data>")]
|
#[delete("/two-factor/webauthn", data = "<data>")]
|
||||||
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let id = data.id.into_i32()?;
|
inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
|
||||||
if !headers.user.check_valid_password(&data.master_password_hash) {
|
}
|
||||||
err!("Invalid password");
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(mut tf) =
|
#[delete("/two-factor/webauthn/all", data = "<data>")]
|
||||||
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
|
async fn delete_webauthns(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
else {
|
inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inner_delete_webauthns(
|
||||||
|
token: &str,
|
||||||
|
retain: impl Fn(&WebauthnRegistration) -> bool,
|
||||||
|
headers: Headers,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
let user = headers.user;
|
||||||
|
|
||||||
|
let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else {
|
||||||
err!("Webauthn data not found!")
|
err!("Webauthn data not found!")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
let mut keys: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||||
|
let keys_id: Vec<i32> = keys.iter().map(|r| r.id).collect();
|
||||||
|
|
||||||
let Some(item_pos) = data.iter().position(|r| r.id == id) else {
|
two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
|
||||||
|
|
||||||
|
let mut removed: HashSet<Vec<u8>> = HashSet::new();
|
||||||
|
let mut migrated = false;
|
||||||
|
|
||||||
|
keys.retain(|key| {
|
||||||
|
let retained = retain(key);
|
||||||
|
if !retained {
|
||||||
|
removed.insert(key.credential.cred_id().to_vec());
|
||||||
|
migrated = migrated || key.migrated;
|
||||||
|
}
|
||||||
|
retained
|
||||||
|
});
|
||||||
|
|
||||||
|
if removed.is_empty() {
|
||||||
err!("Webauthn entry not found")
|
err!("Webauthn entry not found")
|
||||||
};
|
}
|
||||||
|
|
||||||
let removed_item = data.remove(item_pos);
|
if keys.is_empty() {
|
||||||
tf.data = serde_json::to_string(&data)?;
|
tf.delete(conn).await?;
|
||||||
tf.save(&conn).await?;
|
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
|
||||||
drop(tf);
|
} else {
|
||||||
Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?;
|
tf.data = serde_json::to_string(&keys)?;
|
||||||
|
tf.save(conn).await?;
|
||||||
|
drop(tf);
|
||||||
|
}
|
||||||
|
|
||||||
// If entry is migrated from u2f, delete the u2f entry as well
|
// If entry is migrated from u2f, delete the u2f entry as well
|
||||||
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await {
|
||||||
{
|
let Ok(mut data) = serde_json::from_str::<Vec<U2FRegistration>>(&u2f.data) else {
|
||||||
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
|
|
||||||
d
|
|
||||||
} else {
|
|
||||||
err!("Error parsing U2F data")
|
err!("Error parsing U2F data")
|
||||||
};
|
};
|
||||||
|
|
||||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
data.retain(|old| !removed.contains(&old.reg.key_handle));
|
||||||
let new_data_str = serde_json::to_string(&data)?;
|
|
||||||
|
|
||||||
u2f.data = new_data_str;
|
if data.is_empty() {
|
||||||
u2f.save(&conn).await?;
|
u2f.delete(conn).await?;
|
||||||
|
} else {
|
||||||
|
let new_data_str = serde_json::to_string(&data)?;
|
||||||
|
u2f.data = new_data_str;
|
||||||
|
u2f.save(conn).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
|
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": true,
|
"webAuthn": json!({
|
||||||
"keys": keys_json,
|
"enabled": !keys.is_empty(),
|
||||||
"object": "twoFactorU2f"
|
"keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
|
||||||
|
}),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +408,7 @@ pub async fn get_webauthn_registrations(
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
|
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
|
||||||
let type_ = TwoFactorType::Webauthn as i32;
|
match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
|
||||||
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
|
||||||
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
|
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
|
||||||
None => Ok((false, Vec::new())), // If no data, return empty list
|
None => Ok((false, Vec::new())), // If no data, return empty list
|
||||||
}
|
}
|
||||||
@@ -417,8 +455,9 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
let mut state = if let Some(tf) =
|
||||||
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
|
||||||
|
{
|
||||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||||
tf.delete(conn).await?;
|
tf.delete(conn).await?;
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
core::{log_user_event, two_factor::generate_recover_code},
|
core::{
|
||||||
|
log_user_event,
|
||||||
|
two_factor::{VerificationTokenData, generate_recover_code},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::{Headers, two_factor},
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{EventType, TwoFactor, TwoFactorType},
|
models::{EventType, TwoFactor, TwoFactorType},
|
||||||
@@ -22,7 +25,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HttpClientTransport {
|
struct HttpClientTransport {
|
||||||
@@ -60,8 +63,7 @@ struct EnableYubikeyData {
|
|||||||
key4: Option<String>,
|
key4: Option<String>,
|
||||||
key5: Option<String>,
|
key5: Option<String>,
|
||||||
nfc: bool,
|
nfc: bool,
|
||||||
master_password_hash: Option<String>,
|
user_verification_token: String,
|
||||||
otp: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
@@ -125,48 +127,29 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn:
|
|||||||
data.validate(&user, false, &conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let user_id = &user.uuid;
|
let user_id = &user.uuid;
|
||||||
let yubikey_type = TwoFactorType::YubiKey as i32;
|
|
||||||
|
|
||||||
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await;
|
let (enabled, keys, yubikey_json) =
|
||||||
|
if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await {
|
||||||
if let Some(r) = r {
|
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
let enabled = !yubikey_metadata.keys.is_empty();
|
||||||
|
let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone());
|
||||||
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
result["enabled"] = Value::Bool(enabled);
|
||||||
|
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
||||||
result["enabled"] = Value::Bool(true);
|
(enabled, yubikey_metadata.keys, result)
|
||||||
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
} else {
|
||||||
result["object"] = Value::String("twoFactorU2f".to_owned());
|
(false, Vec::new(), json!({"enabled": false}))
|
||||||
|
};
|
||||||
Ok(Json(result))
|
Ok(Json(json!({
|
||||||
} else {
|
"yubiKey": yubikey_json,
|
||||||
Ok(Json(json!({
|
"userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
|
||||||
"enabled": false,
|
})))
|
||||||
"object": "twoFactorU2f",
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/yubikey", data = "<data>")]
|
#[post("/two-factor/yubikey", data = "<data>")]
|
||||||
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: EnableYubikeyData = data.into_inner();
|
let data: EnableYubikeyData = data.into_inner();
|
||||||
let mut user = headers.user;
|
|
||||||
|
|
||||||
PasswordOrOtpData {
|
|
||||||
master_password_hash: data.master_password_hash.clone(),
|
|
||||||
otp: data.otp.clone(),
|
|
||||||
}
|
|
||||||
.validate(&user, true, &conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Check if we already have some data
|
|
||||||
let mut yubikey_data =
|
|
||||||
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await {
|
|
||||||
Some(data) => data,
|
|
||||||
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let yubikeys = parse_yubikeys(&data);
|
let yubikeys = parse_yubikeys(&data);
|
||||||
|
let mut user = headers.user;
|
||||||
|
|
||||||
if yubikeys.is_empty() {
|
if yubikeys.is_empty() {
|
||||||
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
|
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
|
||||||
@@ -174,6 +157,17 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
err!("A key is required.");
|
err!("A key is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we already have some data
|
||||||
|
let mut yubikey_data =
|
||||||
|
if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
|
||||||
|
let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?;
|
||||||
|
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?;
|
||||||
|
yd
|
||||||
|
} else {
|
||||||
|
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?;
|
||||||
|
TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new())
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure they are valid OTPs
|
// Ensure they are valid OTPs
|
||||||
for yubikey in &yubikeys {
|
for yubikey in &yubikeys {
|
||||||
if yubikey.is_empty() || yubikey.len() == 12 {
|
if yubikey.is_empty() || yubikey.len() == 12 {
|
||||||
@@ -195,15 +189,12 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
|
|
||||||
generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
||||||
|
|
||||||
result["enabled"] = Value::Bool(true);
|
result["enabled"] = Value::Bool(true);
|
||||||
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
||||||
result["object"] = Value::String("twoFactorU2f".to_owned());
|
Ok(Json(json!({"yubiKey": result})))
|
||||||
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/two-factor/yubikey", data = "<data>")]
|
#[put("/two-factor/yubikey", data = "<data>")]
|
||||||
@@ -211,6 +202,23 @@ async fn activate_yubikey_put(data: Json<EnableYubikeyData>, headers: Headers, c
|
|||||||
activate_yubikey(data, headers, conn).await
|
activate_yubikey(data, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[delete("/two-factor/yubikey", data = "<data>")]
|
||||||
|
async fn delete_yubikeys(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
|
let user = headers.user;
|
||||||
|
|
||||||
|
if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
|
||||||
|
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||||
|
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?;
|
||||||
|
|
||||||
|
r.delete(&conn).await?;
|
||||||
|
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
|
pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
|
||||||
if response.len() != 44 {
|
if response.len() != 44 {
|
||||||
err!("Invalid Yubikey OTP length");
|
err!("Invalid Yubikey OTP length");
|
||||||
|
|||||||
+16
-5
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
|||||||
// Generate the cookie store
|
// Generate the cookie store
|
||||||
let cookie_store = Arc::new(Jar::default());
|
let cookie_store = Arc::new(Jar::default());
|
||||||
|
|
||||||
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
let pool_idle_timeout = Duration::from_secs(10);
|
||||||
// Reuse the client between requests
|
// Reuse the client between requests
|
||||||
get_reqwest_client_builder(true)
|
get_reqwest_client_builder(true)
|
||||||
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
||||||
let meta = operator.stat(path).await?;
|
let meta = operator.stat(path).await?;
|
||||||
let modified =
|
let modified =
|
||||||
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
||||||
let age = SystemTime::now().duration_since(modified.into())?;
|
let age = SystemTime::now().duration_since(modified.into())?;
|
||||||
|
|
||||||
Ok(ttl > 0 && ttl <= age.as_secs())
|
Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn icon_is_negcached(path: &str) -> bool {
|
async fn icon_is_negcached(path: &str) -> bool {
|
||||||
@@ -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}");
|
debug!("Found base href: {}", href.escape_debug());
|
||||||
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 atleast 67 bytes, which seems to be the smallest png i could create
|
// Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
|
||||||
if body.len() >= 67 {
|
if body.len() >= 67 {
|
||||||
// Check if the icon type is allowed, else try an icon from the list.
|
// Check if the icon type is allowed, else try an icon from the list.
|
||||||
icon_type = get_icon_type(&body);
|
icon_type = get_icon_type(&body);
|
||||||
@@ -663,6 +663,17 @@ 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)
|
||||||
|
|||||||
+168
-44
@@ -29,7 +29,7 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError,
|
||||||
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||||
TwoFactorType, User, UserId,
|
TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
@@ -46,6 +46,7 @@ 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,
|
||||||
@@ -76,14 +77,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).await
|
password_login(data, &mut user_id, &conn, &client_header).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).await
|
api_key_login(data, &mut user_id, &conn, &client_header).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")?;
|
||||||
@@ -94,7 +95,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).await
|
sso_login(data, &mut user_id, &conn, &client_header).await
|
||||||
}
|
}
|
||||||
"authorization_code" => err!("SSO sign-in is not available"),
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
"send_access" => {
|
"send_access" => {
|
||||||
@@ -111,14 +112,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),
|
t => err!("Invalid type", t.escape_debug().to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
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 as i32,
|
EventType::UserLoggedIn,
|
||||||
&user_id,
|
&user_id,
|
||||||
client_header.device_type,
|
client_header.device_type,
|
||||||
&client_header.ip.ip,
|
&client_header.ip.ip,
|
||||||
@@ -128,8 +129,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Some(ev) = e.get_event() {
|
if let Some(ev) = e.get_event() {
|
||||||
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
|
log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,13 @@ 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(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn sso_login(
|
||||||
|
data: ConnectData,
|
||||||
|
user_id: &mut Option<UserId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
client_header: &ClientHeaders,
|
||||||
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||||
|
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
@@ -317,6 +323,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
||||||
|
// Before saving, so that a client that can't log in doesn't create the account
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
user.verified_at = Some(now);
|
user.verified_at = Some(now);
|
||||||
user.save(conn).await?;
|
user.save(conn).await?;
|
||||||
|
|
||||||
@@ -325,6 +333,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
(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),
|
||||||
@@ -334,6 +344,9 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Some((mut user, sso_user)) => {
|
Some((mut user, sso_user)) => {
|
||||||
|
*user_id = Some(user.uuid.clone());
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
|
|
||||||
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?;
|
||||||
@@ -368,7 +381,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
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(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn password_login(
|
||||||
|
data: ConnectData,
|
||||||
|
user_id: &mut Option<UserId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
client_header: &ClientHeaders,
|
||||||
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
// Validate scope
|
// Validate scope
|
||||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||||
|
|
||||||
@@ -438,6 +457,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
|
|
||||||
// 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?;
|
||||||
@@ -448,9 +469,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||||
if user.last_verifying_at.is_none()
|
if user.last_verifying_at.is_none()
|
||||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||||
> CONFIG.signups_verify_resend_time().cast_signed()
|
> i64::from(CONFIG.signups_verify_resend_time())
|
||||||
{
|
{
|
||||||
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
|
||||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||||
// We want to send another email verification if we require signups to verify
|
// We want to send another email verification if we require signups to verify
|
||||||
// their email address, and we haven't sent them a reminder in a while...
|
// their email address, and we haven't sent them a reminder in a while...
|
||||||
@@ -499,6 +520,66 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
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`, every login needs the `Bitwarden-Client-Version` header, and a V2 account
|
||||||
|
/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). 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/15c94ac58bf361a162381749c146d254a21b2098/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L33-L79>
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Core/KeyManagement/Constants.cs#L5-L6>
|
||||||
|
fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult {
|
||||||
|
let Some((error, description)) = client_version_error(
|
||||||
|
user.has_v2_encryption(),
|
||||||
|
client_header.client_version.as_ref(),
|
||||||
|
client_header.device_type,
|
||||||
|
) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
Err(crate::error::Error::from((
|
||||||
|
// Escaped: on a first SSO login the email comes from the provider and isn't validated yet
|
||||||
|
format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()),
|
||||||
|
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>,
|
||||||
|
device_type: i32,
|
||||||
|
) -> Option<(&'static str, &'static str)> {
|
||||||
|
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1);
|
||||||
|
const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2);
|
||||||
|
|
||||||
|
let Some(version) = client_version else {
|
||||||
|
return 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\"",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !is_v2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Upstream's desktop device types
|
||||||
|
// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Libraries/Clients/DeviceTypes.cs#L14-L20>
|
||||||
|
let is_desktop = matches!(
|
||||||
|
DeviceType::from_i32(device_type),
|
||||||
|
DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp
|
||||||
|
);
|
||||||
|
let min_version = if is_desktop {
|
||||||
|
&MIN_V2_DESKTOP_CLIENT
|
||||||
|
} else {
|
||||||
|
&MIN_V2_CLIENT
|
||||||
|
};
|
||||||
|
(version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn authenticated_response(
|
async fn authenticated_response(
|
||||||
user: &User,
|
user: &User,
|
||||||
device: &mut Device,
|
device: &mut Device,
|
||||||
@@ -546,24 +627,13 @@ async fn authenticated_response(
|
|||||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
"MasterKeyEncryptedUserKey": user.akey,
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
"MasterKeyWrappedUserKey": user.akey,
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
"Salt": user.email
|
"Salt": user.master_password_salt()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"access_token": auth_tokens.access_token(),
|
"access_token": auth_tokens.access_token(),
|
||||||
@@ -598,7 +668,13 @@ async fn authenticated_response(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn api_key_login(
|
||||||
|
data: ConnectData,
|
||||||
|
user_id: &mut Option<UserId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
client_header: &ClientHeaders,
|
||||||
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
@@ -610,7 +686,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
|
|||||||
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).await
|
user_api_key_login(data, user_id, conn, client_header).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"),
|
||||||
@@ -621,8 +697,9 @@ async fn user_api_key_login(
|
|||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
ip: &ClientIp,
|
client_header: &ClientHeaders,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
// 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();
|
||||||
let Some(client_user_id) = client_id.strip_prefix("user.") else {
|
let Some(client_user_id) = client_id.strip_prefix("user.") else {
|
||||||
@@ -659,6 +736,8 @@ async fn user_api_key_login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
|
|
||||||
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() {
|
||||||
@@ -703,24 +782,13 @@ async fn user_api_key_login(
|
|||||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
"MasterKeyEncryptedUserKey": user.akey,
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
"MasterKeyWrappedUserKey": user.akey,
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
"Salt": user.email
|
"Salt": user.master_password_salt()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: No refresh_token is returned. The CLI just repeats the
|
// Note: No refresh_token is returned. The CLI just repeats the
|
||||||
// client_credentials login flow when the existing token expires.
|
// client_credentials login flow when the existing token expires.
|
||||||
@@ -904,7 +972,7 @@ async fn twofactor_auth(
|
|||||||
|
|
||||||
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
||||||
|
|
||||||
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||||
|
|
||||||
if CONFIG.mail_enabled()
|
if CONFIG.mail_enabled()
|
||||||
&& let Err(e) =
|
&& let Err(e) =
|
||||||
@@ -974,7 +1042,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(tf_type @ TwoFactorType::YubiKey) => {
|
Some(tf_type @ TwoFactorType::YubiKey) => {
|
||||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
|
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
|
||||||
err!("No YubiKey devices registered")
|
err!("No YubiKey devices registered")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -986,7 +1054,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(tf_type @ TwoFactorType::Email) => {
|
Some(tf_type @ TwoFactorType::Email) => {
|
||||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
|
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
|
||||||
err!("No twofactor email registered")
|
err!("No twofactor email registered")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1035,6 +1103,13 @@ 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)]
|
||||||
@@ -1097,6 +1172,25 @@ 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)?;
|
||||||
@@ -1325,3 +1419,33 @@ 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();
|
||||||
|
let web = DeviceType::ChromeBrowser as i32;
|
||||||
|
let desktop = DeviceType::WindowsDesktop as i32;
|
||||||
|
// Every login needs the header
|
||||||
|
assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing"));
|
||||||
|
assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing"));
|
||||||
|
// V1 accounts log in from any version
|
||||||
|
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None);
|
||||||
|
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None);
|
||||||
|
// V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app
|
||||||
|
assert_eq!(
|
||||||
|
client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0),
|
||||||
|
Some("invalid_client_version")
|
||||||
|
);
|
||||||
|
assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None);
|
||||||
|
assert_eq!(
|
||||||
|
client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0),
|
||||||
|
Some("invalid_client_version")
|
||||||
|
);
|
||||||
|
assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None);
|
||||||
|
assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -23,14 +23,14 @@ pub use crate::api::{
|
|||||||
icons::routes as icons_routes,
|
icons::routes as icons_routes,
|
||||||
identity::routes as identity_routes,
|
identity::routes as identity_routes,
|
||||||
notifications::routes as notifications_routes,
|
notifications::routes as notifications_routes,
|
||||||
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||||
push::{
|
push::{
|
||||||
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
|
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
||||||
unregister_push_device,
|
register_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::{invalidate_css_cache, static_files},
|
web::{STATIC_FILES, invalidate_css_cache},
|
||||||
};
|
};
|
||||||
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(Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct PasswordOrOtpData {
|
struct PasswordOrOtpData {
|
||||||
#[serde(alias = "MasterPasswordHash")]
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
|
|||||||
+58
-13
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
sync::{Arc, LazyLock},
|
sync::{Arc, LazyLock},
|
||||||
time::Duration,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
@@ -41,6 +41,14 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscripti
|
|||||||
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
||||||
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
||||||
|
|
||||||
|
/// How often a Ping is sent to the client.
|
||||||
|
const WS_PING_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Close the connection if nothing, not even a Pong, was received from the client for this long.
|
||||||
|
/// Otherwise half-open connections (client gone without a FIN, e.g. behind a proxy or NAT) are kept forever.
|
||||||
|
/// Same as the default `ClientTimeoutInterval` of ASP.NET Core SignalR, which the official server uses.
|
||||||
|
const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -156,12 +164,16 @@ fn websockets_hub<'r>(
|
|||||||
rocket_ws::Stream! { ws => {
|
rocket_ws::Stream! { ws => {
|
||||||
let mut ws = ws;
|
let mut ws = ws;
|
||||||
let _guard = guard;
|
let _guard = guard;
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
|
||||||
|
let mut last_received = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = ws.next() => {
|
res = ws.next() => {
|
||||||
match res {
|
match res {
|
||||||
Some(Ok(message)) => {
|
Some(Ok(message)) => {
|
||||||
|
// Any message, including a Pong, means the client is still there
|
||||||
|
last_received = Instant::now();
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
// Respond to any pings
|
// Respond to any pings
|
||||||
Message::Ping(ping) => yield Message::Pong(ping),
|
Message::Ping(ping) => yield Message::Pong(ping),
|
||||||
@@ -195,7 +207,13 @@ fn websockets_hub<'r>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = interval.tick() => yield Message::Ping(create_ping())
|
_ = interval.tick() => {
|
||||||
|
// The client stopped responding without closing the connection, drop it
|
||||||
|
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yield Message::Ping(PING.to_vec());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -229,12 +247,16 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
rocket_ws::Stream! { ws => {
|
rocket_ws::Stream! { ws => {
|
||||||
let mut ws = ws;
|
let mut ws = ws;
|
||||||
let _guard = guard;
|
let _guard = guard;
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
|
||||||
|
let mut last_received = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = ws.next() => {
|
res = ws.next() => {
|
||||||
match res {
|
match res {
|
||||||
Some(Ok(message)) => {
|
Some(Ok(message)) => {
|
||||||
|
// Any message, including a Pong, means the client is still there
|
||||||
|
last_received = Instant::now();
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
// Respond to any pings
|
// Respond to any pings
|
||||||
Message::Ping(ping) => yield Message::Pong(ping),
|
Message::Ping(ping) => yield Message::Pong(ping),
|
||||||
@@ -268,7 +290,13 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = interval.tick() => yield Message::Ping(create_ping())
|
_ = interval.tick() => {
|
||||||
|
// The client stopped responding without closing the connection, drop it
|
||||||
|
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yield Message::Ping(PING.to_vec());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -331,6 +359,7 @@ 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> {
|
||||||
@@ -383,23 +412,35 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
|
self.send_logout_with_reason(user, acting_device, None, conn).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A logout that clients may handle differently depending on `reason`
|
||||||
|
pub async fn send_logout_with_reason(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
acting_device: Option<&Device>,
|
||||||
|
reason: Option<LogOutReason>,
|
||||||
|
conn: &DbConn,
|
||||||
|
) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||||
let data = create_update(
|
let mut payload =
|
||||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
|
||||||
UpdateType::LogOut,
|
if let Some(reason) = reason {
|
||||||
acting_device_id,
|
payload.push(("Reason".into(), (reason as i32).into()));
|
||||||
);
|
}
|
||||||
|
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
|
||||||
|
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
self.send_update(&user.uuid, &data).await;
|
self.send_update(&user.uuid, &data).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.push_enabled() {
|
if CONFIG.push_enabled() {
|
||||||
push_logout(user, acting_device, conn).await;
|
push_logout(user, acting_device, reason, conn).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,8 +704,12 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
serialize(&value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ping() -> Vec<u8> {
|
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
|
||||||
serialize(&Value::Array(vec![6.into()]))
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub enum LogOutReason {
|
||||||
|
KeyRotation = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
|
|||||||
+37
-26
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
@@ -139,21 +139,25 @@ 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 auth_api_token = get_auth_api_token().await?;
|
let data = json!({
|
||||||
|
"id": push_id.as_ref().unwrap(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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}");
|
||||||
|
|
||||||
match make_http_request(
|
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
|
||||||
Method::POST,
|
.header(CONTENT_TYPE, "application/json")
|
||||||
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
|
.header(ACCEPT, "application/json")
|
||||||
)?
|
.header(AUTHORIZATION, auth_header)
|
||||||
.header(AUTHORIZATION, auth_header)
|
.json(&data)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await?
|
||||||
|
.error_for_status()
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
err!(format!("An error occurred during device unregistration: {e}"));
|
||||||
Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
|
}
|
||||||
};
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,24 +192,31 @@ 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>, reason: Option<LogOutReason>, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
push_logout_to_relay(user, acting_device, reason);
|
||||||
"userId": user.uuid,
|
|
||||||
"organizationId": (),
|
|
||||||
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
|
||||||
"identifier": acting_device.map(|d| &d.uuid),
|
|
||||||
"type": UpdateType::LogOut as i32,
|
|
||||||
"payload": {
|
|
||||||
"userId": user.uuid,
|
|
||||||
"date": format_date(&user.updated_at)
|
|
||||||
},
|
|
||||||
"clientType": null,
|
|
||||||
"installationId": null
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>, reason: Option<LogOutReason>) {
|
||||||
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
|
"userId": user.uuid,
|
||||||
|
"organizationId": (),
|
||||||
|
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
||||||
|
"identifier": acting_device.map(|d| &d.uuid),
|
||||||
|
"type": UpdateType::LogOut as i32,
|
||||||
|
"payload": {
|
||||||
|
"userId": user.uuid,
|
||||||
|
"date": format_date(&user.updated_at),
|
||||||
|
"reason": reason.map(|r| r as i32),
|
||||||
|
},
|
||||||
|
"clientType": 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 {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
|
|||||||
+64
-33
@@ -1,13 +1,14 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, LazyLock, RwLock},
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Catcher, Route,
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
response::{Redirect, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -19,7 +20,7 @@ use crate::{
|
|||||||
crypto::sha256_hex,
|
crypto::sha256_hex,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AttachmentId, CipherId},
|
models::{AttachmentId, CipherId, custom_roles_enabled},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
util::{Cached, EtagCached},
|
util::{Cached, EtagCached},
|
||||||
@@ -79,16 +80,17 @@ pub fn invalidate_css_cache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/css/vaultwarden.css")]
|
#[get("/css/vaultwarden.css")]
|
||||||
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
fn vaultwarden_css() -> EtagCached<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(Css(cached.css.clone()), &cached.etag);
|
return EtagCached::new(cached.css.clone(), ContentType::CSS, &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
|
||||||
let css_options = json!({
|
let css_options = json!({
|
||||||
|
"custom_roles_enabled": custom_roles_enabled(),
|
||||||
"email_change_allowed": CONFIG.email_change_allowed(),
|
"email_change_allowed": CONFIG.email_change_allowed(),
|
||||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||||
"load_user_scss": true,
|
"load_user_scss": true,
|
||||||
@@ -147,7 +149,7 @@ fn vaultwarden_css() -> EtagCached<Css<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(Css(cached.css.clone()), &cached.etag)
|
EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -256,7 +258,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` function below!
|
// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap 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> {
|
||||||
@@ -276,32 +278,61 @@ 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<(ContentType, &'static [u8]), Error> {
|
pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error> {
|
||||||
match filename {
|
if let Some(file) = STATIC_FILES.get(filename) {
|
||||||
"404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))),
|
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
|
||||||
"mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
} else {
|
||||||
"logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
|
err!(format!("Static file not found: {}", filename.escape_debug()))
|
||||||
"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}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+62
-50
@@ -1,3 +1,6 @@
|
|||||||
|
#[path = "auth/two_factor.rs"]
|
||||||
|
pub mod two_factor;
|
||||||
|
|
||||||
#[path = "auth/send.rs"]
|
#[path = "auth/send.rs"]
|
||||||
pub mod send;
|
pub mod send;
|
||||||
pub type SendTokens = send::SendTokens;
|
pub type SendTokens = send::SendTokens;
|
||||||
@@ -31,7 +34,7 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||||
SendFileId, SendId, User, UserId, UserStampException,
|
SendFileId, SendId, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -311,6 +314,17 @@ impl LoginJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
|
||||||
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
|
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
|
||||||
|
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
|
||||||
|
};
|
||||||
|
let Some(exp) = now.checked_add_signed(hours_delta) else {
|
||||||
|
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
|
||||||
|
};
|
||||||
|
Ok(exp)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct InviteJwtClaims {
|
pub struct InviteJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -334,19 +348,19 @@ pub fn generate_invite_claims(
|
|||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
invited_by_email: Option<String>,
|
invited_by_email: Option<String>,
|
||||||
) -> InviteJwtClaims {
|
) -> Result<InviteJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
InviteJwtClaims {
|
Ok(InviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_INVITE_ISSUER.to_string(),
|
iss: JWT_INVITE_ISSUER.to_string(),
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
email,
|
email,
|
||||||
org_id,
|
org_id,
|
||||||
member_id,
|
member_id,
|
||||||
invited_by_email,
|
invited_by_email,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -372,19 +386,19 @@ pub fn generate_emergency_access_invite_claims(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
grantor_name: String,
|
grantor_name: String,
|
||||||
grantor_email: String,
|
grantor_email: String,
|
||||||
) -> EmergencyAccessInviteJwtClaims {
|
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
EmergencyAccessInviteJwtClaims {
|
Ok(EmergencyAccessInviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
email,
|
email,
|
||||||
emer_id,
|
emer_id,
|
||||||
grantor_name,
|
grantor_name,
|
||||||
grantor_email,
|
grantor_email,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -489,13 +503,17 @@ 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: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()),
|
||||||
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
|
||||||
@@ -518,36 +536,43 @@ impl BasicJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
BasicJwtClaims {
|
Ok(BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_DELETE_ISSUER.to_string(),
|
iss: JWT_DELETE_ISSUER.to_string(),
|
||||||
sub: uuid,
|
sub: uuid,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
BasicJwtClaims {
|
Ok(BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_admin_claims() -> BasicJwtClaims {
|
pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
BasicJwtClaims {
|
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
|
||||||
|
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
|
||||||
|
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
||||||
|
};
|
||||||
|
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
|
||||||
|
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
||||||
|
};
|
||||||
|
Ok(BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||||
sub: "admin_panel".to_owned(),
|
sub: "admin_panel".to_owned(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
||||||
@@ -607,6 +632,9 @@ 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]
|
||||||
@@ -623,10 +651,13 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,31 +713,12 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if user.security_stamp != claims.sstamp {
|
if user.security_stamp != claims.sstamp {
|
||||||
if let Some(stamp_exception) =
|
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
||||||
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
|
err_handler!("Error getting current route for stamp exception")
|
||||||
{
|
};
|
||||||
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
|
||||||
err_handler!("Error getting current route for stamp exception")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if the stamp exception has expired first.
|
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
|
||||||
// Then, check if the current route matches any of the allowed routes.
|
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
|
||||||
// After that check the stamp in exception matches the one in the claims.
|
|
||||||
if Utc::now().timestamp() > stamp_exception.expire {
|
|
||||||
// If the stamp exception has been expired remove it from the database.
|
|
||||||
// This prevents checking this stamp exception for new requests.
|
|
||||||
let mut user = user;
|
|
||||||
user.reset_stamp_exception();
|
|
||||||
if let Err(e) = user.save(&conn).await {
|
|
||||||
error!("Error updating user: {e:#?}");
|
|
||||||
}
|
|
||||||
err_handler!("Stamp exception is expired")
|
|
||||||
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
|
||||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
|
||||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
|
||||||
err_handler!("Invalid security stamp for matched stamp exception")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err_handler!("Invalid security stamp")
|
err_handler!("Invalid security stamp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -72,7 +72,11 @@ 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(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
return Self::invalid_error(
|
||||||
|
&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 {
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
use chrono::{TimeDelta, Utc};
|
||||||
|
use serde::{de::DeserializeOwned, ser::Serialize};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{ApiResult, EmptyResult},
|
||||||
|
auth::{decode_jwt, encode_jwt},
|
||||||
|
db::models::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
static JWT_2FA_AUTH_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin()));
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct TwoFactorClaims<T> {
|
||||||
|
// Not before
|
||||||
|
pub nbf: i64,
|
||||||
|
// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
// Issuer
|
||||||
|
pub iss: String,
|
||||||
|
// Subject
|
||||||
|
pub sub: UserId,
|
||||||
|
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
pub claims: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AuthenticatorClaims {
|
||||||
|
#[serde(rename = "authenticator_key")]
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DuoClaims {
|
||||||
|
#[serde(rename = "duo_data")]
|
||||||
|
pub data: Option<DuoData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WebauthnClaims {
|
||||||
|
#[serde(rename = "webauthn_keys")]
|
||||||
|
pub keys: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct YubikeyClaims {
|
||||||
|
#[serde(rename = "yubi_keys")]
|
||||||
|
pub keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct DuoData {
|
||||||
|
pub host: String, // Duo API hostname
|
||||||
|
pub ik: String, // client id
|
||||||
|
pub sk: String, // client secret
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DuoData {
|
||||||
|
pub fn global() -> Option<Self> {
|
||||||
|
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
|
||||||
|
(true, Some(host)) => Some(Self {
|
||||||
|
host,
|
||||||
|
ik: CONFIG.duo_ikey().unwrap(),
|
||||||
|
sk: CONFIG.duo_skey().unwrap(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn msg(s: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
host: s.into(),
|
||||||
|
ik: s.into(),
|
||||||
|
sk: s.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn secret() -> Self {
|
||||||
|
Self::msg("<global_secret>")
|
||||||
|
}
|
||||||
|
pub fn obscure(self) -> Self {
|
||||||
|
let mut host = self.host;
|
||||||
|
let mut ik = self.ik;
|
||||||
|
let mut sk = self.sk;
|
||||||
|
|
||||||
|
let digits = 4;
|
||||||
|
let replaced = "************";
|
||||||
|
|
||||||
|
host.replace_range(digits.., replaced);
|
||||||
|
ik.replace_range(digits.., replaced);
|
||||||
|
sk.replace_range(digits.., replaced);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
host,
|
||||||
|
ik,
|
||||||
|
sk,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct EmailClaims {
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token<T: Serialize>(user_id: UserId, enabled: bool, claims: T) -> String {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
let claims = TwoFactorClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
|
||||||
|
iss: JWT_2FA_AUTH_ISSUER.to_string(),
|
||||||
|
sub: user_id,
|
||||||
|
enabled,
|
||||||
|
claims,
|
||||||
|
};
|
||||||
|
encode_jwt(&claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate<T: DeserializeOwned>(token: &str, user_id: &UserId, enabled: bool) -> ApiResult<T> {
|
||||||
|
match decode_jwt::<TwoFactorClaims<T>>(token, JWT_2FA_AUTH_ISSUER.to_string()) {
|
||||||
|
Ok(claims) => {
|
||||||
|
if claims.sub != *user_id {
|
||||||
|
err!("Invalid verification token: Invalid user");
|
||||||
|
}
|
||||||
|
if claims.enabled != enabled {
|
||||||
|
err!("Invalid verification token: Invalid state");
|
||||||
|
}
|
||||||
|
Ok(claims.claims)
|
||||||
|
}
|
||||||
|
Err(err) => err!(format!("Failed to decode verification token: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String {
|
||||||
|
token(
|
||||||
|
user_id,
|
||||||
|
enabled,
|
||||||
|
AuthenticatorClaims {
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult {
|
||||||
|
let claims = validate::<AuthenticatorClaims>(token, user_id, enabled)?;
|
||||||
|
if claims.key != key {
|
||||||
|
err!("Invalid verification token: Invalid key");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn duo_token(user_id: UserId, data: Option<DuoData>, enabled: bool) -> String {
|
||||||
|
token(
|
||||||
|
user_id,
|
||||||
|
enabled,
|
||||||
|
DuoClaims {
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When disabling we check that it's the correct data
|
||||||
|
pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult {
|
||||||
|
let claims = validate::<DuoClaims>(token, user_id, enabled)?;
|
||||||
|
if enabled && claims.data.as_ref() != data {
|
||||||
|
err!("Invalid verification token: Invalid duo data");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn email_token(user_id: UserId, email: Option<String>, enabled: bool) -> String {
|
||||||
|
token(
|
||||||
|
user_id,
|
||||||
|
enabled,
|
||||||
|
EmailClaims {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When disabling we check that it's the correct `email`
|
||||||
|
pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult {
|
||||||
|
let claims = validate::<EmailClaims>(token, user_id, enabled)?;
|
||||||
|
if enabled && claims.email != Some(email) {
|
||||||
|
err!("Invalid verification token: Invalid email");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn webauthn_token(user_id: UserId, keys: Vec<i32>, enabled: bool) -> String {
|
||||||
|
token(
|
||||||
|
user_id,
|
||||||
|
enabled,
|
||||||
|
WebauthnClaims {
|
||||||
|
keys,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult {
|
||||||
|
let claims = validate::<WebauthnClaims>(token, user_id, enabled)?;
|
||||||
|
if keys != claims.keys {
|
||||||
|
err!("Invalid verification token: Invalid keys");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn yubikey_token(user_id: UserId, keys: Vec<String>, enabled: bool) -> String {
|
||||||
|
token(
|
||||||
|
user_id,
|
||||||
|
enabled,
|
||||||
|
YubikeyClaims {
|
||||||
|
keys,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec<String>, enabled: bool) -> EmptyResult {
|
||||||
|
let claims = validate::<YubikeyClaims>(token, user_id, enabled)?;
|
||||||
|
if *keys != claims.keys {
|
||||||
|
err!("Invalid verification token: Invalid keys");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::{Value, from_value, to_value};
|
||||||
|
|
||||||
|
fn token<T: Serialize>(claims: T) -> Value {
|
||||||
|
to_value(TwoFactorClaims {
|
||||||
|
nbf: 0,
|
||||||
|
exp: 0,
|
||||||
|
iss: String::new(),
|
||||||
|
sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")),
|
||||||
|
enabled: false,
|
||||||
|
claims,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claims_only_parse_as_their_own_provider() {
|
||||||
|
let tokens = [
|
||||||
|
token(AuthenticatorClaims {
|
||||||
|
key: String::from("JBSWY3DPEHPK3PXP"),
|
||||||
|
}),
|
||||||
|
token(DuoClaims {
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
token(WebauthnClaims {
|
||||||
|
keys: vec![1],
|
||||||
|
}),
|
||||||
|
token(YubikeyClaims {
|
||||||
|
keys: Vec::new(),
|
||||||
|
}),
|
||||||
|
token(EmailClaims {
|
||||||
|
email: None,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
for (issued, token) in tokens.iter().enumerate() {
|
||||||
|
let parsed = [
|
||||||
|
from_value::<TwoFactorClaims<AuthenticatorClaims>>(token.clone()).is_ok(),
|
||||||
|
from_value::<TwoFactorClaims<DuoClaims>>(token.clone()).is_ok(),
|
||||||
|
from_value::<TwoFactorClaims<WebauthnClaims>>(token.clone()).is_ok(),
|
||||||
|
from_value::<TwoFactorClaims<YubikeyClaims>>(token.clone()).is_ok(),
|
||||||
|
from_value::<TwoFactorClaims<EmailClaims>>(token.clone()).is_ok(),
|
||||||
|
];
|
||||||
|
for (checked, ok) in parsed.into_iter().enumerate() {
|
||||||
|
assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-37
@@ -111,6 +111,7 @@ macro_rules! make_config {
|
|||||||
_usr: ConfigBuilder,
|
_usr: ConfigBuilder,
|
||||||
|
|
||||||
_overrides: Vec<&'static str>,
|
_overrides: Vec<&'static str>,
|
||||||
|
_config_file_status: ConfigFileStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
||||||
@@ -259,11 +260,22 @@ macro_rules! make_config {
|
|||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_file() -> Result<Self, Error> {
|
async fn from_file() -> Result<Option<Self>, Error> {
|
||||||
|
// The Fs operator would create the folder if it doesn't exist, which we never want
|
||||||
|
if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||||
let config_bytes = operator.read(&CONFIG_FILENAME).await?;
|
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
// No config file is fine, should not return any error
|
||||||
|
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
|
||||||
|
// Any other error might be an issue and should be passed back to the caller
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
|
||||||
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
||||||
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
Ok(Some(builder))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_non_editable(&mut self) {
|
fn clear_non_editable(&mut self) {
|
||||||
@@ -440,6 +452,8 @@ macro_rules! make_config {
|
|||||||
"domain_origin",
|
"domain_origin",
|
||||||
"domain_path",
|
"domain_path",
|
||||||
"domain",
|
"domain",
|
||||||
|
"duo_ikey",
|
||||||
|
"duo_host",
|
||||||
"helo_name",
|
"helo_name",
|
||||||
"org_creation_users",
|
"org_creation_users",
|
||||||
"signups_domains_whitelist",
|
"signups_domains_whitelist",
|
||||||
@@ -451,6 +465,8 @@ macro_rules! make_config {
|
|||||||
"sso_authority",
|
"sso_authority",
|
||||||
"sso_callback_path",
|
"sso_callback_path",
|
||||||
"sso_client_id",
|
"sso_client_id",
|
||||||
|
"yubico_client_id",
|
||||||
|
"yubico_server",
|
||||||
];
|
];
|
||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
@@ -483,6 +499,10 @@ macro_rules! make_config {
|
|||||||
};
|
};
|
||||||
overrides
|
overrides
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_config_file_status(&self) -> ConfigFileStatus {
|
||||||
|
self.inner.read().unwrap()._config_file_status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -539,7 +559,7 @@ make_config! {
|
|||||||
jobs {
|
jobs {
|
||||||
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
||||||
/// Set to 0 to globally disable scheduled jobs.
|
/// Set to 0 to globally disable scheduled jobs.
|
||||||
job_poll_interval_ms: u64, false, def, 30_000;
|
job_poll_interval_ms: u32, false, def, 30_000;
|
||||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||||
/// Defaults to hourly. Set blank to disable this job.
|
/// Defaults to hourly. Set blank to disable this job.
|
||||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||||
@@ -587,6 +607,9 @@ 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;
|
||||||
|
|
||||||
@@ -595,19 +618,19 @@ make_config! {
|
|||||||
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
|
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
|
||||||
org_attachment_limit: i64, true, option;
|
org_attachment_limit: i64, true, option;
|
||||||
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
|
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
|
||||||
user_send_limit: i64, true, option;
|
user_send_limit: i64, true, option;
|
||||||
|
|
||||||
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
||||||
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
|
/// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
|
||||||
/// sure to inform all users of any changes to this setting.
|
/// sure to inform all users of any changes to this setting.
|
||||||
trash_auto_delete_days: i64, true, option;
|
trash_auto_delete_days: u16, true, option;
|
||||||
|
|
||||||
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
||||||
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
||||||
/// where the correct master password was provided but the required 2FA step was not completed,
|
/// where the correct master password was provided but the required 2FA step was not completed,
|
||||||
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
||||||
/// This setting applies globally to all users.
|
/// This setting applies globally to all users.
|
||||||
incomplete_2fa_time_limit: i64, true, def, 3;
|
incomplete_2fa_time_limit: u16, true, def, 3;
|
||||||
|
|
||||||
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
||||||
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
||||||
@@ -620,9 +643,9 @@ make_config! {
|
|||||||
/// this will prevent logins from succeeding until the address has been verified
|
/// this will prevent logins from succeeding until the address has been verified
|
||||||
signups_verify: bool, true, def, false;
|
signups_verify: bool, true, def, false;
|
||||||
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
||||||
signups_verify_resend_time: u64, true, def, 3_600;
|
signups_verify_resend_time: u32, true, def, 3_600;
|
||||||
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
||||||
signups_verify_resend_limit: u32, true, def, 6;
|
signups_verify_resend_limit: u16, true, def, 6;
|
||||||
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
||||||
signups_domains_whitelist: String, true, def, String::new();
|
signups_domains_whitelist: String, true, def, String::new();
|
||||||
/// Enable event logging |> Enables event logging for organizations.
|
/// Enable event logging |> Enables event logging for organizations.
|
||||||
@@ -634,7 +657,7 @@ make_config! {
|
|||||||
invitations_allowed: bool, true, def, true;
|
invitations_allowed: bool, true, def, true;
|
||||||
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
||||||
/// email verification token and deletion request token will expire (must be at least 1)
|
/// email verification token and deletion request token will expire (must be at least 1)
|
||||||
invitation_expiration_hours: u32, false, def, 120;
|
invitation_expiration_hours: u16, false, def, 120;
|
||||||
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
||||||
emergency_access_allowed: bool, true, def, true;
|
emergency_access_allowed: bool, true, def, true;
|
||||||
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
||||||
@@ -655,8 +678,8 @@ make_config! {
|
|||||||
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
||||||
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
||||||
|
|
||||||
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
/// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
|
||||||
events_days_retain: i64, false, option;
|
events_days_retain: u16, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
client {
|
client {
|
||||||
@@ -694,13 +717,13 @@ make_config! {
|
|||||||
/// Temporary redirects are useful while testing different icon services, but once a service
|
/// Temporary redirects are useful while testing different icon services, but once a service
|
||||||
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
||||||
/// are currently better supported by the Bitwarden clients.
|
/// are currently better supported by the Bitwarden clients.
|
||||||
icon_redirect_code: u32, true, def, 302;
|
icon_redirect_code: u16, true, def, 302;
|
||||||
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
||||||
icon_cache_ttl: u64, true, def, 2_592_000;
|
icon_cache_ttl: u32, true, def, 2_592_000;
|
||||||
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
||||||
icon_cache_negttl: u64, true, def, 259_200;
|
icon_cache_negttl: u32, true, def, 259_200;
|
||||||
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
||||||
icon_download_timeout: u64, true, def, 10;
|
icon_download_timeout: u16, true, def, 10;
|
||||||
|
|
||||||
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
||||||
icon_blacklist_regex: String, false, option;
|
icon_blacklist_regex: String, false, option;
|
||||||
@@ -717,6 +740,8 @@ 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.
|
||||||
@@ -751,11 +776,11 @@ make_config! {
|
|||||||
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
||||||
db_connection_retries: u32, false, def, 15;
|
db_connection_retries: u32, false, def, 15;
|
||||||
|
|
||||||
/// Timeout when acquiring database connection
|
/// Timeout in seconds when acquiring database connection
|
||||||
database_timeout: u64, false, def, 30;
|
database_timeout: u32, false, def, 30;
|
||||||
|
|
||||||
/// Timeout in seconds before idle connections to the database are closed
|
/// Timeout in seconds before idle connections to the database are closed
|
||||||
database_idle_timeout: u64, false, def, 600;
|
database_idle_timeout: u32, false, def, 600;
|
||||||
|
|
||||||
/// Database connection max pool size
|
/// Database connection max pool size
|
||||||
database_max_conns: u32, false, def, 10;
|
database_max_conns: u32, false, def, 10;
|
||||||
@@ -772,30 +797,34 @@ make_config! {
|
|||||||
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
|
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
|
||||||
allowed_iframe_ancestors: String, true, def, String::new();
|
allowed_iframe_ancestors: String, true, def, String::new();
|
||||||
|
|
||||||
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
/// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||||
allowed_connect_src: String, true, def, String::new();
|
allowed_connect_src: String, true, def, String::new();
|
||||||
|
|
||||||
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
||||||
login_ratelimit_seconds: u64, false, def, 60;
|
login_ratelimit_seconds: u16, false, def, 60;
|
||||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||||
login_ratelimit_max_burst: u32, false, def, 10;
|
login_ratelimit_max_burst: u16, false, def, 10;
|
||||||
|
|
||||||
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
unauthenticated_ratelimit_seconds: u16, false, def, 60;
|
||||||
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u64, false, def, 300;
|
admin_ratelimit_seconds: u16, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
admin_ratelimit_max_burst: u32, false, def, 3;
|
admin_ratelimit_max_burst: u16, false, def, 3;
|
||||||
|
|
||||||
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
||||||
admin_session_lifetime: i64, true, def, 20;
|
admin_session_lifetime: u32, true, def, 20;
|
||||||
|
|
||||||
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
||||||
org_groups_enabled: bool, false, def, false;
|
org_groups_enabled: bool, false, def, false;
|
||||||
|
|
||||||
|
/// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations.
|
||||||
|
/// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled.
|
||||||
|
experimental_custom_roles: bool, false, def, false;
|
||||||
|
|
||||||
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
|
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||||
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||||
increase_note_size_limit: bool, true, def, false;
|
increase_note_size_limit: bool, true, def, false;
|
||||||
@@ -844,8 +873,8 @@ make_config! {
|
|||||||
sso_master_password_policy: String, true, option;
|
sso_master_password_policy: String, true, option;
|
||||||
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
||||||
sso_auth_only_not_session: bool, true, def, false;
|
sso_auth_only_not_session: bool, true, def, false;
|
||||||
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
/// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
||||||
sso_client_cache_expiration: u64, true, def, 0;
|
sso_client_cache_expiration: u32, true, def, 0;
|
||||||
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
||||||
sso_debug_tokens: bool, true, def, false;
|
sso_debug_tokens: bool, true, def, false;
|
||||||
},
|
},
|
||||||
@@ -903,11 +932,11 @@ make_config! {
|
|||||||
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
||||||
smtp_auth_mechanism: String, true, option;
|
smtp_auth_mechanism: String, true, option;
|
||||||
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
||||||
smtp_timeout: u64, true, def, 15;
|
smtp_timeout: u16, true, def, 15;
|
||||||
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
||||||
helo_name: String, true, option;
|
helo_name: String, true, option;
|
||||||
/// Embed images as email attachments.
|
/// Embed images as email attachments.
|
||||||
smtp_embed_images: bool, true, def, true;
|
smtp_embed_images: bool, true, def, true;
|
||||||
/// _smtp_img_src
|
/// _smtp_img_src
|
||||||
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
|
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
|
||||||
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
|
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
|
||||||
@@ -925,9 +954,9 @@ make_config! {
|
|||||||
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
||||||
email_token_size: u8, true, def, 6;
|
email_token_size: u8, true, def, 6;
|
||||||
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
||||||
email_expiration_time: u64, true, def, 600;
|
email_expiration_time: u32, true, def, 600;
|
||||||
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
||||||
email_attempts_limit: u64, true, def, 3;
|
email_attempts_limit: u8, true, def, 3;
|
||||||
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
||||||
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
||||||
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
||||||
@@ -983,7 +1012,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.database_min_conns > cfg.database_max_conns {
|
if cfg.database_min_conns > cfg.database_max_conns {
|
||||||
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
|
err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.database_timeout < 1 {
|
||||||
|
err!("`DATABASE_TIMEOUT` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.database_idle_timeout < 1 {
|
||||||
|
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(log_file) = &cfg.log_file
|
if let Some(log_file) = &cfg.log_file
|
||||||
@@ -999,6 +1036,21 @@ 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() {
|
||||||
@@ -1233,6 +1285,10 @@ 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")
|
||||||
@@ -1266,6 +1322,38 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression")
|
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::<Schedule>().is_err() {
|
||||||
|
err!("`DUO_CONTEXT_PURGE_SCHEDULE` is not a valid cron expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.purge_incomplete_sso_auth.is_empty() && cfg.purge_incomplete_sso_auth.parse::<Schedule>().is_err() {
|
||||||
|
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.login_ratelimit_seconds < 1 {
|
||||||
|
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.login_ratelimit_max_burst < 1 {
|
||||||
|
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.admin_ratelimit_seconds < 1 {
|
||||||
|
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.admin_ratelimit_max_burst < 1 {
|
||||||
|
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.unauthenticated_ratelimit_seconds < 1 {
|
||||||
|
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.unauthenticated_ratelimit_max_burst < 1 {
|
||||||
|
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
if !cfg.disable_admin_token {
|
if !cfg.disable_admin_token {
|
||||||
match cfg.admin_token.as_ref() {
|
match cfg.admin_token.as_ref() {
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
@@ -1421,9 +1509,15 @@ 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",
|
||||||
|
"pm-27278-v2-password-registration",
|
||||||
// Mobile Team
|
// Mobile Team
|
||||||
"pm-34171-card-scanner",
|
"pm-34171-card-scanner",
|
||||||
// Platform Team
|
// Platform Team
|
||||||
@@ -1432,11 +1526,34 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
"pm-32009-new-item-types",
|
"pm-32009-new-item-types",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize)]
|
||||||
|
pub enum ConfigFileStatus {
|
||||||
|
NotFound,
|
||||||
|
Loaded,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load() -> Result<Self, Error> {
|
pub async fn load() -> Result<Self, Error> {
|
||||||
// Loading from env and file
|
// Loading from env and file
|
||||||
let env = ConfigBuilder::from_env();
|
let env = ConfigBuilder::from_env();
|
||||||
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
let (usr, config_file_status) = match ConfigBuilder::from_file().await {
|
||||||
|
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
|
||||||
|
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
|
||||||
|
Err(e) => {
|
||||||
|
println!(
|
||||||
|
"[ERROR] Failed to load configuration from `{}`\n\
|
||||||
|
#####################################################################################\n\
|
||||||
|
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
|
||||||
|
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
|
||||||
|
# This file is not used until those errors are resolved. #\n\
|
||||||
|
#####################################################################################\n\
|
||||||
|
{e:?}\n",
|
||||||
|
*CONFIG_FILE
|
||||||
|
);
|
||||||
|
(ConfigBuilder::default(), ConfigFileStatus::Error)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create merged config, config file overwrites env
|
// Create merged config, config file overwrites env
|
||||||
let mut overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
@@ -1456,6 +1573,7 @@ impl Config {
|
|||||||
_env: env,
|
_env: env,
|
||||||
_usr: usr,
|
_usr: usr,
|
||||||
_overrides: overrides,
|
_overrides: overrides,
|
||||||
|
_config_file_status: config_file_status,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1489,6 +1607,7 @@ impl Config {
|
|||||||
writer.config = config;
|
writer.config = config;
|
||||||
writer._usr = builder;
|
writer._usr = builder;
|
||||||
writer._overrides = overrides;
|
writer._overrides = overrides;
|
||||||
|
writer._config_file_status = ConfigFileStatus::Loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Save to file
|
//Save to file
|
||||||
@@ -1507,7 +1626,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}'");
|
warn!("Failed to parse email address '{}'", email.escape_debug());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let email_domain = e[0].to_lowercase();
|
let email_domain = e[0].to_lowercase();
|
||||||
@@ -1580,6 +1699,7 @@ impl Config {
|
|||||||
writer.config = config;
|
writer.config = config;
|
||||||
writer._usr = usr;
|
writer._usr = usr;
|
||||||
writer._overrides = Vec::new();
|
writer._overrides = Vec::new();
|
||||||
|
writer._config_file_status = ConfigFileStatus::NotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
|||||||
+230
-5
@@ -1,7 +1,11 @@
|
|||||||
mod query_logger;
|
mod query_logger;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
sync::{Arc, OnceLock},
|
future::Future,
|
||||||
|
sync::{
|
||||||
|
Arc, OnceLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@ pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
|
|||||||
pub struct DbConn {
|
pub struct DbConn {
|
||||||
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
||||||
permit: Option<OwnedSemaphorePermit>,
|
permit: Option<OwnedSemaphorePermit>,
|
||||||
|
transaction_open: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -150,6 +155,7 @@ impl Drop for DbConn {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
let permit = self.permit.take();
|
let permit = self.permit.take();
|
||||||
|
let transaction_open = self.transaction_open.load(Ordering::Acquire);
|
||||||
|
|
||||||
// Since connection can't be on the stack in an async fn during an
|
// Since connection can't be on the stack in an async fn during an
|
||||||
// await, we have to spawn a new blocking-safe thread...
|
// await, we have to spawn a new blocking-safe thread...
|
||||||
@@ -157,7 +163,13 @@ impl Drop for DbConn {
|
|||||||
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
||||||
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
||||||
|
|
||||||
if let Some(conn) = conn.take() {
|
if let Some(mut conn) = conn.take() {
|
||||||
|
if transaction_open {
|
||||||
|
// A cancelled request must not return an open transaction to the pool.
|
||||||
|
if let Err(error) = conn.batch_execute("ROLLBACK") {
|
||||||
|
error!("Failed to roll back a cancelled database transaction: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
drop(conn);
|
drop(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +224,8 @@ impl DbPool {
|
|||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
.max_size(max_conns)
|
.max_size(max_conns)
|
||||||
.min_idle(Some(CONFIG.database_min_conns()))
|
.min_idle(Some(CONFIG.database_min_conns()))
|
||||||
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
|
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
|
||||||
.connection_timeout(Duration::from_secs(CONFIG.database_timeout()))
|
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
|
||||||
.connection_customizer(Box::new(DbConnOptions {
|
.connection_customizer(Box::new(DbConnOptions {
|
||||||
init_stmts: conn_type.get_init_stmts(),
|
init_stmts: conn_type.get_init_stmts(),
|
||||||
}))
|
}))
|
||||||
@@ -233,7 +245,7 @@ impl DbPool {
|
|||||||
|
|
||||||
// Get a connection from the pool
|
// Get a connection from the pool
|
||||||
pub async fn get(&self) -> Result<DbConn, Error> {
|
pub async fn get(&self) -> Result<DbConn, Error> {
|
||||||
let duration = Duration::from_secs(CONFIG.database_timeout());
|
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
|
||||||
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
||||||
Ok(p) => p.expect("Semaphore should be open"),
|
Ok(p) => p.expect("Semaphore should be open"),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -248,6 +260,7 @@ impl DbPool {
|
|||||||
Ok(DbConn {
|
Ok(DbConn {
|
||||||
conn: Arc::new(Mutex::new(Some(c))),
|
conn: Arc::new(Mutex::new(Some(c))),
|
||||||
permit: Some(permit),
|
permit: Some(permit),
|
||||||
|
transaction_open: AtomicBool::new(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +333,29 @@ impl DbConnType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DbConn {
|
impl DbConn {
|
||||||
|
/// Run asynchronous model calls on this request's pooled connection in one transaction.
|
||||||
|
/// Send external notifications only after this returns successfully.
|
||||||
|
pub async fn transaction<F, Fut, T>(&self, operation: F) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<T, Error>>,
|
||||||
|
{
|
||||||
|
self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?;
|
||||||
|
self.transaction_open.store(true, Ordering::Release);
|
||||||
|
let result = operation().await;
|
||||||
|
if result.is_ok() {
|
||||||
|
let committed = self.run(|conn| conn.batch_execute("COMMIT")).await;
|
||||||
|
let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok();
|
||||||
|
if closed {
|
||||||
|
self.transaction_open.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
committed.map_res("Error committing database transaction")?;
|
||||||
|
} else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() {
|
||||||
|
self.transaction_open.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run<F, R>(&self, f: F) -> R
|
pub async fn run<F, R>(&self, f: F) -> R
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut DbConnInner) -> R + Send,
|
F: FnOnce(&mut DbConnInner) -> R + Send,
|
||||||
@@ -534,3 +570,192 @@ mod postgresql_migrations {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
||||||
|
#[cfg(all(test, sqlite))]
|
||||||
|
pub mod test_db {
|
||||||
|
use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS};
|
||||||
|
use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection};
|
||||||
|
use diesel_migrations::MigrationHarness;
|
||||||
|
use std::sync::{Arc, atomic::AtomicBool};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct TestDb {
|
||||||
|
pool: Pool<DbConnManager>,
|
||||||
|
// Keep the named in-memory database alive across pooled connections.
|
||||||
|
_keeper: SqliteConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestDb {
|
||||||
|
fn new() -> Self {
|
||||||
|
let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid());
|
||||||
|
let mut keeper = SqliteConnection::establish(&url).unwrap();
|
||||||
|
keeper.run_pending_migrations(MIGRATIONS).unwrap();
|
||||||
|
let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap();
|
||||||
|
Self {
|
||||||
|
pool,
|
||||||
|
_keeper: keeper,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conn(&self) -> DbConn {
|
||||||
|
DbConn {
|
||||||
|
conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))),
|
||||||
|
permit: None,
|
||||||
|
transaction_open: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized.
|
||||||
|
pub fn run(test: impl AsyncFnOnce(&TestDb)) {
|
||||||
|
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
||||||
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
||||||
|
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
||||||
|
.env("DATABASE_URL", "sqlite://:memory:")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
||||||
|
"{}\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// DbConn::run uses block_in_place, requiring a multi-threaded runtime.
|
||||||
|
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
|
||||||
|
test(&TestDb::new()).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
||||||
|
use super::schema::users_organizations::dsl::*;
|
||||||
|
use diesel::{migration::MigrationSource, prelude::*};
|
||||||
|
let mut conn = SqliteConnection::establish(":memory:").unwrap();
|
||||||
|
for migration in conn.pending_migrations(MIGRATIONS).unwrap() {
|
||||||
|
if migration.name().version().to_string() == "20261010120000" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
conn.run_migration(&migration).unwrap();
|
||||||
|
}
|
||||||
|
conn.batch_execute(
|
||||||
|
"PRAGMA foreign_keys=OFF;
|
||||||
|
INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b');
|
||||||
|
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES
|
||||||
|
('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1),
|
||||||
|
('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rows = |conn: &mut SqliteConnection| {
|
||||||
|
users_organizations
|
||||||
|
.select((uuid, user_uuid, org_uuid, access_all, akey, status, atype))
|
||||||
|
.order(uuid)
|
||||||
|
.load::<(String, String, String, bool, String, i32, i32)>(conn)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let before = rows(&mut conn);
|
||||||
|
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||||
|
assert_eq!(rows(&mut conn), before);
|
||||||
|
let grants = |conn: &mut SqliteConnection| {
|
||||||
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>(
|
||||||
|
"(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies
|
||||||
|
OR create_new_collections OR edit_any_collection OR delete_any_collection
|
||||||
|
OR access_event_logs OR access_import_export OR access_reports)",
|
||||||
|
))
|
||||||
|
.get_result::<i64>(conn)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(grants(&mut conn), 0);
|
||||||
|
let migration = MigrationSource::<diesel::sqlite::Sqlite>::migrations(&MIGRATIONS)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| m.name().version().to_string() == "20261010120000")
|
||||||
|
.unwrap();
|
||||||
|
for member_status in [1, 2] {
|
||||||
|
conn.batch_execute(&format!(
|
||||||
|
"UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let custom = rows(&mut conn);
|
||||||
|
assert!(conn.revert_migration(&*migration).is_err());
|
||||||
|
assert_eq!(rows(&mut conn), custom);
|
||||||
|
assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::<bool>(&mut conn).unwrap());
|
||||||
|
}
|
||||||
|
conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'")
|
||||||
|
.unwrap();
|
||||||
|
conn.revert_migration(&*migration).unwrap();
|
||||||
|
assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err());
|
||||||
|
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||||
|
assert_eq!(grants(&mut conn), 0);
|
||||||
|
assert_eq!(rows(&mut conn), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User};
|
||||||
|
|
||||||
|
pub async fn organization(conn: &DbConn) -> Organization {
|
||||||
|
let org = Organization::new("Org".into(), "billing@example.com", None, None);
|
||||||
|
org.save(conn).await.unwrap();
|
||||||
|
org
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn member(
|
||||||
|
conn: &DbConn,
|
||||||
|
org: &OrganizationId,
|
||||||
|
role: MembershipType,
|
||||||
|
permissions: &[CustomPermission],
|
||||||
|
) -> (User, Membership) {
|
||||||
|
let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None);
|
||||||
|
user.password_hash = b"existing-account".to_vec();
|
||||||
|
user.save(conn).await.unwrap();
|
||||||
|
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
|
||||||
|
member.set_type(role);
|
||||||
|
member.status = 2;
|
||||||
|
member.set_custom_permissions(&permissions.iter().copied().collect());
|
||||||
|
member.save(conn).await.unwrap();
|
||||||
|
(user, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) {
|
||||||
|
let org = organization(conn).await;
|
||||||
|
let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await;
|
||||||
|
(org, user, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection {
|
||||||
|
let collection = Collection::new(org.clone(), "Collection".into(), None);
|
||||||
|
collection.save(conn).await.unwrap();
|
||||||
|
collection
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transactions_rollback_before_connection_reuse_and_commit_success() {
|
||||||
|
use crate::error::MapResult;
|
||||||
|
use diesel::RunQueryDsl;
|
||||||
|
run(async |db| {
|
||||||
|
let conn = db.conn();
|
||||||
|
conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap();
|
||||||
|
for (sql, expected) in [
|
||||||
|
("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64),
|
||||||
|
("INSERT INTO probe VALUES (1)", 1),
|
||||||
|
] {
|
||||||
|
let result: Result<(), super::Error> = conn
|
||||||
|
.transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(result.is_ok(), expected == 1);
|
||||||
|
let count: i64 = conn
|
||||||
|
.run(|c| {
|
||||||
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>("(SELECT COUNT(*) FROM probe)"))
|
||||||
|
.get_result(c)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, expected);
|
||||||
|
// The same live connection must accept another transaction, before DbConn::drop can help.
|
||||||
|
conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,6 +166,15 @@ impl AuthRequest {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error deleting auth requests for user")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
|
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
|
||||||
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
|
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
@@ -188,7 +197,7 @@ impl AuthRequest {
|
|||||||
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
||||||
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
||||||
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
||||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
|
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
|
||||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||||
auth_request.delete(conn).await.ok();
|
auth_request.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+271
-85
@@ -1,4 +1,4 @@
|
|||||||
use std::borrow::Cow;
|
use std::{borrow::Cow, collections::HashSet};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
@@ -19,13 +19,13 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
util::LowerCase,
|
util::{LowerCase, convert_json_key_lcase_first},
|
||||||
};
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
|
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
|
||||||
MembershipStatus, MembershipType, OrganizationId, User, UserId,
|
MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -63,14 +63,68 @@ pub struct Cipher {
|
|||||||
pub reprompt: Option<i32>,
|
pub reprompt: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
|
||||||
|
pub fn is_data_blob_encrypted(data: &str) -> bool {
|
||||||
|
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
|
||||||
|
fn is_blob_value(data: &Value) -> bool {
|
||||||
|
data.get("format_version").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub enum RepromptType {
|
pub enum RepromptType {
|
||||||
None = 0,
|
None = 0,
|
||||||
Password = 1,
|
Password = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which access a cipher operation is authorized with.
|
||||||
|
///
|
||||||
|
/// The administrative cipher routes (`/ciphers/<id>/admin` and friends) are served by the same handlers as the
|
||||||
|
/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit
|
||||||
|
/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their
|
||||||
|
/// own vault limited to the collections they are assigned to.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CipherAccessScope {
|
||||||
|
User,
|
||||||
|
OrganizationAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CipherAccessScope {
|
||||||
|
/// Whether `member` reaches every cipher of their organization with this access.
|
||||||
|
fn reaches_all_ciphers(self, member: &Membership) -> bool {
|
||||||
|
match self {
|
||||||
|
CipherAccessScope::User => member.has_full_access(),
|
||||||
|
CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the
|
||||||
|
/// administrative one still needs a member who may edit any collection.
|
||||||
|
pub fn requested(admin_request: Option<bool>) -> Self {
|
||||||
|
if admin_request == Some(true) {
|
||||||
|
CipherAccessScope::OrganizationAdmin
|
||||||
|
} else {
|
||||||
|
CipherAccessScope::User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The access for a route that does not tell which one it is, from the member's own membership.
|
||||||
|
pub fn for_member(member: Option<&Membership>) -> Self {
|
||||||
|
if member.is_some_and(Membership::can_edit_any_collection) {
|
||||||
|
CipherAccessScope::OrganizationAdmin
|
||||||
|
} else {
|
||||||
|
CipherAccessScope::User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Cipher {
|
impl Cipher {
|
||||||
pub fn new(atype: i32, name: String) -> Self {
|
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
|
||||||
|
pub fn new(atype: i32) -> Self {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -84,7 +138,7 @@ impl Cipher {
|
|||||||
key: None,
|
key: None,
|
||||||
|
|
||||||
atype,
|
atype,
|
||||||
name,
|
name: String::new(),
|
||||||
|
|
||||||
notes: None,
|
notes: None,
|
||||||
fields: None,
|
fields: None,
|
||||||
@@ -110,6 +164,16 @@ 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(is_blob) {
|
||||||
|
validation_errors
|
||||||
|
.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 {
|
||||||
for pwh in password_history {
|
for pwh in password_history {
|
||||||
@@ -151,9 +215,36 @@ impl Cipher {
|
|||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
sync_type: CipherSyncType,
|
sync_type: CipherSyncType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
|
) -> Result<Value, crate::Error> {
|
||||||
|
self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there.
|
||||||
|
pub async fn to_json_org_admin(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Result<Value, crate::Error> {
|
||||||
|
self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn to_json_scoped(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
|
sync_type: CipherSyncType,
|
||||||
|
scope: CipherAccessScope,
|
||||||
|
conn: &DbConn,
|
||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
use crate::util::{format_date, validate_and_format_date};
|
use crate::util::{format_date, validate_and_format_date};
|
||||||
|
|
||||||
|
// Parsed once, since `data` can be large and this runs for every cipher in a sync
|
||||||
|
let type_data = serde_json::from_str::<Value>(&self.data);
|
||||||
|
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
|
||||||
|
|
||||||
let mut attachments_json: Value = Value::Null;
|
let mut attachments_json: Value = Value::Null;
|
||||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
|
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
|
||||||
@@ -179,7 +270,7 @@ impl Cipher {
|
|||||||
// We don't need these values at all for Organizational syncs
|
// We don't need these values at all for Organizational syncs
|
||||||
// Skip any other database calls if this is the case and just return false.
|
// Skip any other database calls if this is the case and just return false.
|
||||||
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
|
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
|
||||||
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await {
|
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await {
|
||||||
(ro, hp, mn)
|
(ro, hp, mn)
|
||||||
} else {
|
} else {
|
||||||
error!("Cipher ownership assertion failure");
|
error!("Cipher ownership assertion failure");
|
||||||
@@ -250,63 +341,9 @@ impl Cipher {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Get the type_data or a default to an empty json object '{}'.
|
// Like upstream, fields and password history stored as NULL are sent as null, not `[]`
|
||||||
// If not passing an empty object, mobile clients will crash.
|
let fields_json = self.fields.is_some().then_some(fields_json);
|
||||||
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
|
let password_history_json = self.password_history.is_some().then_some(password_history_json);
|
||||||
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
|
|
||||||
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
|
|
||||||
|
|
||||||
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
|
|
||||||
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
|
|
||||||
if self.atype == 1 {
|
|
||||||
// Upstream always has an `uri` key/value
|
|
||||||
type_data_json["uri"] = Value::Null;
|
|
||||||
if let Some(uris) = type_data_json["uris"].as_array_mut()
|
|
||||||
&& !uris.is_empty()
|
|
||||||
{
|
|
||||||
// Fix uri match values first, they are only allowed to be a number or null
|
|
||||||
// If it is a string, convert it to an int or null if that fails
|
|
||||||
for uri in &mut *uris {
|
|
||||||
if uri["match"].is_string() {
|
|
||||||
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
|
|
||||||
Ok(n) => json!(n),
|
|
||||||
_ => Value::Null,
|
|
||||||
};
|
|
||||||
uri["match"] = match_value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type_data_json["uri"] = uris[0]["uri"].clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if `passwordRevisionDate` is a valid date, else convert it
|
|
||||||
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
|
|
||||||
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix secure note issues when data is invalid
|
|
||||||
// This breaks at least the native mobile clients
|
|
||||||
if self.atype == 2 {
|
|
||||||
match type_data_json {
|
|
||||||
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
|
|
||||||
_ => {
|
|
||||||
type_data_json = json!({"type": 0});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix invalid SSH Entries
|
|
||||||
// This breaks at least the native mobile client if invalid
|
|
||||||
// The only way to fix this is by setting type_data_json to `null`
|
|
||||||
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
|
|
||||||
if self.atype == 5
|
|
||||||
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|
|
||||||
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|
|
||||||
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
|
|
||||||
{
|
|
||||||
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
|
|
||||||
type_data_json = Value::Null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
||||||
@@ -403,10 +440,84 @@ impl Cipher {
|
|||||||
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
|
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
|
||||||
};
|
};
|
||||||
|
|
||||||
json_object[key] = type_data_json;
|
if is_blob_encrypted {
|
||||||
|
// Like upstream, sent as-is with the structured fields null
|
||||||
|
json_object["data"] = json!(self.data);
|
||||||
|
json_object["name"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
json_object[key] = self.legacy_type_data_json(type_data);
|
||||||
|
}
|
||||||
Ok(json_object)
|
Ok(json_object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
|
||||||
|
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
|
||||||
|
use crate::util::validate_and_format_date;
|
||||||
|
|
||||||
|
// Get the type_data or a default to an empty json object '{}'.
|
||||||
|
// If not passing an empty object, mobile clients will crash.
|
||||||
|
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
|
||||||
|
convert_json_key_lcase_first(data)
|
||||||
|
} else {
|
||||||
|
warn!("Error parsing data field for {}", self.uuid);
|
||||||
|
Value::Object(serde_json::Map::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
|
||||||
|
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
|
||||||
|
if self.atype == 1 {
|
||||||
|
// Upstream always has an `uri` key/value
|
||||||
|
type_data_json["uri"] = Value::Null;
|
||||||
|
if let Some(uris) = type_data_json["uris"].as_array_mut()
|
||||||
|
&& !uris.is_empty()
|
||||||
|
{
|
||||||
|
// Fix uri match values first, they are only allowed to be a number or null
|
||||||
|
// If it is a string, convert it to an int or null if that fails
|
||||||
|
for uri in &mut *uris {
|
||||||
|
if uri["match"].is_string() {
|
||||||
|
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
|
||||||
|
Ok(n) => json!(n),
|
||||||
|
_ => Value::Null,
|
||||||
|
};
|
||||||
|
uri["match"] = match_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type_data_json["uri"] = uris[0]["uri"].clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if `passwordRevisionDate` is a valid date, else convert it
|
||||||
|
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
|
||||||
|
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix secure note issues when data is invalid
|
||||||
|
// This breaks at least the native mobile clients
|
||||||
|
if self.atype == 2 {
|
||||||
|
match type_data_json {
|
||||||
|
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
|
||||||
|
_ => {
|
||||||
|
type_data_json = json!({"type": 0});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix invalid SSH Entries
|
||||||
|
// This breaks at least the native mobile client if invalid
|
||||||
|
// The only way to fix this is by setting type_data_json to `null`
|
||||||
|
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
|
||||||
|
if self.atype == 5
|
||||||
|
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|
||||||
|
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|
||||||
|
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
|
||||||
|
{
|
||||||
|
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
|
||||||
|
type_data_json = Value::Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type_data_json
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
|
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
|
||||||
let mut user_uuids = Vec::new();
|
let mut user_uuids = Vec::new();
|
||||||
match self.user_uuid {
|
match self.user_uuid {
|
||||||
@@ -494,12 +605,22 @@ impl Cipher {
|
|||||||
|
|
||||||
/// Purge all ciphers that are old enough to be auto-deleted.
|
/// Purge all ciphers that are old enough to be auto-deleted.
|
||||||
pub async fn purge_trash(conn: &DbConn) {
|
pub async fn purge_trash(conn: &DbConn) {
|
||||||
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
|
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
|
||||||
let now = Utc::now().naive_utc();
|
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
|
||||||
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
|
return;
|
||||||
for cipher in Self::find_deleted_before(&dt, conn).await {
|
};
|
||||||
cipher.delete(conn).await.ok();
|
|
||||||
}
|
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
|
||||||
|
error!("Unable to calculate auto delete days!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let Some(dt) = now.checked_sub_signed(days) else {
|
||||||
|
error!("Unable to calculate trash purge date!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for cipher in Self::find_deleted_before(&dt, conn).await {
|
||||||
|
cipher.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +631,16 @@ 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(()),
|
||||||
@@ -543,20 +673,21 @@ impl Cipher {
|
|||||||
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether this cipher is owned by an org in which the user has full access.
|
/// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`.
|
||||||
async fn is_in_full_access_org(
|
async fn is_in_full_access_org(
|
||||||
&self,
|
&self,
|
||||||
user_uuid: &UserId,
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if let Some(ref org_uuid) = self.organization_uuid {
|
if let Some(ref org_uuid) = self.organization_uuid {
|
||||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
||||||
return cached_member.has_full_access();
|
return scope.reaches_all_ciphers(cached_member);
|
||||||
}
|
}
|
||||||
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||||
return member.has_full_access();
|
return scope.reaches_all_ciphers(&member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -589,6 +720,7 @@ impl Cipher {
|
|||||||
pub async fn get_access_restrictions(
|
pub async fn get_access_restrictions(
|
||||||
&self,
|
&self,
|
||||||
user_uuid: &UserId,
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<(bool, bool, bool)> {
|
) -> Option<(bool, bool, bool)> {
|
||||||
@@ -596,7 +728,7 @@ impl Cipher {
|
|||||||
// a collection that the user has full access to. If so, there are no
|
// a collection that the user has full access to. If so, there are no
|
||||||
// access restrictions.
|
// access restrictions.
|
||||||
if self.is_owned_by_user(user_uuid)
|
if self.is_owned_by_user(user_uuid)
|
||||||
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await
|
|| self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await
|
||||||
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
||||||
{
|
{
|
||||||
return Some((false, false, true));
|
return Some((false, false, true));
|
||||||
@@ -719,8 +851,13 @@ impl Cipher {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_write_accessible_to_user(
|
||||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
&self,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> bool {
|
||||||
|
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
|
||||||
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
@@ -728,15 +865,20 @@ impl Cipher {
|
|||||||
|
|
||||||
// used for checking if collection can be edited (only if user has access to a collection they
|
// used for checking if collection can be edited (only if user has access to a collection they
|
||||||
// can write to and also passwords are not hidden to prevent privilege escalation)
|
// can write to and also passwords are not hidden to prevent privilege escalation)
|
||||||
pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_in_editable_collection_by_user(
|
||||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
&self,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> bool {
|
||||||
|
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
|
||||||
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
|
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
|
||||||
self.get_access_restrictions(user_uuid, None, conn).await.is_some()
|
self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns whether this cipher is a favorite of the specified user.
|
// Returns whether this cipher is a favorite of the specified user.
|
||||||
@@ -853,7 +995,7 @@ impl Cipher {
|
|||||||
|
|
||||||
if !visible_only {
|
if !visible_only {
|
||||||
query = query.or_filter(
|
query = query.or_filter(
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +1030,7 @@ impl Cipher {
|
|||||||
|
|
||||||
if !visible_only {
|
if !visible_only {
|
||||||
query = query.or_filter(
|
query = query.or_filter(
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,7 +1234,7 @@ impl Cipher {
|
|||||||
.or(collections_groups::collections_uuid
|
.or(collections_groups::collections_uuid
|
||||||
.is_not_null() // Access via groups
|
.is_not_null() // Access via groups
|
||||||
.and(collections_groups::read_only.eq(false)))
|
.and(collections_groups::read_only.eq(false)))
|
||||||
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner
|
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
|
||||||
)
|
)
|
||||||
.select(ciphers_collections::collection_uuid)
|
.select(ciphers_collections::collection_uuid)
|
||||||
.load::<CollectionId>(conn)
|
.load::<CollectionId>(conn)
|
||||||
@@ -1121,7 +1263,7 @@ impl Cipher {
|
|||||||
.or(users_collections::user_uuid
|
.or(users_collections::user_uuid
|
||||||
.eq(user_uuid) // User has access to collection
|
.eq(user_uuid) // User has access to collection
|
||||||
.and(users_collections::read_only.eq(false)))
|
.and(users_collections::read_only.eq(false)))
|
||||||
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner
|
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
|
||||||
)
|
)
|
||||||
.select(ciphers_collections::collection_uuid)
|
.select(ciphers_collections::collection_uuid)
|
||||||
.load::<CollectionId>(conn)
|
.load::<CollectionId>(conn)
|
||||||
@@ -1163,7 +1305,7 @@ impl Cipher {
|
|||||||
)
|
)
|
||||||
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
|
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
|
||||||
.or_filter(users_organizations::access_all.eq(true)) // User has access all
|
.or_filter(users_organizations::access_all.eq(true)) // User has access all
|
||||||
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
.or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
|
||||||
.or_filter(groups::access_all.eq(true)) //Access via group
|
.or_filter(groups::access_all.eq(true)) //Access via group
|
||||||
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
@@ -1174,6 +1316,50 @@ impl Cipher {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every collection of every cipher of the organization, for the members who may see all of them.
|
||||||
|
pub async fn get_collections_with_cipher_by_organization(
|
||||||
|
org_uuid: &OrganizationId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Vec<(CipherId, CollectionId)> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
ciphers_collections::table
|
||||||
|
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
|
||||||
|
.filter(collections::org_uuid.eq(org_uuid))
|
||||||
|
.select(ciphers_collections::all_columns)
|
||||||
|
.load::<(CipherId, CollectionId)>(conn)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like
|
||||||
|
/// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in
|
||||||
|
/// no collection at all.
|
||||||
|
pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec<Self>, Vec<Self>) {
|
||||||
|
let assigned: Vec<Self> = Self::find_by_user_visible(&member.user_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid))
|
||||||
|
.collect();
|
||||||
|
if !member.can_edit_any_collection() {
|
||||||
|
return (assigned, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listed: HashSet<CipherId> = assigned.iter().map(|cipher| cipher.uuid.clone()).collect();
|
||||||
|
let in_collections: HashSet<CipherId> =
|
||||||
|
Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|(cipher, _)| cipher)
|
||||||
|
.collect();
|
||||||
|
let unassigned = Self::find_by_org(&member.org_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid))
|
||||||
|
.collect();
|
||||||
|
(assigned, unassigned)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
|
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
|
||||||
User, UserId,
|
User, UserId, organization::ORG_ADMIN_ATYPES,
|
||||||
};
|
};
|
||||||
|
|
||||||
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
|
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
|
||||||
@@ -375,7 +375,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
@@ -412,7 +412,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
.select(collections::all_columns)
|
.select(collections::all_columns)
|
||||||
@@ -455,7 +455,7 @@ impl Collection {
|
|||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::atype
|
users_organizations::atype
|
||||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
|
||||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||||
.or(users_collections::collection_uuid
|
.or(users_collections::collection_uuid
|
||||||
.eq(&self.uuid) // write access given to collection
|
.eq(&self.uuid) // write access given to collection
|
||||||
@@ -489,7 +489,7 @@ impl Collection {
|
|||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::atype
|
users_organizations::atype
|
||||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
|
||||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||||
.or(users_collections::collection_uuid
|
.or(users_collections::collection_uuid
|
||||||
.eq(&self.uuid) // write access given to collection
|
.eq(&self.uuid) // write access given to collection
|
||||||
@@ -540,7 +540,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
@@ -600,7 +600,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -112,8 +112,26 @@ impl EmergencyAccess {
|
|||||||
"object": "emergencyAccessGranteeDetails",
|
"object": "emergencyAccessGranteeDetails",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
|
||||||
|
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
|
||||||
|
err!("Emergency access wait time is out-of-bounds!")
|
||||||
|
}
|
||||||
|
|
||||||
|
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
|
||||||
|
|
||||||
|
TimeDelta::try_days(i64::from(self.wait_time_days))
|
||||||
|
.and_then(|w| initiated_at.checked_add_signed(w))
|
||||||
|
.map_res("Emergency access deadline is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
|
||||||
|
Ok(self.deadline_at()?.le(now))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
|
||||||
|
|
||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub enum EmergencyAccessType {
|
pub enum EmergencyAccessType {
|
||||||
View = 0,
|
View = 0,
|
||||||
@@ -169,27 +187,42 @@ impl EmergencyAccess {
|
|||||||
|
|
||||||
pub async fn update_access_status_and_save(
|
pub async fn update_access_status_and_save(
|
||||||
&mut self,
|
&mut self,
|
||||||
status: i32,
|
from_status: i32,
|
||||||
|
to_status: i32,
|
||||||
date: &NaiveDateTime,
|
date: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
|
let uuid = self.uuid.clone();
|
||||||
|
let updated_at = *date;
|
||||||
|
let res = conn
|
||||||
|
.run(move |conn| {
|
||||||
|
crate::util::retry(
|
||||||
|
|| {
|
||||||
|
diesel::update(
|
||||||
|
emergency_access::table
|
||||||
|
.filter(emergency_access::uuid.eq(&uuid))
|
||||||
|
.filter(emergency_access::status.eq(from_status)),
|
||||||
|
)
|
||||||
|
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
|
||||||
|
.execute(conn)
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(1) => {} // One record updated, which is valid
|
||||||
|
Ok(_) => err!("Emergency access status was changed already"),
|
||||||
|
Err(e) => err!("Error updating emergency access status", format!("{e}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status = to_status;
|
||||||
|
self.updated_at = updated_at;
|
||||||
|
|
||||||
// Update the grantee so that it will refresh it's status.
|
// Update the grantee so that it will refresh it's status.
|
||||||
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
||||||
self.status = status;
|
Ok(())
|
||||||
date.clone_into(&mut self.updated_at);
|
|
||||||
|
|
||||||
conn.run(move |conn| {
|
|
||||||
crate::util::retry(
|
|
||||||
|| {
|
|
||||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
|
||||||
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
|
||||||
.execute(conn)
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
)
|
|
||||||
.map_res("Error updating emergency access status")
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||||
|
|||||||
+20
-13
@@ -79,6 +79,7 @@ pub enum EventType {
|
|||||||
CipherSoftDeleted = 1115,
|
CipherSoftDeleted = 1115,
|
||||||
CipherRestored = 1116,
|
CipherRestored = 1116,
|
||||||
CipherClientToggledCardNumberVisible = 1117,
|
CipherClientToggledCardNumberVisible = 1117,
|
||||||
|
CipherClientHttpAuthReleased = 1133,
|
||||||
|
|
||||||
// Collection
|
// Collection
|
||||||
CollectionCreated = 1300,
|
CollectionCreated = 1300,
|
||||||
@@ -111,8 +112,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,
|
||||||
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
|
OrganizationUserRevokedTwoFactorNonCompliance = 1520,
|
||||||
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
|
OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
|
||||||
OrganizationUserNotificationBannerActionClicked = 1522,
|
OrganizationUserNotificationBannerActionClicked = 1522,
|
||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
@@ -347,17 +348,23 @@ impl Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||||
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
|
||||||
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
return Ok(());
|
||||||
conn.run(move |conn| {
|
};
|
||||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
|
||||||
.execute(conn)
|
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
|
||||||
.map_res("Error cleaning old events")
|
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
||||||
})
|
};
|
||||||
.await
|
|
||||||
} else {
|
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
|
||||||
Ok(())
|
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
||||||
}
|
};
|
||||||
|
conn.run(move |conn| {
|
||||||
|
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error cleaning old events")
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,15 +137,15 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json_details_for_group(&self) -> Value {
|
pub fn to_json_details_for_group(&self) -> Value {
|
||||||
// If both read_only and hide_passwords are false, then manage should be true
|
// Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group
|
||||||
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
// permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage"
|
||||||
// Or an entry with everything to false
|
// in GET /organizations/<org>/collections/details, while GET /groups/<id>/details reported the stored
|
||||||
// For backwards compatibility and migration proposes we keep checking read_only and hide_password
|
// value. Saving the collection access dialog then persisted manage=true.
|
||||||
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 || (!self.read_only && !self.hide_passwords),
|
"manage": self.manage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -17,26 +17,30 @@ mod two_factor;
|
|||||||
mod two_factor_duo_context;
|
mod two_factor_duo_context;
|
||||||
mod two_factor_incomplete;
|
mod two_factor_incomplete;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod user_signature_key_pair;
|
||||||
|
|
||||||
pub use self::archive::Archive;
|
pub use self::archive::Archive;
|
||||||
pub use self::attachment::{Attachment, AttachmentId};
|
pub use self::attachment::{Attachment, AttachmentId};
|
||||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||||
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
pub use self::cipher::{Cipher, CipherAccessScope, 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::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
pub use self::emergency_access::{
|
||||||
|
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
|
||||||
|
};
|
||||||
pub use self::event::{Event, EventType};
|
pub use self::event::{Event, EventType};
|
||||||
pub use self::favorite::Favorite;
|
pub use self::favorite::Favorite;
|
||||||
pub use self::folder::{Folder, FolderCipher, FolderId};
|
pub use self::folder::{Folder, FolderCipher, FolderId};
|
||||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||||
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
|
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||||
pub use self::organization::{
|
pub use self::organization::{
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
|
||||||
OrganizationId,
|
OrganizationApiKey, OrganizationId, custom_roles_enabled,
|
||||||
};
|
};
|
||||||
pub use self::send::{Send, SendFileId, SendId, SendType};
|
pub use self::send::{Send, SendFileId, SendId, SendType};
|
||||||
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
||||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||||
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
|
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
|
||||||
|
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, From};
|
use derive_more::{AsRef, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -11,6 +12,7 @@ use crate::{
|
|||||||
schema::{org_policies, users_organizations},
|
schema::{org_policies, users_organizations},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
|
util::format_date,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
||||||
@@ -24,6 +26,7 @@ pub struct OrgPolicy {
|
|||||||
pub atype: i32,
|
pub atype: i32,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub data: String,
|
pub data: String,
|
||||||
|
pub revision_date: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
|
||||||
@@ -46,10 +49,12 @@ pub enum OrgPolicyType {
|
|||||||
RemoveUnlockWithPin = 14,
|
RemoveUnlockWithPin = 14,
|
||||||
RestrictedItemTypes = 15,
|
RestrictedItemTypes = 15,
|
||||||
UriMatchDefaults = 16,
|
UriMatchDefaults = 16,
|
||||||
// AutotypeDefaultSetting = 17, // Not supported yet
|
AutotypeDefaultSetting = 17,
|
||||||
// AutoConfirm = 18, // Not supported (not implemented yet)
|
// AutoConfirm = 18, // Not supported (not implemented yet)
|
||||||
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
||||||
OrganizationUserNotification = 20,
|
OrganizationUserNotification = 20,
|
||||||
|
// 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
|
||||||
@@ -68,6 +73,14 @@ 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 {
|
||||||
@@ -77,6 +90,7 @@ impl OrgPolicy {
|
|||||||
atype: atype as i32,
|
atype: atype as i32,
|
||||||
enabled,
|
enabled,
|
||||||
data,
|
data,
|
||||||
|
revision_date: Utc::now().naive_utc(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +106,7 @@ impl OrgPolicy {
|
|||||||
"type": self.atype,
|
"type": self.atype,
|
||||||
"data": data_json,
|
"data": data_json,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"revisionDate": null,
|
"revisionDate": format_date(&self.revision_date),
|
||||||
"object": "policy",
|
"object": "policy",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,11 +124,13 @@ impl OrgPolicy {
|
|||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl OrgPolicy {
|
impl OrgPolicy {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
|
self.revision_date = Utc::now().naive_utc();
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
sqlite, mysql {
|
||||||
match diesel::replace_into(org_policies::table)
|
match diesel::replace_into(org_policies::table)
|
||||||
.values(self)
|
.values(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
@@ -122,7 +138,7 @@ impl OrgPolicy {
|
|||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
||||||
diesel::update(org_policies::table)
|
diesel::update(org_policies::table)
|
||||||
.filter(org_policies::uuid.eq(&self.uuid))
|
.filter(org_policies::uuid.eq(&self.uuid))
|
||||||
.set(self)
|
.set(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving org_policy")
|
.map_res("Error saving org_policy")
|
||||||
}
|
}
|
||||||
@@ -142,10 +158,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")
|
||||||
}
|
}
|
||||||
|
|||||||
+362
-25
@@ -1,4 +1,7 @@
|
|||||||
use std::cmp::Ordering;
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
@@ -21,8 +24,8 @@ use crate::{
|
|||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
|
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
|
||||||
TwoFactor, User, UserId,
|
OrgPolicyType, TwoFactor, User, UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -41,6 +44,7 @@ pub struct Organization {
|
|||||||
#[diesel(table_name = users_organizations)]
|
#[diesel(table_name = users_organizations)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
#[diesel(primary_key(uuid))]
|
#[diesel(primary_key(uuid))]
|
||||||
|
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
|
||||||
pub struct Membership {
|
pub struct Membership {
|
||||||
pub uuid: MembershipId,
|
pub uuid: MembershipId,
|
||||||
pub user_uuid: UserId,
|
pub user_uuid: UserId,
|
||||||
@@ -54,6 +58,20 @@ pub struct Membership {
|
|||||||
pub atype: i32,
|
pub atype: i32,
|
||||||
pub reset_password_key: Option<String>,
|
pub reset_password_key: Option<String>,
|
||||||
pub external_id: Option<String>,
|
pub external_id: Option<String>,
|
||||||
|
|
||||||
|
// The permissions of a Custom role, only accessed through `CustomPermission`.
|
||||||
|
manage_users: bool,
|
||||||
|
manage_groups: bool,
|
||||||
|
manage_policies: bool,
|
||||||
|
create_new_collections: bool,
|
||||||
|
edit_any_collection: bool,
|
||||||
|
delete_any_collection: bool,
|
||||||
|
access_event_logs: bool,
|
||||||
|
access_import_export: bool,
|
||||||
|
access_reports: bool,
|
||||||
|
|
||||||
|
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
|
||||||
|
pub v2_upgrade_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -89,42 +107,68 @@ impl MembershipStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
|
||||||
pub enum MembershipType {
|
pub enum MembershipType {
|
||||||
Owner = 0,
|
Owner = 0,
|
||||||
Admin = 1,
|
Admin = 1,
|
||||||
User = 2,
|
User = 2,
|
||||||
|
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
|
||||||
Manager = 3,
|
Manager = 3,
|
||||||
|
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
|
||||||
|
Custom = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MembershipType {
|
impl MembershipType {
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
#[expect(
|
|
||||||
clippy::match_same_arms,
|
|
||||||
reason = "Specifically define `4|Custom` since this is a hack, not a default"
|
|
||||||
)]
|
|
||||||
match s {
|
match s {
|
||||||
"0" | "Owner" => Some(MembershipType::Owner),
|
"0" | "Owner" => Some(MembershipType::Owner),
|
||||||
"1" | "Admin" => Some(MembershipType::Admin),
|
"1" | "Admin" => Some(MembershipType::Admin),
|
||||||
"2" | "User" => Some(MembershipType::User),
|
"2" | "User" => Some(MembershipType::User),
|
||||||
"3" | "Manager" => Some(MembershipType::Manager),
|
"3" | "Manager" => Some(MembershipType::Manager),
|
||||||
// HACK: We convert the custom role to a manager role
|
"4" | "Custom" => Some(MembershipType::Custom),
|
||||||
"4" | "Custom" => Some(MembershipType::Manager),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The role a member invite or edit from the Bitwarden clients asks for, given the member's current
|
||||||
|
/// role (`None` for an invite).
|
||||||
|
///
|
||||||
|
/// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a
|
||||||
|
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing
|
||||||
|
/// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never
|
||||||
|
/// turns one into the other.
|
||||||
|
pub fn requested(
|
||||||
|
requested: MembershipType,
|
||||||
|
current: Option<&Membership>,
|
||||||
|
custom_roles_enabled: bool,
|
||||||
|
) -> MembershipType {
|
||||||
|
if requested != MembershipType::Custom {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
match current {
|
||||||
|
Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
|
||||||
|
Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
|
||||||
|
_ if custom_roles_enabled => MembershipType::Custom,
|
||||||
|
_ => MembershipType::Manager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The authority of a role, which is what roles are compared by. A Custom member gets its authority from
|
||||||
|
/// its permissions, never from its role, so the role itself ranks right above User and below Manager.
|
||||||
|
const fn rank(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MembershipType::User => 0,
|
||||||
|
MembershipType::Custom => 1,
|
||||||
|
MembershipType::Manager => 2,
|
||||||
|
MembershipType::Admin => 3,
|
||||||
|
MembershipType::Owner => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ord for MembershipType {
|
impl Ord for MembershipType {
|
||||||
fn cmp(&self, other: &MembershipType) -> Ordering {
|
fn cmp(&self, other: &MembershipType) -> Ordering {
|
||||||
// For easy comparison, map each variant to an access level (where 0 is lowest).
|
self.rank().cmp(&other.rank())
|
||||||
const ACCESS_LEVEL: [i32; 4] = [
|
|
||||||
3, // Owner
|
|
||||||
2, // Admin
|
|
||||||
0, // User
|
|
||||||
1, // Manager && Custom
|
|
||||||
];
|
|
||||||
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +224,91 @@ impl PartialOrd<MembershipType> for i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The granular permissions of the Custom role.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum CustomPermission {
|
||||||
|
ManageUsers,
|
||||||
|
ManageGroups,
|
||||||
|
ManagePolicies,
|
||||||
|
CreateNewCollections,
|
||||||
|
EditAnyCollection,
|
||||||
|
DeleteAnyCollection,
|
||||||
|
AccessEventLogs,
|
||||||
|
AccessImportExport,
|
||||||
|
AccessReports,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
|
||||||
|
/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
|
||||||
|
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[];
|
||||||
|
|
||||||
|
impl CustomPermission {
|
||||||
|
pub const ALL: [CustomPermission; 9] = [
|
||||||
|
CustomPermission::ManageUsers,
|
||||||
|
CustomPermission::ManageGroups,
|
||||||
|
CustomPermission::ManagePolicies,
|
||||||
|
CustomPermission::CreateNewCollections,
|
||||||
|
CustomPermission::EditAnyCollection,
|
||||||
|
CustomPermission::DeleteAnyCollection,
|
||||||
|
CustomPermission::AccessEventLogs,
|
||||||
|
CustomPermission::AccessImportExport,
|
||||||
|
CustomPermission::AccessReports,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The key of the permission in the `permissions` object of the Bitwarden clients.
|
||||||
|
pub const fn json_key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
CustomPermission::ManageUsers => "manageUsers",
|
||||||
|
CustomPermission::ManageGroups => "manageGroups",
|
||||||
|
CustomPermission::ManagePolicies => "managePolicies",
|
||||||
|
CustomPermission::CreateNewCollections => "createNewCollections",
|
||||||
|
CustomPermission::EditAnyCollection => "editAnyCollection",
|
||||||
|
CustomPermission::DeleteAnyCollection => "deleteAnyCollection",
|
||||||
|
CustomPermission::AccessEventLogs => "accessEventLogs",
|
||||||
|
CustomPermission::AccessImportExport => "accessImportExport",
|
||||||
|
CustomPermission::AccessReports => "accessReports",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported(self) -> bool {
|
||||||
|
SUPPORTED_CUSTOM_PERMISSIONS.contains(&self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The permissions a member invite or edit grants a Custom role.
|
||||||
|
///
|
||||||
|
/// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this
|
||||||
|
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
|
||||||
|
/// permission this version does not enforce is rejected as well. The clients also send permissions
|
||||||
|
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
|
||||||
|
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
|
||||||
|
let mut granted = HashSet::new();
|
||||||
|
for permission in CustomPermission::ALL {
|
||||||
|
match permissions.get(permission.json_key()) {
|
||||||
|
None | Some(Value::Bool(false)) => {}
|
||||||
|
Some(Value::Bool(true)) if permission.is_supported() => {
|
||||||
|
granted.insert(permission);
|
||||||
|
}
|
||||||
|
Some(Value::Bool(true)) => {
|
||||||
|
err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key()))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
err!(format!("The Custom role permission '{}' must be true or false", permission.json_key()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored roles with authority over the whole organization. Queries match them by value, comparing
|
||||||
|
/// `atype <= Admin` would also match any unknown value below it.
|
||||||
|
pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32];
|
||||||
|
|
||||||
|
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
|
||||||
|
pub fn custom_roles_enabled() -> bool {
|
||||||
|
CONFIG.experimental_custom_roles()
|
||||||
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Organization {
|
impl Organization {
|
||||||
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
|
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
|
||||||
@@ -271,9 +400,123 @@ impl Membership {
|
|||||||
atype: MembershipType::User as i32,
|
atype: MembershipType::User as i32,
|
||||||
reset_password_key: None,
|
reset_password_key: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
|
||||||
|
manage_users: false,
|
||||||
|
manage_groups: false,
|
||||||
|
manage_policies: false,
|
||||||
|
create_new_collections: false,
|
||||||
|
edit_any_collection: false,
|
||||||
|
delete_any_collection: false,
|
||||||
|
access_event_logs: false,
|
||||||
|
access_import_export: false,
|
||||||
|
access_reports: false,
|
||||||
|
|
||||||
|
v2_upgrade_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool {
|
||||||
|
match permission {
|
||||||
|
CustomPermission::ManageUsers => &mut self.manage_users,
|
||||||
|
CustomPermission::ManageGroups => &mut self.manage_groups,
|
||||||
|
CustomPermission::ManagePolicies => &mut self.manage_policies,
|
||||||
|
CustomPermission::CreateNewCollections => &mut self.create_new_collections,
|
||||||
|
CustomPermission::EditAnyCollection => &mut self.edit_any_collection,
|
||||||
|
CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection,
|
||||||
|
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
|
||||||
|
CustomPermission::AccessImportExport => &mut self.access_import_export,
|
||||||
|
CustomPermission::AccessReports => &mut self.access_reports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are
|
||||||
|
/// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy
|
||||||
|
/// `access_all` flag, its reach only comes from its permissions.
|
||||||
|
pub fn set_type(&mut self, new_type: MembershipType) {
|
||||||
|
if self.atype != new_type as i32 {
|
||||||
|
for permission in CustomPermission::ALL {
|
||||||
|
*self.custom_permission_mut(permission) = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if new_type == MembershipType::Custom {
|
||||||
|
self.access_all = false;
|
||||||
|
}
|
||||||
|
self.atype = new_type as i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a
|
||||||
|
/// permission this version does not enforce is kept as it is: it grants nothing here.
|
||||||
|
pub fn set_custom_permissions(&mut self, permissions: &HashSet<CustomPermission>) {
|
||||||
|
for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) {
|
||||||
|
*self.custom_permission_mut(permission) = permissions.contains(&permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A
|
||||||
|
/// conversion must not silently change their access, and a Custom role can't keep access to all collections
|
||||||
|
/// (which also puts every collection in the Manager's own vault), nor manage any collection yet.
|
||||||
|
pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> {
|
||||||
|
if !self.has_type(MembershipType::Manager) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.access_all {
|
||||||
|
return Some(
|
||||||
|
"This Manager has access to all collections, which a Custom member can't have: the collections they \
|
||||||
|
aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \
|
||||||
|
or keep the Manager role.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Whatever their status, the collections they manage directly, through a group or through a group with
|
||||||
|
// access to all collections
|
||||||
|
let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
|
||||||
|
|| CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.manage);
|
||||||
|
for group_user in GroupUser::find_by_member(&self.uuid, conn).await {
|
||||||
|
if manages {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn)
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.manage);
|
||||||
|
}
|
||||||
|
manages.then_some(
|
||||||
|
"This Manager manages collections, with the manage permission or through a group with access to all \
|
||||||
|
collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_permission(&self, permission: CustomPermission) -> bool {
|
||||||
|
match permission {
|
||||||
|
CustomPermission::ManageUsers => self.manage_users,
|
||||||
|
CustomPermission::ManageGroups => self.manage_groups,
|
||||||
|
CustomPermission::ManagePolicies => self.manage_policies,
|
||||||
|
CustomPermission::CreateNewCollections => self.create_new_collections,
|
||||||
|
CustomPermission::EditAnyCollection => self.edit_any_collection,
|
||||||
|
CustomPermission::DeleteAnyCollection => self.delete_any_collection,
|
||||||
|
CustomPermission::AccessEventLogs => self.access_event_logs,
|
||||||
|
CustomPermission::AccessImportExport => self.access_import_export,
|
||||||
|
CustomPermission::AccessReports => self.access_reports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles
|
||||||
|
/// are enabled, and only the permissions this version enforces.
|
||||||
|
fn grants_custom_permission(&self, permission: CustomPermission) -> bool {
|
||||||
|
custom_roles_enabled()
|
||||||
|
&& permission.is_supported()
|
||||||
|
&& self.has_type(MembershipType::Custom)
|
||||||
|
&& self.custom_permission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any
|
||||||
|
/// other role, it needs a confirmed membership.
|
||||||
|
pub fn has_custom_permission(&self, permission: CustomPermission) -> bool {
|
||||||
|
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn restore(&mut self) -> bool {
|
pub fn restore(&mut self) -> bool {
|
||||||
if self.status < MembershipStatus::Invited as i32 {
|
if self.status < MembershipStatus::Invited as i32 {
|
||||||
self.status += ACTIVATE_REVOKE_DIFF;
|
self.status += ACTIVATE_REVOKE_DIFF;
|
||||||
@@ -523,7 +766,8 @@ impl Membership {
|
|||||||
|
|
||||||
// These are per user
|
// These are per user
|
||||||
"userId": self.user_uuid,
|
"userId": self.user_uuid,
|
||||||
"key": self.akey,
|
// Accepted (not yet confirmed) members have no key yet; send null like upstream, not ""
|
||||||
|
"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,
|
||||||
@@ -711,6 +955,12 @@ impl Membership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
|
// The legacy `access_all` flag reaches every collection, which a Custom role may only do through its
|
||||||
|
// permissions, so it must never be stored on one. `set_type` takes care of this.
|
||||||
|
if self.has_type(MembershipType::Custom) && self.access_all {
|
||||||
|
err!("A Custom role membership can't have access to all collections")
|
||||||
|
}
|
||||||
|
|
||||||
User::update_uuid_revision(&self.user_uuid, conn).await;
|
User::update_uuid_revision(&self.user_uuid, conn).await;
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
@@ -785,6 +1035,13 @@ impl Membership {
|
|||||||
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
|
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this member may edit every collection and the items in them in the Admin Console: every member with
|
||||||
|
/// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add
|
||||||
|
/// anything to the member's own vault.
|
||||||
|
pub fn can_edit_any_collection(&self) -> bool {
|
||||||
|
self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
||||||
@@ -1227,13 +1484,93 @@ pub struct OrgApiKeyId(String);
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use MembershipType::{Admin, Custom, Manager, Owner, User};
|
||||||
|
|
||||||
|
fn member(role: MembershipType) -> Membership {
|
||||||
|
let mut member = Membership::new("user".into(), "org".into(), None);
|
||||||
|
member.atype = role as i32;
|
||||||
|
member.status = MembershipStatus::Confirmed as i32;
|
||||||
|
member
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(non_snake_case)]
|
fn membership_type_authority() {
|
||||||
fn partial_cmp_MembershipType() {
|
assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
|
||||||
assert!(MembershipType::Owner > MembershipType::Admin);
|
assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
|
||||||
assert!(MembershipType::Admin > MembershipType::Manager);
|
for raw in [99, -1] {
|
||||||
assert!(MembershipType::Manager > MembershipType::User);
|
assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
|
||||||
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
|
}
|
||||||
|
for (value, expected) in [
|
||||||
|
("3", Some(Manager)),
|
||||||
|
("Manager", Some(Manager)),
|
||||||
|
("4", Some(Custom)),
|
||||||
|
("Custom", Some(Custom)),
|
||||||
|
("5", None),
|
||||||
|
] {
|
||||||
|
assert_eq!(MembershipType::from_str(value), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_membership_type() {
|
||||||
|
for (current, disabled, enabled) in [
|
||||||
|
(None, Manager, Custom),
|
||||||
|
(Some(User), Manager, Custom),
|
||||||
|
(Some(Manager), Manager, Manager),
|
||||||
|
(Some(Custom), Custom, Custom),
|
||||||
|
] {
|
||||||
|
let current = current.map(member);
|
||||||
|
for (flag, expected) in [(false, disabled), (true, enabled)] {
|
||||||
|
assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User);
|
||||||
|
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User);
|
||||||
|
for requested in [Owner, Admin, Manager] {
|
||||||
|
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_permission_requests() {
|
||||||
|
let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap();
|
||||||
|
assert!(CustomPermission::requested(&ignored).unwrap().is_empty());
|
||||||
|
for permission in CustomPermission::ALL {
|
||||||
|
for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] {
|
||||||
|
let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]);
|
||||||
|
let result = CustomPermission::requested(&request);
|
||||||
|
if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) {
|
||||||
|
assert_eq!(result.unwrap(), HashSet::from([permission]));
|
||||||
|
} else {
|
||||||
|
assert!(result.is_err(), "{permission:?}: {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_changes_drop_custom_permissions() {
|
||||||
|
for (from, to) in [
|
||||||
|
(Custom, Owner),
|
||||||
|
(Custom, Admin),
|
||||||
|
(Custom, Manager),
|
||||||
|
(Custom, User),
|
||||||
|
(User, Custom),
|
||||||
|
(Manager, Custom),
|
||||||
|
(Custom, Custom),
|
||||||
|
(Admin, User),
|
||||||
|
] {
|
||||||
|
let mut member = member(from);
|
||||||
|
member.access_all = true;
|
||||||
|
for permission in CustomPermission::ALL {
|
||||||
|
*member.custom_permission_mut(permission) = true;
|
||||||
|
}
|
||||||
|
member.set_type(to);
|
||||||
|
for permission in CustomPermission::ALL {
|
||||||
|
assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(member.access_all, to != Custom);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,6 @@ 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: i32, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
twofactor::table
|
twofactor::table
|
||||||
.filter(twofactor::user_uuid.eq(user_uuid))
|
.filter(twofactor::user_uuid.eq(user_uuid))
|
||||||
.filter(twofactor::atype.eq(atype))
|
.filter(twofactor::atype.eq(atype as i32))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+210
-30
@@ -6,6 +6,7 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
|
auth::DEFAULT_ACCESS_VALIDITY,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
@@ -19,7 +20,8 @@ use crate::{
|
|||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
|
||||||
|
TwoFactorIncomplete, UserSignatureKeyPair,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
@@ -71,6 +73,13 @@ pub struct User {
|
|||||||
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
pub key_id: Option<KeyId>,
|
pub key_id: Option<KeyId>,
|
||||||
|
|
||||||
|
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
|
||||||
|
pub signed_public_key: Option<String>,
|
||||||
|
pub security_state: Option<String>,
|
||||||
|
pub security_version: Option<i32>,
|
||||||
|
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
|
||||||
|
pub v2_upgrade_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
@@ -99,13 +108,28 @@ enum UserStatus {
|
|||||||
_Disabled = 2,
|
_Disabled = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A previous security stamp that is still accepted, until `expire`.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct UserStampException {
|
pub struct UserStampException {
|
||||||
pub routes: Vec<String>,
|
/// The routes the stamp is still accepted on, or `None` for any route.
|
||||||
|
pub routes: Option<Vec<String>>,
|
||||||
pub security_stamp: String,
|
pub security_stamp: String,
|
||||||
pub expire: i64,
|
pub expire: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl UserStampException {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now().timestamp() > self.expire
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
|
||||||
|
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
|
||||||
|
!self.is_expired()
|
||||||
|
&& self.security_stamp == security_stamp
|
||||||
|
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl User {
|
impl User {
|
||||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||||
@@ -158,9 +182,31 @@ impl User {
|
|||||||
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
key_id: None,
|
key_id: None,
|
||||||
|
|
||||||
|
signed_public_key: None,
|
||||||
|
security_state: None,
|
||||||
|
security_version: None,
|
||||||
|
v2_upgrade_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_v2(&self) -> bool {
|
||||||
|
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2.
|
||||||
|
/// Upstream only uses it to decide which accounts need a v2-capable client.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/Entities/User.cs#L245-L278>
|
||||||
|
pub fn has_v2_encryption(&self) -> bool {
|
||||||
|
self.security_version == Some(2)
|
||||||
|
&& self
|
||||||
|
.private_key
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|private_key| private_key.split_once('.'))
|
||||||
|
.is_some_and(|(enc_type, _)| enc_type.parse::<u8>() == Ok(7))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check_valid_password(&self, password: &str) -> bool {
|
pub fn check_valid_password(&self, password: &str) -> bool {
|
||||||
crypto::verify_password_hash(
|
crypto::verify_password_hash(
|
||||||
password.as_bytes(),
|
password.as_bytes(),
|
||||||
@@ -170,6 +216,11 @@ impl User {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
|
||||||
|
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())
|
||||||
@@ -220,10 +271,33 @@ impl User {
|
|||||||
|
|
||||||
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
self.security_stamp = get_uuid();
|
self.security_stamp = get_uuid();
|
||||||
|
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
|
||||||
|
let mut exceptions = self.stamp_exceptions();
|
||||||
|
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
|
||||||
|
self.set_stamp_exceptions(&exceptions);
|
||||||
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
|
||||||
|
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
|
let previous_stamp = self.security_stamp.clone();
|
||||||
|
let mut grace: Vec<UserStampException> =
|
||||||
|
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
|
||||||
|
|
||||||
|
self.reset_security_stamp(conn).await?;
|
||||||
|
|
||||||
|
grace.push(UserStampException {
|
||||||
|
routes: None,
|
||||||
|
security_stamp: previous_stamp,
|
||||||
|
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
|
||||||
|
});
|
||||||
|
let mut exceptions = self.stamp_exceptions();
|
||||||
|
exceptions.extend(grace);
|
||||||
|
self.set_stamp_exceptions(&exceptions);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -232,17 +306,30 @@ impl User {
|
|||||||
/// After these 2 minutes this stamp will expire.
|
/// After these 2 minutes this stamp will expire.
|
||||||
///
|
///
|
||||||
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
||||||
let stamp_exception = UserStampException {
|
self.set_stamp_exceptions(&[UserStampException {
|
||||||
routes: route_exception,
|
routes: Some(route_exception),
|
||||||
security_stamp: self.security_stamp.clone(),
|
security_stamp: self.security_stamp.clone(),
|
||||||
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||||
};
|
}]);
|
||||||
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
|
/// The previous security stamps that are still accepted, expired ones included.
|
||||||
pub fn reset_stamp_exception(&mut self) {
|
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
|
||||||
self.stamp_exception = None;
|
let Some(stored) = self.stamp_exception.as_deref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
// Before key rotations kept sessions alive, only a single route exception was stored.
|
||||||
|
serde_json::from_str::<Vec<UserStampException>>(stored)
|
||||||
|
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
|
||||||
|
self.stamp_exception = if exceptions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::to_string(&exceptions).unwrap_or_default())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display_name(&self) -> &str {
|
pub fn display_name(&self) -> &str {
|
||||||
@@ -261,18 +348,37 @@ impl User {
|
|||||||
!CONFIG.mail_enabled() || self.verified_at.is_some()
|
!CONFIG.mail_enabled() || self.verified_at.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
|
||||||
|
if !self.is_v2() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
|
||||||
|
}
|
||||||
|
|
||||||
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
|
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
|
||||||
pub fn account_keys_json(&self) -> Value {
|
pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
|
||||||
if self.private_key.is_some() {
|
if self.private_key.is_some() {
|
||||||
|
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
|
||||||
|
Some(key_pair) => (
|
||||||
|
json!(self.signed_public_key),
|
||||||
|
key_pair.to_json(),
|
||||||
|
json!({
|
||||||
|
"securityState": self.security_state,
|
||||||
|
"securityVersion": self.security_version,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
None => (Value::Null, Value::Null, Value::Null),
|
||||||
|
};
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"publicKeyEncryptionKeyPair": {
|
"publicKeyEncryptionKeyPair": {
|
||||||
"wrappedPrivateKey": self.private_key,
|
"wrappedPrivateKey": self.private_key,
|
||||||
"publicKey": self.public_key,
|
"publicKey": self.public_key,
|
||||||
"signedPublicKey": null,
|
"signedPublicKey": signed_public_key,
|
||||||
"object": "publicKeyEncryptionKeyPair",
|
"object": "publicKeyEncryptionKeyPair",
|
||||||
},
|
},
|
||||||
"securityState": null,
|
"securityState": security_state,
|
||||||
"signatureKeyPair": null,
|
"signatureKeyPair": signature_key_pair,
|
||||||
"object": "privateKeys"
|
"object": "privateKeys"
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -280,6 +386,24 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
|
||||||
|
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
|
||||||
|
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
|
||||||
|
None => (Value::Null, Value::Null),
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"publicKey": self.public_key,
|
||||||
|
"signedPublicKey": signed_public_key,
|
||||||
|
"verifyingKey": verifying_key,
|
||||||
|
"object": "publicKeys"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn v2_upgrade_token_json(&self) -> Option<Value> {
|
||||||
|
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
||||||
let mut orgs_json = Vec::new();
|
let mut orgs_json = Vec::new();
|
||||||
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||||
@@ -300,7 +424,7 @@ impl User {
|
|||||||
UserStatus::Enabled
|
UserStatus::Enabled
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = self.account_keys_json();
|
let account_keys = self.account_keys_json(conn).await;
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"_status": status as i32,
|
"_status": status as i32,
|
||||||
@@ -357,7 +481,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
|
||||||
@@ -373,13 +497,15 @@ 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?;
|
||||||
|
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -547,25 +673,26 @@ impl Invitation {
|
|||||||
pub struct UserId(String);
|
pub struct UserId(String);
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Clone,
|
Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam,
|
||||||
Debug,
|
|
||||||
DieselNewType,
|
|
||||||
FromForm,
|
|
||||||
PartialEq,
|
|
||||||
Eq,
|
|
||||||
Hash,
|
|
||||||
Serialize,
|
|
||||||
Deserialize,
|
|
||||||
AsRef,
|
|
||||||
Deref,
|
|
||||||
Display,
|
|
||||||
From,
|
|
||||||
UuidFromParam,
|
|
||||||
)]
|
)]
|
||||||
#[deref(forward)]
|
#[deref(forward)]
|
||||||
#[from(forward)]
|
#[from(forward)]
|
||||||
pub struct KeyId(String);
|
pub struct KeyId(String);
|
||||||
|
|
||||||
|
/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other
|
||||||
|
/// format would never match the id the clients send.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/KeyManagement/Models/Data/KeyId.cs#L20-L42>
|
||||||
|
impl<'de> serde::Deserialize<'de> for KeyId {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let key_id = String::deserialize(deserializer)?;
|
||||||
|
if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
|
||||||
|
return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters"));
|
||||||
|
}
|
||||||
|
Ok(Self(key_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SsoUser {
|
impl SsoUser {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
@@ -619,3 +746,56 @@ impl SsoUser {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_id_format() {
|
||||||
|
let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap();
|
||||||
|
assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef");
|
||||||
|
|
||||||
|
for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"]
|
||||||
|
{
|
||||||
|
assert!(serde_json::from_str::<KeyId>(&format!("\"{invalid}\"")).is_err(), "{invalid:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absent key id stays optional
|
||||||
|
assert_eq!(serde_json::from_str::<Option<KeyId>>("null").unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_v2_encryption() {
|
||||||
|
// User::new loads CONFIG; isolate it from the DNS bootstrap test and filesystem configuration.
|
||||||
|
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
||||||
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
||||||
|
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
||||||
|
.env("DATABASE_URL", "sqlite://:memory:")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
||||||
|
"{}\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
||||||
|
let mut user = User::new("user@example.com", None);
|
||||||
|
user.private_key = private_key.map(String::from);
|
||||||
|
user.security_version = security_version;
|
||||||
|
user
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(user(Some("7.cose"), Some(2)).has_v2_encryption());
|
||||||
|
assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption());
|
||||||
|
assert!(!user(Some("7.cose"), None).has_v2_encryption());
|
||||||
|
assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption());
|
||||||
|
assert!(!user(Some("7"), Some(2)).has_v2_encryption());
|
||||||
|
assert!(!user(None, Some(2)).has_v2_encryption());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{DbConn, schema::user_signature_key_pairs},
|
||||||
|
error::MapResult,
|
||||||
|
util::get_uuid,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::UserId;
|
||||||
|
|
||||||
|
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
|
||||||
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
|
#[diesel(table_name = user_signature_key_pairs)]
|
||||||
|
#[diesel(treat_none_as_null = true)]
|
||||||
|
#[diesel(primary_key(uuid))]
|
||||||
|
pub struct UserSignatureKeyPair {
|
||||||
|
pub uuid: UserSignatureKeyPairId,
|
||||||
|
pub user_uuid: UserId,
|
||||||
|
|
||||||
|
pub signature_algorithm: i32,
|
||||||
|
/// The signing (private) key, wrapped by the user key.
|
||||||
|
pub signing_key: String,
|
||||||
|
/// The COSE-encoded public verifying key.
|
||||||
|
pub verifying_key: String,
|
||||||
|
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SignatureAlgorithm {
|
||||||
|
Ed25519 = 0,
|
||||||
|
MlDsa44 = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignatureAlgorithm {
|
||||||
|
pub fn parse(algorithm: &str) -> Option<Self> {
|
||||||
|
match algorithm {
|
||||||
|
"ed25519" => Some(Self::Ed25519),
|
||||||
|
"mldsa44" => Some(Self::MlDsa44),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local methods
|
||||||
|
impl UserSignatureKeyPair {
|
||||||
|
pub fn new(
|
||||||
|
user_uuid: UserId,
|
||||||
|
signature_algorithm: SignatureAlgorithm,
|
||||||
|
signing_key: String,
|
||||||
|
verifying_key: String,
|
||||||
|
) -> Self {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uuid: UserSignatureKeyPairId(get_uuid()),
|
||||||
|
user_uuid,
|
||||||
|
signature_algorithm: signature_algorithm as i32,
|
||||||
|
signing_key,
|
||||||
|
verifying_key,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"wrappedSigningKey": self.signing_key,
|
||||||
|
"verifyingKey": self.verifying_key,
|
||||||
|
"object": "signatureKeyPair",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database methods
|
||||||
|
impl UserSignatureKeyPair {
|
||||||
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
|
self.updated_at = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
db_run! { conn:
|
||||||
|
mysql {
|
||||||
|
diesel::insert_into(user_signature_key_pairs::table)
|
||||||
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving user signature key pair")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(user_signature_key_pairs::table)
|
||||||
|
.values(&*self)
|
||||||
|
.on_conflict(user_signature_key_pairs::user_uuid)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving user signature key pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
|
||||||
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
user_signature_key_pairs::table
|
||||||
|
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
|
||||||
|
.first::<Self>(conn)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error deleting user signature key pairs")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UserSignatureKeyPairId(String);
|
||||||
@@ -116,6 +116,7 @@ table! {
|
|||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
enabled -> Bool,
|
enabled -> Bool,
|
||||||
data -> Text,
|
data -> Text,
|
||||||
|
revision_date -> Timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +219,22 @@ table! {
|
|||||||
avatar_color -> Nullable<Text>,
|
avatar_color -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
key_id -> Nullable<Text>,
|
key_id -> Nullable<Text>,
|
||||||
|
signed_public_key -> Nullable<Text>,
|
||||||
|
security_state -> Nullable<Text>,
|
||||||
|
security_version -> Nullable<Integer>,
|
||||||
|
v2_upgrade_token -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
user_signature_key_pairs (uuid) {
|
||||||
|
uuid -> Text,
|
||||||
|
user_uuid -> Text,
|
||||||
|
signature_algorithm -> Integer,
|
||||||
|
signing_key -> Text,
|
||||||
|
verifying_key -> Text,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
updated_at -> Timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +260,16 @@ table! {
|
|||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
reset_password_key -> Nullable<Text>,
|
reset_password_key -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
|
manage_users -> Bool,
|
||||||
|
manage_groups -> Bool,
|
||||||
|
manage_policies -> Bool,
|
||||||
|
create_new_collections -> Bool,
|
||||||
|
edit_any_collection -> Bool,
|
||||||
|
delete_any_collection -> Bool,
|
||||||
|
access_event_logs -> Bool,
|
||||||
|
access_import_export -> Bool,
|
||||||
|
access_reports -> Bool,
|
||||||
|
v2_upgrade_token -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +410,7 @@ joinable!(collections_groups -> groups (groups_uuid));
|
|||||||
joinable!(event -> users_organizations (uuid));
|
joinable!(event -> users_organizations (uuid));
|
||||||
joinable!(auth_requests -> users (user_uuid));
|
joinable!(auth_requests -> users (user_uuid));
|
||||||
joinable!(sso_users -> users (user_uuid));
|
joinable!(sso_users -> users (user_uuid));
|
||||||
|
joinable!(user_signature_key_pairs -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
archives,
|
archives,
|
||||||
@@ -409,4 +437,5 @@ allow_tables_to_appear_in_same_query!(
|
|||||||
collections_groups,
|
collections_groups,
|
||||||
event,
|
event,
|
||||||
auth_requests,
|
auth_requests,
|
||||||
|
user_signature_key_pairs,
|
||||||
);
|
);
|
||||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user