mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-27 10:46:47 +02:00
Compare commits
69
Commits
1.36.0
...
3714b504f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3714b504f6 | ||
|
|
f9f011d50c | ||
|
|
cb89088bc3 | ||
|
|
4444442075 | ||
|
|
061694d0cb | ||
|
|
32098ca7d1 | ||
|
|
d2660324e6 | ||
|
|
cc67d644f6 | ||
|
|
3347698712 | ||
|
|
a24683636a | ||
|
|
64c56411f6 | ||
|
|
c3f5477525 | ||
|
|
9c8aa2359f | ||
|
|
8719f86fcf | ||
|
|
fddc8e71be | ||
|
|
eb212e23fa | ||
|
|
25dfedafd7 | ||
|
|
e992cbb4f5 | ||
|
|
5b51b60f94 | ||
|
|
de7abaaafa | ||
|
|
b7667e27bf | ||
|
|
f1c36b8c1d | ||
|
|
f1ff613008 | ||
|
|
57fbed1bed | ||
|
|
277e1536eb | ||
|
|
2ffad8775d | ||
|
|
32d85d03bb | ||
|
|
a6c3bd6d18 | ||
|
|
6729e83521 | ||
|
|
fdc156b247 | ||
|
|
2073c03092 | ||
|
|
923f5d0b5e | ||
|
|
83724b301e | ||
|
|
10e044f563 | ||
|
|
fa2566d14f | ||
|
|
46d71107f5 | ||
|
|
9e78911a2f | ||
|
|
0cefa4cca7 | ||
|
|
b30cc08562 | ||
|
|
55f883a566 | ||
|
|
74ceaf2354 | ||
|
|
2629bcbe13 | ||
|
|
46ae59eaf4 | ||
|
|
a6a88e7929 | ||
|
|
5040bcb7c0 | ||
|
|
660faee68e | ||
|
|
683a23e43c | ||
|
|
4a9bcb0694 | ||
|
|
169aa5efcc | ||
|
|
64d28ab66e | ||
|
|
4720cdbe86 | ||
|
|
5447ee6af2 | ||
|
|
5c5e8e1a6f | ||
|
|
7320a1db4b | ||
|
|
a058a35ccd | ||
|
|
a16b5afaaa | ||
|
|
fddc16d2b8 | ||
|
|
ec7fa137b7 | ||
|
|
b25f715364 | ||
|
|
d6a3d539ed | ||
|
|
d626ea81ab | ||
|
|
1ba2c6a26c | ||
|
|
22f5e0496c | ||
|
|
70f9dfbe8b | ||
|
|
54895ad4be | ||
|
|
a057c7deae | ||
|
|
2f85b62d2f | ||
|
|
9bc14e6e77 | ||
|
|
cdf711bb30 |
No files matched your search
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
// Ignore everything
|
# Ignore everything
|
||||||
*
|
*
|
||||||
|
|
||||||
// Allow what is needed
|
# Allow what is needed
|
||||||
!.git
|
!.git
|
||||||
!docker/healthcheck.sh
|
!docker/healthcheck.sh
|
||||||
!docker/start.sh
|
!docker/start.sh
|
||||||
|
|||||||
+37
-4
@@ -50,10 +50,11 @@
|
|||||||
#########################
|
#########################
|
||||||
|
|
||||||
## Database URL
|
## Database URL
|
||||||
## When using SQLite, this is the path to the DB file, and it defaults to
|
## When using SQLite, this should use the sqlite:// scheme followed by the path
|
||||||
## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this
|
## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3.
|
||||||
## must be set to a local sqlite3 file path.
|
## Bare paths without the sqlite:// scheme are supported for backwards compatibility,
|
||||||
# DATABASE_URL=data/db.sqlite3
|
## but only if the database file already exists.
|
||||||
|
# DATABASE_URL=sqlite://data/db.sqlite3
|
||||||
## When using MySQL, specify an appropriate connection URI.
|
## When using MySQL, specify an appropriate connection URI.
|
||||||
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
|
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
|
||||||
# DATABASE_URL=mysql://user:password@host[:port]/database_name
|
# DATABASE_URL=mysql://user:password@host[:port]/database_name
|
||||||
@@ -315,6 +316,14 @@
|
|||||||
## unauthenticated access to potentially sensitive data.
|
## unauthenticated access to potentially sensitive data.
|
||||||
# SHOW_PASSWORD_HINT=false
|
# SHOW_PASSWORD_HINT=false
|
||||||
|
|
||||||
|
#########################
|
||||||
|
### Client settings ###
|
||||||
|
#########################
|
||||||
|
|
||||||
|
## Control whether clients onboarding interstitials are suppressed
|
||||||
|
## (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals)
|
||||||
|
# CLIENT_SUPPRESS_ONBOARDING=false
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
### Advanced settings ###
|
### Advanced settings ###
|
||||||
#########################
|
#########################
|
||||||
@@ -323,6 +332,14 @@
|
|||||||
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||||
# IP_HEADER=X-Real-IP
|
# IP_HEADER=X-Real-IP
|
||||||
|
|
||||||
|
## Which addresses the header above is accepted from, defaults to "local".
|
||||||
|
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
|
||||||
|
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
|
||||||
|
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
|
||||||
|
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
|
||||||
|
## proxy as IPs and CIDR ranges if it connects from a public address.
|
||||||
|
# IP_HEADER_TRUSTED_PROXIES=local
|
||||||
|
|
||||||
## Icon service
|
## Icon service
|
||||||
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
@@ -373,20 +390,26 @@
|
|||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||||
|
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
|
||||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
||||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||||
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
||||||
|
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
|
||||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
||||||
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
||||||
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
||||||
## - "pm-30529-webauthn-related-origins":
|
## - "pm-30529-webauthn-related-origins":
|
||||||
|
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
|
||||||
|
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
|
||||||
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
|
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
|
||||||
|
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||||
|
|
||||||
## Require new device emails. When a user logs in an email is required to be sent.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
@@ -459,6 +482,13 @@
|
|||||||
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
||||||
# LOGIN_RATELIMIT_MAX_BURST=10
|
# LOGIN_RATELIMIT_MAX_BURST=10
|
||||||
|
|
||||||
|
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
|
||||||
|
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
|
||||||
|
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
|
||||||
|
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
|
||||||
|
|
||||||
## BETA FEATURE: Groups
|
## BETA FEATURE: Groups
|
||||||
## Controls whether group support is enabled for organizations
|
## Controls whether group support is enabled for organizations
|
||||||
## This setting applies to organizations.
|
## This setting applies to organizations.
|
||||||
@@ -493,6 +523,9 @@
|
|||||||
## Prevent users from logging in directly without going through SSO
|
## Prevent users from logging in directly without going through SSO
|
||||||
# SSO_ONLY=false
|
# SSO_ONLY=false
|
||||||
|
|
||||||
|
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
|
||||||
|
# SSO_SIGNUPS_ALLOWED=true
|
||||||
|
|
||||||
## On SSO Signup if a user with a matching email already exists make the association
|
## On SSO Signup if a user with a matching email already exists make the association
|
||||||
# SSO_SIGNUPS_MATCH_EMAIL=true
|
# SSO_SIGNUPS_MATCH_EMAIL=true
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
# Enable Rust Caching
|
# Enable Rust Caching
|
||||||
- name: Rust Caching
|
- name: Rust Caching
|
||||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -30,24 +30,25 @@ jobs:
|
|||||||
driver-opts: |
|
driver-opts: |
|
||||||
network=host
|
network=host
|
||||||
|
|
||||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
|
||||||
- name: Download hadolint
|
|
||||||
run: |
|
|
||||||
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
|
|
||||||
sudo chmod +x /usr/local/bin/hadolint
|
|
||||||
env:
|
|
||||||
HADOLINT_VERSION: 2.14.0
|
|
||||||
# End Download hadolint
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|
||||||
# Test Dockerfiles with hadolint
|
# Test Dockerfiles with hadolint
|
||||||
- name: Run hadolint
|
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
|
||||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
|
||||||
|
- name: Run hadolint on Dockerfile.debian
|
||||||
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||||
|
with:
|
||||||
|
dockerfile: docker/Dockerfile.debian
|
||||||
|
|
||||||
|
- name: Run hadolint on Dockerfile.alpine
|
||||||
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||||
|
with:
|
||||||
|
dockerfile: docker/Dockerfile.alpine
|
||||||
# End Test Dockerfiles with hadolint
|
# End Test Dockerfiles with hadolint
|
||||||
|
|
||||||
# Test Dockerfiles with docker build checks
|
# Test Dockerfiles with docker build checks
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Initialize QEMU binfmt support
|
- name: Initialize QEMU binfmt support
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
with:
|
with:
|
||||||
platforms: "arm64,arm"
|
platforms: "arm64,arm"
|
||||||
|
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
# We need fetch-depth of 0 so we also get all the tag metadata
|
# We need fetch-depth of 0 so we also get all the tag metadata
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
|
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
@@ -237,7 +237,7 @@ jobs:
|
|||||||
|
|
||||||
# Upload artifacts to Github Actions and Attest the binaries
|
# Upload artifacts to Github Actions and Attest the binaries
|
||||||
- name: Attest binaries
|
- name: Attest binaries
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ jobs:
|
|||||||
|
|
||||||
merge-manifests:
|
merge-manifests:
|
||||||
name: Merge manifests
|
name: Merge manifests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: docker-build
|
needs: docker-build
|
||||||
environment:
|
environment:
|
||||||
name: release
|
name: release
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -303,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -365,7 +365,7 @@ jobs:
|
|||||||
# Attest container images
|
# Attest container images
|
||||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
@@ -373,7 +373,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.GHCR_REPO }}
|
subject-name: ${{ vars.GHCR_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
@@ -381,7 +381,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.QUAY_REPO }}
|
subject-name: ${{ vars.QUAY_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -50,6 +50,6 @@ jobs:
|
|||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -16,11 +16,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2
|
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
|
||||||
@@ -14,17 +14,17 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
zizmor:
|
zizmor:
|
||||||
name: Run zizmor
|
name: Run zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write # To write the security report
|
security-events: write # To write the security report
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ repos:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||||
- repo: https://github.com/crate-ci/typos
|
- repo: https://github.com/crate-ci/typos
|
||||||
rev: 7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2
|
rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: typos
|
- id: typos
|
||||||
|
always_run: true
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
@@ -38,8 +39,7 @@ repos:
|
|||||||
entry: cargo test
|
entry: cargo test
|
||||||
language: system
|
language: system
|
||||||
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||||
types_or: [ rust, file ]
|
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: cargo-clippy
|
- id: cargo-clippy
|
||||||
name: cargo clippy
|
name: cargo clippy
|
||||||
@@ -47,8 +47,7 @@ repos:
|
|||||||
entry: cargo clippy
|
entry: cargo clippy
|
||||||
language: system
|
language: system
|
||||||
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||||
types_or: [ rust, file ]
|
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: check-docker-templates
|
- id: check-docker-templates
|
||||||
name: check docker templates
|
name: check docker templates
|
||||||
|
|||||||
Generated
+1121
-1218
File diff suppressed because it is too large.
Load diff
+155
-91
@@ -1,6 +1,6 @@
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.93.0"
|
rust-version = "1.96.1"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||||
publish = false
|
publish = false
|
||||||
@@ -12,9 +12,7 @@ members = ["macros"]
|
|||||||
name = "vaultwarden"
|
name = "vaultwarden"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||||
readme = "README.md"
|
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
resolver = "2"
|
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
@@ -23,7 +21,8 @@ publish.workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = [
|
default = [
|
||||||
# "sqlite" or "sqlite_system",
|
# "sqlite",
|
||||||
|
# "sqlite_system",
|
||||||
# "mysql",
|
# "mysql",
|
||||||
# "postgresql",
|
# "postgresql",
|
||||||
]
|
]
|
||||||
@@ -32,14 +31,23 @@ enable_syslog = []
|
|||||||
# Please enable at least one of these DB backends.
|
# Please enable at least one of these DB backends.
|
||||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||||
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"]
|
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite
|
||||||
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Alternative to the above, statically linked SQLite into the binary instead of dynamically.
|
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Statically link SQLite into the binary instead of dynamically.
|
||||||
# Enable to use a vendored and statically linked openssl
|
# Enable to use a vendored and statically linked openssl
|
||||||
vendored_openssl = ["openssl/vendored"]
|
vendored_openssl = ["openssl/vendored"]
|
||||||
# Enable MiMalloc memory allocator to replace the default malloc
|
# Enable MiMalloc memory allocator to replace the default malloc
|
||||||
# This can improve performance for Alpine builds
|
# This can improve performance for Alpine builds
|
||||||
enable_mimalloc = ["dep:mimalloc"]
|
enable_mimalloc = ["dep:mimalloc"]
|
||||||
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
|
s3 = [
|
||||||
|
"opendal/services-s3",
|
||||||
|
"dep:opendal-http-transport-reqwest",
|
||||||
|
"dep:aws-config",
|
||||||
|
"dep:aws-credential-types",
|
||||||
|
"dep:aws-smithy-runtime-api",
|
||||||
|
"dep:http",
|
||||||
|
"dep:reqsign-aws-v4",
|
||||||
|
"dep:reqsign-core",
|
||||||
|
]
|
||||||
|
|
||||||
# OIDC specific features
|
# OIDC specific features
|
||||||
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
||||||
@@ -50,6 +58,7 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
|
|||||||
unstable = []
|
unstable = []
|
||||||
|
|
||||||
[target."cfg(unix)".dependencies]
|
[target."cfg(unix)".dependencies]
|
||||||
|
nix = { version = "0.31.3", features = ["fs"] }
|
||||||
# Logging
|
# Logging
|
||||||
syslog = "7.0.0"
|
syslog = "7.0.0"
|
||||||
|
|
||||||
@@ -57,75 +66,90 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.29"
|
log = "0.4.34"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||||
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
|
|
||||||
# A `dotenv` implementation for Rust
|
# A `dotenv` implementation for Rust
|
||||||
dotenvy = { version = "0.15.7", default-features = false }
|
dotenvy = { version = "0.15.7", default-features = false }
|
||||||
|
|
||||||
# Numerical libraries
|
# Numerical libraries
|
||||||
num-traits = "0.2.19"
|
num-traits = "0.2.19"
|
||||||
num-derive = "0.4.2"
|
num-derive = "0.5.1"
|
||||||
bigdecimal = "0.4.10"
|
bigdecimal = "0.4.10"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||||
rocket_ws = { version ="0.1.1" }
|
rocket_ws = { version = "0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
rmpv = "1.3.1" # MessagePack library
|
rmpv = "1.3.1" # MessagePack library
|
||||||
|
|
||||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.2.1"
|
||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.32"
|
futures = "0.3.34"
|
||||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
tokio = { version = "1.53.1", features = [
|
||||||
tokio-util = { version = "0.7.18", features = ["compat"]}
|
"fs",
|
||||||
|
"io-util",
|
||||||
|
"net",
|
||||||
|
"parking_lot",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"signal",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
|
tokio-util = { version = "0.7.19", features = ["compat"] }
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility
|
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
|
||||||
diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] }
|
|
||||||
diesel_migrations = "2.3.2"
|
diesel_migrations = "2.3.2"
|
||||||
|
|
||||||
derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
derive_more = { version = "2.1.1", features = [
|
||||||
diesel-derive-newtype = "2.1.2"
|
"as_ref",
|
||||||
|
"deref",
|
||||||
|
"display",
|
||||||
|
"from",
|
||||||
|
"into",
|
||||||
|
] }
|
||||||
|
diesel-derive-newtype = "2.1.3"
|
||||||
|
|
||||||
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||||
libsqlite3-sys = { version = "0.37.0", optional = true }
|
libsqlite3-sys = { version = "0.38.2", optional = true }
|
||||||
|
|
||||||
# Crypto-related libraries
|
# Crypto-related libraries
|
||||||
rand = "0.10.1"
|
rand = "0.10.2"
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
|
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.23.1", features = ["v4"] }
|
uuid = { version = "1.26.1", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
|
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
time = "0.3.47"
|
time = "0.3.55"
|
||||||
|
|
||||||
# Job scheduler
|
# Job scheduler
|
||||||
job_scheduler_ng = "2.4.0"
|
job_scheduler_ng = "2.5.0"
|
||||||
|
|
||||||
# Data encoding library Hex/Base32/Base64
|
# Data encoding library Hex/Base32/Base64
|
||||||
data-encoding = "2.11.0"
|
data-encoding = "2.11.1"
|
||||||
|
|
||||||
# JWT library
|
# JWT library
|
||||||
jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false }
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
||||||
|
|
||||||
# TOTP library
|
# TOTP library
|
||||||
totp-lite = "2.0.1"
|
totp-lite = "2.0.1"
|
||||||
|
|
||||||
# Yubico Library
|
# Yubico Library
|
||||||
yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false }
|
yubico_ng = { version = "1.0.0", default-features = false }
|
||||||
|
|
||||||
# WebAuthn libraries
|
# WebAuthn libraries
|
||||||
# danger-allow-state-serialisation is needed to save the state in the db
|
# danger-allow-state-serialisation is needed to save the state in the db
|
||||||
@@ -138,73 +162,116 @@ webauthn-rs-core = "0.5.5"
|
|||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
||||||
# Email libraries
|
# Email libraries
|
||||||
lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
lettre = { version = "0.11.23", default-features = false, features = [
|
||||||
|
# Misc
|
||||||
|
"tracing",
|
||||||
|
"serde",
|
||||||
|
"builder",
|
||||||
|
"hostname",
|
||||||
|
# TLS/Security
|
||||||
|
"ring",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"tokio1-rustls",
|
||||||
|
# Transport
|
||||||
|
"smtp-transport",
|
||||||
|
"sendmail-transport",
|
||||||
|
] }
|
||||||
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
||||||
email_address = "0.2.9"
|
email_address = "0.2.9"
|
||||||
|
|
||||||
# HTML Template library
|
# HTML Template library
|
||||||
handlebars = { version = "6.4.0", features = ["dir_source"] }
|
handlebars = { version = "6.4.4", features = ["dir_source"] }
|
||||||
|
|
||||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||||
reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||||
hickory-resolver = "0.26.1"
|
# Misc
|
||||||
|
"charset",
|
||||||
|
"cookies",
|
||||||
|
"http2",
|
||||||
|
"json",
|
||||||
|
"form",
|
||||||
|
"rustls-no-provider",
|
||||||
|
"stream",
|
||||||
|
# Compression
|
||||||
|
"brotli",
|
||||||
|
"deflate",
|
||||||
|
"gzip",
|
||||||
|
"zstd",
|
||||||
|
# Proxy
|
||||||
|
"socks",
|
||||||
|
"system-proxy",
|
||||||
|
] }
|
||||||
|
hickory-resolver = "0.26.3"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.8.3"
|
html5gum = "0.8.4"
|
||||||
regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false }
|
regex = { version = "1.13.1", default-features = false, features = [
|
||||||
|
"perf",
|
||||||
|
"std",
|
||||||
|
"unicode-perl",
|
||||||
|
] }
|
||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
bytes = "1.11.1"
|
bytes = "1.12.1"
|
||||||
svg-hush = "0.9.6"
|
svg-hush = "0.9.7"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "0.59.0", features = ["async"] }
|
cached = { version = "4.0.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.1"
|
cookie = "0.18.2"
|
||||||
cookie_store = "0.22.1"
|
cookie_store = "0.22.1"
|
||||||
|
|
||||||
# Used by U2F, JWT and PostgreSQL
|
# Used by U2F, JWT and PostgreSQL
|
||||||
openssl = "0.10.78"
|
openssl = "0.10.81"
|
||||||
|
|
||||||
# CLI argument parsing
|
# CLI argument parsing
|
||||||
pico-args = "0.5.0"
|
pico-args = "0.5.0"
|
||||||
|
|
||||||
# Macro ident concatenation
|
# Macro ident concatenation
|
||||||
pastey = "0.2.2"
|
pastey = "0.2.3"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
|
|
||||||
|
# CIDR parsing for the trusted proxies of the client IP header
|
||||||
|
ipnet = "2.12.2"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] }
|
openidconnect = { version = "4.0.1", default-features = false }
|
||||||
moka = { version = "0.12.15", features = ["future"] }
|
moka = { version = "0.12.16", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
semver = "1.0.28"
|
semver = "1.0.28"
|
||||||
|
|
||||||
# Allow overriding the default memory allocator
|
# Allow overriding the default memory allocator
|
||||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||||
mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true }
|
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
|
||||||
|
|
||||||
which = "8.0.2"
|
which = "8.0.6"
|
||||||
|
|
||||||
# Argon2 library with support for the PHC format
|
# Argon2 library with support for the PHC format
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.6.0"
|
||||||
|
|
||||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||||
rpassword = "7.5.1"
|
rpassword = "7.5.4"
|
||||||
|
|
||||||
# Loading a dynamic CSS Stylesheet
|
# Loading a dynamic CSS Stylesheet
|
||||||
grass_compiler = { version = "0.13.4", default-features = false }
|
grass_compiler = { version = "0.13.4", default-features = false }
|
||||||
|
|
||||||
# File are accessed through Apache OpenDAL
|
# File are accessed through Apache OpenDAL
|
||||||
opendal = { version = "0.55.0", features = ["services-fs"], default-features = false }
|
opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] }
|
||||||
|
opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
anyhow = { version = "1.0.102", optional = true }
|
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
||||||
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
"behavior-version-latest",
|
||||||
aws-credential-types = { version = "1.2.14", optional = true }
|
"credentials-process",
|
||||||
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
"rt-tokio",
|
||||||
http = { version = "1.4.0", optional = true }
|
"sso",
|
||||||
reqsign = { version = "0.16.5", optional = true }
|
] }
|
||||||
|
aws-credential-types = { version = "1.3.0", optional = true }
|
||||||
|
aws-smithy-runtime-api = { version = "1.16.2", optional = true }
|
||||||
|
http = { version = "1.5.0", optional = true }
|
||||||
|
reqsign-aws-v4 = { version = "3.3.0", optional = true }
|
||||||
|
reqsign-core = { version = "3.3.1", optional = true }
|
||||||
|
|
||||||
# Strip debuginfo from the release builds
|
# Strip debuginfo from the release builds
|
||||||
# The debug symbols are to provide better panic traces
|
# The debug symbols are to provide better panic traces
|
||||||
@@ -264,77 +331,74 @@ unsafe_code = "forbid"
|
|||||||
non_ascii_idents = "forbid"
|
non_ascii_idents = "forbid"
|
||||||
|
|
||||||
# Deny
|
# Deny
|
||||||
deprecated_in_future = "deny"
|
warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end
|
||||||
|
|
||||||
|
# Deny lint groups
|
||||||
deprecated_safe = { level = "deny", priority = -1 }
|
deprecated_safe = { level = "deny", priority = -1 }
|
||||||
future_incompatible = { level = "deny", priority = -1 }
|
future_incompatible = { level = "deny", priority = -1 }
|
||||||
keyword_idents = { level = "deny", priority = -1 }
|
keyword_idents = { level = "deny", priority = -1 }
|
||||||
let_underscore = { level = "deny", priority = -1 }
|
let_underscore = { level = "deny", priority = -1 }
|
||||||
nonstandard_style = { level = "deny", priority = -1 }
|
nonstandard_style = { level = "deny", priority = -1 }
|
||||||
noop_method_call = "deny"
|
|
||||||
refining_impl_trait = { level = "deny", priority = -1 }
|
refining_impl_trait = { level = "deny", priority = -1 }
|
||||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||||
rust_2021_compatibility = { level = "deny", priority = -1 }
|
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||||
rust_2024_compatibility = { level = "deny", priority = -1 }
|
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||||
|
unused = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
# Deny individual lints
|
||||||
|
closure_returning_async_block = "deny"
|
||||||
|
deprecated_in_future = "deny"
|
||||||
single_use_lifetimes = "deny"
|
single_use_lifetimes = "deny"
|
||||||
trivial_casts = "deny"
|
trivial_casts = "deny"
|
||||||
trivial_numeric_casts = "deny"
|
trivial_numeric_casts = "deny"
|
||||||
unused = { level = "deny", priority = -1 }
|
|
||||||
unused_import_braces = "deny"
|
unused_import_braces = "deny"
|
||||||
unused_lifetimes = "deny"
|
unused_lifetimes = "deny"
|
||||||
unused_qualifications = "deny"
|
unused_qualifications = "deny"
|
||||||
variant_size_differences = "deny"
|
variant_size_differences = "deny"
|
||||||
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
|
||||||
# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues
|
|
||||||
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
|
||||||
if_let_rescope = "allow"
|
|
||||||
tail_expr_drop_order = "allow"
|
|
||||||
|
|
||||||
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
# Warn
|
# Warn only so you can still use these during development, but not in the final code
|
||||||
dbg_macro = "warn"
|
dbg_macro = "warn"
|
||||||
todo = "warn"
|
todo = "warn"
|
||||||
|
|
||||||
# Ignore/Allow
|
# Ignore/Allow
|
||||||
result_large_err = "allow"
|
result_large_err = "allow"
|
||||||
|
|
||||||
# Deny
|
# Warn on these lint group (Some might be warn by default already though)
|
||||||
|
# Will be denied during CI!
|
||||||
|
complexity = { level = "warn", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
perf = { level = "warn", priority = -1 }
|
||||||
|
style = { level = "warn", priority = -1 }
|
||||||
|
suspicious = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
|
# Deny individual lints
|
||||||
branches_sharing_code = "deny"
|
branches_sharing_code = "deny"
|
||||||
case_sensitive_file_extension_comparisons = "deny"
|
|
||||||
cast_lossless = "deny"
|
|
||||||
clone_on_ref_ptr = "deny"
|
clone_on_ref_ptr = "deny"
|
||||||
duration_suboptimal_units = "deny"
|
|
||||||
equatable_if_let = "deny"
|
equatable_if_let = "deny"
|
||||||
excessive_precision = "deny"
|
|
||||||
filter_map_next = "deny"
|
|
||||||
float_cmp_const = "deny"
|
float_cmp_const = "deny"
|
||||||
implicit_clone = "deny"
|
|
||||||
inefficient_to_string = "deny"
|
|
||||||
iter_on_empty_collections = "deny"
|
iter_on_empty_collections = "deny"
|
||||||
iter_on_single_items = "deny"
|
iter_on_single_items = "deny"
|
||||||
linkedlist = "deny"
|
|
||||||
macro_use_imports = "deny"
|
|
||||||
manual_assert = "deny"
|
|
||||||
manual_instant_elapsed = "deny"
|
|
||||||
manual_string_new = "deny"
|
|
||||||
match_wildcard_for_single_variants = "deny"
|
|
||||||
mem_forget = "deny"
|
mem_forget = "deny"
|
||||||
needless_borrow = "deny"
|
|
||||||
needless_collect = "deny"
|
needless_collect = "deny"
|
||||||
needless_continue = "deny"
|
|
||||||
needless_lifetimes = "deny"
|
|
||||||
option_option = "deny"
|
|
||||||
redundant_clone = "deny"
|
redundant_clone = "deny"
|
||||||
ref_option = "deny"
|
|
||||||
string_add_assign = "deny"
|
|
||||||
unnecessary_join = "deny"
|
|
||||||
unnecessary_self_imports = "deny"
|
unnecessary_self_imports = "deny"
|
||||||
unnested_or_patterns = "deny"
|
|
||||||
unused_async = "deny"
|
|
||||||
unused_self = "deny"
|
|
||||||
useless_let_if_seq = "deny"
|
useless_let_if_seq = "deny"
|
||||||
verbose_file_reads = "deny"
|
verbose_file_reads = "deny"
|
||||||
zero_sized_map_values = "deny"
|
str_to_string = "deny"
|
||||||
|
|
||||||
|
# Pedantic Opt-Outs
|
||||||
|
inline_always = "allow" # We use this sparsely
|
||||||
|
struct_field_names = "allow" # Noisy and some items are Bitwarden controlled
|
||||||
|
large_futures = "allow" # Causes a fail in some Rocket macro's, since we experience no issues, allow it
|
||||||
|
too_many_lines = "allow" # For now, allow this, good to enable in the future and see if we can refactor
|
||||||
|
unnecessary_wraps = "allow" # Too much false positives because of Rocket integrations
|
||||||
|
# We do not use these doc items
|
||||||
|
doc_link_with_quotes = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::env;
|
use std::{env, io::Error, process::Command};
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
||||||
@@ -42,13 +41,12 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
fn run(args: &[&str]) -> Result<String, Error> {
|
||||||
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
use std::io::Error;
|
|
||||||
return Err(Error::other("Command not successful"));
|
return Err(Error::other("Command not successful"));
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
|
Ok(String::from_utf8(out.stdout).unwrap().trim().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This method reads info from Git, namely tags, branch, and revision
|
/// This method reads info from Git, namely tags, branch, and revision
|
||||||
@@ -58,7 +56,7 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
|||||||
/// - `env!("GIT_BRANCH")`
|
/// - `env!("GIT_BRANCH")`
|
||||||
/// - `env!("GIT_REV")`
|
/// - `env!("GIT_REV")`
|
||||||
/// - `env!("VW_VERSION")`
|
/// - `env!("VW_VERSION")`
|
||||||
fn version_from_git_info() -> Result<String, std::io::Error> {
|
fn version_from_git_info() -> Result<String, Error> {
|
||||||
// The exact tag for the current commit, can be empty when
|
// The exact tag for the current commit, can be empty when
|
||||||
// the current commit doesn't have an associated tag
|
// the current commit doesn't have an associated tag
|
||||||
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2026.4.1"
|
vault_version: "v2026.7.0"
|
||||||
vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe"
|
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
|
||||||
# Cross Compile Docker Helper Scripts v1.9.0
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
rust_version: 1.95.0 # Rust version to be used
|
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
||||||
|
# rust_version: x.yy.z # Rust version to be used
|
||||||
debian_version: trixie # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
alpine_version: "3.23" # Alpine version to be used
|
alpine_version: "3.24" # Alpine version to be used
|
||||||
# For which platforms/architectures will we try to build images
|
# For which platforms/architectures will we try to build images
|
||||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||||
# Determine the build images per OS/Arch
|
# Determine the build images per OS/Arch
|
||||||
|
|||||||
+17
-17
@@ -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.4.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.98.1 AS build_amd64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.1 AS build_arm64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.1 AS build_armv7
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.1 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
@@ -57,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
# Debian Trixie uses libpq v17
|
# Debian Trixie uses libpq v17
|
||||||
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||||
|
|
||||||
|
|
||||||
# Create CARGO_HOME folder and don't download rust docs
|
# Create CARGO_HOME folder and don't download rust docs
|
||||||
RUN mkdir -pv "${CARGO_HOME}" && \
|
RUN mkdir -pv "${CARGO_HOME}" && \
|
||||||
rustup set profile minimal
|
rustup set profile minimal
|
||||||
@@ -67,11 +66,11 @@ RUN USER=root cargo new --bin /app
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -87,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
|||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
@@ -98,13 +97,13 @@ COPY . .
|
|||||||
ARG VW_VERSION
|
ARG VW_VERSION
|
||||||
|
|
||||||
# Builds again, this time it will be the actual source files being build
|
# Builds again, this time it will be the actual source files being build
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||||
@@ -127,7 +126,8 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23
|
# hadolint ignore=DL3065
|
||||||
|
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
ROCKET_ADDRESS=0.0.0.0 \
|
ROCKET_ADDRESS=0.0.0.0 \
|
||||||
|
|||||||
+50
-44
@@ -19,15 +19,15 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
@@ -36,7 +36,8 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
|
|||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build
|
||||||
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
@@ -51,7 +52,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
TERM=xterm-256color \
|
TERM=xterm-256color \
|
||||||
CARGO_HOME="/root/.cargo" \
|
CARGO_HOME="/root/.cargo" \
|
||||||
USER="root"
|
USER="root"
|
||||||
# Install clang to get `xx-cargo` working
|
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||||
# Install pkg-config to allow amd64 builds to find all libraries
|
# Install pkg-config to allow amd64 builds to find all libraries
|
||||||
# Install git so build.rs can determine the correct version
|
# Install git so build.rs can determine the correct version
|
||||||
# Install the libc cross packages based upon the debian-arch
|
# Install the libc cross packages based upon the debian-arch
|
||||||
@@ -59,19 +60,16 @@ RUN apt-get update && \
|
|||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
clang \
|
clang \
|
||||||
pkg-config \
|
git && \
|
||||||
git \
|
|
||||||
"libc6-$(xx-info debian-arch)-cross" \
|
|
||||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
|
||||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
|
||||||
xx-apt-get install -y \
|
xx-apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
gcc \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libmariadb-dev \
|
libmariadb-dev \
|
||||||
zlib1g-dev && \
|
pkg-config \
|
||||||
|
zlib1g-dev \
|
||||||
|
xx-c-essentials && \
|
||||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
||||||
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||||
|
|
||||||
@@ -83,30 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
|||||||
RUN USER=root cargo new --bin /app
|
RUN USER=root cargo new --bin /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Environment variables for Cargo on Debian based builds
|
RUN . /env-cargo && \
|
||||||
ARG TARGET_PKG_CONFIG_PATH
|
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
|
||||||
if xx-info is-cross ; then \
|
|
||||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
|
||||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
|
||||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
|
||||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
|
||||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
|
||||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
|
||||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
|
||||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
|
||||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
|
||||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
|
||||||
else \
|
|
||||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
|
||||||
fi && \
|
|
||||||
echo "# End of env-cargo" >> /env-cargo ; \
|
|
||||||
fi && \
|
|
||||||
# Output the current contents of the file
|
|
||||||
cat /env-cargo
|
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -121,8 +96,23 @@ ARG DB=sqlite,mysql,postgresql
|
|||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||||
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||||
|
if xx-info is-cross; then \
|
||||||
|
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
fi && \
|
||||||
|
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||||
|
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||||
|
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||||
|
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||||
|
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||||
|
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||||
|
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||||
|
fi && \
|
||||||
|
export XX_RUSTFLAGS && \
|
||||||
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
# Copies the complete project
|
# Copies the complete project
|
||||||
@@ -132,13 +122,28 @@ COPY . .
|
|||||||
ARG VW_VERSION
|
ARG VW_VERSION
|
||||||
|
|
||||||
# Builds again, this time it will be the actual source files being build
|
# Builds again, this time it will be the actual source files being build
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||||
|
if xx-info is-cross; then \
|
||||||
|
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
fi && \
|
||||||
|
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||||
|
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||||
|
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||||
|
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||||
|
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||||
|
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||||
|
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||||
|
fi && \
|
||||||
|
export XX_RUSTFLAGS && \
|
||||||
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||||
|
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||||
@@ -161,6 +166,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
|
# hadolint ignore=DL3065
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
|
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
|
|||||||
+40
-39
@@ -27,6 +27,24 @@
|
|||||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||||
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||||
#
|
#
|
||||||
|
{% macro xx_cargo_config() -%}
|
||||||
|
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||||
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||||
|
if xx-info is-cross; then \
|
||||||
|
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
fi && \
|
||||||
|
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||||
|
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||||
|
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||||
|
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||||
|
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||||
|
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||||
|
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||||
|
fi && \
|
||||||
|
export XX_RUSTFLAGS && \
|
||||||
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
||||||
|
{%- endmacro %}
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
@@ -47,6 +65,7 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
|
|||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
{% endif %}
|
{% endif %}
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -66,10 +85,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
|
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
|
||||||
# Debian Trixie uses libpq v17
|
# Debian Trixie uses libpq v17
|
||||||
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
# Install clang to get `xx-cargo` working
|
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||||
# Install pkg-config to allow amd64 builds to find all libraries
|
# Install pkg-config to allow amd64 builds to find all libraries
|
||||||
# Install git so build.rs can determine the correct version
|
# Install git so build.rs can determine the correct version
|
||||||
# Install the libc cross packages based upon the debian-arch
|
# Install the libc cross packages based upon the debian-arch
|
||||||
@@ -77,19 +96,16 @@ RUN apt-get update && \
|
|||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
clang \
|
clang \
|
||||||
pkg-config \
|
git && \
|
||||||
git \
|
|
||||||
"libc6-$(xx-info debian-arch)-cross" \
|
|
||||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
|
||||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
|
||||||
xx-apt-get install -y \
|
xx-apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
gcc \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libmariadb-dev \
|
libmariadb-dev \
|
||||||
zlib1g-dev && \
|
pkg-config \
|
||||||
|
zlib1g-dev \
|
||||||
|
xx-c-essentials && \
|
||||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
||||||
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -102,38 +118,14 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
|||||||
RUN USER=root cargo new --bin /app
|
RUN USER=root cargo new --bin /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "alpine" %}
|
||||||
# Environment variables for Cargo on Debian based builds
|
|
||||||
ARG TARGET_PKG_CONFIG_PATH
|
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
|
||||||
if xx-info is-cross ; then \
|
|
||||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
|
||||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
|
||||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
|
||||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
|
||||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
|
||||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
|
||||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
|
||||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
|
||||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
|
||||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
|
||||||
else \
|
|
||||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
|
||||||
fi && \
|
|
||||||
echo "# End of env-cargo" >> /env-cargo ; \
|
|
||||||
fi && \
|
|
||||||
# Output the current contents of the file
|
|
||||||
cat /env-cargo
|
|
||||||
|
|
||||||
{% elif base == "alpine" %}
|
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -153,8 +145,12 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
|||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
|
{% if base == "debian" %}
|
||||||
|
{{ xx_cargo_config() }} && \
|
||||||
|
{% elif base == "alpine" %}
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
|
{% endif %}
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
# Copies the complete project
|
# Copies the complete project
|
||||||
@@ -164,13 +160,17 @@ COPY . .
|
|||||||
ARG VW_VERSION
|
ARG VW_VERSION
|
||||||
|
|
||||||
# Builds again, this time it will be the actual source files being build
|
# Builds again, this time it will be the actual source files being build
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
|
{% if base == "debian" %}
|
||||||
|
{{ xx_cargo_config() }} && \
|
||||||
|
{% elif base == "alpine" %}
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
{% endif %}
|
||||||
|
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||||
@@ -193,6 +193,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
|
# hadolint ignore=DL3065
|
||||||
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
|
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
|
|||||||
@@ -3,17 +3,23 @@
|
|||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import tomllib
|
||||||
import yaml
|
import yaml
|
||||||
import jinja2
|
import jinja2
|
||||||
|
|
||||||
# Load settings file
|
# Load settings file
|
||||||
with open("DockerSettings.yaml", 'r') as yaml_file:
|
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
|
||||||
yaml_data = yaml.safe_load(yaml_file)
|
yaml_data = yaml.safe_load(yaml_file)
|
||||||
|
|
||||||
|
# Extract the rust_version from the rust-toolchain.toml file
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
|
||||||
|
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
|
||||||
|
|
||||||
settings_env = jinja2.Environment(
|
settings_env = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(os.getcwd()),
|
loader=jinja2.FileSystemLoader(os.getcwd()),
|
||||||
)
|
)
|
||||||
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
|
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
|
||||||
|
|
||||||
args_parser = argparse.ArgumentParser()
|
args_parser = argparse.ArgumentParser()
|
||||||
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
|
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
|||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.45"
|
quote = "1.0.47"
|
||||||
syn = "2.0.117"
|
syn = "3.0.6"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
+5
-4
@@ -1,14 +1,15 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
use syn::{DeriveInput, parse_macro_input};
|
||||||
|
|
||||||
#[proc_macro_derive(UuidFromParam)]
|
#[proc_macro_derive(UuidFromParam)]
|
||||||
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
||||||
let ast = syn::parse(input).unwrap();
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_derive_uuid_macro(&ast)
|
impl_derive_uuid_macro(&ast)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
let name = &ast.ident;
|
let name = &ast.ident;
|
||||||
let gen_derive = quote! {
|
let gen_derive = quote! {
|
||||||
#[automatically_derived]
|
#[automatically_derived]
|
||||||
@@ -30,12 +31,12 @@ fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
|||||||
|
|
||||||
#[proc_macro_derive(IdFromParam)]
|
#[proc_macro_derive(IdFromParam)]
|
||||||
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
||||||
let ast = syn::parse(input).unwrap();
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_derive_safestring_macro(&ast)
|
impl_derive_safestring_macro(&ast)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
|
fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
let name = &ast.ident;
|
let name = &ast.ident;
|
||||||
let gen_derive = quote! {
|
let gen_derive = quote! {
|
||||||
#[automatically_derived]
|
#[automatically_derived]
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
-- Dynamically create DROP FOREIGN KEY
|
SELECT if (
|
||||||
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
|
EXISTS(
|
||||||
-- This checks if the key exists, and if so, will drop it.
|
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||||
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
CONSTRAINT_SCHEMA = DATABASE() AND
|
AND TABLE_NAME = 'sso_users'
|
||||||
TABLE_NAME = 'sso_users' AND
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
|
AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
|
||||||
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
|
)
|
||||||
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
|
,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
|
||||||
'SELECT 1');
|
,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
|
||||||
PREPARE stmt FROM @drop_sso_fk;
|
) INTO @drop_stmt;
|
||||||
EXECUTE stmt;
|
PREPARE drop_stmt FROM @drop_stmt;
|
||||||
DEALLOCATE PREPARE stmt;
|
EXECUTE drop_stmt;
|
||||||
|
|
||||||
|
SELECT if (
|
||||||
|
EXISTS(
|
||||||
|
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'sso_users'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
AND CONSTRAINT_NAME = '1'
|
||||||
|
)
|
||||||
|
,'ALTER TABLE sso_users DROP FOREIGN KEY `1`'
|
||||||
|
,'SELECT "info: FK sso_users 1 does not exist."'
|
||||||
|
) INTO @drop_stmt;
|
||||||
|
PREPARE drop_stmt FROM @drop_stmt;
|
||||||
|
EXECUTE drop_stmt;
|
||||||
|
|
||||||
|
DEALLOCATE PREPARE drop_stmt;
|
||||||
|
|
||||||
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sso_auth DROP COLUMN code_response_error;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sso_auth DROP COLUMN code_response_error;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
@@ -21,11 +21,19 @@ TEST_USER3=test3
|
|||||||
TEST_USER3_PASSWORD=${TEST_USER3}
|
TEST_USER3_PASSWORD=${TEST_USER3}
|
||||||
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
|
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
|
||||||
|
|
||||||
|
TEST_USER4=test4
|
||||||
|
TEST_USER4_PASSWORD=${TEST_USER4}
|
||||||
|
TEST_USER4_MAIL=${TEST_USER4}@yopmail.com
|
||||||
|
|
||||||
|
TEST_USER5=test5
|
||||||
|
TEST_USER5_PASSWORD=${TEST_USER5}
|
||||||
|
TEST_USER5_MAIL=${TEST_USER5}@yopmail.com
|
||||||
|
|
||||||
###################
|
###################
|
||||||
# Keycloak Config #
|
# Keycloak Config #
|
||||||
###################
|
###################
|
||||||
KEYCLOAK_ADMIN=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||||
KC_HTTP_HOST=127.0.0.1
|
KC_HTTP_HOST=127.0.0.1
|
||||||
KC_HTTP_PORT=8080
|
KC_HTTP_PORT=8080
|
||||||
|
|
||||||
@@ -39,8 +47,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
######################
|
######################
|
||||||
ROCKET_ADDRESS=0.0.0.0
|
ROCKET_ADDRESS=0.0.0.0
|
||||||
ROCKET_PORT=8000
|
ROCKET_PORT=8000
|
||||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
|
||||||
|
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
|
SSO_DEBUG_TOKENS=true
|
||||||
I_REALLY_WANT_VOLATILE_STORAGE=true
|
I_REALLY_WANT_VOLATILE_STORAGE=true
|
||||||
|
|
||||||
SSO_ENABLED=true
|
SSO_ENABLED=true
|
||||||
|
|||||||
+14
-16
@@ -1,8 +1,8 @@
|
|||||||
# Integration tests
|
# Integration tests
|
||||||
|
|
||||||
This allows running integration tests using [Playwright](https://playwright.dev/).
|
This allows running integration tests using [Playwright](https://playwright.dev/).
|
||||||
|
\
|
||||||
It uses its own `test.env` with different ports to not collide with a running dev instance.
|
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
|
|||||||
|
|
||||||
### Running Playwright outside docker
|
### Running Playwright outside docker
|
||||||
|
|
||||||
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
|
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
|
||||||
You will additionally need `nodejs` then run:
|
You'll additionally need `nodejs` then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci --ignore-scripts
|
npm ci --ignore-scripts --allow-git=none --allow-remote=none
|
||||||
npx playwright install-deps
|
npx playwright install-deps
|
||||||
npx playwright install firefox
|
npx playwright install firefox
|
||||||
```
|
```
|
||||||
@@ -65,7 +65,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
|
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running specific tests
|
### Running specific tests
|
||||||
@@ -77,7 +77,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
|
||||||
```
|
```
|
||||||
|
|
||||||
To run only a specifc test (It might fail if it has dependency):
|
To run only a specific test (It might fail if it has dependency):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
|
||||||
@@ -92,7 +92,7 @@ This does not start the server, you will need to start it manually.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
npx playwright codegen "http://127.0.0.1:8003"
|
npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
|
||||||
```
|
```
|
||||||
|
|
||||||
## Override web-vault
|
## Override web-vault
|
||||||
@@ -112,12 +112,11 @@ You can check the result running:
|
|||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
```
|
```
|
||||||
|
|
||||||
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
Then check `https://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
||||||
|
|
||||||
# OpenID Connect test setup
|
# OpenID Connect test setup
|
||||||
|
|
||||||
Additionally this `docker-compose` template allows to run locally Vaultwarden,
|
Additionally this `docker-compose` template allow to run locally `Vaultwarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
||||||
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
|
|||||||
```bash
|
```bash
|
||||||
> docker compose --profile vaultwarden --env-file .env up
|
> docker compose --profile vaultwarden --env-file .env up
|
||||||
....
|
....
|
||||||
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
|
keycloakSetup_1 | Logging into https://127.0.0.1:8080 as user admin of realm master
|
||||||
keycloakSetup_1 | Created new realm with id 'test'
|
keycloakSetup_1 | Created new realm with id 'test'
|
||||||
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
||||||
oidc_keycloakSetup_1 exited with code 0
|
oidc_keycloakSetup_1 exited with code 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user
|
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
|
||||||
(It is normal for this container to stop once the configuration is done).
|
|
||||||
|
|
||||||
Then you can access :
|
Then you can access :
|
||||||
|
|
||||||
- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
- `Vaultwarden` on https://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
||||||
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
||||||
- `Maildev` on http://0.0.0.0:1080
|
- `Maildev` on http://0.0.0.0:1080
|
||||||
|
|
||||||
@@ -171,7 +169,7 @@ docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild V
|
|||||||
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
||||||
The content of the file will be loaded as environment variables in all containers.
|
The content of the file will be loaded as environment variables in all containers.
|
||||||
|
|
||||||
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
|
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
|
||||||
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
|
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
|
||||||
|
|
||||||
## Cleanup
|
## Cleanup
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ done
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
|
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
|
||||||
|
|
||||||
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
||||||
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
||||||
@@ -39,6 +39,6 @@ kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLi
|
|||||||
# THEN in another terminal:
|
# THEN in another terminal:
|
||||||
# docker exec -it keycloakSetup-dev /bin/bash
|
# docker exec -it keycloakSetup-dev /bin/bash
|
||||||
# export PATH=$PATH:/opt/keycloak/bin
|
# export PATH=$PATH:/opt/keycloak/bin
|
||||||
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
|
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
|
||||||
# ENJOY
|
# ENJOY
|
||||||
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
|
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
|
||||||
@@ -28,7 +28,7 @@ RUN mkdir /playwright
|
|||||||
WORKDIR /playwright
|
WORKDIR /playwright
|
||||||
|
|
||||||
COPY package.json package-lock.json .
|
COPY package.json package-lock.json .
|
||||||
RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox
|
RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox
|
||||||
|
|
||||||
COPY docker-compose.yml test.env ./
|
COPY docker-compose.yml test.env ./
|
||||||
COPY compose ./compose
|
COPY compose ./compose
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ WORKDIR /
|
|||||||
|
|
||||||
COPY --from=prebuilt /start.sh .
|
COPY --from=prebuilt /start.sh .
|
||||||
COPY --from=prebuilt /vaultwarden .
|
COPY --from=prebuilt /vaultwarden .
|
||||||
|
COPY --from=build /data ./data
|
||||||
COPY --from=build /web-vault ./web-vault
|
COPY --from=build /web-vault ./web-vault
|
||||||
|
|
||||||
ENTRYPOINT ["/start.sh"]
|
ENTRYPOINT ["/start.sh"]
|
||||||
@@ -22,3 +22,14 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
|||||||
|
|
||||||
mv build /web-vault
|
mv build /web-vault
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Lower the KDF iterations default for faster tests.
|
||||||
|
sed -i 's/(6e5,2e6,6e5)/(1e5,2e6,1e5)/' /web-vault/app/main.*.js
|
||||||
|
|
||||||
|
# Generate a self signed cert
|
||||||
|
mkdir -p /data/ssl; cd /data/ssl
|
||||||
|
|
||||||
|
openssl req -x509 -out localhost.crt -keyout localhost.key \
|
||||||
|
-newkey rsa:2048 -nodes -sha256 \
|
||||||
|
-subj '/CN=localhost' -extensions EXT -config <( \
|
||||||
|
printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
|
||||||
@@ -24,12 +24,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- ADMIN_TOKEN
|
- ADMIN_TOKEN
|
||||||
- DATABASE_URL
|
- DATABASE_URL
|
||||||
|
- CLIENT_SUPPRESS_ONBOARDING
|
||||||
|
- EMAIL_2FA_AUTO_FALLBACK
|
||||||
- I_REALLY_WANT_VOLATILE_STORAGE
|
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||||
- LOG_LEVEL
|
- LOG_LEVEL
|
||||||
- LOGIN_RATELIMIT_MAX_BURST
|
- LOGIN_RATELIMIT_MAX_BURST
|
||||||
- SMTP_HOST
|
- SMTP_HOST
|
||||||
- SMTP_FROM
|
- SMTP_FROM
|
||||||
- SMTP_DEBUG
|
- SMTP_DEBUG
|
||||||
|
- SSO_AUTH_ONLY_NOT_SESSION
|
||||||
- SSO_DEBUG_TOKENS
|
- SSO_DEBUG_TOKENS
|
||||||
- SSO_ENABLED
|
- SSO_ENABLED
|
||||||
- SSO_FRONTEND
|
- SSO_FRONTEND
|
||||||
@@ -58,7 +61,7 @@ services:
|
|||||||
Mariadb:
|
Mariadb:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_mariadb
|
container_name: playwright_mariadb
|
||||||
image: mariadb:11.2.4
|
image: mariadb:12.2.2
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
@@ -70,7 +73,7 @@ services:
|
|||||||
Mysql:
|
Mysql:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_mysql
|
container_name: playwright_mysql
|
||||||
image: mysql:8.4.1
|
image: mysql:9.7.0
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
@@ -82,7 +85,7 @@ services:
|
|||||||
Postgres:
|
Postgres:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_postgres
|
container_name: playwright_postgres
|
||||||
image: postgres:16.3
|
image: postgres:18.4
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
@@ -94,7 +97,7 @@ services:
|
|||||||
Maildev:
|
Maildev:
|
||||||
profiles: ["vaultwarden", "maildev"]
|
profiles: ["vaultwarden", "maildev"]
|
||||||
container_name: maildev
|
container_name: maildev
|
||||||
image: timshel/maildev:3.0.4
|
image: timshel/maildev:3.2.19
|
||||||
ports:
|
ports:
|
||||||
- ${SMTP_PORT}:1025
|
- ${SMTP_PORT}:1025
|
||||||
- 1080:1080
|
- 1080:1080
|
||||||
@@ -102,7 +105,7 @@ services:
|
|||||||
Keycloak:
|
Keycloak:
|
||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloak-${ENV:-dev}
|
container_name: keycloak-${ENV:-dev}
|
||||||
image: quay.io/keycloak/keycloak:26.3.4
|
image: quay.io/keycloak/keycloak:26.6.2
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
@@ -112,12 +115,12 @@ services:
|
|||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloakSetup-${ENV:-dev}
|
container_name: keycloakSetup-${ENV:-dev}
|
||||||
image: keycloak_setup-${ENV:-dev}
|
image: keycloak_setup-${ENV:-dev}
|
||||||
|
network_mode: "host"
|
||||||
build:
|
build:
|
||||||
context: compose/keycloak
|
context: compose/keycloak
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
KEYCLOAK_VERSION: 26.3.4
|
KEYCLOAK_VERSION: 26.6.2
|
||||||
network_mode: "host"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- Keycloak
|
- Keycloak
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { firefox, type FullConfig } from '@playwright/test';
|
import { type FullConfig } from '@playwright/test';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stopVault(force: boolean = false) {
|
export async function stopVault(force: boolean = false) {
|
||||||
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||||
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Vaultwarden stopping`);
|
console.log(`Vaultwarden stopping`);
|
||||||
@@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanLanding(page: Page) {
|
export async function cleanLanding(page: Page) {
|
||||||
|
await page.context().clearCookies();
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
||||||
|
|
||||||
@@ -248,15 +249,3 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ignoreExtension(page: Page) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
|
||||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Extension setup not visible. Continuing');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Generated
+582
-580
File diff suppressed because it is too large.
Load diff
@@ -8,14 +8,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.60.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.4.2",
|
||||||
"dotenv-expand": "12.0.3",
|
"dotenv-expand": "13.0.0",
|
||||||
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
"maildev": "npm:@timshel_npm/maildev@3.2.19"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "3.15.3",
|
"mysql2": "3.22.3",
|
||||||
"otpauth": "9.4.1",
|
"otpauth": "9.5.1",
|
||||||
"pg": "8.16.3"
|
"pg": "8.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,10 +25,12 @@ export default defineConfig({
|
|||||||
/* Long global timeout for complex tests
|
/* Long global timeout for complex tests
|
||||||
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
||||||
*/
|
*/
|
||||||
timeout: 120 * 1000,
|
timeout: 240 * 1000,
|
||||||
actionTimeout: 20 * 1000,
|
actionTimeout: 40 * 1000,
|
||||||
navigationTimeout: 20 * 1000,
|
navigationTimeout: 40 * 1000,
|
||||||
expect: { timeout: 20 * 1000 },
|
expect: { timeout: 40 * 1000 },
|
||||||
|
|
||||||
|
"permissions": ["clipboard-read"],
|
||||||
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
@@ -37,6 +39,10 @@ export default defineConfig({
|
|||||||
browserName: 'firefox',
|
browserName: 'firefox',
|
||||||
locale: 'en-GB',
|
locale: 'en-GB',
|
||||||
timezoneId: 'Europe/London',
|
timezoneId: 'Europe/London',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--ignore-certificate-errors']
|
||||||
|
},
|
||||||
|
|
||||||
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on',
|
trace: 'on',
|
||||||
|
|||||||
+6
-4
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
|
|||||||
#####################
|
#####################
|
||||||
# Playwright Config #
|
# Playwright Config #
|
||||||
#####################
|
#####################
|
||||||
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
|
PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false}
|
||||||
PW_SMTP_FROM=vaultwarden@playwright.test
|
PW_SMTP_FROM=vaultwarden@playwright.test
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
|
|||||||
###################
|
###################
|
||||||
# Keycloak Config #
|
# Keycloak Config #
|
||||||
###################
|
###################
|
||||||
KEYCLOAK_ADMIN=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||||
KC_HTTP_HOST=127.0.0.1
|
KC_HTTP_HOST=127.0.0.1
|
||||||
KC_HTTP_PORT=8081
|
KC_HTTP_PORT=8081
|
||||||
|
|
||||||
@@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
# Vaultwarden Config #
|
# Vaultwarden Config #
|
||||||
######################
|
######################
|
||||||
ROCKET_PORT=8003
|
ROCKET_PORT=8003
|
||||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
|
||||||
|
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
LOGIN_RATELIMIT_MAX_BURST=100
|
LOGIN_RATELIMIT_MAX_BURST=100
|
||||||
ADMIN_TOKEN=admin
|
ADMIN_TOKEN=admin
|
||||||
|
CLIENT_SUPPRESS_ONBOARDING=true
|
||||||
|
|
||||||
SMTP_SECURITY=off
|
SMTP_SECURITY=off
|
||||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect, type TestInfo } from '@playwright/test';
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
|
|
||||||
|
import * as orgs from './setups/orgs';
|
||||||
import { createAccount } from './setups/user';
|
import { createAccount } from './setups/user';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
@@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
|
|||||||
test('Create', async ({ page }) => {
|
test('Create', async ({ page }) => {
|
||||||
await createAccount(test, page, users.user1);
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
await test.step('Create Org', async () => {
|
await orgs.create(test, page, 'New organisation');
|
||||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
|
||||||
await page.getByLabel('Organisation name (required)').fill('Test');
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Organisation created');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Create Collection', async () => {
|
await test.step('Create Collection', async () => {
|
||||||
await page.getByRole('link', { name: 'Collections' }).click();
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'New' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Collection' }).click();
|
await page.getByRole('menuitem', { name: 'Collection' }).click();
|
||||||
await page.getByLabel('Name (required)').fill('RandomCollec');
|
await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Created collection RandomCollec');
|
await utils.checkNotification(page, 'Created collection RandomCollec');
|
||||||
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP, disableTOTP } from './setups/2fa';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Change Key settings', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Change SHA-256 Iterations', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Keys' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('700000');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Update settings' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Switch to Argon2', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Keys' }).click();
|
||||||
|
|
||||||
|
await page.locator('.ng-arrow-wrapper').click();
|
||||||
|
await page.getByText('Argon2id').click();
|
||||||
|
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF memory (MB) * (required)'}).fill('16');
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('2');
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF parallelism * (required)'}).fill('1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Update settings' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
@@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
|
|||||||
test('Login', async ({ context, page }) => {
|
test('Login', async ({ context, page }) => {
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await logUser(test, page, users.user1, mailBuffer);
|
await logUser(test, page, users.user1, { mailBuffer });
|
||||||
|
|
||||||
await test.step('verify email', async () => {
|
await test.step('verify email', async () => {
|
||||||
await page.getByText('Verify your account\'s email').click();
|
await page.getByRole('button', { name: "Send email" }).click();
|
||||||
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
||||||
|
|
||||||
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
||||||
@@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
|
|||||||
test('2fa', async ({ page }) => {
|
test('2fa', async ({ page }) => {
|
||||||
const emails = mailserver.buffer(users.user1.email);
|
const emails = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await test.step('login', async () => {
|
await logUser(test, page, users.user1, {
|
||||||
await page.goto('/');
|
mailBuffer: emails,
|
||||||
|
mail2fa: true,
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
});
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
|
||||||
const code = await retrieveEmailCode(test, page, emails);
|
|
||||||
await page.getByLabel(/Verification code/).fill(code);
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add it later' }).click();
|
|
||||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Vaults/);
|
|
||||||
})
|
|
||||||
|
|
||||||
await disableEmail(test, page, users.user1);
|
|
||||||
|
|
||||||
emails.close();
|
emails.close();
|
||||||
});
|
});
|
||||||
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
|
|||||||
import * as utils from '../global-utils';
|
import * as utils from '../global-utils';
|
||||||
import * as orgs from './setups/orgs';
|
import * as orgs from './setups/orgs';
|
||||||
import { createAccount, logUser } from './setups/user';
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP } from './setups/2fa';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
|||||||
await utils.startVault(browser, testInfo, {
|
await utils.startVault(browser, testInfo, {
|
||||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
|
EMAIL_2FA_AUTO_FALLBACK: "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
mail1Buffer = mailServer.buffer(users.user1.email);
|
mail1Buffer = mailServer.buffer(users.user1.email);
|
||||||
@@ -40,6 +42,16 @@ test('Invite users', async ({ page }) => {
|
|||||||
await createAccount(test, page, users.user1, mail1Buffer);
|
await createAccount(test, page, users.user1, mail1Buffer);
|
||||||
|
|
||||||
await orgs.create(test, page, 'Test');
|
await orgs.create(test, page, 'Test');
|
||||||
|
|
||||||
|
await test.step(`Set account recovery`, async () => {
|
||||||
|
await orgs.policies(test, page, 'Test');
|
||||||
|
await page.getByRole('button', { name: 'Account recovery' }).click();
|
||||||
|
await page.getByRole('checkbox', { name: 'Turn on' }).check();
|
||||||
|
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Edited policy Account recovery');
|
||||||
|
});
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await orgs.invite(test, page, 'Test', users.user2.email);
|
await orgs.invite(test, page, 'Test', users.user2.email);
|
||||||
await orgs.invite(test, page, 'Test', users.user3.email, {
|
await orgs.invite(test, page, 'Test', users.user3.email, {
|
||||||
@@ -56,18 +68,16 @@ test('invited with new account', async ({ page }) => {
|
|||||||
await page.goto(link);
|
await page.goto(link);
|
||||||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
|
||||||
//await page.getByLabel('Name').fill(users.user2.name);
|
// await page.getByLabel('Name').fill(users.user2.name);
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await utils.checkNotification(page, 'Your new account has been created');
|
await utils.checkNotification(page, 'Your new account has been created');
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// Redirected to the vault
|
// Redirected to the vault
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
// await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -90,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
|
|
||||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Confirm invited user', async ({ page }) => {
|
test('Confirm invited user', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1, mail1Buffer);
|
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await orgs.confirm(test, page, 'Test', users.user2.email);
|
await orgs.confirm(test, page, 'Test', users.user2.email);
|
||||||
@@ -113,7 +121,40 @@ test('Confirm invited user', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Organization is visible', async ({ page }) => {
|
test('Organization is visible', async ({ page }) => {
|
||||||
await logUser(test, page, users.user2, mail2Buffer);
|
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
|
||||||
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Recover user password', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
|
||||||
|
await activateTOTP(test, page, users.user2);
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||||
|
|
||||||
|
let newPassword = "TotoNewPassword";
|
||||||
|
|
||||||
|
await orgs.members(test, page, 'Test');
|
||||||
|
await test.step(`Recover ${users.user2.email}`, async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
|
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Recover account' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
|
||||||
|
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
|
||||||
|
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Account recovery success');
|
||||||
|
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
|
||||||
|
});
|
||||||
|
|
||||||
|
let user2 = {
|
||||||
|
email: users.user2.email,
|
||||||
|
name: users.user2.name,
|
||||||
|
password: newPassword,
|
||||||
|
};
|
||||||
|
await logUser(test, page, user2, {
|
||||||
|
mailBuffer: mail2Buffer,
|
||||||
|
mail2fa: true,
|
||||||
|
notNewDevice: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Password', async ({ context, page }, testInfo: TestInfo) => {
|
||||||
|
const label = 'Test Password';
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Create password entry', async () => {
|
||||||
|
await page.getByRole('button', { name: 'New item' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
|
||||||
|
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item added');
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Check', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||||
|
await page.getByTestId('copy-username').click();
|
||||||
|
await utils.checkNotification(page, 'Username copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
|
||||||
|
await page.getByTestId('copy-password').click();
|
||||||
|
await utils.checkNotification(page, 'Password copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item sent to bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Deleted', async () => {
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
|
||||||
|
const label = 'Test SSH key';
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
const privateKey = await test.step('Create key entry', async () => {
|
||||||
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'SSH key' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item added');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||||
|
await utils.checkNotification(page, 'Private key copied');
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Check', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||||
|
await utils.checkNotification(page, 'Private key copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item sent to bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Deleted', async () => {
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Send', async ({ browser, page }) => {
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
const send_url = await test.step('Create', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Send' }).click();
|
||||||
|
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'New Send', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||||
|
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
const context2 = await browser.newContext();
|
||||||
|
const page2 = await context2.newPage();
|
||||||
|
|
||||||
|
await test.step('View', async () => {
|
||||||
|
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||||
|
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pwd_url = await test.step('Create with password', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Send' }).click();
|
||||||
|
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'New' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
|
||||||
|
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
|
||||||
|
await page.getByRole('combobox', { name: 'Who can view' }).click();
|
||||||
|
await page.getByText('Anyone with a password set by you').click();
|
||||||
|
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||||
|
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('View with password', async () => {
|
||||||
|
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
|
||||||
|
await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
|
||||||
|
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||||
|
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,10 +11,11 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
const secret = await page.getByLabel('Key').innerText();
|
const secret = await page.getByLabel('Key', { exact: true }).innerText();
|
||||||
|
|
||||||
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
||||||
|
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate());
|
await page.getByLabel(/Verification code/).fill(totp.generate());
|
||||||
@@ -33,8 +34,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').click();
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
await page.getByRole('button', { name: 'Yes' }).click();
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
@@ -49,7 +50,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
await page.getByRole('button', { name: 'Send email' }).click();
|
||||||
});
|
});
|
||||||
@@ -81,8 +82,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').click();
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
await page.getByRole('button', { name: 'Yes' }).click();
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, type Browser, Page } from '@playwright/test';
|
||||||
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
export async function login(test, page: Page) {
|
||||||
|
await test.step(`Admin login`, async () => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN);
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invite(test, page: Page, email: string) {
|
||||||
|
await test.step(`Invite user with ${email}`, async () => {
|
||||||
|
await page.getByRole('link', { name: 'Users' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Enter email' }).fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Invite' }).click();
|
||||||
|
await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
|
|||||||
utils.loadEnv();
|
utils.loadEnv();
|
||||||
|
|
||||||
test('DB teardown ?', async ({ serviceName }) => {
|
test('DB teardown ?', async ({ serviceName }) => {
|
||||||
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
|
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
|
||||||
utils.stopComposeService(serviceName);
|
utils.stopComposeService(serviceName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
|
|||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
export async function create(test, page: Page, name: string) {
|
export async function create(test, page: Page, name: string) {
|
||||||
await test.step('Create Org', async () => {
|
await test.step(`Create Org ${name}`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
|
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
|
||||||
|
if( await pm_locator.count() > 0 ){
|
||||||
|
pm_locator.first().click();
|
||||||
|
}
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
await page.getByRole('link', { name: 'New organisation' }).click();
|
||||||
await page.getByLabel('Organisation name (required)').fill(name);
|
await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name);
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Organisation created');
|
await utils.checkNotification(page, 'Organisation created');
|
||||||
@@ -18,7 +21,7 @@ export async function policies(test, page: Page, name: string) {
|
|||||||
await test.step(`Navigate to ${name} policies`, async () => {
|
await test.step(`Navigate to ${name} policies`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
||||||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
||||||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
|
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
await page.getByRole('link', { name: 'Policies' }).click();
|
await page.getByRole('link', { name: 'Policies' }).click();
|
||||||
@@ -30,11 +33,11 @@ export async function members(test, page: Page, name: string) {
|
|||||||
await test.step(`Navigate to ${name} members`, async () => {
|
await test.step(`Navigate to ${name} members`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
||||||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
||||||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
|
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
||||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
await page.getByRole('link', { name: 'Members' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
|
await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +45,13 @@ export async function invite(test, page: Page, name: string, email: string) {
|
|||||||
await test.step(`Invite ${email}`, async () => {
|
await test.step(`Invite ${email}`, async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Invite member' }).click();
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
await page.getByLabel('Email (required)').fill(email);
|
await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email);
|
||||||
await page.getByRole('tab', { name: 'Collections' }).click();
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
await page.getByRole('combobox', { name: 'Permission' }).click();
|
await page.getByRole('combobox', { name: 'Permission' }).click();
|
||||||
await page.getByText('Edit items', { exact: true }).click();
|
await page.getByText('Edit items', { exact: true }).click();
|
||||||
await page.getByLabel('Select collections').click();
|
await page.getByRole('combobox', { name: 'Select collections' }).click();
|
||||||
await page.getByText('Default collection').click();
|
await page.getByLabel('Options List').getByText('Default collection').click();
|
||||||
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
|
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'User(s) invited');
|
await utils.checkNotification(page, 'User(s) invited');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
|
|||||||
utils.loadEnv();
|
utils.loadEnv();
|
||||||
|
|
||||||
test('Keycloak teardown', async () => {
|
test('Keycloak teardown', async () => {
|
||||||
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||||
console.log("Keep Keycloak running");
|
console.log("Keep Keycloak running");
|
||||||
} else {
|
} else {
|
||||||
console.log("Keycloak stopping");
|
console.log("Keycloak stopping");
|
||||||
|
|||||||
@@ -15,11 +15,8 @@ export async function logNewUser(
|
|||||||
options: { mailBuffer?: MailBuffer } = {}
|
options: { mailBuffer?: MailBuffer } = {}
|
||||||
) {
|
) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
await page.context().clearCookies();
|
|
||||||
|
|
||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
@@ -33,26 +30,24 @@ export async function logNewUser(
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
|
||||||
if( options.mailBuffer ){
|
if( options.mailBuffer ){
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
await test.step('Check emails', async () => {
|
await test.step('Check emails', async () => {
|
||||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
|
||||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,16 +64,14 @@ export async function logUser(
|
|||||||
mailBuffer ?: MailBuffer,
|
mailBuffer ?: MailBuffer,
|
||||||
totp?: OTPAuth.TOTP,
|
totp?: OTPAuth.TOTP,
|
||||||
mail2fa?: boolean,
|
mail2fa?: boolean,
|
||||||
|
notNewDevice?: boolean,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
|
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
await page.context().clearCookies();
|
|
||||||
|
|
||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
@@ -117,14 +110,12 @@ export async function logUser(
|
|||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( mailBuffer && !options.notNewDevice ){
|
||||||
await test.step('Check email', async () => {
|
await test.step('Check email', async () => {
|
||||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
|
|||||||
import { type MailBuffer } from 'maildev';
|
import { type MailBuffer } from 'maildev';
|
||||||
|
|
||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
import { retrieveEmailCode } from './2fa';
|
||||||
|
|
||||||
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
@@ -17,12 +18,11 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Vault finish Creation
|
// Vault finish Creation
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Your new account has been created')
|
await utils.checkNotification(page, 'Your new account has been created')
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
@@ -35,7 +35,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
|
export async function logUser(
|
||||||
|
test,
|
||||||
|
page: Page,
|
||||||
|
user: { email: string, password: string },
|
||||||
|
options: {
|
||||||
|
mailBuffer ?: MailBuffer,
|
||||||
|
mail2fa?: boolean,
|
||||||
|
notNewDevice?: boolean,
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
@@ -43,16 +52,23 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
if( options.mail2fa ){
|
||||||
|
await test.step('2FA check', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
|
let code = await retrieveEmailCode(test, page, options.mailBuffer);
|
||||||
|
await page.getByLabel(/Verification code/).fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( options.mailBuffer && !options.notNewDevice ){
|
||||||
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, type TestInfo } from '@playwright/test';
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
import { MailDev } from 'maildev';
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
import * as admin from "./setups/admin";
|
||||||
import { logNewUser, logUser } from './setups/sso';
|
import { logNewUser, logUser } from './setups/sso';
|
||||||
import { activateEmail, disableEmail } from './setups/2fa';
|
import { activateEmail, disableEmail } from './setups/2fa';
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
@@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
await utils.startVault(browser, testInfo, {
|
await utils.startVault(browser, testInfo, {
|
||||||
SSO_ENABLED: true,
|
SSO_ENABLED: true,
|
||||||
SSO_ONLY: false,
|
SSO_ONLY: true,
|
||||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
});
|
});
|
||||||
@@ -32,22 +33,64 @@ test.afterAll('Teardown', async ({}) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Create and activate 2FA', async ({ page }) => {
|
test('2FA email', async ({ page }) => {
|
||||||
|
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
||||||
|
|
||||||
await activateEmail(test, page, users.user1, mailBuffer);
|
await activateEmail(test, page, users.user1, mailBuffer);
|
||||||
|
|
||||||
mailBuffer.close();
|
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
|
||||||
});
|
|
||||||
|
|
||||||
test('Log and disable', async ({ page }) => {
|
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
|
||||||
|
|
||||||
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
|
|
||||||
|
|
||||||
await disableEmail(test, page, users.user1);
|
await disableEmail(test, page, users.user1);
|
||||||
|
|
||||||
mailBuffer.close();
|
mailBuffer.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Admin invite', async ({ page }) => {
|
||||||
|
const mailBuffer = mailserver.buffer(users.user2.email);
|
||||||
|
|
||||||
|
await admin.login(test, page);
|
||||||
|
await admin.invite(test, page, users.user2.email);
|
||||||
|
|
||||||
|
|
||||||
|
const link = await test.step('Extract email link', async () => {
|
||||||
|
const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden");
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
return await page.getByTestId("invite").getAttribute("href");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
|
await page.goto(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create Vault account', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Check mails', async () => {
|
||||||
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
mailBuffer.close();
|
||||||
|
});
|
||||||
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Other' }).click();
|
await page.getByRole('button', { name: 'Other' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
@@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
||||||
|
|
||||||
// Check that SSO login is available
|
// Check that SSO login is available
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
||||||
@@ -66,7 +67,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
||||||
await utils.restartVault(page, testInfo, {
|
await utils.restartVault(page, testInfo, {
|
||||||
SSO_ENABLED: false
|
SSO_ENABLED: false
|
||||||
@@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
|
||||||
// No SSO button (rely on a correct selector checked in previous test)
|
// No SSO button (rely on a correct selector checked in previous test)
|
||||||
|
await page.getByLabel('Master password');
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
||||||
|
|
||||||
// Can continue to Master password
|
// Can continue to Master password
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
@@ -67,17 +67,16 @@ test('invited with new account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Redirect to Keycloak', async () => {
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
await page.goto(link);
|
await page.goto(link);
|
||||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Keycloak login', async () => {
|
await test.step('Keycloak login', async () => {
|
||||||
@@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Enforce password policy', async ({ page }) => {
|
test('Activate password policy', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1);
|
await logUser(test, page, users.user1);
|
||||||
await orgs.policies(test, page, '/Test');
|
await orgs.policies(test, page, '/Test');
|
||||||
|
|
||||||
@@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
await utils.logout(test, page, users.user1);
|
|
||||||
|
test('Unlock trigger policyy', async ({ page }) => {
|
||||||
await test.step(`Unlock trigger policy`, async () => {
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
|
||||||
await page.getByRole('button', { name: 'Use single sign-on' }).click();
|
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
|
||||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
});
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Unlock vault', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
|
||||||
|
await page.getByLabel('Master password').fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
||||||
});
|
});
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.95.0"
|
channel = "1.98.1"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
edition = "2021"
|
edition = "2024"
|
||||||
max_width = 120
|
max_width = 120
|
||||||
newline_style = "Unix"
|
newline_style = "Unix"
|
||||||
use_small_heuristics = "Off"
|
use_small_heuristics = "Off"
|
||||||
+114
-88
@@ -2,40 +2,40 @@ use std::{env, sync::LazyLock};
|
|||||||
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Catcher, Route,
|
||||||
form::Form,
|
form::Form,
|
||||||
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
response::{content::RawHtml as Html, Redirect},
|
response::{Redirect, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Catcher, Route,
|
|
||||||
};
|
};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG, VERSION,
|
||||||
api::{
|
api::{
|
||||||
|
ApiResult, EmptyResult, JsonResult, Notify,
|
||||||
core::{log_event, two_factor},
|
core::{log_event, two_factor},
|
||||||
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
|
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||||
config::ConfigBuilder,
|
config::ConfigBuilder,
|
||||||
db::{
|
db::{
|
||||||
backup_sqlite, get_sql_server_version,
|
ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version,
|
||||||
models::{
|
models::{
|
||||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbConnType, ACTIVE_DB_TYPE,
|
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
mail,
|
mail,
|
||||||
sso::FAKE_SSO_IDENTIFIER,
|
sso::FAKE_SSO_IDENTIFIER,
|
||||||
util::{
|
util::{
|
||||||
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
|
FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
|
||||||
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
|
get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
|
||||||
},
|
},
|
||||||
CONFIG, VERSION,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -93,8 +93,7 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
static CAN_BACKUP: LazyLock<bool> =
|
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite));
|
||||||
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
|
|
||||||
#[cfg(not(sqlite))]
|
#[cfg(not(sqlite))]
|
||||||
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
|
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
|
||||||
|
|
||||||
@@ -200,13 +199,7 @@ fn post_admin_login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if !_validate_token(&data.token) {
|
if validate_token(&data.token) {
|
||||||
error!("Invalid admin token. IP: {}", ip.ip);
|
|
||||||
Err(AdminResponse::Unauthorized(render_admin_login(
|
|
||||||
Some("Invalid admin token, please try again."),
|
|
||||||
redirect.as_deref(),
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = generate_admin_claims();
|
let claims = generate_admin_claims();
|
||||||
let jwt = encode_jwt(&claims);
|
let jwt = encode_jwt(&claims);
|
||||||
@@ -224,15 +217,21 @@ fn post_admin_login(
|
|||||||
} else {
|
} else {
|
||||||
Err(AdminResponse::Ok(render_admin_page()))
|
Err(AdminResponse::Ok(render_admin_page()))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Invalid admin token. IP: {}", ip.ip);
|
||||||
|
Err(AdminResponse::Unauthorized(render_admin_login(
|
||||||
|
Some("Invalid admin token, please try again."),
|
||||||
|
redirect.as_deref(),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _validate_token(token: &str) -> bool {
|
fn validate_token(token: &str) -> bool {
|
||||||
match CONFIG.admin_token().as_ref() {
|
match CONFIG.admin_token().as_ref() {
|
||||||
None => false,
|
None => false,
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
use argon2::password_hash::PasswordVerifier;
|
use argon2::password_hash::PasswordVerifier;
|
||||||
match argon2::password_hash::PasswordHash::new(t) {
|
match argon2::password_hash::phc::PasswordHash::new(t) {
|
||||||
Ok(h) => {
|
Ok(h) => {
|
||||||
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
|
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
|
||||||
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
|
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
|
||||||
@@ -307,21 +306,14 @@ async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
|
|||||||
|
|
||||||
#[post("/invite", format = "application/json", data = "<data>")]
|
#[post("/invite", format = "application/json", data = "<data>")]
|
||||||
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||||
let data: InviteData = data.into_inner();
|
async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||||
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
|
||||||
err_code!("User already exists", Status::Conflict.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut user = User::new(&data.email, None);
|
|
||||||
|
|
||||||
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||||
FAKE_SSO_IDENTIFIER.into()
|
FAKE_SSO_IDENTIFIER.into()
|
||||||
} else {
|
} else {
|
||||||
FAKE_ADMIN_UUID.into()
|
FAKE_ADMIN_UUID.into()
|
||||||
};
|
};
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
let invitation = Invitation::new(&user.email);
|
let invitation = Invitation::new(&user.email);
|
||||||
@@ -329,7 +321,14 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
let data: InviteData = data.into_inner();
|
||||||
|
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
||||||
|
err_code!("User already exists", Status::Conflict.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user = User::new(&data.email, None);
|
||||||
|
|
||||||
|
generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||||
user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||||
|
|
||||||
Ok(Json(user.to_json(&conn).await))
|
Ok(Json(user.to_json(&conn).await))
|
||||||
@@ -386,7 +385,7 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
|
|||||||
None => json!("Never"),
|
None => json!("Never"),
|
||||||
};
|
};
|
||||||
|
|
||||||
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
|
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||||
|
|
||||||
users_json.push(usr);
|
users_json.push(usr);
|
||||||
}
|
}
|
||||||
@@ -426,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
|
|||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserDeleted as i32,
|
EventType::OrganizationUserDeleted,
|
||||||
&membership.uuid,
|
&membership.uuid,
|
||||||
&membership.org_uuid,
|
&membership.org_uuid,
|
||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
@@ -447,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
|
|||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUnlinkedSso as i32,
|
EventType::OrganizationUserUnlinkedSso,
|
||||||
&membership.uuid,
|
&membership.uuid,
|
||||||
&membership.org_uuid,
|
&membership.org_uuid,
|
||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
@@ -472,7 +471,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
|||||||
match unregister_push_device(device.push_uuid.as_ref()).await {
|
match unregister_push_device(device.push_uuid.as_ref()).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +527,7 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
|||||||
} else {
|
} else {
|
||||||
FAKE_ADMIN_UUID.into()
|
FAKE_ADMIN_UUID.into()
|
||||||
};
|
};
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -554,9 +553,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
err!("The specified user isn't member of the organization")
|
err!("The specified user isn't member of the organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid type"),
|
} else {
|
||||||
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||||
@@ -571,7 +571,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdated as i32,
|
EventType::OrganizationUserUpdated,
|
||||||
&member_to_edit.uuid,
|
&member_to_edit.uuid,
|
||||||
&data.org_uuid,
|
&data.org_uuid,
|
||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
@@ -643,11 +643,11 @@ async fn has_http_access() -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use cached::proc_macro::cached;
|
use cached::macros::cached;
|
||||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||||
/// Any cache will be lost if Vaultwarden is restarted
|
/// Any cache will be lost if Vaultwarden is restarted
|
||||||
#[cached(time = 600, sync_writes = "default")]
|
#[cached(ttl_secs = 600, sync_writes = "default")]
|
||||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||||
if has_http_access {
|
if has_http_access {
|
||||||
@@ -656,42 +656,40 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r.tag_name,
|
Ok(r) => r.tag_name,
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
||||||
Ok(mut c) => {
|
Ok(mut c) => {
|
||||||
c.sha.truncate(8);
|
c.sha.truncate(8);
|
||||||
c.sha
|
c.sha
|
||||||
}
|
}
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
// Do not fetch the web-vault version when running within a container
|
// Do not fetch the web-vault version when running within a container
|
||||||
// The web-vault version is embedded within the container it self, and should not be updated manually
|
// The web-vault version is embedded within the container it self, and should not be updated manually
|
||||||
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
|
Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
("-".to_string(), "-".to_string(), "-".to_string())
|
("-".to_owned(), "-".to_owned(), "-".to_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_ntp_time(has_http_access: bool) -> String {
|
async fn get_ntp_time(has_http_access: bool) -> String {
|
||||||
if has_http_access {
|
if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||||
if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
for line in cf_trace.lines() {
|
||||||
for line in cf_trace.lines() {
|
if let Some((key, value)) = line.split_once('=')
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
&& key == "ts"
|
||||||
if key == "ts" {
|
{
|
||||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,6 +716,36 @@ fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_template_overrides() -> Vec<&'static str> {
|
||||||
|
let template_folder = std::path::PathBuf::from(CONFIG.templates_folder());
|
||||||
|
let mut overrides = Vec::new();
|
||||||
|
for folder in ["admin", "email", "scss"] {
|
||||||
|
if folder_has_hbs_files(&template_folder.join(folder)) {
|
||||||
|
overrides.push(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder_has_hbs_files(&template_folder) {
|
||||||
|
overrides.push("other");
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_has_hbs_files(dir: &std::path::Path) -> bool {
|
||||||
|
let Ok(files) = std::fs::read_dir(dir) else {
|
||||||
|
// No files in this directory at all, so we can return false
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
files.flatten().any(|f| {
|
||||||
|
// Validate if it is a file and if it has the `.hbs` extension and starts with a-z or 0-9
|
||||||
|
f.file_type().is_ok_and(|t| t.is_file())
|
||||||
|
&& f.path().extension().is_some_and(|e| e.eq_ignore_ascii_case("hbs"))
|
||||||
|
&& f.file_name().to_str().is_some_and(|n| n.starts_with(|c: char| c.is_ascii_alphanumeric()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/diagnostics")]
|
#[get("/diagnostics")]
|
||||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
@@ -734,7 +762,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
// Check if we are able to resolve DNS entries
|
// Check if we are able to resolve DNS entries
|
||||||
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
||||||
Ok(Some(a)) => a.ip().to_string(),
|
Ok(Some(a)) => a.ip().to_string(),
|
||||||
_ => "Unable to resolve domain name.".to_string(),
|
_ => "Unable to resolve domain name.".to_owned(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
||||||
@@ -745,7 +773,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
|
|
||||||
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||||
&CONFIG.experimental_client_feature_flags(),
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
FeatureFlagFilter::InvalidOnly,
|
&FeatureFlagFilter::InvalidOnly,
|
||||||
)
|
)
|
||||||
.into_keys()
|
.into_keys()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -772,6 +800,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"template_overrides": check_template_overrides().join(", "),
|
||||||
"invalid_feature_flags": invalid_feature_flags,
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
@@ -834,33 +863,30 @@ impl<'r> FromRequest<'r> for AdminToken {
|
|||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if !CONFIG.disable_admin_token() {
|
if !CONFIG.disable_admin_token() {
|
||||||
let cookies = request.cookies();
|
let cookies = request.cookies();
|
||||||
|
|
||||||
let access_token = match cookies.get(COOKIE_NAME) {
|
let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
|
||||||
Some(cookie) => cookie.value(),
|
cookie.value()
|
||||||
None => {
|
} else {
|
||||||
let requested_page =
|
let requested_page =
|
||||||
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||||
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
||||||
// Else, return a 401 failure, which will be caught
|
// Else, return a 401 failure, which will be caught
|
||||||
if requested_page.is_empty() {
|
if requested_page.is_empty() {
|
||||||
return Outcome::Forward(Status::Unauthorized);
|
return Outcome::Forward(Status::Unauthorized);
|
||||||
} else {
|
|
||||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||||
};
|
};
|
||||||
|
|
||||||
if decode_admin(access_token).is_err() {
|
if decode_admin(access_token).is_err() {
|
||||||
// Remove admin cookie
|
// Remove admin cookie
|
||||||
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
||||||
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
|
error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
|
||||||
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -879,23 +905,23 @@ mod tests {
|
|||||||
fn validate_web_vault_compare() {
|
fn validate_web_vault_compare() {
|
||||||
// web_vault_compare(active, latest)
|
// web_vault_compare(active, latest)
|
||||||
// Test normal versions
|
// Test normal versions
|
||||||
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
|
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
|
||||||
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
|
||||||
|
|
||||||
// Test patched/+build.n versions
|
// Test patched/+build.n versions
|
||||||
// Newer latest version
|
// Newer latest version
|
||||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
|
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
|
||||||
// Equal versions
|
// Equal versions
|
||||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
|
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
|
||||||
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
|
assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
|
||||||
// Newer active version
|
// Newer active version
|
||||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
|
||||||
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
|
||||||
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
|
||||||
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
|
assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+266
-123
@@ -1,34 +1,38 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::db::DbPool;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api::{
|
|
||||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
|
||||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
|
||||||
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
|
||||||
},
|
|
||||||
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
|
||||||
crypto,
|
|
||||||
db::{
|
|
||||||
models::{
|
|
||||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
|
|
||||||
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
|
|
||||||
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
|
||||||
},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
mail,
|
|
||||||
util::{deser_opt_nonempty_str, format_date, NumberOrString},
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
http::Status,
|
http::Status,
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
|
serde::json::Json,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{
|
||||||
|
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
|
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||||
|
master_password_policy, register_push_device, unregister_push_device,
|
||||||
|
},
|
||||||
|
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||||
|
crypto,
|
||||||
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
|
models::{
|
||||||
|
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
|
||||||
|
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
|
||||||
|
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
|
||||||
|
UserKdfType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mail,
|
||||||
|
util::{NumberOrString, deser_opt_nonempty_str, format_date},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ciphers::{CipherData, update_cipher_from_data},
|
||||||
|
sends::{SendData, update_send_from_data},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<rocket::Route> {
|
pub fn routes() -> Vec<rocket::Route> {
|
||||||
@@ -43,6 +47,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
post_set_password,
|
post_set_password,
|
||||||
post_kdf,
|
post_kdf,
|
||||||
post_rotatekey,
|
post_rotatekey,
|
||||||
|
post_user_key,
|
||||||
post_sstamp,
|
post_sstamp,
|
||||||
post_email_token,
|
post_email_token,
|
||||||
post_email,
|
post_email,
|
||||||
@@ -54,9 +59,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
delete_account,
|
delete_account,
|
||||||
revision_date,
|
revision_date,
|
||||||
password_hint,
|
password_hint,
|
||||||
prelogin,
|
post_prelogin,
|
||||||
verify_password,
|
verify_password,
|
||||||
api_key,
|
post_api_key,
|
||||||
rotate_api_key,
|
rotate_api_key,
|
||||||
get_known_device,
|
get_known_device,
|
||||||
get_all_devices,
|
get_all_devices,
|
||||||
@@ -94,14 +99,11 @@ pub struct RegisterData {
|
|||||||
email: String,
|
email: String,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
kdf: KDFData,
|
compat: RegisterDataCompat,
|
||||||
|
|
||||||
#[serde(alias = "userSymmetricKey")]
|
|
||||||
key: String,
|
|
||||||
#[serde(alias = "userAsymmetricKeys")]
|
#[serde(alias = "userAsymmetricKeys")]
|
||||||
keys: Option<KeysData>,
|
keys: Option<KeysData>,
|
||||||
|
|
||||||
master_password_hash: String,
|
|
||||||
master_password_hint: Option<String>,
|
master_password_hint: Option<String>,
|
||||||
|
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -116,6 +118,102 @@ pub struct RegisterData {
|
|||||||
org_invite_token: Option<String>,
|
org_invite_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RegisterData {
|
||||||
|
fn hash(&self) -> String {
|
||||||
|
self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kdf(&self) -> &KDFData {
|
||||||
|
self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self) -> String {
|
||||||
|
self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When comparing with salt, email need to be normalized:
|
||||||
|
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
|
||||||
|
fn unprocessable(&self) -> bool {
|
||||||
|
let mut unprocessable = false;
|
||||||
|
*self.compat.fold(
|
||||||
|
|_| &false,
|
||||||
|
|rdcu| {
|
||||||
|
let email = self.email.trim().to_lowercase();
|
||||||
|
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|
||||||
|
|| rdcu.master_password_authentication.salt != email
|
||||||
|
|| rdcu.master_password_unlock.salt != email;
|
||||||
|
&unprocessable
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RegisterDataOld {
|
||||||
|
#[serde(flatten)]
|
||||||
|
kdf: KDFData,
|
||||||
|
|
||||||
|
#[serde(alias = "userSymmetricKey")]
|
||||||
|
key: String,
|
||||||
|
|
||||||
|
#[serde(alias = "masterPasswordHash")]
|
||||||
|
master_password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RegisterDataCur {
|
||||||
|
master_password_authentication: MasterPasswordAuthentication,
|
||||||
|
master_password_unlock: MasterPasswordUnlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum RegisterDataCompat {
|
||||||
|
RegisterDataOld(RegisterDataOld),
|
||||||
|
RegisterDataCur(RegisterDataCur),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterDataCompat {
|
||||||
|
fn fold<'a, T>(
|
||||||
|
&'a self,
|
||||||
|
fct: impl FnOnce(&'a RegisterDataOld) -> &'a T,
|
||||||
|
fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T,
|
||||||
|
) -> &'a T {
|
||||||
|
match self {
|
||||||
|
RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc),
|
||||||
|
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct KeysData {
|
||||||
|
encrypted_private_key: String,
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MasterPasswordAuthentication {
|
||||||
|
kdf: KDFData,
|
||||||
|
salt: String,
|
||||||
|
|
||||||
|
#[serde(alias = "masterPasswordAuthenticationHash")]
|
||||||
|
hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MasterPasswordUnlock {
|
||||||
|
kdf: KDFData,
|
||||||
|
salt: String,
|
||||||
|
|
||||||
|
#[serde(alias = "masterKeyWrappedUserKey")]
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SetPasswordData {
|
pub struct SetPasswordData {
|
||||||
@@ -129,20 +227,13 @@ pub struct SetPasswordData {
|
|||||||
org_identifier: Option<String>,
|
org_identifier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct KeysData {
|
|
||||||
encrypted_private_key: String,
|
|
||||||
public_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
||||||
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
|
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
|
||||||
match password_hint {
|
match password_hint {
|
||||||
None => None,
|
None => None,
|
||||||
Some(h) => match h.trim() {
|
Some(h) => match h.trim() {
|
||||||
"" => None,
|
"" => None,
|
||||||
ht => Some(ht.to_string()),
|
ht => Some(ht.to_owned()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +257,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||||
let mut data: RegisterData = data.into_inner();
|
let mut data: RegisterData = data.into_inner();
|
||||||
let email = data.email.to_lowercase();
|
let email = data.email.to_lowercase();
|
||||||
|
|
||||||
@@ -174,6 +265,10 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
|
|
||||||
let mut pending_emergency_access = None;
|
let mut pending_emergency_access = None;
|
||||||
|
|
||||||
|
if data.unprocessable() {
|
||||||
|
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// First, validate the provided verification tokens
|
// First, validate the provided verification tokens
|
||||||
if email_verification {
|
if email_verification {
|
||||||
match (
|
match (
|
||||||
@@ -237,10 +332,10 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
|
|
||||||
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
|
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
|
||||||
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
||||||
if let Some(ref name) = data.name {
|
if let Some(ref name) = data.name
|
||||||
if name.len() > 50 {
|
&& name.len() > 50
|
||||||
err!("The field Name must be a string with a maximum length of 50.");
|
{
|
||||||
}
|
err!("The field Name must be a string with a maximum length of 50.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check against the password hint setting here so if it fails, the user
|
// Check against the password hint setting here so if it fails, the user
|
||||||
@@ -254,8 +349,8 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
err!("Registration not allowed or user already exists")
|
err!("Registration not allowed or user already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(token) = data.org_invite_token {
|
if let Some(token) = data.org_invite_token.as_ref() {
|
||||||
let claims = decode_invite(&token)?;
|
let claims = decode_invite(token)?;
|
||||||
if claims.email == email {
|
if claims.email == email {
|
||||||
// Verify the email address when signing up via a valid invite token
|
// Verify the email address when signing up via a valid invite token
|
||||||
email_verified = true;
|
email_verified = true;
|
||||||
@@ -293,9 +388,9 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
// Make sure we don't leave a lingering invitation.
|
// Make sure we don't leave a lingering invitation.
|
||||||
Invitation::take(&email, &conn).await;
|
Invitation::take(&email, &conn).await;
|
||||||
|
|
||||||
set_kdf_data(&mut user, &data.kdf)?;
|
set_kdf_data(&mut user, data.kdf())?;
|
||||||
|
|
||||||
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
|
user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?;
|
||||||
user.password_hint = password_hint;
|
user.password_hint = password_hint;
|
||||||
|
|
||||||
// Add extra fields if present
|
// Add extra fields if present
|
||||||
@@ -373,18 +468,19 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
user.public_key = Some(keys.public_key);
|
user.public_key = Some(keys.public_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(identifier) = data.org_identifier {
|
if let Some(identifier) = data.org_identifier
|
||||||
if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
|
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER
|
||||||
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
&& identifier != crate::api::admin::FAKE_ADMIN_UUID
|
||||||
err!("Failed to retrieve the associated organization")
|
{
|
||||||
};
|
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
||||||
|
err!("Failed to retrieve the associated organization")
|
||||||
|
};
|
||||||
|
|
||||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
||||||
err!("Failed to retrieve the invitation")
|
err!("Failed to retrieve the invitation")
|
||||||
};
|
};
|
||||||
|
|
||||||
accept_org_invite(&user, membership, None, &conn).await?;
|
accept_org_invite(&user, membership, None, &conn).await?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -451,10 +547,10 @@ async fn put_avatar(data: Json<AvatarData>, headers: Headers, conn: DbConn) -> J
|
|||||||
// It looks like it only supports the 6 hex color format.
|
// It looks like it only supports the 6 hex color format.
|
||||||
// If you try to add the short value it will not show that color.
|
// If you try to add the short value it will not show that color.
|
||||||
// Check and force 7 chars, including the #.
|
// Check and force 7 chars, including the #.
|
||||||
if let Some(color) = &data.avatar_color {
|
if let Some(color) = &data.avatar_color
|
||||||
if color.len() != 7 {
|
&& color.len() != 7
|
||||||
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
{
|
||||||
}
|
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
@@ -501,29 +597,52 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ChangePassData {
|
struct ChangePassData {
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
new_master_password_hash: String,
|
|
||||||
master_password_hint: Option<String>,
|
master_password_hint: Option<String>,
|
||||||
key: String,
|
authentication_data: Option<AuthenticationData>,
|
||||||
|
unlock_data: Option<UnlockData>,
|
||||||
|
|
||||||
|
// Outdated values, might still be used by older clients
|
||||||
|
new_master_password_hash: Option<String>,
|
||||||
|
key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/password", data = "<data>")]
|
#[post("/accounts/password", data = "<data>")]
|
||||||
async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: ChangePassData = data.into_inner();
|
let data: ChangePassData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
if !user.check_valid_password(&data.master_password_hash) {
|
if !user.check_valid_password(&data.master_password_hash) {
|
||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
|
||||||
enforce_password_hint_setting(user.password_hint.as_ref())?;
|
|
||||||
|
|
||||||
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let (new_master_password_hash, new_key) =
|
||||||
|
if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) {
|
||||||
|
if authentication_data.kdf != unlock_data.kdf {
|
||||||
|
err!("KDF settings must be equal for authentication and unlock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.email != authentication_data.salt || user.email != unlock_data.salt {
|
||||||
|
err!("Invalid master password salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
|
||||||
|
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
|
||||||
|
(new_master_password_hash, new_key)
|
||||||
|
} else {
|
||||||
|
err!("Invalid request!")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut user = user;
|
||||||
|
|
||||||
|
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||||
|
enforce_password_hint_setting(user.password_hint.as_ref())?;
|
||||||
|
|
||||||
user.set_password(
|
user.set_password(
|
||||||
&data.new_master_password_hash,
|
&new_master_password_hash,
|
||||||
Some(data.key),
|
Some(new_key),
|
||||||
true,
|
true,
|
||||||
Some(vec![
|
Some(vec![
|
||||||
String::from("post_rotatekey"),
|
String::from("post_rotatekey"),
|
||||||
@@ -599,10 +718,6 @@ struct UnlockData {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ChangeKdfData {
|
struct ChangeKdfData {
|
||||||
#[allow(dead_code)]
|
|
||||||
new_master_password_hash: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
key: String,
|
|
||||||
authentication_data: AuthenticationData,
|
authentication_data: AuthenticationData,
|
||||||
unlock_data: UnlockData,
|
unlock_data: UnlockData,
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
@@ -668,9 +783,6 @@ struct UpdateResetPasswordData {
|
|||||||
reset_password_key: String,
|
reset_password_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::ciphers::CipherData;
|
|
||||||
use super::sends::{update_send_from_data, SendData};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct KeyData {
|
struct KeyData {
|
||||||
@@ -840,7 +952,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
};
|
};
|
||||||
|
|
||||||
saved_folder.name = folder_data.name;
|
saved_folder.name = folder_data.name;
|
||||||
saved_folder.save(&conn).await?
|
saved_folder.save(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,7 +965,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
};
|
};
|
||||||
|
|
||||||
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
||||||
saved_emergency_access.save(&conn).await?
|
saved_emergency_access.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update reset password data
|
// Update reset password data
|
||||||
@@ -865,7 +977,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
};
|
};
|
||||||
|
|
||||||
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
||||||
membership.save(&conn).await?
|
membership.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update send data
|
// Update send data
|
||||||
@@ -878,8 +990,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update cipher data
|
// Update cipher data
|
||||||
use super::ciphers::update_cipher_from_data;
|
|
||||||
|
|
||||||
for cipher_data in data.account_data.ciphers {
|
for cipher_data in data.account_data.ciphers {
|
||||||
if cipher_data.organization_id.is_none() {
|
if cipher_data.organization_id.is_none() {
|
||||||
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
||||||
@@ -890,7 +1000,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
||||||
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
||||||
// We force the users to logout after the user has been saved to try and prevent these issues.
|
// We force the users to logout after the user has been saved to try and prevent these issues.
|
||||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?
|
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +1027,23 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct KeyIdData {
|
||||||
|
user_key_id: KeyId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/accounts/key-management/user-key-id", data = "<data>")]
|
||||||
|
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
|
let mut user = headers.user;
|
||||||
|
if user.key_id.is_some() {
|
||||||
|
err_code!("Unexpected data", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.key_id = Some(data.into_inner().user_key_id);
|
||||||
|
user.save(&conn).await
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/accounts/security-stamp", data = "<data>")]
|
#[post("/accounts/security-stamp", data = "<data>")]
|
||||||
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
@@ -1020,24 +1147,22 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
|||||||
err!("Email already in use");
|
err!("Email already in use");
|
||||||
}
|
}
|
||||||
|
|
||||||
match user.email_new {
|
if let Some(ref val) = user.email_new {
|
||||||
Some(ref val) => {
|
if val != &data.new_email {
|
||||||
if val != &data.new_email {
|
err!("Email change mismatch");
|
||||||
err!("Email change mismatch");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => err!("No email change pending"),
|
} else {
|
||||||
|
err!("No email change pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
// Only check the token if we sent out an email...
|
// Only check the token if we sent out an email...
|
||||||
match user.email_new_token {
|
if let Some(ref val) = user.email_new_token {
|
||||||
Some(ref val) => {
|
if *val != data.token.into_string() {
|
||||||
if *val != data.token.into_string() {
|
err!("Token mismatch");
|
||||||
err!("Token mismatch");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => err!("No email change pending"),
|
} else {
|
||||||
|
err!("No email change pending")
|
||||||
}
|
}
|
||||||
user.verified_at = Some(Utc::now().naive_utc());
|
user.verified_at = Some(Utc::now().naive_utc());
|
||||||
} else {
|
} else {
|
||||||
@@ -1110,14 +1235,16 @@ struct DeleteRecoverData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete-recover", data = "<data>")]
|
#[post("/accounts/delete-recover", data = "<data>")]
|
||||||
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult {
|
async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data: DeleteRecoverData = data.into_inner();
|
let data: DeleteRecoverData = data.into_inner();
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
if let Some(user) = User::find_by_mail(&data.email, &conn).await {
|
if let Some(user) = User::find_by_mail(&data.email, &conn).await
|
||||||
if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await {
|
&& let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await
|
||||||
error!("Error sending delete account email: {e:#?}");
|
{
|
||||||
}
|
error!("Error sending delete account email: {e:#?}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -1169,6 +1296,7 @@ async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: D
|
|||||||
user.delete(&conn).await
|
user.delete(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
#[get("/accounts/revision-date")]
|
#[get("/accounts/revision-date")]
|
||||||
fn revision_date(headers: Headers) -> JsonResult {
|
fn revision_date(headers: Headers) -> JsonResult {
|
||||||
let revision_date = headers.user.updated_at.and_utc().timestamp_millis();
|
let revision_date = headers.user.updated_at.and_utc().timestamp_millis();
|
||||||
@@ -1182,13 +1310,15 @@ struct PasswordHintData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/password-hint", data = "<data>")]
|
#[post("/accounts/password-hint", data = "<data>")]
|
||||||
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult {
|
async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||||
|
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
||||||
err!("This server is not configured to provide password hints.");
|
err!("This server is not configured to provide password hints.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
|
||||||
|
|
||||||
let data: PasswordHintData = data.into_inner();
|
let data: PasswordHintData = data.into_inner();
|
||||||
let email = &data.email;
|
let email = &data.email;
|
||||||
|
|
||||||
@@ -1199,9 +1329,9 @@ async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResul
|
|||||||
// There is still a timing side channel here in that the code
|
// There is still a timing side channel here in that the code
|
||||||
// paths that send mail take noticeably longer than ones that
|
// paths that send mail take noticeably longer than ones that
|
||||||
// don't. Add a randomized sleep to mitigate this somewhat.
|
// don't. Add a randomized sleep to mitigate this somewhat.
|
||||||
use rand::{rngs::SmallRng, RngExt};
|
use rand::{RngExt, rngs::SmallRng};
|
||||||
let mut rng: SmallRng = rand::make_rng();
|
let mut rng: SmallRng = rand::make_rng();
|
||||||
let sleep_ms = rng.random_range(900..=1100) as u64;
|
let sleep_ms: u64 = rng.random_range(900..=1100);
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -1229,11 +1359,13 @@ pub struct PreloginData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin", data = "<data>")]
|
#[post("/accounts/prelogin", data = "<data>")]
|
||||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
_prelogin(data, conn).await
|
prelogin(data, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data: PreloginData = data.into_inner();
|
let data: PreloginData = data.into_inner();
|
||||||
|
|
||||||
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
||||||
@@ -1241,12 +1373,19 @@ pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
|||||||
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
|
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
Json(json!({
|
Ok(Json(json!({
|
||||||
"kdf": kdf_type,
|
"kdf": kdf_type,
|
||||||
"kdfIterations": kdf_iter,
|
"kdfIterations": kdf_iter,
|
||||||
"kdfMemory": kdf_mem,
|
"kdfMemory": kdf_mem,
|
||||||
"kdfParallelism": kdf_para,
|
"kdfParallelism": kdf_para,
|
||||||
}))
|
"kdfSettings": {
|
||||||
|
"iterations": kdf_iter,
|
||||||
|
"kdfType": kdf_type,
|
||||||
|
"memory": kdf_mem,
|
||||||
|
"parallelism": kdf_para
|
||||||
|
},
|
||||||
|
"salt": null,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
|
||||||
@@ -1283,9 +1422,7 @@ async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers
|
|||||||
Ok(Json(master_password_policy(&user, &conn).await))
|
Ok(Json(master_password_policy(&user, &conn).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn update_api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
use crate::util::format_date;
|
|
||||||
|
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
@@ -1304,13 +1441,13 @@ async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/api-key", data = "<data>")]
|
#[post("/accounts/api-key", data = "<data>")]
|
||||||
async fn api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn post_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
_api_key(data, false, headers, conn).await
|
update_api_key(data, false, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/rotate-api-key", data = "<data>")]
|
#[post("/accounts/rotate-api-key", data = "<data>")]
|
||||||
async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
_api_key(data, true, headers, conn).await
|
update_api_key(data, true, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/devices/knowndevice")]
|
#[get("/devices/knowndevice")]
|
||||||
@@ -1353,7 +1490,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
|
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
|
||||||
uuid.to_string().into()
|
uuid.to_owned().into()
|
||||||
} else {
|
} else {
|
||||||
return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
|
return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
|
||||||
};
|
};
|
||||||
@@ -1368,7 +1505,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
|||||||
#[get("/devices")]
|
#[get("/devices")]
|
||||||
async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await;
|
let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await;
|
||||||
let devices = devices.iter().map(|device| device.to_json()).collect::<Vec<Value>>();
|
let devices = devices.iter().map(DeviceWithAuthRequest::to_json).collect::<Vec<Value>>();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"data": devices,
|
"data": devices,
|
||||||
@@ -1424,7 +1561,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/devices/identifier/<device_id>/clear-token")]
|
#[put("/devices/identifier/<device_id>/clear-token")]
|
||||||
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
// This only clears push token
|
// This only clears push token
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
||||||
@@ -1446,8 +1585,8 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
|
|||||||
|
|
||||||
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
||||||
#[post("/devices/identifier/<device_id>/clear-token")]
|
#[post("/devices/identifier/<device_id>/clear-token")]
|
||||||
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
put_clear_device_token(device_id, conn).await
|
put_clear_device_token(device_id, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/tasks")]
|
#[get("/tasks")]
|
||||||
@@ -1477,6 +1616,8 @@ async fn post_auth_request(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
|
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
|
||||||
@@ -1489,7 +1630,7 @@ async fn post_auth_request(
|
|||||||
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
|
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut auth_request = AuthRequest::new(
|
let auth_request = AuthRequest::new(
|
||||||
user.uuid.clone(),
|
user.uuid.clone(),
|
||||||
data.device_identifier.clone(),
|
data.device_identifier.clone(),
|
||||||
client_headers.device_type,
|
client_headers.device_type,
|
||||||
@@ -1638,6 +1779,8 @@ async fn get_auth_request_response(
|
|||||||
client_headers: ClientHeaders,
|
client_headers: ClientHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
|
||||||
|
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
|
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
|
||||||
err!("AuthRequest doesn't exist", "User not found")
|
err!("AuthRequest doesn't exist", "User not found")
|
||||||
};
|
};
|
||||||
@@ -1708,6 +1851,6 @@ pub async fn purge_auth_requests(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
AuthRequest::purge_expired_auth_requests(&conn).await;
|
AuthRequest::purge_expired_auth_requests(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging auth requests")
|
error!("Failed to get DB connection while purging auth requests");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+187
-154
@@ -2,30 +2,31 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use rocket::fs::TempFile;
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
form::{Form, FromForm},
|
|
||||||
Route,
|
Route,
|
||||||
|
form::{Form, FromForm},
|
||||||
|
fs::TempFile,
|
||||||
|
http::Status,
|
||||||
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::auth::ClientVersion;
|
|
||||||
use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
CONFIG,
|
||||||
|
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||||
|
auth::ClientVersion,
|
||||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
|
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
||||||
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbPool,
|
|
||||||
},
|
},
|
||||||
CONFIG,
|
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::folders::FolderData;
|
use super::folders::FolderData;
|
||||||
@@ -108,7 +109,7 @@ pub async fn purge_trashed_ciphers(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Cipher::purge_trash(&conn).await;
|
Cipher::purge_trash(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging trashed ciphers")
|
error!("Failed to get DB connection while purging trashed ciphers");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,13 +162,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
let policies_json: Vec<Value> =
|
let policies_json: Vec<Value> =
|
||||||
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
|
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
|
||||||
|
|
||||||
|
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(OrgPolicy::to_json)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let domains_json = if data.exclude_domains {
|
let domains_json = if data.exclude_domains {
|
||||||
Value::Null
|
Value::Null
|
||||||
} else {
|
} else {
|
||||||
api::core::_get_eq_domains(&headers, true).into_inner()
|
api::core::get_eq_domains(&headers, true).into_inner()
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is very similar to the the userDecryptionOptions sent in connect/token,
|
// This is very similar to the userDecryptionOptions sent in connect/token,
|
||||||
// but as of 2025-12-19 they're both using different casing conventions.
|
// but as of 2025-12-19 they're both using different casing conventions.
|
||||||
let has_master_password = !headers.user.password_hash.is_empty();
|
let has_master_password = !headers.user.password_hash.is_empty();
|
||||||
let master_password_unlock = if has_master_password {
|
let master_password_unlock = if has_master_password {
|
||||||
@@ -193,11 +200,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
"folders": folders_json,
|
"folders": folders_json,
|
||||||
"collections": collections_json,
|
"collections": collections_json,
|
||||||
"policies": policies_json,
|
"policies": policies_json,
|
||||||
|
"policiesNew": policies_new_json,
|
||||||
"ciphers": ciphers_json,
|
"ciphers": ciphers_json,
|
||||||
"domains": domains_json,
|
"domains": domains_json,
|
||||||
"sends": sends_json,
|
"sends": sends_json,
|
||||||
"userDecryption": {
|
"userDecryption": {
|
||||||
"masterPasswordUnlock": master_password_unlock,
|
"masterPasswordUnlock": master_password_unlock,
|
||||||
|
"userKeyId": headers.user.key_id,
|
||||||
},
|
},
|
||||||
"object": "sync"
|
"object": "sync"
|
||||||
})))
|
})))
|
||||||
@@ -260,12 +269,19 @@ pub struct CipherData {
|
|||||||
|
|
||||||
key: Option<String>,
|
key: Option<String>,
|
||||||
|
|
||||||
|
pub encrypted_for: UserId, // Added in web-v2025.6.0
|
||||||
|
// Added in web-v2025.8.1, Optional for compat
|
||||||
|
pub encrypted_by_key_id: Option<KeyId>,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Login = 1,
|
Login = 1,
|
||||||
SecureNote = 2,
|
SecureNote = 2,
|
||||||
Card = 3,
|
Card = 3,
|
||||||
Identity = 4,
|
Identity = 4,
|
||||||
SshKey = 5
|
SshKey = 5
|
||||||
|
BankAccount = 6
|
||||||
|
DriversLicense = 7
|
||||||
|
Passport = 8
|
||||||
*/
|
*/
|
||||||
pub r#type: i32,
|
pub r#type: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -278,6 +294,9 @@ pub struct CipherData {
|
|||||||
card: Option<Value>,
|
card: Option<Value>,
|
||||||
identity: Option<Value>,
|
identity: Option<Value>,
|
||||||
ssh_key: Option<Value>,
|
ssh_key: Option<Value>,
|
||||||
|
bank_account: Option<Value>,
|
||||||
|
drivers_license: Option<Value>,
|
||||||
|
passport: Option<Value>,
|
||||||
|
|
||||||
favorite: Option<bool>,
|
favorite: Option<bool>,
|
||||||
reprompt: Option<i32>,
|
reprompt: Option<i32>,
|
||||||
@@ -333,6 +352,10 @@ async fn post_ciphers_create(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut data: ShareCipherData = data.into_inner();
|
let mut data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
|
if data.cipher.encrypted_for != headers.user.uuid {
|
||||||
|
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// This check is usually only needed in update_cipher_from_data(), but we
|
// This check is usually only needed in update_cipher_from_data(), but we
|
||||||
// need it here as well to avoid creating an empty cipher in the call to
|
// need it here as well to avoid creating an empty cipher in the call to
|
||||||
// cipher.save() below.
|
// cipher.save() below.
|
||||||
@@ -362,6 +385,17 @@ async fn post_ciphers_create(
|
|||||||
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
let mut data: CipherData = data.into_inner();
|
let mut data: CipherData = data.into_inner();
|
||||||
|
|
||||||
|
if data.encrypted_for != headers.user.uuid {
|
||||||
|
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cipher_key_id) = &data.encrypted_by_key_id
|
||||||
|
&& let Some(user_key_id) = &headers.user.key_id
|
||||||
|
&& cipher_key_id != user_key_id
|
||||||
|
{
|
||||||
|
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// The web/browser clients set this field to null as expected, but the
|
// The web/browser clients set this field to null as expected, but the
|
||||||
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
|
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
|
||||||
// which results in a warning message being logged. This field isn't
|
// which results in a warning message being logged. This field isn't
|
||||||
@@ -401,20 +435,34 @@ pub async fn update_cipher_from_data(
|
|||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
ut: UpdateType,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
|
// Cleanup cipher data, like removing the 'Response' key.
|
||||||
|
// This key is somewhere generated during Javascript so no way for us this fix this.
|
||||||
|
// Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key.
|
||||||
|
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
|
||||||
|
// But, we at least know we do not need to store and return this specific key.
|
||||||
|
fn clean_cipher_data(mut json_data: Value) -> Value {
|
||||||
|
if json_data.is_array() {
|
||||||
|
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
|
||||||
|
f.as_object_mut().unwrap().remove("response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
json_data
|
||||||
|
}
|
||||||
|
|
||||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||||
|
|
||||||
// Check that the client isn't updating an existing cipher with stale data.
|
// Check that the client isn't updating an existing cipher with stale data.
|
||||||
// And only perform this check when not importing ciphers, else the date/time check will fail.
|
// And only perform this check when not importing ciphers, else the date/time check will fail.
|
||||||
if ut != UpdateType::None {
|
if ut != UpdateType::None
|
||||||
if let Some(dt) = data.last_known_revision_date {
|
&& let Some(dt) = data.last_known_revision_date
|
||||||
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
{
|
||||||
// ISO 8601 format
|
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
||||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
// ISO 8601 format
|
||||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
||||||
}
|
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
||||||
Ok(_) => (),
|
|
||||||
}
|
}
|
||||||
|
Ok(_) => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +484,9 @@ pub async fn update_cipher_from_data(
|
|||||||
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
||||||
None => err!("You don't have permission to add item to organization"),
|
None => err!("You don't have permission to add item to organization"),
|
||||||
Some(member) => {
|
Some(member) => {
|
||||||
if shared_to_collections.is_some()
|
// A non-empty list of collections implies the caller already validated the user's write
|
||||||
|
// access to them, so we can move the cipher into the organization on that basis.
|
||||||
|
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|
||||||
|| member.has_full_access()
|
|| member.has_full_access()
|
||||||
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|
||||||
{
|
{
|
||||||
@@ -456,25 +506,22 @@ pub async fn update_cipher_from_data(
|
|||||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
cipher.user_uuid = Some(headers.user.uuid.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id
|
||||||
if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() {
|
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
{
|
||||||
}
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify attachments name and keys when rotating
|
// Modify attachments name and keys when rotating
|
||||||
if let Some(attachments) = data.attachments2 {
|
if let Some(attachments) = data.attachments2 {
|
||||||
for (id, attachment) in attachments {
|
for (id, attachment) in attachments {
|
||||||
let mut saved_att = match Attachment::find_by_id(&id, conn).await {
|
let Some(mut saved_att) = Attachment::find_by_id(&id, conn).await else {
|
||||||
Some(att) => att,
|
// Warn and continue here.
|
||||||
None => {
|
// A missing attachment means it was removed via an other client.
|
||||||
// Warn and continue here.
|
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||||
// A missing attachment means it was removed via an other client.
|
// Bitwarden it self ignores these mismatches server side.
|
||||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
warn!("Attachment {id} doesn't exist");
|
||||||
// Bitwarden it self ignores these mismatches server side.
|
continue;
|
||||||
warn!("Attachment {id} doesn't exist");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if saved_att.cipher_uuid != cipher.uuid {
|
if saved_att.cipher_uuid != cipher.uuid {
|
||||||
@@ -491,46 +538,34 @@ pub async fn update_cipher_from_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup cipher data, like removing the 'Response' key.
|
|
||||||
// This key is somewhere generated during Javascript so no way for us this fix this.
|
|
||||||
// Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key.
|
|
||||||
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
|
|
||||||
// But, we at least know we do not need to store and return this specific key.
|
|
||||||
fn _clean_cipher_data(mut json_data: Value) -> Value {
|
|
||||||
if json_data.is_array() {
|
|
||||||
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
|
|
||||||
f.as_object_mut().unwrap().remove("response");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
json_data
|
|
||||||
}
|
|
||||||
|
|
||||||
let type_data_opt = match data.r#type {
|
let type_data_opt = match data.r#type {
|
||||||
1 => data.login,
|
1 => data.login,
|
||||||
2 => data.secure_note,
|
2 => data.secure_note,
|
||||||
3 => data.card,
|
3 => data.card,
|
||||||
4 => data.identity,
|
4 => data.identity,
|
||||||
5 => data.ssh_key,
|
5 => data.ssh_key,
|
||||||
|
6 => data.bank_account,
|
||||||
|
7 => data.drivers_license,
|
||||||
|
8 => data.passport,
|
||||||
_ => err!("Invalid type"),
|
_ => err!("Invalid type"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let type_data = match type_data_opt {
|
let type_data = if let Some(mut data) = type_data_opt {
|
||||||
Some(mut data) => {
|
// Remove the 'Response' key from the base object.
|
||||||
// Remove the 'Response' key from the base object.
|
data.as_object_mut().unwrap().remove("response");
|
||||||
data.as_object_mut().unwrap().remove("response");
|
// Remove the 'Response' key from every Uri.
|
||||||
// Remove the 'Response' key from every Uri.
|
if data["uris"].is_array() {
|
||||||
if data["uris"].is_array() {
|
data["uris"] = clean_cipher_data(data["uris"].clone());
|
||||||
data["uris"] = _clean_cipher_data(data["uris"].clone());
|
|
||||||
}
|
|
||||||
data
|
|
||||||
}
|
}
|
||||||
None => err!("Data missing"),
|
data
|
||||||
|
} else {
|
||||||
|
err!("Data missing")
|
||||||
};
|
};
|
||||||
|
|
||||||
cipher.key = data.key;
|
cipher.key = data.key;
|
||||||
cipher.name = data.name;
|
cipher.name = data.name;
|
||||||
cipher.notes = data.notes;
|
cipher.notes = data.notes;
|
||||||
cipher.fields = data.fields.map(|f| _clean_cipher_data(f).to_string());
|
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
|
||||||
cipher.data = type_data.to_string();
|
cipher.data = type_data.to_string();
|
||||||
cipher.password_history = data.password_history.map(|f| f.to_string());
|
cipher.password_history = data.password_history.map(|f| f.to_string());
|
||||||
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
||||||
@@ -539,11 +574,12 @@ pub async fn update_cipher_from_data(
|
|||||||
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
|
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
|
||||||
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
|
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
|
||||||
|
|
||||||
if let Some(dt_str) = data.archived_date {
|
match data.archived_date {
|
||||||
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||||
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
||||||
}
|
},
|
||||||
|
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ut != UpdateType::None {
|
if ut != UpdateType::None {
|
||||||
@@ -555,16 +591,8 @@ pub async fn update_cipher_from_data(
|
|||||||
(_, _) => EventType::CipherUpdated,
|
(_, _) => EventType::CipherUpdated,
|
||||||
};
|
};
|
||||||
|
|
||||||
log_event(
|
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
||||||
event_type as i32,
|
.await;
|
||||||
&cipher.uuid,
|
|
||||||
org_id,
|
|
||||||
&headers.user.uuid,
|
|
||||||
headers.device.atype,
|
|
||||||
&headers.ip.ip,
|
|
||||||
conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
ut,
|
ut,
|
||||||
@@ -612,7 +640,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
let existing_folders: HashSet<Option<FolderId>> =
|
let existing_folders: HashSet<Option<FolderId>> =
|
||||||
Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect();
|
Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect();
|
||||||
let mut folders: Vec<FolderId> = Vec::with_capacity(data.folders.len());
|
let mut folders: Vec<FolderId> = Vec::with_capacity(data.folders.len());
|
||||||
for folder in data.folders.into_iter() {
|
for folder in data.folders {
|
||||||
let folder_id = if existing_folders.contains(&folder.id) {
|
let folder_id = if existing_folders.contains(&folder.id) {
|
||||||
folder.id.unwrap()
|
folder.id.unwrap()
|
||||||
} else {
|
} else {
|
||||||
@@ -633,7 +661,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
|
|
||||||
// Read and create the ciphers
|
// Read and create the ciphers
|
||||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||||
let folder_id = relations_map.get(&index).map(|i| folders[*i].clone());
|
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||||
cipher_data.folder_id = folder_id;
|
cipher_data.folder_id = folder_id;
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
||||||
@@ -737,10 +765,10 @@ async fn put_cipher_partial(
|
|||||||
err!("Cipher does not exist", "Cipher is not accessible for the current user")
|
err!("Cipher does not exist", "Cipher is not accessible for the current user")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id
|
||||||
if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() {
|
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none()
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
{
|
||||||
}
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move cipher
|
// Move cipher
|
||||||
@@ -852,7 +880,7 @@ async fn post_collections_update(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherUpdatedCollections as i32,
|
EventType::CipherUpdatedCollections,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_uuid,
|
org_uuid,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -872,7 +900,7 @@ async fn put_collections_admin(
|
|||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> JsonResult {
|
||||||
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,7 +911,7 @@ async fn post_collections_admin(
|
|||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> JsonResult {
|
||||||
let data: CollectionsAdminData = data.into_inner();
|
let data: CollectionsAdminData = data.into_inner();
|
||||||
|
|
||||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
@@ -932,7 +960,7 @@ async fn post_collections_admin(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherUpdatedCollections as i32,
|
EventType::CipherUpdatedCollections,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_uuid,
|
org_uuid,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -942,7 +970,7 @@ async fn post_collections_admin(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1004,7 +1032,7 @@ async fn put_cipher_share_selected(
|
|||||||
err!("You must select at least one collection.")
|
err!("You must select at least one collection.")
|
||||||
}
|
}
|
||||||
|
|
||||||
for cipher in data.ciphers.iter() {
|
for cipher in &data.ciphers {
|
||||||
if cipher.id.is_none() {
|
if cipher.id.is_none() {
|
||||||
err!("Request missing ids field")
|
err!("Request missing ids field")
|
||||||
}
|
}
|
||||||
@@ -1016,11 +1044,10 @@ async fn put_cipher_share_selected(
|
|||||||
collection_ids: data.collection_ids.clone(),
|
collection_ids: data.collection_ids.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match shared_cipher_data.cipher.id.take() {
|
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||||
Some(id) => {
|
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
} else {
|
||||||
}
|
err!("Request missing ids field")
|
||||||
None => err!("Request missing ids field"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,17 +1065,23 @@ async fn share_cipher_by_uuid(
|
|||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
override_ut: Option<UpdateType>,
|
override_ut: Option<UpdateType>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await {
|
let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await {
|
||||||
Some(cipher) => {
|
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||||
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
cipher
|
||||||
cipher
|
} else {
|
||||||
} else {
|
err!("Cipher is not write accessible")
|
||||||
err!("Cipher is not write accessible")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => err!("Cipher doesn't exist"),
|
} else {
|
||||||
|
err!("Cipher doesn't exist")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `update_cipher_from_data()` rejects this too, but only after the collections below were
|
||||||
|
// already linked. There are no transactions, so that would leave the cipher linked to a
|
||||||
|
// collection of another organization.
|
||||||
|
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id {
|
||||||
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
|
}
|
||||||
|
|
||||||
let mut shared_to_collections = vec![];
|
let mut shared_to_collections = vec![];
|
||||||
|
|
||||||
if let Some(organization_id) = &data.cipher.organization_id {
|
if let Some(organization_id) = &data.cipher.organization_id {
|
||||||
@@ -1065,7 +1098,7 @@ async fn share_cipher_by_uuid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
|
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
|
||||||
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
|
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
|
||||||
@@ -1263,10 +1296,10 @@ async fn save_attachment(
|
|||||||
err!("Cipher is neither owned by a user nor an organization");
|
err!("Cipher is neither owned by a user nor an organization");
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(size_limit) = size_limit {
|
if let Some(size_limit) = size_limit
|
||||||
if size > size_limit {
|
&& size > size_limit
|
||||||
err!("Attachment storage limit exceeded with this file");
|
{
|
||||||
}
|
err!("Attachment storage limit exceeded with this file");
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_id = match &attachment {
|
let file_id = match &attachment {
|
||||||
@@ -1332,7 +1365,7 @@ async fn save_attachment(
|
|||||||
|
|
||||||
if let Some(org_id) = &cipher.organization_uuid {
|
if let Some(org_id) = &cipher.organization_uuid {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherAttachmentCreated as i32,
|
EventType::CipherAttachmentCreated,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1408,7 +1441,7 @@ async fn post_attachment_share(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?;
|
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?;
|
||||||
post_attachment(cipher_id, data, headers, conn, nt).await
|
post_attachment(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,7 +1475,7 @@ async fn delete_attachment(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/ciphers/<cipher_id>/attachment/<attachment_id>/admin")]
|
#[delete("/ciphers/<cipher_id>/attachment/<attachment_id>/admin")]
|
||||||
@@ -1453,42 +1486,42 @@ async fn delete_attachment_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>/delete")]
|
#[post("/ciphers/<cipher_id>/delete")]
|
||||||
async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>/delete-admin")]
|
#[post("/ciphers/<cipher_id>/delete-admin")]
|
||||||
async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/delete")]
|
#[put("/ciphers/<cipher_id>/delete")]
|
||||||
async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/delete-admin")]
|
#[put("/ciphers/<cipher_id>/delete-admin")]
|
||||||
async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/ciphers/<cipher_id>")]
|
#[delete("/ciphers/<cipher_id>")]
|
||||||
async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/ciphers/<cipher_id>/admin")]
|
#[delete("/ciphers/<cipher_id>/admin")]
|
||||||
async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,7 +1532,7 @@ async fn delete_cipher_selected(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,7 +1543,7 @@ async fn delete_cipher_selected_post(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,7 +1554,7 @@ async fn delete_cipher_selected_put(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1532,7 +1565,7 @@ async fn delete_cipher_selected_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,7 +1576,7 @@ async fn delete_cipher_selected_post_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1554,18 +1587,18 @@ async fn delete_cipher_selected_put_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/restore")]
|
#[put("/ciphers/<cipher_id>/restore")]
|
||||||
async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
_restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/restore-admin")]
|
#[put("/ciphers/<cipher_id>/restore-admin")]
|
||||||
async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
_restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/restore-admin", data = "<data>")]
|
#[put("/ciphers/restore-admin", data = "<data>")]
|
||||||
@@ -1575,7 +1608,7 @@ async fn restore_cipher_selected_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/restore", data = "<data>")]
|
#[put("/ciphers/restore", data = "<data>")]
|
||||||
@@ -1585,7 +1618,7 @@ async fn restore_cipher_selected(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1606,10 +1639,10 @@ async fn move_cipher_selected(
|
|||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
let user_id = &headers.user.uuid;
|
let user_id = &headers.user.uuid;
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id
|
||||||
if Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() {
|
&& Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none()
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
{
|
||||||
}
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cipher_count = data.ids.len();
|
let cipher_count = data.ids.len();
|
||||||
@@ -1693,7 +1726,7 @@ async fn purge_org_vault(
|
|||||||
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationPurgedVault as i32,
|
EventType::OrganizationPurgedVault,
|
||||||
&organization.org_id,
|
&organization.org_id,
|
||||||
&organization.org_id,
|
&organization.org_id,
|
||||||
&user.uuid,
|
&user.uuid,
|
||||||
@@ -1773,7 +1806,7 @@ pub enum CipherDeleteOptions {
|
|||||||
HardMulti,
|
HardMulti,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_cipher_by_uuid(
|
async fn delete_cipher_by_uuid(
|
||||||
cipher_id: &CipherId,
|
cipher_id: &CipherId,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -1821,9 +1854,9 @@ async fn _delete_cipher_by_uuid(
|
|||||||
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|
||||||
|| *delete_options == CipherDeleteOptions::SoftMulti
|
|| *delete_options == CipherDeleteOptions::SoftMulti
|
||||||
{
|
{
|
||||||
EventType::CipherSoftDeleted as i32
|
EventType::CipherSoftDeleted
|
||||||
} else {
|
} else {
|
||||||
EventType::CipherDeleted as i32
|
EventType::CipherDeleted
|
||||||
};
|
};
|
||||||
|
|
||||||
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
||||||
@@ -1839,7 +1872,7 @@ struct CipherIdsData {
|
|||||||
ids: Vec<CipherId>,
|
ids: Vec<CipherId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_multiple_ciphers(
|
async fn delete_multiple_ciphers(
|
||||||
data: Json<CipherIdsData>,
|
data: Json<CipherIdsData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
@@ -1849,9 +1882,9 @@ async fn _delete_multiple_ciphers(
|
|||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
for cipher_id in data.ids {
|
for cipher_id in data.ids {
|
||||||
if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await {
|
if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await {
|
||||||
return error;
|
return error;
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here
|
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here
|
||||||
@@ -1860,7 +1893,7 @@ async fn _delete_multiple_ciphers(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _restore_cipher_by_uuid(
|
async fn restore_cipher_by_uuid(
|
||||||
cipher_id: &CipherId,
|
cipher_id: &CipherId,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
multi_restore: bool,
|
multi_restore: bool,
|
||||||
@@ -1892,7 +1925,7 @@ async fn _restore_cipher_by_uuid(
|
|||||||
|
|
||||||
if let Some(org_id) = &cipher.organization_uuid {
|
if let Some(org_id) = &cipher.organization_uuid {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherRestored as i32,
|
EventType::CipherRestored,
|
||||||
&cipher.uuid.clone(),
|
&cipher.uuid.clone(),
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1906,7 +1939,7 @@ async fn _restore_cipher_by_uuid(
|
|||||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _restore_multiple_ciphers(
|
async fn restore_multiple_ciphers(
|
||||||
data: Json<CipherIdsData>,
|
data: Json<CipherIdsData>,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -1916,7 +1949,7 @@ async fn _restore_multiple_ciphers(
|
|||||||
|
|
||||||
let mut ciphers: Vec<Value> = Vec::new();
|
let mut ciphers: Vec<Value> = Vec::new();
|
||||||
for cipher_id in data.ids {
|
for cipher_id in data.ids {
|
||||||
match _restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await {
|
match restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await {
|
||||||
Ok(json) => ciphers.push(json.into_inner()),
|
Ok(json) => ciphers.push(json.into_inner()),
|
||||||
err => return err,
|
err => return err,
|
||||||
}
|
}
|
||||||
@@ -1932,7 +1965,7 @@ async fn _restore_multiple_ciphers(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_cipher_attachment_by_id(
|
async fn delete_cipher_attachment_by_id(
|
||||||
cipher_id: &CipherId,
|
cipher_id: &CipherId,
|
||||||
attachment_id: &AttachmentId,
|
attachment_id: &AttachmentId,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
@@ -1969,7 +2002,7 @@ async fn _delete_cipher_attachment_by_id(
|
|||||||
|
|
||||||
if let Some(ref org_id) = cipher.organization_uuid {
|
if let Some(ref org_id) = cipher.organization_uuid {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherAttachmentDeleted as i32,
|
EventType::CipherAttachmentDeleted,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2141,14 +2174,21 @@ impl CipherSyncData {
|
|||||||
// Organization Sync does not support Folders, Favorites, or Archives.
|
// Organization Sync does not support Folders, Favorites, or Archives.
|
||||||
// If these are set, it will cause issues in the web-vault.
|
// If these are set, it will cause issues in the web-vault.
|
||||||
CipherSyncType::Organization => {
|
CipherSyncType::Organization => {
|
||||||
cipher_folders = HashMap::with_capacity(0);
|
cipher_folders = HashMap::new();
|
||||||
cipher_favorites = HashSet::with_capacity(0);
|
cipher_favorites = HashSet::new();
|
||||||
cipher_archives = HashMap::with_capacity(0);
|
cipher_archives = HashMap::new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a HashMap with the Organization UUID as key and the Membership record
|
||||||
|
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.org_uuid.clone(), m))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
|
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
|
||||||
let orgs = Membership::get_orgs_by_user(user_id, conn).await;
|
let orgs: Vec<OrganizationId> = members.keys().cloned().collect();
|
||||||
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
|
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
|
||||||
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
|
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
@@ -2163,13 +2203,6 @@ impl CipherSyncData {
|
|||||||
cipher_collections.entry(cipher).or_default().push(collection);
|
cipher_collections.entry(cipher).or_default().push(collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a HashMap with the Organization UUID as key and the Membership record
|
|
||||||
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| (m.org_uuid.clone(), m))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
||||||
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
||||||
.await
|
.await
|
||||||
@@ -2206,11 +2239,11 @@ impl CipherSyncData {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cipher_archives,
|
|
||||||
cipher_attachments,
|
cipher_attachments,
|
||||||
cipher_folders,
|
cipher_folders,
|
||||||
cipher_favorites,
|
cipher_favorites,
|
||||||
cipher_collections,
|
cipher_collections,
|
||||||
|
cipher_archives,
|
||||||
members,
|
members,
|
||||||
user_collections,
|
user_collections,
|
||||||
user_collections_groups,
|
user_collections_groups,
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use rocket::{serde::json::Json, Route};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{CipherSyncData, CipherSyncType},
|
|
||||||
EmptyResult, JsonResult,
|
EmptyResult, JsonResult,
|
||||||
|
core::{CipherSyncData, CipherSyncType},
|
||||||
},
|
},
|
||||||
auth::{decode_emergency_access_invite, Headers},
|
auth::{Headers, decode_emergency_access_invite},
|
||||||
db::{
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbPool,
|
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -55,7 +55,7 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||||
for ea in emergency_access_list {
|
for ea in emergency_access_list {
|
||||||
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
||||||
emergency_access_list_json.push(grantee)
|
emergency_access_list_json.push(grantee);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +89,14 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await {
|
if let Some(emergency_access) =
|
||||||
Some(emergency_access) => Ok(Json(
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
|
{
|
||||||
|
Ok(Json(
|
||||||
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
|
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
|
||||||
)),
|
))
|
||||||
None => err!("Emergency access not valid."),
|
} else {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +139,10 @@ async fn post_emergency_access(
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid emergency access type."),
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.atype = new_type;
|
emergency_access.atype = new_type;
|
||||||
@@ -205,9 +209,10 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
|
|
||||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
|
|
||||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid emergency access type."),
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let grantor_user = headers.user;
|
let grantor_user = headers.user;
|
||||||
@@ -342,12 +347,11 @@ async fn accept_invite(
|
|||||||
err!("Claim email does not match current users email")
|
err!("Claim email does not match current users email")
|
||||||
}
|
}
|
||||||
|
|
||||||
let grantee_user = match User::find_by_mail(&claims.email, &conn).await {
|
let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await {
|
||||||
Some(user) => {
|
Invitation::take(&claims.email, &conn).await;
|
||||||
Invitation::take(&claims.email, &conn).await;
|
user
|
||||||
user
|
} else {
|
||||||
}
|
err!("Invited user not found")
|
||||||
None => err!("Invited user not found"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
||||||
@@ -766,7 +770,7 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while searching emergency request timed out")
|
error!("Failed to get DB connection while searching emergency request timed out");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,6 +829,6 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while searching emergency notification reminder")
|
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+82
-62
@@ -1,18 +1,18 @@
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use rocket::{form::FromForm, serde::json::Json, Route};
|
use rocket::{Route, form::FromForm, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{EmptyResult, JsonResult},
|
api::{EmptyResult, JsonResult},
|
||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
||||||
},
|
},
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// ###############################################################################################################
|
/// ###############################################################################################################
|
||||||
@@ -38,9 +38,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
|
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when we org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||||
Vec::with_capacity(0)
|
|
||||||
} else {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -51,8 +49,10 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.to_json())
|
.map(Event::to_json)
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -64,27 +64,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
|
|
||||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||||
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
||||||
Vec::with_capacity(0)
|
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
||||||
} else {
|
{
|
||||||
let mut events_json = Vec::with_capacity(0);
|
let start_date = parse_date(&data.start);
|
||||||
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
let start_date = parse_date(&data.start);
|
parse_date(before_date)
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
} else {
|
||||||
parse_date(before_date)
|
parse_date(&data.end)
|
||||||
} else {
|
};
|
||||||
parse_date(&data.end)
|
|
||||||
};
|
|
||||||
|
|
||||||
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn)
|
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
||||||
.await
|
} else {
|
||||||
.iter()
|
Vec::new()
|
||||||
.map(|e| e.to_json())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
events_json
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -107,9 +101,7 @@ async fn get_user_events(
|
|||||||
}
|
}
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when we org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||||
Vec::with_capacity(0)
|
|
||||||
} else {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -120,8 +112,10 @@ async fn get_user_events(
|
|||||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.to_json())
|
.map(Event::to_json)
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -134,7 +128,8 @@ async fn get_user_events(
|
|||||||
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
||||||
// When the length of the vec equals the max page_size there probably is more data
|
// When the length of the vec equals the max page_size there probably is more data
|
||||||
// When it is less, then all events are loaded.
|
// When it is less, then all events are loaded.
|
||||||
if events_json.len() as i64 == Event::PAGE_SIZE {
|
#[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")]
|
||||||
|
if events_json.len() == Event::PAGE_SIZE as usize {
|
||||||
if let Some(last_event) = events_json.last() {
|
if let Some(last_event) = events_json.last() {
|
||||||
last_event["date"].as_str()
|
last_event["date"].as_str()
|
||||||
} else {
|
} else {
|
||||||
@@ -176,7 +171,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
let event_date = parse_date(&event.date);
|
let event_date = parse_date(&event.date);
|
||||||
match event.r#type {
|
match event.r#type {
|
||||||
1000..=1099 => {
|
1000..=1099 => {
|
||||||
_log_user_event(
|
log_user_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
@@ -187,8 +182,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
1600..=1699 => {
|
1600..=1699 => {
|
||||||
if let Some(org_id) = &event.organization_id {
|
// Only allow logging events for an organization the user is actually a member of.
|
||||||
_log_event(
|
if let Some(org_id) = &event.organization_id
|
||||||
|
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
|
||||||
|
{
|
||||||
|
log_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
org_id,
|
org_id,
|
||||||
org_id,
|
org_id,
|
||||||
@@ -201,23 +199,45 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only the vault notification banner click is accepted from clients. The rest of
|
||||||
|
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
|
||||||
|
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
|
||||||
|
if let Some(org_id) = &event.organization_id
|
||||||
|
&& let Some(membership) =
|
||||||
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
|
||||||
|
{
|
||||||
|
log_event_impl(
|
||||||
|
event.r#type,
|
||||||
|
&membership.uuid,
|
||||||
|
org_id,
|
||||||
|
&headers.user.uuid,
|
||||||
|
headers.device.atype,
|
||||||
|
Some(event_date),
|
||||||
|
&headers.ip.ip,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(cipher_uuid) = &event.cipher_id {
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await {
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(org_id) = cipher.organization_uuid {
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
_log_event(
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
event.r#type,
|
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
cipher_uuid,
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
&org_id,
|
{
|
||||||
&headers.user.uuid,
|
log_event_impl(
|
||||||
headers.device.atype,
|
event.r#type,
|
||||||
Some(event_date),
|
cipher_uuid,
|
||||||
&headers.ip.ip,
|
&org_id,
|
||||||
&conn,
|
&headers.user.uuid,
|
||||||
)
|
headers.device.atype,
|
||||||
.await;
|
Some(event_date),
|
||||||
}
|
&headers.ip.ip,
|
||||||
}
|
&conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,10 +249,10 @@ pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32,
|
|||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log_user_event(event_type, user_id, device_type, None, ip, conn).await;
|
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _log_user_event(
|
async fn log_user_event_impl(
|
||||||
event_type: i32,
|
event_type: i32,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
device_type: i32,
|
device_type: i32,
|
||||||
@@ -267,7 +287,7 @@ async fn _log_user_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn log_event(
|
pub async fn log_event(
|
||||||
event_type: i32,
|
event_type: EventType,
|
||||||
source_uuid: &str,
|
source_uuid: &str,
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
act_user_id: &UserId,
|
act_user_id: &UserId,
|
||||||
@@ -278,11 +298,11 @@ pub async fn log_event(
|
|||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[expect(clippy::too_many_arguments)]
|
||||||
async fn _log_event(
|
async fn log_event_impl(
|
||||||
event_type: i32,
|
event_type: i32,
|
||||||
source_uuid: &str,
|
source_uuid: &str,
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
@@ -298,24 +318,24 @@ async fn _log_event(
|
|||||||
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
||||||
// Cipher Events
|
// Cipher Events
|
||||||
1100..=1199 => {
|
1100..=1199 => {
|
||||||
event.cipher_uuid = Some(source_uuid.to_string().into());
|
event.cipher_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Collection Events
|
// Collection Events
|
||||||
1300..=1399 => {
|
1300..=1399 => {
|
||||||
event.collection_uuid = Some(source_uuid.to_string().into());
|
event.collection_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Group Events
|
// Group Events
|
||||||
1400..=1499 => {
|
1400..=1499 => {
|
||||||
event.group_uuid = Some(source_uuid.to_string().into());
|
event.group_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Org User Events
|
// Org User Events
|
||||||
1500..=1599 => {
|
1500..=1599 => {
|
||||||
event.org_user_uuid = Some(source_uuid.to_string().into());
|
event.org_user_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
||||||
// Policy Events
|
// Policy Events
|
||||||
1700..=1799 => {
|
1700..=1799 => {
|
||||||
event.policy_uuid = Some(source_uuid.to_string().into());
|
event.policy_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Ignore others
|
// Ignore others
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -338,6 +358,6 @@ pub async fn event_cleanup_job(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Event::clean_events(&conn).await.ok();
|
Event::clean_events(&conn).await.ok();
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table")
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@ use crate::{
|
|||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{
|
db::{
|
||||||
models::{Folder, FolderId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{Folder, FolderId},
|
||||||
},
|
},
|
||||||
util::deser_opt_nonempty_str,
|
util::deser_opt_nonempty_str,
|
||||||
};
|
};
|
||||||
@@ -29,9 +29,10 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
|
|
||||||
#[get("/folders/<folder_id>")]
|
#[get("/folders/<folder_id>")]
|
||||||
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
||||||
Some(folder) => Ok(Json(folder.to_json())),
|
Ok(Json(folder.to_json()))
|
||||||
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
|
} else {
|
||||||
|
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-40
@@ -1,4 +1,6 @@
|
|||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod two_factor;
|
||||||
|
|
||||||
mod ciphers;
|
mod ciphers;
|
||||||
mod emergency_access;
|
mod emergency_access;
|
||||||
mod events;
|
mod events;
|
||||||
@@ -6,17 +8,32 @@ mod folders;
|
|||||||
mod organizations;
|
mod organizations;
|
||||||
mod public;
|
mod public;
|
||||||
mod sends;
|
mod sends;
|
||||||
pub mod two_factor;
|
|
||||||
|
|
||||||
pub use accounts::purge_auth_requests;
|
pub use accounts::purge_auth_requests;
|
||||||
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
|
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
|
||||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||||
use reqwest::Method;
|
|
||||||
pub use sends::purge_sends;
|
pub use sends::purge_sends;
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
|
auth::Headers,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
http_client::make_http_request,
|
||||||
|
mail,
|
||||||
|
util::{FeatureFlagFilter, parse_experimental_client_feature_flags},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
|
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
|
||||||
let mut hibp_routes = routes![hibp_breach];
|
let mut hibp_routes = routes![hibp_breach];
|
||||||
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||||
|
|
||||||
@@ -44,25 +61,6 @@ pub fn events_routes() -> Vec<Route> {
|
|||||||
routes
|
routes
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// Move this somewhere else
|
|
||||||
//
|
|
||||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
|
||||||
auth::Headers,
|
|
||||||
db::{
|
|
||||||
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
error::Error,
|
|
||||||
http_client::make_http_request,
|
|
||||||
mail,
|
|
||||||
util::{parse_experimental_client_feature_flags, FeatureFlagFilter},
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GlobalDomain {
|
struct GlobalDomain {
|
||||||
@@ -73,15 +71,17 @@ struct GlobalDomain {
|
|||||||
|
|
||||||
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
||||||
|
|
||||||
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
#[get("/settings/domains")]
|
#[get("/settings/domains")]
|
||||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
fn get_settings_domains(headers: Headers) -> Json<Value> {
|
||||||
_get_eq_domains(&headers, false)
|
get_eq_domains(&headers, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||||
let user = &headers.user;
|
|
||||||
use serde_json::from_str;
|
use serde_json::from_str;
|
||||||
|
|
||||||
|
let user = &headers.user;
|
||||||
|
|
||||||
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
||||||
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
||||||
|
|
||||||
@@ -110,17 +110,23 @@ struct EquivDomainData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/settings/domains", data = "<data>")]
|
#[post("/settings/domains", data = "<data>")]
|
||||||
async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_settings_domains(
|
||||||
|
data: Json<EquivDomainData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
use serde_json::to_string;
|
||||||
|
|
||||||
let data: EquivDomainData = data.into_inner();
|
let data: EquivDomainData = data.into_inner();
|
||||||
|
|
||||||
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
||||||
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
use serde_json::to_string;
|
|
||||||
|
|
||||||
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
|
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_owned());
|
||||||
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
|
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned());
|
||||||
|
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
@@ -130,8 +136,13 @@ async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: Db
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/settings/domains", data = "<data>")]
|
#[put("/settings/domains", data = "<data>")]
|
||||||
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn put_settings_domains(
|
||||||
post_eq_domains(data, headers, conn, nt).await
|
data: Json<EquivDomainData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
post_settings_domains(data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/hibp/breach?<username>")]
|
#[get("/hibp/breach?<username>")]
|
||||||
@@ -206,9 +217,9 @@ fn config() -> Json<Value> {
|
|||||||
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||||
let mut feature_states = parse_experimental_client_feature_flags(
|
let mut feature_states = parse_experimental_client_feature_flags(
|
||||||
&CONFIG.experimental_client_feature_flags(),
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
FeatureFlagFilter::ValidOnly,
|
&FeatureFlagFilter::ValidOnly,
|
||||||
);
|
);
|
||||||
feature_states.insert("pm-19148-innovation-archive".to_string(), true);
|
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
// Note: The clients use this version to handle backwards compatibility concerns
|
// Note: The clients use this version to handle backwards compatibility concerns
|
||||||
@@ -217,14 +228,17 @@ fn config() -> Json<Value> {
|
|||||||
// Version history:
|
// Version history:
|
||||||
// - Individual cipher key encryption: 2024.2.0
|
// - Individual cipher key encryption: 2024.2.0
|
||||||
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||||
"version": "2025.12.0",
|
"version": "2026.6.0",
|
||||||
"gitHash": option_env!("GIT_REV"),
|
"gitHash": option_env!("GIT_REV"),
|
||||||
"server": {
|
"server": {
|
||||||
"name": "Vaultwarden",
|
"name": "Vaultwarden",
|
||||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"disableUserRegistration": CONFIG.is_signup_disabled()
|
"disableUserRegistration": CONFIG.is_signup_disabled(),
|
||||||
|
// When enabled, this setting signals to clients that onboarding interstitials
|
||||||
|
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
|
||||||
|
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"vault": domain,
|
"vault": domain,
|
||||||
@@ -240,6 +254,10 @@ fn config() -> Json<Value> {
|
|||||||
"vapidPublicKey": null
|
"vapidPublicKey": null
|
||||||
},
|
},
|
||||||
"featureStates": feature_states,
|
"featureStates": feature_states,
|
||||||
|
// Not supported right now
|
||||||
|
// Used for by clients to learn if the server requires extra work to establish a connection.
|
||||||
|
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
|
||||||
|
"communication": null,
|
||||||
"object": "config",
|
"object": "config",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -278,9 +296,8 @@ async fn accept_org_invite(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
|
||||||
Some(org) => org,
|
err!("Organization not found.")
|
||||||
None => err!("Organization not found."),
|
|
||||||
};
|
};
|
||||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
// User was invited to an organization, so they must be confirmed manually after acceptance
|
||||||
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
||||||
|
|||||||
+356
-216
File diff suppressed because it is too large.
Load diff
+72
-68
@@ -1,23 +1,24 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use rocket::{
|
|
||||||
request::{FromRequest, Outcome},
|
|
||||||
serde::json::Json,
|
|
||||||
Request, Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use rocket::{
|
||||||
|
Request, Route,
|
||||||
|
request::{FromRequest, Outcome},
|
||||||
|
serde::json::Json,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
auth,
|
auth,
|
||||||
db::{
|
db::{
|
||||||
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
|
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
|
||||||
OrganizationApiKey, OrganizationId, User,
|
OrganizationApiKey, OrganizationId, User,
|
||||||
},
|
},
|
||||||
DbConn,
|
|
||||||
},
|
},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -83,26 +84,32 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
}
|
}
|
||||||
// If user is part of the organization, restore it
|
// If user is part of the organization, restore it
|
||||||
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||||
let restored = member.restore();
|
let mut restored = member.restore();
|
||||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||||
|
// Enforce org policies as every other restore path does.
|
||||||
|
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
|
||||||
|
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
|
||||||
|
warn!("Not restoring {}: {e:?}", user_data.email);
|
||||||
|
member.revoke();
|
||||||
|
restored = false;
|
||||||
|
}
|
||||||
if restored || ext_modified {
|
if restored || ext_modified {
|
||||||
member.save(&conn).await?;
|
member.save(&conn).await?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If user is not part of the organization
|
// If user is not part of the organization
|
||||||
let user = match User::find_by_mail(&user_data.email, &conn).await {
|
let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await {
|
||||||
Some(user) => user, // exists in vaultwarden
|
user
|
||||||
None => {
|
} else {
|
||||||
// User does not exist yet
|
// User does not exist yet
|
||||||
let mut new_user = User::new(&user_data.email, None);
|
let mut new_user = User::new(&user_data.email, None);
|
||||||
new_user.save(&conn).await?;
|
new_user.save(&conn).await?;
|
||||||
|
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
Invitation::new(&new_user.email).save(&conn).await?;
|
Invitation::new(&new_user.email).save(&conn).await?;
|
||||||
}
|
|
||||||
user_created = true;
|
|
||||||
new_user
|
|
||||||
}
|
}
|
||||||
|
user_created = true;
|
||||||
|
new_user
|
||||||
};
|
};
|
||||||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||||
MembershipStatus::Invited as i32
|
MembershipStatus::Invited as i32
|
||||||
@@ -110,9 +117,10 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||||
};
|
};
|
||||||
|
|
||||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await {
|
let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||||
Some(org) => (org.name, org.billing_email),
|
(org.name, org.billing_email)
|
||||||
None => err!("Error looking up organization"),
|
} else {
|
||||||
|
err!("Error looking up organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||||
@@ -123,37 +131,33 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
|
|
||||||
new_member.save(&conn).await?;
|
new_member.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled()
|
||||||
if let Err(e) =
|
&& let Err(e) =
|
||||||
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||||
{
|
{
|
||||||
// Upon error delete the user, invite and org member records when needed
|
// Upon error delete the user, invite and org member records when needed
|
||||||
if user_created {
|
if user_created {
|
||||||
user.delete(&conn).await?;
|
user.delete(&conn).await?;
|
||||||
} else {
|
} else {
|
||||||
new_member.delete(&conn).await?;
|
new_member.delete(&conn).await?;
|
||||||
}
|
|
||||||
|
|
||||||
err!(format!("Error sending invite: {e:?} "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err!(format!("Error sending invite: {e:?} "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.org_groups_enabled() {
|
if CONFIG.org_groups_enabled() {
|
||||||
for group_data in &data.groups {
|
for group_data in &data.groups {
|
||||||
let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await {
|
let group_uuid = if let Some(group) =
|
||||||
Some(group) => group.uuid,
|
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
||||||
None => {
|
{
|
||||||
let mut group = Group::new(
|
group.uuid
|
||||||
org_id.clone(),
|
} else {
|
||||||
group_data.name.clone(),
|
let mut group =
|
||||||
false,
|
Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()));
|
||||||
Some(group_data.external_id.clone()),
|
group.save(&conn).await?;
|
||||||
);
|
group.uuid
|
||||||
group.save(&conn).await?;
|
|
||||||
group.uuid
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||||
@@ -174,18 +178,17 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
// Generate a HashSet to quickly verify if a member is listed or not.
|
// Generate a HashSet to quickly verify if a member is listed or not.
|
||||||
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
||||||
for member in Membership::find_by_org(&org_id, &conn).await {
|
for member in Membership::find_by_org(&org_id, &conn).await {
|
||||||
if let Some(ref user_external_id) = member.external_id {
|
if let Some(ref user_external_id) = member.external_id
|
||||||
if !sync_members.contains(user_external_id) {
|
&& !sync_members.contains(user_external_id)
|
||||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
{
|
||||||
// Removing owner, check that there is at least one other confirmed owner
|
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
|
// Removing owner, check that there is at least one other confirmed owner
|
||||||
{
|
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
||||||
warn!("Can't delete the last owner");
|
warn!("Can't delete the last owner");
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
member.delete(&conn).await?;
|
|
||||||
}
|
}
|
||||||
|
member.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,12 +205,14 @@ impl<'r> FromRequest<'r> for PublicToken {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
// Get access_token
|
// Get access_token
|
||||||
let access_token: &str = match headers.get_one("Authorization") {
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
Some(a) => match a.rsplit("Bearer ").next() {
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
Some(split) => split,
|
split
|
||||||
None => err_handler!("No access token provided"),
|
} else {
|
||||||
},
|
err_handler!("No access token provided")
|
||||||
None => err_handler!("No access token provided"),
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
};
|
};
|
||||||
// Check JWT token is valid and get device and user from it
|
// Check JWT token is valid and get device and user from it
|
||||||
let Ok(claims) = auth::decode_api_org(access_token) else {
|
let Ok(claims) = auth::decode_api_org(access_token) else {
|
||||||
@@ -229,14 +234,13 @@ impl<'r> FromRequest<'r> for PublicToken {
|
|||||||
|
|
||||||
// Check if claims.sub is org_api_key.uuid
|
// Check if claims.sub is org_api_key.uuid
|
||||||
// Check if claims.client_sub is org_api_key.org_uuid
|
// Check if claims.client_sub is org_api_key.org_uuid
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
||||||
err_handler!("Malformed client_id")
|
err_handler!("Malformed client_id")
|
||||||
};
|
};
|
||||||
let org_id: OrganizationId = org_id.to_string().into();
|
let org_id: OrganizationId = org_id.to_owned().into();
|
||||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
||||||
err_handler!("Invalid client_id")
|
err_handler!("Invalid client_id")
|
||||||
};
|
};
|
||||||
|
|||||||
+93
-55
@@ -10,15 +10,15 @@ use rocket::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::{ClientIp, Headers, Host},
|
auth::{ClientIp, Headers, Host, SendHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
db::{
|
db::{
|
||||||
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||||
},
|
},
|
||||||
util::{save_temp_file, NumberOrString},
|
util::{NumberOrString, save_temp_file},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
||||||
@@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
post_send,
|
post_send,
|
||||||
post_send_file,
|
post_send_file,
|
||||||
post_access,
|
post_access,
|
||||||
|
post_access_legacy,
|
||||||
post_access_file,
|
post_access_file,
|
||||||
|
post_access_file_legacy,
|
||||||
put_send,
|
put_send,
|
||||||
delete_send,
|
delete_send,
|
||||||
put_remove_password,
|
put_remove_password,
|
||||||
@@ -63,7 +65,7 @@ pub async fn purge_sends(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Send::purge(&conn).await;
|
Send::purge(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging sends")
|
error!("Failed to get DB connection while purging sends");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ pub struct SendData {
|
|||||||
deletion_date: DateTime<Utc>,
|
deletion_date: DateTime<Utc>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
hide_email: Option<bool>,
|
hide_email: Option<bool>,
|
||||||
|
emails: Option<String>,
|
||||||
|
|
||||||
// Data field
|
// Data field
|
||||||
name: String,
|
name: String,
|
||||||
@@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.emails.is_some() {
|
||||||
|
err!("Sends with email verification is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
||||||
send.user_uuid = Some(user_id);
|
send.user_uuid = Some(user_id);
|
||||||
send.notes = data.notes;
|
send.notes = data.notes;
|
||||||
@@ -168,7 +175,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
|||||||
#[get("/sends")]
|
#[get("/sends")]
|
||||||
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let sends = Send::find_by_user(&headers.user.uuid, &conn);
|
let sends = Send::find_by_user(&headers.user.uuid, &conn);
|
||||||
let sends_json: Vec<Value> = sends.await.iter().map(|s| s.to_json()).collect();
|
let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect();
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"data": sends_json,
|
"data": sends_json,
|
||||||
@@ -179,9 +186,10 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
|
|
||||||
#[get("/sends/<send_id>")]
|
#[get("/sends/<send_id>")]
|
||||||
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
||||||
Some(send) => Ok(Json(send.to_json())),
|
Ok(Json(send.to_json()))
|
||||||
None => err!("Send not found", "Invalid send uuid or does not belong to user"),
|
} else {
|
||||||
|
err!("Send not found", "Invalid send uuid or does not belong to user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,9 +318,10 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
|||||||
|
|
||||||
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||||
|
|
||||||
let file_length = match &data.file_length {
|
let file_length = if let Some(m) = &data.file_length {
|
||||||
Some(m) => m.into_i64()?,
|
m.into_i64()?
|
||||||
_ => err!("Invalid send length"),
|
} else {
|
||||||
|
err!("Invalid send length")
|
||||||
};
|
};
|
||||||
if file_length < 0 {
|
if file_length < 0 {
|
||||||
err!("Send size can't be negative")
|
err!("Send size can't be negative")
|
||||||
@@ -369,7 +378,7 @@ pub struct SendFileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
|
||||||
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
|
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
|
||||||
async fn post_send_file_v2_data(
|
async fn post_send_file_v2_data(
|
||||||
send_id: SendId,
|
send_id: SendId,
|
||||||
file_id: SendFileId,
|
file_id: SendFileId,
|
||||||
@@ -439,41 +448,45 @@ async fn post_send_file_v2_data(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/sends/access")]
|
||||||
|
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
|
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
};
|
||||||
|
if !send.is_accessible() {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
process_access(send, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SendAccessData {
|
pub struct SendAccessData {
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy since web-2026.6.0
|
||||||
#[post("/sends/access/<access_id>", data = "<data>")]
|
#[post("/sends/access/<access_id>", data = "<data>")]
|
||||||
async fn post_access(
|
async fn post_access_legacy(
|
||||||
access_id: &str,
|
access_id: &str,
|
||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(max_access_count) = send.max_access_count {
|
if let Some(max_access_count) = send.max_access_count
|
||||||
if send.access_count >= max_access_count {
|
&& send.access_count >= max_access_count
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
{
|
||||||
}
|
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(expiration) = send.expiration_date {
|
if !send.is_accessible() {
|
||||||
if Utc::now().naive_utc() >= expiration {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if Utc::now().naive_utc() >= send.deletion_date {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
if send.disabled {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +500,17 @@ async fn post_access(
|
|||||||
|
|
||||||
// Files are incremented during the download
|
// Files are incremented during the download
|
||||||
if send.atype == SendType::Text as i32 {
|
if send.atype == SendType::Text as i32 {
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
send.save(&conn).await?;
|
process_access(send, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
@@ -504,36 +523,47 @@ async fn post_access(
|
|||||||
Ok(Json(send.to_json_access(&conn).await))
|
Ok(Json(send.to_json_access(&conn).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
#[post("/sends/access/file/<file_id>", rank = 1)]
|
||||||
async fn post_access_file(
|
async fn post_access_file(
|
||||||
|
file_id: SendFileId,
|
||||||
|
headers: SendHeaders,
|
||||||
|
host: Host,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
};
|
||||||
|
if !send.is_accessible() {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy since web-2026.6.0
|
||||||
|
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
||||||
|
async fn post_access_file_legacy(
|
||||||
send_id: SendId,
|
send_id: SendId,
|
||||||
file_id: SendFileId,
|
file_id: SendFileId,
|
||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
host: Host,
|
host: Host,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(max_access_count) = send.max_access_count {
|
if let Some(max_access_count) = send.max_access_count
|
||||||
if send.access_count >= max_access_count {
|
&& send.access_count >= max_access_count
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
{
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expiration) = send.expiration_date {
|
|
||||||
if Utc::now().naive_utc() >= expiration {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if Utc::now().naive_utc() >= send.deletion_date {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if send.disabled {
|
if !send.is_accessible() {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,10 +575,14 @@ async fn post_access_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
|
||||||
send.save(&conn).await?;
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
@@ -561,18 +595,18 @@ async fn post_access_file(
|
|||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"object": "send-fileDownload",
|
"object": "send-fileDownload",
|
||||||
"id": file_id,
|
"id": file_id,
|
||||||
"url": download_url(&host, &send_id, &file_id).await?,
|
"url": download_url(&host, &send.uuid, &file_id).await?,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
|
||||||
|
|
||||||
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
|
if crate::storage::is_fs_operator(&operator) {
|
||||||
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
||||||
let token = crate::auth::encode_jwt(&token_claims);
|
let token = crate::auth::encode_jwt(&token_claims);
|
||||||
|
|
||||||
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host))
|
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", host.host))
|
||||||
} else {
|
} else {
|
||||||
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
||||||
}
|
}
|
||||||
@@ -580,10 +614,10 @@ async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Re
|
|||||||
|
|
||||||
#[get("/sends/<send_id>/<file_id>?<t>")]
|
#[get("/sends/<send_id>/<file_id>?<t>")]
|
||||||
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
||||||
if let Ok(claims) = crate::auth::decode_send(t) {
|
if let Ok(claims) = crate::auth::decode_send(t)
|
||||||
if claims.sub == format!("{send_id}/{file_id}") {
|
&& claims.sub == format!("{send_id}/{file_id}")
|
||||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
{
|
||||||
}
|
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -599,6 +633,10 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
|
|||||||
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if data.emails.is_some() {
|
||||||
|
err!("Sends with email verification is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
|
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
|
||||||
|
|
||||||
Ok(Json(send.to_json()))
|
Ok(Json(send.to_json()))
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData},
|
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
|
||||||
auth::{ClientIp, Headers},
|
auth::{ClientIp, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
@@ -70,9 +69,10 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Validate key as base32 and 20 bytes length
|
// Validate key as base32 and 20 bytes length
|
||||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
||||||
Ok(decoded) => decoded,
|
decoded
|
||||||
_ => err!("Invalid totp secret"),
|
} else {
|
||||||
|
err!("Invalid totp secret")
|
||||||
};
|
};
|
||||||
|
|
||||||
if decoded_key.len() != 20 {
|
if decoded_key.len() != 20 {
|
||||||
@@ -82,7 +82,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
// Validate the token provided with the key, and save new twofactor
|
// Validate the token provided with the key, and save new twofactor
|
||||||
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
|
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ pub async fn validate_totp_code(
|
|||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
use totp_lite::{totp_custom, Sha1};
|
use totp_lite::{Sha1, totp_custom};
|
||||||
|
|
||||||
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
||||||
err!("Invalid TOTP secret")
|
err!("Invalid TOTP secret")
|
||||||
@@ -128,7 +128,7 @@ pub async fn validate_totp_code(
|
|||||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||||
{
|
{
|
||||||
Some(tf) => tf,
|
Some(tf) => tf,
|
||||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()),
|
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The amount of steps back and forward in time
|
// The amount of steps back and forward in time
|
||||||
@@ -145,7 +145,7 @@ pub async fn validate_totp_code(
|
|||||||
|
|
||||||
// We need to calculate the time offsite and cast it as an u64.
|
// We need to calculate the time offsite and cast it as an u64.
|
||||||
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
||||||
let time = (current_timestamp + step * 30i64) as u64;
|
let time: u64 = (current_timestamp + step * 30i64).cast_unsigned();
|
||||||
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
||||||
|
|
||||||
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use data_encoding::BASE64;
|
use data_encoding::BASE64;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult,
|
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
|
||||||
PasswordOrOtpData,
|
core::two_factor::generate_recover_code,
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -82,8 +81,7 @@ enum DuoStatus {
|
|||||||
impl DuoStatus {
|
impl DuoStatus {
|
||||||
fn data(self) -> Option<DuoData> {
|
fn data(self) -> Option<DuoData> {
|
||||||
match self {
|
match self {
|
||||||
DuoStatus::Global(data) => Some(data),
|
DuoStatus::Global(data) | DuoStatus::User(data) => Some(data),
|
||||||
DuoStatus::User(data) => Some(data),
|
|
||||||
DuoStatus::Disabled(_) => None,
|
DuoStatus::Disabled(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +180,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
|||||||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
||||||
twofactor.save(&conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -201,14 +199,14 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||||
use reqwest::{header, Method};
|
use reqwest::{Method, header};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
// https://duo.com/docs/authapi#api-details
|
// https://duo.com/docs/authapi#api-details
|
||||||
let url = format!("https://{}{path}", &data.host);
|
let url = format!("https://{}{path}", data.host);
|
||||||
let date = Utc::now().to_rfc2822();
|
let dt = Utc::now().to_rfc2822();
|
||||||
let username = &data.ik;
|
let username = &data.ik;
|
||||||
let fields = [&date, method, &data.host, path, params];
|
let fields = [&dt, method, &data.host, path, params];
|
||||||
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
||||||
|
|
||||||
let m = Method::from_str(method).unwrap_or_default();
|
let m = Method::from_str(method).unwrap_or_default();
|
||||||
@@ -216,7 +214,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
|||||||
make_http_request(m, &url)?
|
make_http_request(m, &url)?
|
||||||
.basic_auth(username, Some(password))
|
.basic_auth(username, Some(password))
|
||||||
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
||||||
.header(header::DATE, date)
|
.header(header::DATE, dt)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
@@ -356,9 +354,10 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
|
|||||||
err!("Invalid ikey")
|
err!("Invalid ikey")
|
||||||
}
|
}
|
||||||
|
|
||||||
let expire: i64 = match expire.parse() {
|
let expire: i64 = if let Ok(e) = expire.parse() {
|
||||||
Ok(e) => e,
|
e
|
||||||
Err(_) => err!("Invalid expire time"),
|
} else {
|
||||||
|
err!("Invalid expire time")
|
||||||
};
|
};
|
||||||
|
|
||||||
if time >= expire {
|
if time >= expire {
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use reqwest::{header, StatusCode};
|
use reqwest::{StatusCode, header};
|
||||||
use ring::digest::{digest, Digest, SHA512_256};
|
use ring::digest::{Digest, SHA512_256, digest};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
|
CONFIG,
|
||||||
|
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
||||||
// built in to the Bitwarden clients.
|
// built in to the Bitwarden clients.
|
||||||
@@ -124,7 +125,7 @@ impl DuoClient {
|
|||||||
ClientAssertion {
|
ClientAssertion {
|
||||||
iss: self.client_id.clone(),
|
iss: self.client_id.clone(),
|
||||||
sub: self.client_id.clone(),
|
sub: self.client_id.clone(),
|
||||||
aud: url.to_string(),
|
aud: url.to_owned(),
|
||||||
exp: now + JWT_VALIDITY_SECS,
|
exp: now + JWT_VALIDITY_SECS,
|
||||||
jti: jwt_id,
|
jti: jwt_id,
|
||||||
iat: now,
|
iat: now,
|
||||||
@@ -302,7 +303,7 @@ impl DuoClient {
|
|||||||
|
|
||||||
if !(matching_nonces && matching_usernames) {
|
if !(matching_nonces && matching_usernames) {
|
||||||
err!("Error validating Duo authorization, nonce or username mismatch.")
|
err!("Error validating Duo authorization, nonce or username mismatch.")
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -347,7 +348,7 @@ pub async fn purge_duo_contexts(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging expired Duo authentications")
|
error!("Failed to get DB connection while purging expired Duo authentications");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +395,7 @@ pub async fn get_duo_auth_url(
|
|||||||
match client.health_check().await {
|
match client.health_check().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
}
|
||||||
|
|
||||||
// Generate random OAuth2 state and OIDC Nonce
|
// Generate random OAuth2 state and OIDC Nonce
|
||||||
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||||
@@ -438,16 +439,13 @@ pub async fn validate_duo_login(
|
|||||||
|
|
||||||
// Get the context by the state reported by the client. If we don't have one,
|
// Get the context by the state reported by the client. If we don't have one,
|
||||||
// it means the context is either missing or expired.
|
// it means the context is either missing or expired.
|
||||||
let ctx = match extract_context(state, conn).await {
|
let Some(ctx) = extract_context(state, conn).await else {
|
||||||
Some(c) => c,
|
err!(
|
||||||
None => {
|
"Error validating duo authentication",
|
||||||
err!(
|
ErrorEvent {
|
||||||
"Error validating duo authentication",
|
event: EventType::UserFailedLogIn2fa
|
||||||
ErrorEvent {
|
}
|
||||||
event: EventType::UserFailedLogIn2fa
|
)
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Context validation steps
|
// Context validation steps
|
||||||
@@ -476,13 +474,13 @@ pub async fn validate_duo_login(
|
|||||||
match client.health_check().await {
|
match client.health_check().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
}
|
||||||
|
|
||||||
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
||||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||||
|
|
||||||
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
||||||
Ok(_) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
err!(
|
err!(
|
||||||
"Error validating duo authentication",
|
"Error validating duo authentication",
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, Headers},
|
auth::{ClientHeaders, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -25,7 +25,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SendEmailLoginData {
|
struct SendEmailLoginData {
|
||||||
#[serde(alias = "DeviceIdentifier")]
|
#[serde(alias = "DeviceIdentifier")]
|
||||||
device_identifier: DeviceId,
|
device_identifier: Option<DeviceId>,
|
||||||
#[serde(alias = "Email")]
|
#[serde(alias = "Email")]
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
#[serde(alias = "MasterPasswordHash")]
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
@@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
|
|
||||||
let user = if let Some(email) = email {
|
let user = if let Some(email) = email {
|
||||||
let Some(user) = User::find_by_mail(email, &conn).await else {
|
let Some(user) = User::find_by_mail(email, &conn).await else {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!(
|
||||||
|
"Username or password is incorrect. Try again",
|
||||||
|
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(master_password_hash) = master_password_hash {
|
if let Some(master_password_hash) = master_password_hash {
|
||||||
// Check password
|
// Check password
|
||||||
if !user.check_valid_password(master_password_hash) {
|
if !user.check_valid_password(master_password_hash) {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!(
|
||||||
|
"Username or password is incorrect. Try again",
|
||||||
|
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if let Some(auth_request_id) = auth_request_id {
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||||
@@ -91,9 +97,15 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
|
|
||||||
user
|
user
|
||||||
} else {
|
} else {
|
||||||
|
let Some(device_identifier) = &data.device_identifier else {
|
||||||
|
err!("No device identifier has been submitted.")
|
||||||
|
};
|
||||||
// SSO login only sends device id, so we get the user by the most recently used device
|
// SSO login only sends device id, so we get the user by the most recently used device
|
||||||
let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else {
|
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!(
|
||||||
|
"Username or password is incorrect. Try again",
|
||||||
|
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
user
|
user
|
||||||
@@ -229,7 +241,7 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
|||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
twofactor.save(&conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -281,9 +293,9 @@ pub async fn validate_email_code_str(
|
|||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
twofactor.save(conn).await?;
|
twofactor.save(conn).await?;
|
||||||
|
|
||||||
let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||||
let max_time = CONFIG.email_expiration_time() as i64;
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
@@ -339,9 +351,10 @@ impl EmailTokenData {
|
|||||||
|
|
||||||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
||||||
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
||||||
match res {
|
if let Ok(x) = res {
|
||||||
Ok(x) => Ok(x),
|
Ok(x)
|
||||||
Err(_) => err!("Could not decode EmailTokenData from string"),
|
} else {
|
||||||
|
err!("Could not decode EmailTokenData from string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,18 +372,17 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
|
|||||||
pub fn obscure_email(email: &str) -> String {
|
pub fn obscure_email(email: &str) -> String {
|
||||||
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
|
|
||||||
let mut name = split[1].to_string();
|
let mut name = split[1].to_owned();
|
||||||
let domain = &split[0];
|
let domain = &split[0];
|
||||||
|
|
||||||
let name_size = name.chars().count();
|
let name_size = name.chars().count();
|
||||||
|
|
||||||
let new_name = match name_size {
|
let new_name = if let 1..=3 = name_size {
|
||||||
1..=3 => "*".repeat(name_size),
|
"*".repeat(name_size)
|
||||||
_ => {
|
} else {
|
||||||
let stars = "*".repeat(name_size - 2);
|
let stars = "*".repeat(name_size - 2);
|
||||||
name.truncate(2);
|
name.truncate(2);
|
||||||
format!("{name}{stars}")
|
format!("{name}{stars}")
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
format!("{new_name}@{domain}")
|
format!("{new_name}@{domain}")
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_event, log_user_event},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_event, log_user_event},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{
|
|
||||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
|
||||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
|
||||||
},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{
|
||||||
|
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
|
||||||
|
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod authenticator;
|
pub mod authenticator;
|
||||||
@@ -37,7 +36,7 @@ fn has_global_duo_credentials() -> bool {
|
|||||||
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool {
|
pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DuoProviderData {
|
struct DuoProviderData {
|
||||||
host: String,
|
host: String,
|
||||||
@@ -46,7 +45,7 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
|||||||
}
|
}
|
||||||
|
|
||||||
match provider_type {
|
match provider_type {
|
||||||
TwoFactorType::Authenticator => true,
|
TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true,
|
||||||
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||||
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||||
provider_data
|
provider_data
|
||||||
@@ -59,7 +58,6 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
|||||||
}
|
}
|
||||||
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||||
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||||
TwoFactorType::RecoveryCode => true,
|
|
||||||
TwoFactorType::U2f
|
TwoFactorType::U2f
|
||||||
| TwoFactorType::U2fRegisterChallenge
|
| TwoFactorType::U2fRegisterChallenge
|
||||||
| TwoFactorType::U2fLoginChallenge
|
| TwoFactorType::U2fLoginChallenge
|
||||||
@@ -96,7 +94,7 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tf| {
|
.filter_map(|tf| {
|
||||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||||
if user.totp_recover.is_none() {
|
if user.totp_recover.is_none() {
|
||||||
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||||
user.totp_recover = Some(totp_recover);
|
user.totp_recover = Some(totp_recover);
|
||||||
@@ -153,6 +151,7 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
|
|||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
twofactor.delete(&conn).await?;
|
twofactor.delete(&conn).await?;
|
||||||
|
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
|
||||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -180,9 +179,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
ip: &std::net::IpAddr,
|
ip: &std::net::IpAddr,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
for member in
|
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
|
||||||
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
|
|
||||||
{
|
|
||||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||||
if member.atype < MembershipType::Admin {
|
if member.atype < MembershipType::Admin {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -194,7 +191,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevoked as i32,
|
EventType::OrganizationUserRevoked,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&member.org_uuid,
|
&member.org_uuid,
|
||||||
act_user_id,
|
act_user_id,
|
||||||
@@ -217,7 +214,7 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
||||||
for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() {
|
for member in Membership::find_confirmed_by_org(org_id, conn).await {
|
||||||
// Don't enforce the policy for Admins and Owners.
|
// Don't enforce the policy for Admins and Owners.
|
||||||
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -228,16 +225,8 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
member.revoke();
|
member.revoke();
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
||||||
EventType::OrganizationUserRevoked as i32,
|
.await;
|
||||||
&member.uuid,
|
|
||||||
org_id,
|
|
||||||
act_user_id,
|
|
||||||
device_type,
|
|
||||||
ip,
|
|
||||||
conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +240,9 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = match pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
Ok(conn) => conn,
|
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||||
_ => {
|
return;
|
||||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
@@ -278,7 +264,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
if let Err(e) = login.delete(&conn).await {
|
if let Err(e) = login.delete(&conn).await {
|
||||||
error!("Error deleting incomplete 2FA record: {e:#?}");
|
error!("Error deleting incomplete 2FA record: {e:#?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds};
|
||||||
use rocket::{serde::json::Json, Route};
|
use rocket::{Route, serde::json::Json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -44,9 +45,10 @@ impl ProtectedActionData {
|
|||||||
|
|
||||||
pub fn from_json(string: &str) -> Result<Self, Error> {
|
pub fn from_json(string: &str) -> Result<Self, Error> {
|
||||||
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
||||||
match res {
|
if let Ok(x) = res {
|
||||||
Ok(x) => Ok(x),
|
Ok(x)
|
||||||
Err(_) => err!("Could not decode ProtectedActionData from string"),
|
} else {
|
||||||
|
err!("Could not decode ProtectedActionData from string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,9 @@ impl ProtectedActionData {
|
|||||||
#[post("/accounts/request-otp")]
|
#[post("/accounts/request-otp")]
|
||||||
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
err!(
|
||||||
|
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -102,7 +106,9 @@ struct ProtectedActionVerify {
|
|||||||
#[post("/accounts/verify-otp", data = "<data>")]
|
#[post("/accounts/verify-otp", data = "<data>")]
|
||||||
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
err!(
|
||||||
|
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -133,7 +139,7 @@ pub async fn validate_protected_action_otp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token has expired (Using the email 2fa expiration time)
|
// Check if the token has expired (Using the email 2fa expiration time)
|
||||||
let max_time = CONFIG.email_expiration_time() as i64;
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||||
pa.delete(conn).await?;
|
pa.delete(conn).await?;
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
use crate::{
|
use std::{str::FromStr, sync::LazyLock, time::Duration};
|
||||||
api::{
|
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
use rocket::{Route, serde::json::Json};
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
|
||||||
},
|
|
||||||
auth::Headers,
|
|
||||||
crypto::ct_eq,
|
|
||||||
db::{
|
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
error::Error,
|
|
||||||
util::NumberOrString,
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
|
use webauthn_rs::{
|
||||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
Webauthn, WebauthnBuilder,
|
||||||
|
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
|
||||||
|
};
|
||||||
use webauthn_rs_proto::{
|
use webauthn_rs_proto::{
|
||||||
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
||||||
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
||||||
RequestAuthenticationExtensions, UserVerificationPolicy,
|
RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{
|
||||||
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
|
},
|
||||||
|
auth::Headers,
|
||||||
|
crypto::ct_eq,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
util::NumberOrString,
|
||||||
|
};
|
||||||
|
|
||||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||||
let domain = CONFIG.domain();
|
let domain = CONFIG.domain();
|
||||||
let domain_origin = CONFIG.domain_origin();
|
let domain_origin = CONFIG.domain_origin();
|
||||||
@@ -149,7 +150,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
state["rs"]["policy"] = Value::String("discouraged".to_string());
|
state["rs"]["policy"] = Value::String("discouraged".to_owned());
|
||||||
state["rs"]["extensions"].as_object_mut().unwrap().clear();
|
state["rs"]["extensions"].as_object_mut().unwrap().clear();
|
||||||
|
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
||||||
@@ -265,13 +266,12 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
|
|
||||||
// Retrieve and delete the saved challenge state
|
// Retrieve and delete the saved challenge state
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
||||||
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
Some(tf) => {
|
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
tf.delete(&conn).await?;
|
||||||
tf.delete(&conn).await?;
|
state
|
||||||
state
|
} else {
|
||||||
}
|
err!("Can't recover challenge")
|
||||||
None => err!("Can't recover challenge"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify the credentials with the saved state
|
// Verify the credentials with the saved state
|
||||||
@@ -291,7 +291,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||||
.save(&conn)
|
.save(&conn)
|
||||||
.await?;
|
.await?;
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -342,9 +342,10 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo
|
|||||||
// If entry is migrated from u2f, delete the u2f entry as well
|
// If entry is migrated from u2f, delete the u2f entry as well
|
||||||
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
||||||
{
|
{
|
||||||
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
|
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
|
||||||
Ok(d) => d,
|
d
|
||||||
Err(_) => err!("Error parsing U2F data"),
|
} else {
|
||||||
|
err!("Error parsing U2F data")
|
||||||
};
|
};
|
||||||
|
|
||||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||||
@@ -388,10 +389,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
|||||||
|
|
||||||
// Modify to discourage user verification
|
// Modify to discourage user verification
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
state["ast"]["policy"] = Value::String("discouraged".to_string());
|
state["ast"]["policy"] = Value::String("discouraged".to_owned());
|
||||||
|
|
||||||
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
||||||
let app_id = format!("{}/app-id.json", &CONFIG.domain());
|
let app_id = format!("{}/app-id.json", CONFIG.domain());
|
||||||
state["ast"]["appid"] = Value::String(app_id.clone());
|
state["ast"]["appid"] = Value::String(app_id.clone());
|
||||||
|
|
||||||
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||||
@@ -416,18 +417,17 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
|||||||
|
|
||||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||||
let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||||
Some(tf) => {
|
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
tf.delete(conn).await?;
|
||||||
tf.delete(conn).await?;
|
state
|
||||||
state
|
} else {
|
||||||
}
|
err!(
|
||||||
None => err!(
|
|
||||||
"Can't recover login challenge",
|
"Can't recover login challenge",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn2fa
|
event: EventType::UserFailedLogIn2fa
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yubico::{config::Config, verify_async};
|
use yubico_ng::{
|
||||||
|
Verifier, YubicoError,
|
||||||
|
config::Config,
|
||||||
|
transport::{AsyncTransport, Response},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
CONFIG,
|
http_client,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct HttpClientTransport {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientTransport {
|
||||||
|
fn new() -> Result<Self, reqwest::Error> {
|
||||||
|
http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(
|
||||||
|
|client| Self {
|
||||||
|
client,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncTransport for HttpClientTransport {
|
||||||
|
type Error = YubicoError;
|
||||||
|
|
||||||
|
async fn yubico_get(&self, url: &str) -> Result<Response, Self::Error> {
|
||||||
|
let response = self.client.get(url).send().await.map_err(YubicoError::transport)?;
|
||||||
|
Ok(Response {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
body: response.text().await.map_err(YubicoError::transport)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EnableYubikeyData {
|
struct EnableYubikeyData {
|
||||||
@@ -45,8 +75,7 @@ pub struct YubikeyMetadata {
|
|||||||
|
|
||||||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||||
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
||||||
|
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
|
||||||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||||
@@ -64,22 +93,25 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
|
|||||||
err!("Yubico support is disabled");
|
err!("Yubico support is disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||||
(Some(id), Some(secret)) => Ok((id, secret)),
|
Ok((id, secret))
|
||||||
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
|
} else {
|
||||||
|
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
||||||
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
||||||
|
|
||||||
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
|
let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
|
||||||
|
if let Some(yubico_server) = CONFIG.yubico_server() {
|
||||||
match CONFIG.yubico_server() {
|
config = config.set_api_host(yubico_server);
|
||||||
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
|
|
||||||
None => verify_async(otp, config).await,
|
|
||||||
}
|
}
|
||||||
.map_res("Failed to verify OTP")
|
|
||||||
|
let client = HttpClientTransport::new()?;
|
||||||
|
let verifier = Verifier::with_client(config, client)?;
|
||||||
|
|
||||||
|
verifier.verify(otp).await.map_res("Failed to verify OTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-yubikey", data = "<data>")]
|
#[post("/two-factor/get-yubikey", data = "<data>")]
|
||||||
@@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
let yubikeys = parse_yubikeys(&data);
|
let yubikeys = parse_yubikeys(&data);
|
||||||
|
|
||||||
if yubikeys.is_empty() {
|
if yubikeys.is_empty() {
|
||||||
return Ok(Json(json!({
|
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
|
||||||
"enabled": false,
|
// To remove all keys users should click the `Deactivate all keys` button
|
||||||
"object": "twoFactorU2f",
|
err!("A key is required.");
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure they are valid OTPs
|
// Ensure they are valid OTPs
|
||||||
@@ -162,7 +193,7 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
||||||
yubikey_data.save(&conn).await?;
|
yubikey_data.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
|
|||||||
+54
-39
@@ -6,28 +6,29 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use bytes::{Bytes, BytesMut};
|
use bytes::{Bytes, BytesMut};
|
||||||
use futures::{stream::StreamExt, TryFutureExt};
|
use futures::{TryFutureExt, stream::StreamExt};
|
||||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{self, HeaderMap, HeaderValue},
|
|
||||||
Client, Response,
|
Client, Response,
|
||||||
|
header::{self, HeaderMap, HeaderValue},
|
||||||
};
|
};
|
||||||
use rocket::{http::ContentType, response::Redirect, Route};
|
use rocket::{Route, http::ContentType, response::Redirect};
|
||||||
use svg_hush::{data_url_filter, Filter};
|
use svg_hush::{Filter, data_url_filter};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
config::PathType,
|
config::PathType,
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::{get_reqwest_client_builder, get_valid_host, should_block_host, CustomHttpClientError},
|
http_client::{CustomHttpClientError, get_reqwest_client_builder, get_valid_host, should_block_host},
|
||||||
util::Cached,
|
util::Cached,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
match CONFIG.icon_service().as_str() {
|
if CONFIG.icon_service().as_str() == "internal" {
|
||||||
"internal" => routes![icon_internal],
|
routes![icon_internal]
|
||||||
_ => routes![icon_external],
|
} else {
|
||||||
|
routes![icon_external]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
|||||||
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
let pool_idle_timeout = Duration::from_secs(10);
|
||||||
// Reuse the client between requests
|
// Reuse the client between requests
|
||||||
get_reqwest_client_builder()
|
get_reqwest_client_builder(true)
|
||||||
.cookie_provider(Arc::clone(&cookie_store))
|
.cookie_provider(Arc::clone(&cookie_store))
|
||||||
.timeout(icon_download_timeout)
|
.timeout(icon_download_timeout)
|
||||||
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
||||||
@@ -147,7 +148,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
|
|
||||||
if let Some(icon) = get_cached_icon(&path).await {
|
if let Some(icon) = get_cached_icon(&path).await {
|
||||||
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
||||||
return Some((icon, icon_type.to_string()));
|
return Some((icon, icon_type.to_owned()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.disable_icon_download() {
|
if CONFIG.disable_icon_download() {
|
||||||
@@ -158,7 +159,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
match download_icon(domain).await {
|
match download_icon(domain).await {
|
||||||
Ok((icon, icon_type)) => {
|
Ok((icon, icon_type)) => {
|
||||||
save_icon(&path, icon.to_vec()).await;
|
save_icon(&path, icon.to_vec()).await;
|
||||||
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string()))
|
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_owned()))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// If this error comes from the custom resolver, this means this is a blocked domain
|
// If this error comes from the custom resolver, this means this is a blocked domain
|
||||||
@@ -183,10 +184,10 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to read the cached icon, and return it if it exists
|
// Try to read the cached icon, and return it if it exists
|
||||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)
|
||||||
if let Ok(buf) = operator.read(path).await {
|
&& let Ok(buf) = operator.read(path).await
|
||||||
return Some(buf.to_vec());
|
{
|
||||||
}
|
return Some(buf.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -280,17 +281,17 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
}
|
}
|
||||||
|
|
||||||
for icon_tag in icon_tags {
|
for icon_tag in icon_tags {
|
||||||
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) {
|
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF)
|
||||||
if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) {
|
&& let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default())
|
||||||
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
{
|
||||||
std::str::from_utf8(v).unwrap_or_default()
|
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||||
} else {
|
std::str::from_utf8(v).unwrap_or_default()
|
||||||
""
|
} else {
|
||||||
};
|
""
|
||||||
let priority = get_icon_priority(full_href.as_str(), sizes);
|
};
|
||||||
icons.push(Icon::new(priority, full_href.to_string()));
|
let priority = get_icon_priority(full_href.as_str(), sizes);
|
||||||
}
|
icons.push(Icon::new(priority, full_href.to_string()));
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,9 +405,25 @@ async fn get_page(url: &str) -> Result<Response, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
||||||
|
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
|
||||||
|
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
|
||||||
|
let Ok(parsed_url) = url::Url::parse(url) else {
|
||||||
|
err_silent!("Invalid URL", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
err_silent!("Invalid scheme", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(host) = parsed_url.host() else {
|
||||||
|
err_silent!("Invalid host", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
should_block_host(&host)?;
|
||||||
|
|
||||||
let mut client = CLIENT.get(url);
|
let mut client = CLIENT.get(url);
|
||||||
if !referer.is_empty() {
|
if !referer.is_empty() {
|
||||||
client = client.header("Referer", referer)
|
client = client.header("Referer", referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(client.send().await?.error_for_status()?)
|
Ok(client.send().await?.error_for_status()?)
|
||||||
@@ -494,12 +511,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
let mut buffer = Bytes::new();
|
let mut buffer = Bytes::new();
|
||||||
let mut icon_type: Option<&str> = None;
|
let mut icon_type: Option<&str> = None;
|
||||||
|
|
||||||
use data_url::DataUrl;
|
|
||||||
|
|
||||||
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
||||||
while let Some(icon) = icons.next() {
|
while let Some(icon) = icons.next() {
|
||||||
if icon.href.starts_with("data:image") {
|
if icon.href.starts_with("data:image") {
|
||||||
let Ok(datauri) = DataUrl::process(&icon.href) else {
|
let Ok(datauri) = data_url::DataUrl::process(&icon.href) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Check if we are able to decode the data uri
|
// Check if we are able to decode the data uri
|
||||||
@@ -523,7 +538,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => debug!("Extracted icon from data:image uri is invalid"),
|
_ => debug!("Extracted icon from data:image uri is invalid"),
|
||||||
};
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("Trying {}", icon.href);
|
debug!("Trying {}", icon.href);
|
||||||
// Make sure all icons are checked before returning error
|
// Make sure all icons are checked before returning error
|
||||||
@@ -587,10 +602,10 @@ async fn save_icon(path: &str, icon: Vec<u8>) {
|
|||||||
fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
|
fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
|
||||||
fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
||||||
// Look for SVG tag within the first 1KB
|
// Look for SVG tag within the first 1KB
|
||||||
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) {
|
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)])
|
||||||
if content.contains("<svg") || content.contains("<SVG") {
|
&& (content.contains("<svg") || content.contains("<SVG"))
|
||||||
return Some("svg+xml");
|
{
|
||||||
}
|
return Some("svg+xml");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -733,7 +748,7 @@ impl FaviconEmitter {
|
|||||||
let rel_value =
|
let rel_value =
|
||||||
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
||||||
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
||||||
self.emit_token = true
|
self.emit_token = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
@@ -806,13 +821,13 @@ impl Emitter for FaviconEmitter {
|
|||||||
|
|
||||||
fn push_attribute_name(&mut self, s: &[u8]) {
|
fn push_attribute_name(&mut self, s: &[u8]) {
|
||||||
if let Some(attr) = &mut self.current_attribute {
|
if let Some(attr) = &mut self.current_attribute {
|
||||||
attr.0.extend(s)
|
attr.0.extend(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_attribute_value(&mut self, s: &[u8]) {
|
fn push_attribute_value(&mut self, s: &[u8]) {
|
||||||
if let Some(attr) = &mut self.current_attribute {
|
if let Some(attr) = &mut self.current_attribute {
|
||||||
attr.1.extend(s)
|
attr.1.extend(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+227
-164
@@ -1,18 +1,20 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Route,
|
||||||
form::{Form, FromForm},
|
form::{Form, FromForm},
|
||||||
http::{Cookie, CookieJar, SameSite},
|
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Route,
|
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
|
ApiResult, EmptyResult, JsonResult,
|
||||||
core::{
|
core::{
|
||||||
accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
|
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
|
||||||
log_user_event,
|
log_user_event,
|
||||||
two_factor::{
|
two_factor::{
|
||||||
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
||||||
@@ -21,28 +23,28 @@ use crate::{
|
|||||||
},
|
},
|
||||||
master_password_policy,
|
master_password_policy,
|
||||||
push::register_push_device,
|
push::register_push_device,
|
||||||
ApiResult, EmptyResult, JsonResult,
|
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure},
|
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{
|
|
||||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
|
|
||||||
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
|
||||||
},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{
|
||||||
|
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
||||||
|
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||||
|
TwoFactorType, User, UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
mail, sso,
|
mail, sso,
|
||||||
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
||||||
util, CONFIG,
|
util,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![
|
routes![
|
||||||
login,
|
login,
|
||||||
prelogin,
|
post_prelogin,
|
||||||
prelogin_password,
|
prelogin_password,
|
||||||
identity_register,
|
identity_register,
|
||||||
register_verification_email,
|
register_verification_email,
|
||||||
@@ -67,45 +69,59 @@ async fn login(
|
|||||||
|
|
||||||
let login_result = match data.grant_type.as_ref() {
|
let login_result = match data.grant_type.as_ref() {
|
||||||
"refresh_token" => {
|
"refresh_token" => {
|
||||||
_check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
|
check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
|
||||||
_refresh_login(data, &conn, &client_header.ip).await
|
refresh_login(data, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||||
"password" => {
|
"password" => {
|
||||||
_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.password.as_ref(), "password cannot be blank")?;
|
check_is_some(data.password.as_ref(), "password 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")?;
|
||||||
_check_is_some(data.username.as_ref(), "username cannot be blank")?;
|
check_is_some(data.username.as_ref(), "username cannot be blank")?;
|
||||||
|
|
||||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||||
_check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
_check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
_password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).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")?;
|
||||||
|
|
||||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||||
_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")?;
|
||||||
|
|
||||||
_api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"authorization_code" if CONFIG.sso_enabled() => {
|
"authorization_code" if CONFIG.sso_enabled() => {
|
||||||
_check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
_check_is_some(data.code.as_ref(), "code cannot be blank")?;
|
check_is_some(data.code.as_ref(), "code cannot be blank")?;
|
||||||
_check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?;
|
check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?;
|
||||||
|
|
||||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||||
_check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
_check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
_sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
||||||
}
|
}
|
||||||
"authorization_code" => err!("SSO sign-in is not available"),
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
|
"send_access" => {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
|
||||||
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
|
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
|
||||||
|
|
||||||
|
let tokens = auth::SendTokens::generate_tokens(
|
||||||
|
data.send_id.as_ref().unwrap(),
|
||||||
|
data.password_hash_b64,
|
||||||
|
&client_header.ip,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(tokens.to_json()))
|
||||||
|
}
|
||||||
t => err!("Invalid type", t),
|
t => err!("Invalid type", t),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,7 +140,7 @@ async fn login(
|
|||||||
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 as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
|
||||||
.await
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +149,7 @@ async fn login(
|
|||||||
login_result
|
login_result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
|
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
|
||||||
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
|
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
|
||||||
// See the link below for details
|
// See the link below for details
|
||||||
@@ -174,7 +190,7 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||||
async fn _sso_login(
|
async fn sso_login(
|
||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -186,7 +202,7 @@ async fn _sso_login(
|
|||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
|
let (code, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
|
||||||
(None, _) => err!(
|
(None, _) => err!(
|
||||||
"Got no code in OIDC data",
|
"Got no code in OIDC data",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
@@ -202,7 +218,7 @@ async fn _sso_login(
|
|||||||
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
|
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?;
|
let (sso_auth, user_infos) = sso::exchange_code(code, code_verifier, conn).await?;
|
||||||
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
|
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
|
||||||
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
|
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
|
||||||
None => None,
|
None => None,
|
||||||
@@ -218,6 +234,24 @@ async fn _sso_login(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Some((user, None))
|
||||||
|
if user.private_key.is_none()
|
||||||
|
&& !CONFIG.sso_signups_allowed()
|
||||||
|
&& !CONFIG.is_email_domain_allowed(&user.email)
|
||||||
|
&& !CONFIG.mail_enabled()
|
||||||
|
&& Invitation::find_by_mail(&user.email, conn).await.is_none() =>
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"Login failure ({}), no invitation with email ({}) was found",
|
||||||
|
user_infos.identifier, user.email
|
||||||
|
);
|
||||||
|
err_silent!(
|
||||||
|
"Missing invitation",
|
||||||
|
ErrorEvent {
|
||||||
|
event: EventType::UserFailedLogIn
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
||||||
error!(
|
error!(
|
||||||
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
||||||
@@ -265,7 +299,15 @@ async fn _sso_login(
|
|||||||
// Will trigger 2FA flow if needed
|
// Will trigger 2FA flow if needed
|
||||||
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
|
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
|
||||||
None => {
|
None => {
|
||||||
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
|
if !CONFIG.is_sso_signup_allowed(&user_infos.email) {
|
||||||
|
if CONFIG.signups_domains_whitelist().is_empty() {
|
||||||
|
err!(
|
||||||
|
"Signups are disabled. You will need an invitation",
|
||||||
|
ErrorEvent {
|
||||||
|
event: EventType::UserFailedLogIn
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
err!(
|
err!(
|
||||||
"Email domain not allowed",
|
"Email domain not allowed",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
@@ -302,7 +344,7 @@ async fn _sso_login(
|
|||||||
Some((user, _)) if !user.enabled => {
|
Some((user, _)) if !user.enabled => {
|
||||||
err!(
|
err!(
|
||||||
"This user has been disabled",
|
"This user has been disabled",
|
||||||
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
|
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn
|
event: EventType::UserFailedLogIn
|
||||||
}
|
}
|
||||||
@@ -343,7 +385,7 @@ async fn _sso_login(
|
|||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _password_login(
|
async fn password_login(
|
||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -427,9 +469,9 @@ async fn _password_login(
|
|||||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||||
if user.last_verifying_at.is_none()
|
if user.last_verifying_at.is_none()
|
||||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||||
> CONFIG.signups_verify_resend_time() as i64
|
> CONFIG.signups_verify_resend_time().cast_signed()
|
||||||
{
|
{
|
||||||
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
|
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
||||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||||
// We want to send another email verification if we require signups to verify
|
// We want to send another email verification if we require signups to verify
|
||||||
// their email address, and we haven't sent them a reminder in a while...
|
// their email address, and we haven't sent them a reminder in a while...
|
||||||
@@ -561,23 +603,23 @@ async fn authenticated_response(
|
|||||||
result["TwoFactorToken"] = Value::String(token);
|
result["TwoFactorToken"] = Value::String(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip);
|
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
|
||||||
Ok(Json(result))
|
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, ip: &ClientIp) -> JsonResult {
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
// Validate scope
|
// Validate scope
|
||||||
match data.scope.as_ref() {
|
match data.scope.as_ref() {
|
||||||
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => user_api_key_login(data, user_id, conn, ip).await,
|
||||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||||
_ => err!("Scope not supported"),
|
_ => err!("Scope not supported"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _user_api_key_login(
|
async fn user_api_key_login(
|
||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -709,13 +751,13 @@ async fn _user_api_key_login(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
// Get the org via the client_id
|
// Get the org via the client_id
|
||||||
let client_id = data.client_id.as_ref().unwrap();
|
let client_id = data.client_id.as_ref().unwrap();
|
||||||
let Some(org_id) = client_id.strip_prefix("organization.") else {
|
let Some(org_id) = client_id.strip_prefix("organization.") else {
|
||||||
err!("Malformed client_id", format!("IP: {}.", ip.ip))
|
err!("Malformed client_id", format!("IP: {}.", ip.ip))
|
||||||
};
|
};
|
||||||
let org_id: OrganizationId = org_id.to_string().into();
|
let org_id: OrganizationId = org_id.to_owned().into();
|
||||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else {
|
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else {
|
||||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||||
};
|
};
|
||||||
@@ -746,14 +788,13 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult
|
|||||||
let device_name = data.device_name.clone().expect("No device name provided");
|
let device_name = data.device_name.clone().expect("No device name provided");
|
||||||
|
|
||||||
// Find device or create new
|
// Find device or create new
|
||||||
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
if let Some(device) = Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||||
Some(device) => Ok(device),
|
Ok(device)
|
||||||
None => {
|
} else {
|
||||||
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
||||||
// save device without updating `device.updated_at`
|
// save device without updating `device.updated_at`
|
||||||
device.save(false, conn).await?;
|
device.save(false, conn).await?;
|
||||||
Ok(device)
|
Ok(device)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,7 +820,7 @@ async fn twofactor_auth(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tf| {
|
.filter_map(|tf| {
|
||||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
|
(tf.enabled && is_twofactor_provider_usable(&provider_type, Some(&tf.data))).then_some(tf.atype)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if twofactor_ids.is_empty() {
|
if twofactor_ids.is_empty() {
|
||||||
@@ -787,59 +828,51 @@ async fn twofactor_auth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
||||||
// Ignore Remember and RecoveryCode Types during this check, these are special
|
// Ignore Remember and RecoveryCode Types during this check, these are special
|
||||||
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
|
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
|
||||||
&& !twofactor_ids.contains(&selected_id)
|
&& !twofactor_ids.contains(&selected_id)
|
||||||
{
|
{
|
||||||
err_json!(
|
err_json!(
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
"Invalid two factor provider"
|
"Invalid two factor provider"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let twofactor_code = match data.two_factor_token {
|
let Some(ref twofactor_code) = data.two_factor_token else {
|
||||||
Some(ref code) => code,
|
err_json!(
|
||||||
None => {
|
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
err_json!(
|
"2FA token not provided"
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
)
|
||||||
"2FA token not provided"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
||||||
|
|
||||||
use crate::crypto::ct_eq;
|
let selected_data = selected_data(selected_twofactor);
|
||||||
|
|
||||||
let selected_data = _selected_data(selected_twofactor);
|
|
||||||
|
|
||||||
match TwoFactorType::from_i32(selected_id) {
|
match TwoFactorType::from_i32(selected_id) {
|
||||||
Some(TwoFactorType::Authenticator) => {
|
Some(TwoFactorType::Authenticator) => {
|
||||||
authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?
|
authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?;
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
||||||
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
||||||
Some(TwoFactorType::Duo) => {
|
Some(TwoFactorType::Duo) => {
|
||||||
match CONFIG.duo_use_iframe() {
|
if CONFIG.duo_use_iframe() {
|
||||||
true => {
|
// Legacy iframe prompt flow
|
||||||
// Legacy iframe prompt flow
|
duo::validate_duo_login(&user.email, twofactor_code, conn).await?;
|
||||||
duo::validate_duo_login(&user.email, twofactor_code, conn).await?
|
} else {
|
||||||
}
|
// OIDC based flow
|
||||||
false => {
|
duo_oidc::validate_duo_login(
|
||||||
// OIDC based flow
|
&user.email,
|
||||||
duo_oidc::validate_duo_login(
|
twofactor_code,
|
||||||
&user.email,
|
data.client_id.as_ref().unwrap(),
|
||||||
twofactor_code,
|
data.device_identifier.as_ref().unwrap(),
|
||||||
data.client_id.as_ref().unwrap(),
|
conn,
|
||||||
data.device_identifier.as_ref().unwrap(),
|
)
|
||||||
conn,
|
.await?;
|
||||||
)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Email) => {
|
Some(TwoFactorType::Email) => {
|
||||||
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?
|
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Remember) => {
|
Some(TwoFactorType::Remember) => {
|
||||||
match device.twofactor_remember {
|
match device.twofactor_remember {
|
||||||
@@ -847,7 +880,7 @@ async fn twofactor_auth(
|
|||||||
// If it is invalid we need to trigger the 2FA Login prompt
|
// If it is invalid we need to trigger the 2FA Login prompt
|
||||||
Some(ref token)
|
Some(ref token)
|
||||||
if !CONFIG.disable_2fa_remember()
|
if !CONFIG.disable_2fa_remember()
|
||||||
&& (ct_eq(token, twofactor_code)
|
&& (crypto::ct_eq(token, twofactor_code)
|
||||||
&& auth::decode_2fa_remember(twofactor_code)
|
&& auth::decode_2fa_remember(twofactor_code)
|
||||||
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
|
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -858,7 +891,7 @@ async fn twofactor_auth(
|
|||||||
device.save(true, conn).await?;
|
device.save(true, conn).await?;
|
||||||
}
|
}
|
||||||
err_json!(
|
err_json!(
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
"2FA Remember token not provided or expired"
|
"2FA Remember token not provided or expired"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -872,6 +905,12 @@ async fn twofactor_auth(
|
|||||||
|
|
||||||
// Remove all twofactors from the user
|
// Remove all twofactors from the user
|
||||||
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
|
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
|
||||||
|
|
||||||
|
// No device may keep skipping 2FA once every second factor is gone.
|
||||||
|
// `device` is cleared in memory too, since saving it later would restore its token.
|
||||||
|
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
|
||||||
|
device.delete_twofactor_remember();
|
||||||
|
|
||||||
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
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 as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||||
@@ -899,11 +938,11 @@ async fn twofactor_auth(
|
|||||||
Ok(two_factor)
|
Ok(two_factor)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
fn selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
||||||
tf.map(|t| t.data).map_res("Two factor doesn't exist")
|
tf.map(|t| t.data).map_res("Two factor doesn't exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _json_err_twofactor(
|
async fn json_err_twofactor(
|
||||||
providers: &[i32],
|
providers: &[i32],
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
data: &ConnectData,
|
data: &ConnectData,
|
||||||
@@ -924,42 +963,38 @@ async fn _json_err_twofactor(
|
|||||||
result["TwoFactorProviders2"][provider.to_string()] = Value::Null;
|
result["TwoFactorProviders2"][provider.to_string()] = Value::Null;
|
||||||
|
|
||||||
match TwoFactorType::from_i32(*provider) {
|
match TwoFactorType::from_i32(*provider) {
|
||||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
|
||||||
|
|
||||||
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
||||||
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(TwoFactorType::Duo) => {
|
Some(TwoFactorType::Duo) => {
|
||||||
let email = match User::find_by_uuid(user_id, conn).await {
|
let email = if let Some(u) = User::find_by_uuid(user_id, conn).await {
|
||||||
Some(u) => u.email,
|
u.email
|
||||||
None => err!("User does not exist"),
|
} else {
|
||||||
|
err!("User does not exist")
|
||||||
};
|
};
|
||||||
|
|
||||||
match CONFIG.duo_use_iframe() {
|
if CONFIG.duo_use_iframe() {
|
||||||
true => {
|
// Legacy iframe prompt flow
|
||||||
// Legacy iframe prompt flow
|
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
||||||
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
"Host": host,
|
||||||
"Host": host,
|
"Signature": signature,
|
||||||
"Signature": signature,
|
});
|
||||||
})
|
} else {
|
||||||
}
|
// OIDC based flow
|
||||||
false => {
|
let auth_url = duo_oidc::get_duo_auth_url(
|
||||||
// OIDC based flow
|
&email,
|
||||||
let auth_url = duo_oidc::get_duo_auth_url(
|
data.client_id.as_ref().unwrap(),
|
||||||
&email,
|
data.device_identifier.as_ref().unwrap(),
|
||||||
data.client_id.as_ref().unwrap(),
|
conn,
|
||||||
data.device_identifier.as_ref().unwrap(),
|
)
|
||||||
conn,
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"AuthUrl": auth_url,
|
"AuthUrl": auth_url,
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +1007,7 @@ async fn _json_err_twofactor(
|
|||||||
|
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"Nfc": yubikey_metadata.nfc,
|
"Nfc": yubikey_metadata.nfc,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(tf_type @ TwoFactorType::Email) => {
|
Some(tf_type @ TwoFactorType::Email) => {
|
||||||
@@ -990,16 +1025,30 @@ async fn _json_err_twofactor(
|
|||||||
|
|
||||||
// Send email immediately if email is the only 2FA option.
|
// Send email immediately if email is the only 2FA option.
|
||||||
if providers.len() == 1 && !disabled_send {
|
if providers.len() == 1 && !disabled_send {
|
||||||
email::send_token(user_id, conn).await?
|
email::send_token(user_id, conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
|
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"Email": email::obscure_email(&email_data.email),
|
"Email": email::obscure_email(&email_data.email),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {}
|
None
|
||||||
|
| Some(
|
||||||
|
TwoFactorType::Authenticator
|
||||||
|
| TwoFactorType::EmailVerificationChallenge
|
||||||
|
| TwoFactorType::OrganizationDuo
|
||||||
|
| TwoFactorType::ProtectedActions
|
||||||
|
| TwoFactorType::RecoveryCode
|
||||||
|
| TwoFactorType::Remember
|
||||||
|
| TwoFactorType::U2f
|
||||||
|
| TwoFactorType::U2fLoginChallenge
|
||||||
|
| TwoFactorType::U2fRegisterChallenge
|
||||||
|
| TwoFactorType::Webauthn
|
||||||
|
| TwoFactorType::WebauthnLoginChallenge
|
||||||
|
| TwoFactorType::WebauthnRegisterChallenge,
|
||||||
|
) => { /* Nothing special to do for these providers */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,18 +1056,18 @@ async fn _json_err_twofactor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin", data = "<data>")]
|
#[post("/accounts/prelogin", data = "<data>")]
|
||||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
_prelogin(data, conn).await
|
prelogin(data, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin/password", data = "<data>")]
|
#[post("/accounts/prelogin/password", data = "<data>")]
|
||||||
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
_prelogin(data, conn).await
|
prelogin(data, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register", data = "<data>")]
|
#[post("/accounts/register", data = "<data>")]
|
||||||
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||||
_register(data, false, conn).await
|
register(data, false, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1034,13 +1083,23 @@ enum RegisterVerificationResponse {
|
|||||||
#[response(status = 204)]
|
#[response(status = 204)]
|
||||||
NoContent(()),
|
NoContent(()),
|
||||||
Token(Json<String>),
|
Token(Json<String>),
|
||||||
|
PlainToken(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return JSON only when the client explicitly requests it, otherwise return plain text.
|
||||||
|
fn accepts_json(accept: Option<&Accept>) -> bool {
|
||||||
|
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
||||||
async fn register_verification_email(
|
async fn register_verification_email(
|
||||||
data: Json<RegisterVerificationData>,
|
data: Json<RegisterVerificationData>,
|
||||||
|
accept: Option<&Accept>,
|
||||||
|
ip: ClientIp,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> ApiResult<RegisterVerificationResponse> {
|
) -> ApiResult<RegisterVerificationResponse> {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
// the registration can only continue if signup is allowed or there exists an invitation
|
// the registration can only continue if signup is allowed or there exists an invitation
|
||||||
@@ -1057,13 +1116,13 @@ async fn register_verification_email(
|
|||||||
|
|
||||||
if should_send_mail {
|
if should_send_mail {
|
||||||
let user = User::find_by_mail(&data.email, &conn).await;
|
let user = User::find_by_mail(&data.email, &conn).await;
|
||||||
if user.filter(|u| u.private_key.is_some()).is_some() {
|
if user.as_ref().is_some_and(|u| u.private_key.is_some()) {
|
||||||
// There is still a timing side channel here in that the code
|
// There is still a timing side channel here in that the code
|
||||||
// paths that send mail take noticeably longer than ones that don't.
|
// paths that send mail take noticeably longer than ones that don't.
|
||||||
// Add a randomized sleep to mitigate this somewhat.
|
// Add a randomized sleep to mitigate this somewhat.
|
||||||
use rand::{rngs::SmallRng, RngExt};
|
use rand::{RngExt, rngs::SmallRng};
|
||||||
let mut rng: SmallRng = rand::make_rng();
|
let mut rng: SmallRng = rand::make_rng();
|
||||||
let sleep_ms = rng.random_range(900..=1100) as u64;
|
let sleep_ms: u64 = rng.random_range(900..=1100);
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||||
} else {
|
} else {
|
||||||
mail::send_register_verify_email(&data.email, &token).await?;
|
mail::send_register_verify_email(&data.email, &token).await?;
|
||||||
@@ -1073,13 +1132,17 @@ async fn register_verification_email(
|
|||||||
} else {
|
} else {
|
||||||
// If email verification is not required, return the token directly
|
// If email verification is not required, return the token directly
|
||||||
// the clients will use this token to finish the registration
|
// the clients will use this token to finish the registration
|
||||||
Ok(RegisterVerificationResponse::Token(Json(token)))
|
Ok(if accepts_json(accept) {
|
||||||
|
RegisterVerificationResponse::Token(Json(token))
|
||||||
|
} else {
|
||||||
|
RegisterVerificationResponse::PlainToken(token)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register/finish", data = "<data>")]
|
#[post("/accounts/register/finish", data = "<data>")]
|
||||||
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||||
_register(data, true, conn).await
|
register(data, true, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
||||||
@@ -1138,11 +1201,15 @@ struct ConnectData {
|
|||||||
|
|
||||||
// Needed for authorization code
|
// Needed for authorization code
|
||||||
#[field(name = uncased("code"))]
|
#[field(name = uncased("code"))]
|
||||||
code: Option<OIDCState>,
|
code: Option<OIDCCode>,
|
||||||
#[field(name = uncased("code_verifier"))]
|
#[field(name = uncased("code_verifier"))]
|
||||||
code_verifier: Option<OIDCCodeVerifier>,
|
code_verifier: Option<OIDCCodeVerifier>,
|
||||||
|
|
||||||
|
// Needed for send access
|
||||||
|
send_id: Option<SendId>,
|
||||||
|
password_hash_b64: Option<String>,
|
||||||
}
|
}
|
||||||
fn _check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
|
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
|
||||||
if value.is_none() {
|
if value.is_none() {
|
||||||
err!(msg)
|
err!(msg)
|
||||||
}
|
}
|
||||||
@@ -1165,19 +1232,12 @@ const SSO_BINDING_COOKIE: &str = "VW_SSO_BINDING";
|
|||||||
|
|
||||||
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
||||||
async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<Redirect> {
|
async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<Redirect> {
|
||||||
_oidcsignin_redirect(
|
oidcsignin_redirect(state, code, None, cookies, &mut conn).await
|
||||||
state,
|
|
||||||
OIDCCodeWrapper::Ok {
|
|
||||||
code,
|
|
||||||
},
|
|
||||||
cookies,
|
|
||||||
&mut conn,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bitwarden client appear to only care for code and state so we pipe it through
|
// Bitwarden client appear to only care for code and state
|
||||||
// cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141
|
// We save the error in the database and set the encoded state as the code to be able to retrieve them later on
|
||||||
|
// cf: https://github.com/bitwarden/clients/blob/afd36d290ce18fb0048e0575e7d5a8f78b5dbffc/libs/auth/src/angular/sso/sso.component.ts#L156
|
||||||
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
|
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
|
||||||
async fn oidcsignin_error(
|
async fn oidcsignin_error(
|
||||||
state: String,
|
state: String,
|
||||||
@@ -1186,12 +1246,13 @@ async fn oidcsignin_error(
|
|||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
mut conn: DbConn,
|
mut conn: DbConn,
|
||||||
) -> ApiResult<Redirect> {
|
) -> ApiResult<Redirect> {
|
||||||
_oidcsignin_redirect(
|
oidcsignin_redirect(
|
||||||
state,
|
state.clone(),
|
||||||
OIDCCodeWrapper::Error {
|
state.into(),
|
||||||
|
Some(OIDCCodeResponseError {
|
||||||
error,
|
error,
|
||||||
error_description,
|
error_description,
|
||||||
},
|
}),
|
||||||
cookies,
|
cookies,
|
||||||
&mut conn,
|
&mut conn,
|
||||||
)
|
)
|
||||||
@@ -1201,30 +1262,32 @@ async fn oidcsignin_error(
|
|||||||
// The state was encoded using Base64 to ensure no issue with providers.
|
// The state was encoded using Base64 to ensure no issue with providers.
|
||||||
// iss and scope parameters are needed for redirection to work on IOS.
|
// iss and scope parameters are needed for redirection to work on IOS.
|
||||||
// We pass the state as the code to get it back later on.
|
// We pass the state as the code to get it back later on.
|
||||||
async fn _oidcsignin_redirect(
|
async fn oidcsignin_redirect(
|
||||||
base64_state: String,
|
base64_state: String,
|
||||||
code_response: OIDCCodeWrapper,
|
code: OIDCCode,
|
||||||
|
error: Option<OIDCCodeResponseError>,
|
||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
conn: &mut DbConn,
|
conn: &mut DbConn,
|
||||||
) -> ApiResult<Redirect> {
|
) -> ApiResult<Redirect> {
|
||||||
let state = sso::decode_state(&base64_state)?;
|
let state = sso::decode_state(&base64_state)?;
|
||||||
|
|
||||||
let mut sso_auth = match SsoAuth::find(&state, conn).await {
|
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else {
|
||||||
None => err!(format!("Cannot retrieve sso_auth for {state}")),
|
err!(format!("Cannot retrieve sso_auth for {state}"))
|
||||||
Some(sso_auth) => sso_auth,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Browser-binding check
|
// Browser-binding check
|
||||||
// The cookie was set on /connect/authorize and must come from the same browser that initiated the flow.
|
// The cookie was set on /connect/authorize and must come from the same browser that initiated the flow.
|
||||||
let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_string());
|
let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_owned());
|
||||||
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
|
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
|
||||||
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
|
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
|
||||||
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
|
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
|
||||||
_ => err!(format!("SSO session binding mismatch for {state}")),
|
_ => err!(format!("SSO session binding mismatch for {state}")),
|
||||||
}
|
}
|
||||||
cookies.remove(Cookie::build(SSO_BINDING_COOKIE).path("/identity/connect/").build());
|
cookies
|
||||||
|
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
|
||||||
|
|
||||||
sso_auth.code_response = Some(code_response);
|
sso_auth.code_response = Some(code.clone());
|
||||||
|
sso_auth.code_response_error = error;
|
||||||
sso_auth.updated_at = Utc::now().naive_utc();
|
sso_auth.updated_at = Utc::now().naive_utc();
|
||||||
sso_auth.save(conn).await?;
|
sso_auth.save(conn).await?;
|
||||||
|
|
||||||
@@ -1234,7 +1297,7 @@ async fn _oidcsignin_redirect(
|
|||||||
};
|
};
|
||||||
|
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("code", &state)
|
.append_pair("code", &code)
|
||||||
.append_pair("state", &state)
|
.append_pair("state", &state)
|
||||||
.append_pair("scope", &AuthMethod::Sso.scope())
|
.append_pair("scope", &AuthMethod::Sso.scope())
|
||||||
.append_pair("iss", &CONFIG.domain());
|
.append_pair("iss", &CONFIG.domain());
|
||||||
@@ -1294,7 +1357,7 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
|
|||||||
|
|
||||||
cookies.add(
|
cookies.add(
|
||||||
Cookie::build((SSO_BINDING_COOKIE, binding_token))
|
Cookie::build((SSO_BINDING_COOKIE, binding_token))
|
||||||
.path("/identity/connect/")
|
.path(format!("{}/identity/connect/", CONFIG.domain_path()))
|
||||||
.max_age(time::Duration::seconds(sso::SSO_AUTH_EXPIRATION.num_seconds()))
|
.max_age(time::Duration::seconds(sso::SSO_AUTH_EXPIRATION.num_seconds()))
|
||||||
.same_site(SameSite::Lax) // Lax is needed because the IdP runs on a different FQDN
|
.same_site(SameSite::Lax) // Lax is needed because the IdP runs on a different FQDN
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
|
|||||||
+8
-5
@@ -30,13 +30,15 @@ pub use crate::api::{
|
|||||||
},
|
},
|
||||||
web::catchers as web_catchers,
|
web::catchers as web_catchers,
|
||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::static_files,
|
web::{invalidate_css_cache, static_files},
|
||||||
};
|
};
|
||||||
use crate::db::{
|
use crate::{
|
||||||
models::{OrgPolicy, OrgPolicyType, User},
|
CONFIG,
|
||||||
DbConn,
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{OrgPolicy, OrgPolicyType, User},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use crate::CONFIG;
|
|
||||||
|
|
||||||
// Type aliases for API methods results
|
// Type aliases for API methods results
|
||||||
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||||
@@ -74,6 +76,7 @@ impl PasswordOrOtpData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")]
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MasterPasswordPolicy {
|
pub struct MasterPasswordPolicy {
|
||||||
|
|||||||
+71
-20
@@ -6,17 +6,22 @@ use std::{
|
|||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use rmpv::Value;
|
use rmpv::Value;
|
||||||
use rocket::{futures::StreamExt, Route};
|
use rocket::{Route, futures::StreamExt};
|
||||||
use rocket_ws::{Message, WebSocket};
|
use rocket_ws::{Message, WebSocket};
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG, Error,
|
||||||
auth::{ClientIp, WsAccessTokenHeader},
|
auth::{ClientIp, WsAccessTokenHeader},
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
||||||
},
|
},
|
||||||
Error, CONFIG,
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
||||||
|
push_send_update, push_user_update,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
||||||
@@ -28,13 +33,13 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
|||||||
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
||||||
Arc::new(AnonymousWebSocketSubscriptions {
|
Arc::new(AnonymousWebSocketSubscriptions {
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
|
connections: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
use super::{
|
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
|
||||||
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
||||||
push_send_update, push_user_update,
|
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
||||||
};
|
|
||||||
|
|
||||||
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());
|
||||||
|
|
||||||
@@ -82,14 +87,21 @@ impl Drop for WSEntryMapGuard {
|
|||||||
struct WSAnonymousEntryMapGuard {
|
struct WSAnonymousEntryMapGuard {
|
||||||
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
token: String,
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
addr: IpAddr,
|
addr: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WSAnonymousEntryMapGuard {
|
impl WSAnonymousEntryMapGuard {
|
||||||
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self {
|
fn new(
|
||||||
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
|
addr: IpAddr,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
subscriptions,
|
subscriptions,
|
||||||
token,
|
token,
|
||||||
|
entry_uuid,
|
||||||
addr,
|
addr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,11 +110,15 @@ impl WSAnonymousEntryMapGuard {
|
|||||||
impl Drop for WSAnonymousEntryMapGuard {
|
impl Drop for WSAnonymousEntryMapGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
info!("Closing WS connection from {}", self.addr);
|
info!("Closing WS connection from {}", self.addr);
|
||||||
self.subscriptions.map.remove(&self.token);
|
if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
|
||||||
|
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
|
||||||
|
}
|
||||||
|
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
|
||||||
|
self.subscriptions.release(self.addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(tail_expr_drop_order)]
|
#[expect(tail_expr_drop_order)]
|
||||||
#[get("/hub?<data..>")]
|
#[get("/hub?<data..>")]
|
||||||
fn websockets_hub<'r>(
|
fn websockets_hub<'r>(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -186,7 +202,7 @@ fn websockets_hub<'r>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(tail_expr_drop_order)]
|
#[expect(tail_expr_drop_order)]
|
||||||
#[get("/anonymous-hub?<token..>")]
|
#[get("/anonymous-hub?<token..>")]
|
||||||
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
||||||
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
||||||
@@ -194,12 +210,19 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
let (mut rx, guard) = {
|
let (mut rx, guard) = {
|
||||||
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
||||||
|
|
||||||
// Add a channel to send messages to this client to the map
|
if !subscriptions.try_reserve(ip.ip) {
|
||||||
|
err_code!("Too many connections", 429)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a channel to send messages to this client to the map.
|
||||||
|
// Clients reconnect with the same token while a login request is still pending, so keep
|
||||||
|
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
||||||
subscriptions.map.insert(token.clone(), tx);
|
let entry_uuid = uuid::Uuid::new_v4();
|
||||||
|
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
|
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -268,14 +291,15 @@ fn serialize(val: &Value) -> Vec<u8> {
|
|||||||
let mut len_buf: Vec<u8> = Vec::new();
|
let mut len_buf: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut size_part = size & 0x7f;
|
#[expect(clippy::cast_possible_truncation, reason = "masked to 7 bits, fits u8")]
|
||||||
|
let mut size_part = (size & 0x7f) as u8;
|
||||||
size >>= 7;
|
size >>= 7;
|
||||||
|
|
||||||
if size > 0 {
|
if size > 0 {
|
||||||
size_part |= 0x80;
|
size_part |= 0x80;
|
||||||
}
|
}
|
||||||
|
|
||||||
len_buf.push(size_part as u8);
|
len_buf.push(size_part);
|
||||||
|
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
break;
|
break;
|
||||||
@@ -329,7 +353,7 @@ pub struct WebSocketUsers {
|
|||||||
impl WebSocketUsers {
|
impl WebSocketUsers {
|
||||||
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
||||||
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
||||||
for (_, sender) in user.iter() {
|
for (_, sender) in &user {
|
||||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
@@ -533,12 +557,39 @@ impl WebSocketUsers {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AnonymousWebSocketSubscriptions {
|
pub struct AnonymousWebSocketSubscriptions {
|
||||||
map: Arc<dashmap::DashMap<String, Sender<Message>>>,
|
map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
|
||||||
|
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnonymousWebSocketSubscriptions {
|
impl AnonymousWebSocketSubscriptions {
|
||||||
|
/// Takes a connection slot for this address, returns false when it already reached the limit.
|
||||||
|
fn try_reserve(&self, addr: IpAddr) -> bool {
|
||||||
|
let mut count = self.connections.entry(addr).or_insert(0);
|
||||||
|
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases a slot taken by `try_reserve`.
|
||||||
|
fn release(&self, addr: IpAddr) {
|
||||||
|
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
*count == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// Only remove once the guard above is dropped, otherwise this deadlocks.
|
||||||
|
if empty {
|
||||||
|
self.connections.remove_if(&addr, |_, count| *count == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_update(&self, token: &str, data: &[u8]) {
|
async fn send_update(&self, token: &str, data: &[u8]) {
|
||||||
if let Some(sender) = self.map.get(token).map(|v| v.clone()) {
|
// Clone the senders so the map isn't kept locked while sending.
|
||||||
|
let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
|
||||||
|
for (_, sender) in senders {
|
||||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
@@ -582,7 +633,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
|
|||||||
V::Nil,
|
V::Nil,
|
||||||
"ReceiveMessage".into(),
|
"ReceiveMessage".into(),
|
||||||
V::Array(vec![V::Map(vec![
|
V::Array(vec![V::Map(vec![
|
||||||
("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)),
|
("ContextId".into(), acting_device_id.map_or(V::Nil, |v| v.to_string().into())),
|
||||||
("Type".into(), (ut as i32).into()),
|
("Type".into(), (ut as i32).into()),
|
||||||
("Payload".into(), payload.into()),
|
("Payload".into(), payload.into()),
|
||||||
])]),
|
])]),
|
||||||
|
|||||||
+24
-24
@@ -4,21 +4,21 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
|
||||||
Method,
|
Method,
|
||||||
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, UpdateType},
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
},
|
},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
util::{format_date, get_uuid},
|
util::{format_date, get_uuid},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -74,9 +74,9 @@ async fn get_auth_api_token() -> ApiResult<String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut api_token = API_TOKEN.write().await;
|
let mut api_token = API_TOKEN.write().await;
|
||||||
api_token.valid_until = Instant::now()
|
// Token valid for half the specified time
|
||||||
.checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time
|
let half_expires_in = u64::from((json_pushtoken.expires_in / 2).max(0).cast_unsigned());
|
||||||
.unwrap();
|
api_token.valid_until = Instant::now().checked_add(Duration::from_secs(half_expires_in)).unwrap();
|
||||||
|
|
||||||
api_token.access_token = json_pushtoken.access_token;
|
api_token.access_token = json_pushtoken.access_token;
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
||||||
if cipher.organization_uuid.is_some() {
|
if cipher.organization_uuid.is_some() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let Some(user_id) = &cipher.user_uuid else {
|
let Some(user_id) = &cipher.user_uuid else {
|
||||||
debug!("Cipher has no uuid");
|
debug!("Cipher has no uuid");
|
||||||
return;
|
return;
|
||||||
@@ -244,23 +244,23 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) {
|
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) {
|
||||||
if let Some(s) = &send.user_uuid {
|
if let Some(s) = &send.user_uuid
|
||||||
if Device::check_user_has_push_device(s, conn).await {
|
&& Device::check_user_has_push_device(s, conn).await
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
{
|
||||||
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
|
"userId": send.user_uuid,
|
||||||
|
"organizationId": null,
|
||||||
|
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
|
||||||
|
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
|
||||||
|
"type": ut as i32,
|
||||||
|
"payload": {
|
||||||
|
"id": send.uuid,
|
||||||
"userId": send.user_uuid,
|
"userId": send.user_uuid,
|
||||||
"organizationId": null,
|
"revisionDate": format_date(&send.revision_date)
|
||||||
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
|
},
|
||||||
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
|
"clientType": null,
|
||||||
"type": ut as i32,
|
"installationId": null
|
||||||
"payload": {
|
})));
|
||||||
"id": send.uuid,
|
|
||||||
"userId": send.user_uuid,
|
|
||||||
"revisionDate": format_date(&send.revision_date)
|
|
||||||
},
|
|
||||||
"clientType": null,
|
|
||||||
"installationId": null
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ async fn send_to_push_relay(notification_data: Value) {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!("An error occurred while sending a send update to the push relay: {e}");
|
error!("An error occurred while sending a send update to the push relay: {e}");
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) {
|
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) {
|
||||||
|
|||||||
+78
-19
@@ -1,21 +1,28 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{content::RawCss as Css, content::RawHtml as Html, Redirect},
|
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Catcher, Route,
|
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::now, ApiResult, EmptyResult},
|
|
||||||
auth::decode_file_download,
|
|
||||||
db::models::{AttachmentId, CipherId},
|
|
||||||
error::Error,
|
|
||||||
util::Cached,
|
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
api::{ApiResult, EmptyResult, core::now},
|
||||||
|
auth::decode_file_download,
|
||||||
|
crypto::sha256_hex,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{AttachmentId, CipherId},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
util::{Cached, EtagCached},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -23,12 +30,20 @@ pub fn routes() -> Vec<Route> {
|
|||||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||||
let mut routes = routes![attachments, alive, alive_head, static_files];
|
let mut routes = routes![attachments, alive, alive_head, static_files];
|
||||||
if CONFIG.web_vault_enabled() {
|
if CONFIG.web_vault_enabled() {
|
||||||
routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
|
routes.append(&mut routes![
|
||||||
|
web_index,
|
||||||
|
web_index_direct,
|
||||||
|
web_index_head,
|
||||||
|
app_id,
|
||||||
|
apple_app_site_association,
|
||||||
|
web_files,
|
||||||
|
vaultwarden_css
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
if CONFIG.reload_templates() {
|
if CONFIG.reload_templates() {
|
||||||
routes.append(&mut routes![_static_files_dev]);
|
routes.append(&mut routes![static_files_dev]);
|
||||||
}
|
}
|
||||||
|
|
||||||
routes
|
routes
|
||||||
@@ -52,9 +67,29 @@ fn not_found() -> ApiResult<Html<String>> {
|
|||||||
Ok(Html(text))
|
Ok(Html(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CssCache {
|
||||||
|
css: String,
|
||||||
|
etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static CSS_CACHE: RwLock<Option<Arc<CssCache>>> = RwLock::new(None);
|
||||||
|
|
||||||
|
pub fn invalidate_css_cache() {
|
||||||
|
*CSS_CACHE.write().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/css/vaultwarden.css")]
|
#[get("/css/vaultwarden.css")]
|
||||||
fn vaultwarden_css() -> Cached<Css<String>> {
|
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
||||||
|
// If reload_templates is false, and we already have the CSS Cached, return this
|
||||||
|
if !CONFIG.reload_templates()
|
||||||
|
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
||||||
|
{
|
||||||
|
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
||||||
let css_options = json!({
|
let css_options = json!({
|
||||||
|
"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,
|
||||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||||
@@ -101,8 +136,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache for one day should be enough and not too much
|
let etag = sha256_hex(css.as_bytes());
|
||||||
Cached::ttl(Css(css), 86_400, false)
|
let cached = Arc::new(CssCache {
|
||||||
|
css,
|
||||||
|
etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
if !CONFIG.reload_templates() {
|
||||||
|
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||||
|
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -160,6 +205,24 @@ fn app_id() -> Cached<(ContentType, Json<Value>)> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/.well-known/apple-app-site-association")]
|
||||||
|
fn apple_app_site_association() -> Cached<(ContentType, Json<Value>)> {
|
||||||
|
Cached::long(
|
||||||
|
(
|
||||||
|
ContentType::JSON,
|
||||||
|
Json(json!({
|
||||||
|
"webcredentials": {
|
||||||
|
"apps": [
|
||||||
|
"LTZ2PFU5D6.com.8bit.bitwarden",
|
||||||
|
"LTZ2PFU5D6.com.8bit.bitwarden.beta"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
||||||
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
||||||
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
||||||
@@ -178,7 +241,6 @@ async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
||||||
use crate::db::DbConn;
|
|
||||||
#[get("/alive")]
|
#[get("/alive")]
|
||||||
fn alive(_conn: DbConn) -> Json<String> {
|
fn alive(_conn: DbConn) -> Json<String> {
|
||||||
now()
|
now()
|
||||||
@@ -197,7 +259,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
|||||||
// 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` function below!
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
#[get("/vw_static/<filename>", rank = 1)]
|
#[get("/vw_static/<filename>", rank = 1)]
|
||||||
pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||||
warn!("LOADING STATIC FILES FROM DISK");
|
warn!("LOADING STATIC FILES FROM DISK");
|
||||||
let file = filename.to_str().unwrap_or_default();
|
let file = filename.to_str().unwrap_or_default();
|
||||||
let ext = filename.extension().unwrap_or_default();
|
let ext = filename.extension().unwrap_or_default();
|
||||||
@@ -210,7 +272,7 @@ pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
|||||||
|
|
||||||
if let Ok(path) = path {
|
if let Ok(path) = path {
|
||||||
return NamedFile::open(path).await.ok();
|
return NamedFile::open(path).await.ok();
|
||||||
};
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,9 +302,6 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
|
|||||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
"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.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||||
"jquery-4.0.0.slim.js" => {
|
|
||||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
|
|
||||||
}
|
|
||||||
_ => err!(format!("Static file not found: {filename}")),
|
_ => err!(format!("Static file not found: {filename}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+137
-98
@@ -1,3 +1,8 @@
|
|||||||
|
#[path = "auth/send.rs"]
|
||||||
|
pub mod send;
|
||||||
|
pub type SendTokens = send::SendTokens;
|
||||||
|
pub type SendHeaders = send::SendHeaders;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
@@ -5,21 +10,31 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
use ipnet::IpNet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use openssl::rsa::Rsa;
|
use openssl::rsa::Rsa;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::{de::DeserializeOwned, ser::Serialize};
|
||||||
use serde::ser::Serialize;
|
|
||||||
|
use rocket::{
|
||||||
|
outcome::try_outcome,
|
||||||
|
request::{FromRequest, Outcome, Request},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::ApiResult,
|
CONFIG,
|
||||||
|
api::{ApiResult, core::log_event},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
db::models::{
|
db::{
|
||||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
DbConn,
|
||||||
OrganizationId, SendFileId, SendId, UserId,
|
models::{
|
||||||
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
|
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||||
|
SendFileId, SendId, User, UserId, UserStampException,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
sso, CONFIG,
|
sso,
|
||||||
};
|
};
|
||||||
|
|
||||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||||
@@ -52,16 +67,12 @@ static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
|||||||
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||||
|
|
||||||
pub async fn initialize_keys() -> Result<(), Error> {
|
pub async fn initialize_keys() -> Result<(), Error> {
|
||||||
use std::io::Error;
|
use std::io::Error as IoError;
|
||||||
|
|
||||||
let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key())
|
let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
|
||||||
.file_name()
|
.ok_or_else(|| IoError::other("Private RSA key path missing filename"))?;
|
||||||
.ok_or_else(|| Error::other("Private RSA key path missing filename"))?
|
|
||||||
.to_str()
|
|
||||||
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(IoError::other)?;
|
||||||
|
|
||||||
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
||||||
Ok(buffer) => Some(buffer),
|
Ok(buffer) => Some(buffer),
|
||||||
@@ -230,7 +241,7 @@ impl LoginJwtClaims {
|
|||||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
|
||||||
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||||
warn!("Raise access_token lifetime to more than 5min.")
|
warn!("Raise access_token lifetime to more than 5min.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the JWT claims struct, to send to the client
|
// Create the JWT claims struct, to send to the client
|
||||||
@@ -257,7 +268,7 @@ impl LoginJwtClaims {
|
|||||||
sstamp: user.security_stamp.clone(),
|
sstamp: user.security_stamp.clone(),
|
||||||
device: device.uuid.clone(),
|
device: device.uuid.clone(),
|
||||||
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
||||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
client_id: client_id.unwrap_or("undefined".to_owned()),
|
||||||
scope,
|
scope,
|
||||||
amr: vec!["Application".into()],
|
amr: vec!["Application".into()],
|
||||||
}
|
}
|
||||||
@@ -482,6 +493,16 @@ pub struct BasicJwtClaims {
|
|||||||
pub sub: String,
|
pub sub: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BasicJwtClaims {
|
||||||
|
pub fn expires_in(&self) -> i64 {
|
||||||
|
self.exp - Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> String {
|
||||||
|
encode_jwt(&self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
@@ -510,7 +531,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims {
|
|||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||||
sub: "admin_panel".to_string(),
|
sub: "admin_panel".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,16 +548,6 @@ pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtC
|
|||||||
//
|
//
|
||||||
// Bearer token authentication
|
// Bearer token authentication
|
||||||
//
|
//
|
||||||
use rocket::{
|
|
||||||
outcome::try_outcome,
|
|
||||||
request::{FromRequest, Outcome, Request},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::db::{
|
|
||||||
models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException},
|
|
||||||
DbConn,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Host {
|
pub struct Host {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
}
|
}
|
||||||
@@ -552,7 +563,7 @@ impl<'r> FromRequest<'r> for Host {
|
|||||||
let host = if CONFIG.domain_set() {
|
let host = if CONFIG.domain_set() {
|
||||||
CONFIG.domain()
|
CONFIG.domain()
|
||||||
} else if let Some(referer) = headers.get_one("Referer") {
|
} else if let Some(referer) = headers.get_one("Referer") {
|
||||||
referer.to_string()
|
referer.to_owned()
|
||||||
} else {
|
} else {
|
||||||
// Try to guess from the headers
|
// Try to guess from the headers
|
||||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||||
@@ -588,13 +599,15 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
|||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
// When unknown or unable to parse, return 'UnknownBrowser'
|
||||||
let device_type: i32 =
|
let device_type: i32 = request
|
||||||
request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14);
|
.headers()
|
||||||
|
.get_one("device-type")
|
||||||
|
.and_then(|d| d.parse().ok())
|
||||||
|
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||||
|
|
||||||
Outcome::Success(ClientHeaders {
|
Outcome::Success(ClientHeaders {
|
||||||
device_type,
|
device_type,
|
||||||
@@ -618,18 +631,19 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
|
|
||||||
let host = try_outcome!(Host::from_request(request).await).host;
|
let host = try_outcome!(Host::from_request(request).await).host;
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get access_token
|
// Get access_token
|
||||||
let access_token: &str = match headers.get_one("Authorization") {
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
Some(a) => match a.rsplit("Bearer ").next() {
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
Some(split) => split,
|
split
|
||||||
None => err_handler!("No access token provided"),
|
} else {
|
||||||
},
|
err_handler!("No access token provided")
|
||||||
None => err_handler!("No access token provided"),
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check JWT token is valid and get device and user from it
|
// Check JWT token is valid and get device and user from it
|
||||||
@@ -640,9 +654,8 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
let device_id = claims.device;
|
let device_id = claims.device;
|
||||||
let user_id = claims.sub;
|
let user_id = claims.sub;
|
||||||
|
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
||||||
@@ -673,7 +686,7 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
error!("Error updating user: {e:#?}");
|
error!("Error updating user: {e:#?}");
|
||||||
}
|
}
|
||||||
err_handler!("Stamp exception is expired")
|
err_handler!("Stamp exception is expired")
|
||||||
} else if !stamp_exception.routes.contains(¤t_route.to_string()) {
|
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
||||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
||||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
} else if stamp_exception.security_stamp != claims.sstamp {
|
||||||
err_handler!("Invalid security stamp for matched stamp exception")
|
err_handler!("Invalid security stamp for matched stamp exception")
|
||||||
@@ -761,9 +774,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
|||||||
|
|
||||||
match url_org_id {
|
match url_org_id {
|
||||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -810,6 +822,12 @@ pub struct AdminHeaders {
|
|||||||
pub org_id: OrganizationId,
|
pub org_id: OrganizationId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AdminHeaders {
|
||||||
|
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
|
||||||
|
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for AdminHeaders {
|
impl<'r> FromRequest<'r> for AdminHeaders {
|
||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
@@ -835,16 +853,16 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
|||||||
// but there could be cases where it is a query value.
|
// but there could be cases where it is a query value.
|
||||||
// First check the path, if this is not a valid uuid, try the query values.
|
// First check the path, if this is not a valid uuid, try the query values.
|
||||||
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
||||||
if let Some(Ok(col_id)) = request.param::<String>(3) {
|
if let Some(Ok(col_id)) = request.param::<String>(3)
|
||||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||||
return Some(col_id.into());
|
{
|
||||||
}
|
return Some(col_id.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId") {
|
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId")
|
||||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||||
return Some(col_id.into());
|
{
|
||||||
}
|
return Some(col_id.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -868,18 +886,16 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||||
if headers.is_confirmed_and_manager() {
|
if headers.is_confirmed_and_manager() {
|
||||||
match get_col_id(request) {
|
if let Some(col_id) = get_col_id(request) {
|
||||||
Some(col_id) => {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
let conn = match DbConn::from_request(request).await {
|
err_handler!("Error getting DB")
|
||||||
Outcome::Success(conn) => conn,
|
};
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
||||||
err_handler!("The current user isn't a manager for this collection")
|
err_handler!("The current user isn't a manager for this collection")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => err_handler!("Error getting the collection id"),
|
} else {
|
||||||
|
err_handler!("Error getting the collection id")
|
||||||
}
|
}
|
||||||
|
|
||||||
Outcome::Success(Self {
|
Outcome::Success(Self {
|
||||||
@@ -1040,17 +1056,49 @@ impl From<OrgMemberHeaders> for Headers {
|
|||||||
//
|
//
|
||||||
// Client IP address detection
|
// Client IP address detection
|
||||||
//
|
//
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
pub struct ClientIp {
|
pub struct ClientIp {
|
||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
|
||||||
|
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
|
||||||
|
let entry = entry.trim();
|
||||||
|
match entry.parse::<IpNet>() {
|
||||||
|
Ok(net) => Some(net),
|
||||||
|
// Without a prefix length it is a single address, which is a valid way to write this.
|
||||||
|
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
|
||||||
|
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
|
||||||
|
let trusted = CONFIG.ip_header_trusted_proxies();
|
||||||
|
let trusted = trusted.trim();
|
||||||
|
if trusted.eq_ignore_ascii_case("all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(remote) = remote else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
|
||||||
|
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
|
||||||
|
let remote = remote.to_canonical();
|
||||||
|
if trusted.eq_ignore_ascii_case("local") {
|
||||||
|
return !crate::util::is_global(remote);
|
||||||
|
}
|
||||||
|
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for ClientIp {
|
impl<'r> FromRequest<'r> for ClientIp {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = if CONFIG._ip_header_enabled() {
|
let remote = req.remote().map(|r| r.ip());
|
||||||
|
|
||||||
|
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
|
||||||
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
||||||
match ip.find(',') {
|
match ip.find(',') {
|
||||||
Some(idx) => &ip[..idx],
|
Some(idx) => &ip[..idx],
|
||||||
@@ -1061,10 +1109,15 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
|
||||||
|
// Log the canonical IP, which is what the user filter will need to match against
|
||||||
|
let remote = remote.map(|ip| ip.to_canonical());
|
||||||
|
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
|
||||||
|
}
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
||||||
|
|
||||||
Outcome::Success(ClientIp {
|
Outcome::Success(ClientIp {
|
||||||
ip,
|
ip,
|
||||||
@@ -1072,6 +1125,7 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
pub struct Secure {
|
pub struct Secure {
|
||||||
pub https: bool,
|
pub https: bool,
|
||||||
}
|
}
|
||||||
@@ -1157,15 +1211,14 @@ pub enum AuthMethod {
|
|||||||
impl AuthMethod {
|
impl AuthMethod {
|
||||||
pub fn scope(&self) -> String {
|
pub fn scope(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
AuthMethod::OrgApiKey => "api.organization".to_owned(),
|
||||||
AuthMethod::Password => "api offline_access".to_string(),
|
AuthMethod::UserApiKey => "api".to_owned(),
|
||||||
AuthMethod::Sso => "api offline_access".to_string(),
|
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
|
||||||
AuthMethod::UserApiKey => "api".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scope_vec(&self) -> Vec<String> {
|
pub fn scope_vec(&self) -> Vec<String> {
|
||||||
self.scope().split_whitespace().map(str::to_string).collect()
|
self.scope().split_whitespace().map(str::to_owned).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||||
@@ -1259,36 +1312,22 @@ pub async fn refresh_tokens(
|
|||||||
) -> ApiResult<(Device, AuthTokens)> {
|
) -> ApiResult<(Device, AuthTokens)> {
|
||||||
let refresh_claims = match decode_refresh(refresh_token) {
|
let refresh_claims = match decode_refresh(refresh_token) {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
|
error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
|
||||||
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
err_silent!("Invalid refresh token")
|
||||||
|
|
||||||
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
|
|
||||||
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
|
|
||||||
// check expiration or issuer, so they're not included here.
|
|
||||||
RefreshJwtClaims {
|
|
||||||
nbf: 0,
|
|
||||||
exp: 0,
|
|
||||||
iss: String::new(),
|
|
||||||
sub: AuthMethod::Password,
|
|
||||||
device_token: refresh_token.into(),
|
|
||||||
token: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(claims) => claims,
|
Ok(claims) => claims,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get device by refresh token
|
// Get device by refresh token
|
||||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
let Some(mut device) = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await else {
|
||||||
None => err!("Invalid refresh token"),
|
err!("Invalid refresh token")
|
||||||
Some(device) => device,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to update `updated_at`.
|
// Save to update `updated_at`.
|
||||||
device.save(true, conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
let Some(user) = User::find_by_uuid(&device.user_uuid, conn).await else {
|
||||||
None => err!("Impossible to find user"),
|
err!("Impossible to find user")
|
||||||
Some(user) => user,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_tokens = match refresh_claims.sub {
|
let auth_tokens = match refresh_claims.sub {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use chrono::{TimeDelta, Utc};
|
||||||
|
|
||||||
|
use rocket::request::{FromRequest, Outcome, Request};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiResult,
|
||||||
|
auth,
|
||||||
|
auth::{BasicJwtClaims, ClientIp},
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Send, SendId},
|
||||||
|
},
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
BasicJwtClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||||
|
iss: auth::JWT_SEND_ISSUER.to_string(),
|
||||||
|
sub: format!("{send_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SendTokens {
|
||||||
|
pub access_claims: BasicJwtClaims,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendTokens {
|
||||||
|
pub fn as_send_id(access_id: &str) -> Option<SendId> {
|
||||||
|
data_encoding::BASE64URL_NOPAD
|
||||||
|
.decode(access_id.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"access_token": self.access_claims.token(),
|
||||||
|
"expires_in": self.access_claims.expires_in(),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": "api.send.access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
|
||||||
|
let err = json!({
|
||||||
|
"kind": "expected_server",
|
||||||
|
"error": "invalid_request",
|
||||||
|
"send_access_error_type": error_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
|
||||||
|
let err = json!({
|
||||||
|
"kind": "expected_server",
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"send_access_error_type": error_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_tokens(
|
||||||
|
access_id: &str,
|
||||||
|
password: Option<String>,
|
||||||
|
ip: &ClientIp,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> ApiResult<SendTokens> {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||||
|
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(max_access_count) = send.max_access_count
|
||||||
|
&& send.access_count >= max_access_count
|
||||||
|
{
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !send.is_accessible() {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if send.password_hash.is_some() {
|
||||||
|
match password {
|
||||||
|
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
|
||||||
|
Some(_) => {
|
||||||
|
return Self::invalid_error(
|
||||||
|
&format!("Send {send_id}, Invalid password from {}", ip.ip),
|
||||||
|
"password_hash_b64_invalid",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => return Self::expected_error("Password required", "password_hash_b64_required"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !send.register_access(conn).await? {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
access_claims: generate_send_access_claims(&send_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SendHeaders {
|
||||||
|
pub send_id: SendId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for SendHeaders {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let headers = request.headers();
|
||||||
|
|
||||||
|
// Get access_token
|
||||||
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
|
split
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check JWT token is valid and get send_id
|
||||||
|
let Ok(claims) = auth::decode_send(access_token) else {
|
||||||
|
err_handler!("Invalid claim")
|
||||||
|
};
|
||||||
|
|
||||||
|
Outcome::Success(SendHeaders {
|
||||||
|
send_id: claims.sub.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
-238
@@ -3,8 +3,8 @@ use std::{
|
|||||||
fmt,
|
fmt,
|
||||||
process::exit,
|
process::exit,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
LazyLock, RwLock,
|
LazyLock, RwLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,26 +14,23 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::Error,
|
error::Error,
|
||||||
|
storage,
|
||||||
util::{
|
util::{
|
||||||
get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags,
|
FeatureFlagFilter, get_active_web_release, get_env, get_env_bool, is_valid_email,
|
||||||
FeatureFlagFilter,
|
parse_experimental_client_feature_flags,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
|
static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
|
||||||
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
|
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
|
||||||
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
|
get_env("CONFIG_FILE").unwrap_or_else(|| storage::join_path(&data_folder, "config.json"))
|
||||||
});
|
});
|
||||||
|
|
||||||
static CONFIG_FILE_PARENT_DIR: LazyLock<String> = LazyLock::new(|| {
|
static CONFIG_FILE_PARENT_DIR: LazyLock<String> =
|
||||||
let path = std::path::PathBuf::from(&*CONFIG_FILE);
|
LazyLock::new(|| storage::parent(&CONFIG_FILE).unwrap_or_else(|| "data".to_owned()));
|
||||||
path.parent().unwrap_or(std::path::Path::new("data")).to_str().unwrap_or("data").to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
static CONFIG_FILENAME: LazyLock<String> = LazyLock::new(|| {
|
static CONFIG_FILENAME: LazyLock<String> =
|
||||||
let path = std::path::PathBuf::from(&*CONFIG_FILE);
|
LazyLock::new(|| storage::file_name(&CONFIG_FILE).unwrap_or_else(|| "config.json".to_owned()));
|
||||||
path.file_name().unwrap_or(std::ffi::OsStr::new("config.json")).to_str().unwrap_or("config.json").to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
@@ -263,7 +260,7 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn from_file() -> Result<Self, Error> {
|
async fn from_file() -> Result<Self, Error> {
|
||||||
let operator = opendal_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 = operator.read(&CONFIG_FILENAME).await?;
|
||||||
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)
|
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
||||||
@@ -363,13 +360,7 @@ macro_rules! make_config {
|
|||||||
)+)+
|
)+)+
|
||||||
|
|
||||||
pub fn prepare_json(&self) -> serde_json::Value {
|
pub fn prepare_json(&self) -> serde_json::Value {
|
||||||
let (def, cfg, overridden) = {
|
fn get_form_type(rust_type: &'static str) -> &'static str {
|
||||||
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
|
||||||
let inner = &self.inner.read().unwrap();
|
|
||||||
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
fn _get_form_type(rust_type: &'static str) -> &'static str {
|
|
||||||
match rust_type {
|
match rust_type {
|
||||||
"Pass" => "password",
|
"Pass" => "password",
|
||||||
"String" => "text",
|
"String" => "text",
|
||||||
@@ -378,7 +369,7 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_doc(doc_str: &'static str) -> ElementDoc {
|
fn get_doc(doc_str: &'static str) -> ElementDoc {
|
||||||
let mut split = doc_str.split("|>").map(str::trim);
|
let mut split = doc_str.split("|>").map(str::trim);
|
||||||
ElementDoc {
|
ElementDoc {
|
||||||
name: split.next().unwrap_or_default(),
|
name: split.next().unwrap_or_default(),
|
||||||
@@ -386,6 +377,12 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (def, cfg, overridden) = {
|
||||||
|
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
||||||
|
let inner = &self.inner.read().unwrap();
|
||||||
|
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
||||||
|
};
|
||||||
|
|
||||||
let data: Vec<GroupData> = vec![
|
let data: Vec<GroupData> = vec![
|
||||||
$( // This repetition is for each group
|
$( // This repetition is for each group
|
||||||
GroupData {
|
GroupData {
|
||||||
@@ -400,8 +397,8 @@ macro_rules! make_config {
|
|||||||
name: stringify!($name),
|
name: stringify!($name),
|
||||||
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
|
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
|
||||||
default: serde_json::to_value(&def.$name).unwrap_or_default(),
|
default: serde_json::to_value(&def.$name).unwrap_or_default(),
|
||||||
r#type: _get_form_type(stringify!($ty)),
|
r#type: get_form_type(stringify!($ty)),
|
||||||
doc: _get_doc(concat!($($doc),+)),
|
doc: get_doc(concat!($($doc),+)),
|
||||||
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
|
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
|
||||||
},
|
},
|
||||||
)+], // End of elements repetition
|
)+], // End of elements repetition
|
||||||
@@ -411,9 +408,31 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_support_json(&self) -> serde_json::Value {
|
pub fn get_support_json(&self) -> serde_json::Value {
|
||||||
|
/// We map over the string and remove all alphanumeric, _ and - characters.
|
||||||
|
/// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds)
|
||||||
|
fn privacy_mask(value: &str) -> String {
|
||||||
|
let mut n: u16 = 0;
|
||||||
|
let mut colon_match = false;
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
n += 1;
|
||||||
|
match c {
|
||||||
|
':' if n <= 11 => {
|
||||||
|
colon_match = true;
|
||||||
|
c
|
||||||
|
}
|
||||||
|
'/' if n <= 13 && colon_match => c,
|
||||||
|
',' => c,
|
||||||
|
_ => '*',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
// Define which config keys need to be masked.
|
// Define which config keys need to be masked.
|
||||||
// Pass types will always be masked and no need to put them in the list.
|
// Pass types will always be masked and no need to put them in the list.
|
||||||
// Besides Pass, only String types will be masked via _privacy_mask.
|
// Besides Pass, only String types will be masked via privacy_mask.
|
||||||
const PRIVACY_CONFIG: &[&str] = &[
|
const PRIVACY_CONFIG: &[&str] = &[
|
||||||
"allowed_connect_src",
|
"allowed_connect_src",
|
||||||
"allowed_iframe_ancestors",
|
"allowed_iframe_ancestors",
|
||||||
@@ -440,28 +459,6 @@ macro_rules! make_config {
|
|||||||
inner.config.clone()
|
inner.config.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
/// We map over the string and remove all alphanumeric, _ and - characters.
|
|
||||||
/// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds)
|
|
||||||
fn _privacy_mask(value: &str) -> String {
|
|
||||||
let mut n: u16 = 0;
|
|
||||||
let mut colon_match = false;
|
|
||||||
value
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
n += 1;
|
|
||||||
match c {
|
|
||||||
':' if n <= 11 => {
|
|
||||||
colon_match = true;
|
|
||||||
c
|
|
||||||
}
|
|
||||||
'/' if n <= 13 && colon_match => c,
|
|
||||||
',' => c,
|
|
||||||
_ => '*',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::Value::Object({
|
serde_json::Value::Object({
|
||||||
let mut json = serde_json::Map::new();
|
let mut json = serde_json::Map::new();
|
||||||
$($(
|
$($(
|
||||||
@@ -471,7 +468,7 @@ macro_rules! make_config {
|
|||||||
for mask_key in PRIVACY_CONFIG {
|
for mask_key in PRIVACY_CONFIG {
|
||||||
if let Some(value) = json.get_mut(*mask_key) {
|
if let Some(value) = json.get_mut(*mask_key) {
|
||||||
if let Some(s) = value.as_str() {
|
if let Some(s) = value.as_str() {
|
||||||
*value = _privacy_mask(s).into();
|
*value = privacy_mask(s).into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,23 +502,23 @@ macro_rules! make_config {
|
|||||||
make_config! {
|
make_config! {
|
||||||
folders {
|
folders {
|
||||||
/// Data folder |> Main data folder
|
/// Data folder |> Main data folder
|
||||||
data_folder: String, false, def, "data".to_string();
|
data_folder: String, false, def, "data".to_owned();
|
||||||
/// Database URL
|
/// Database URL
|
||||||
database_url: String, false, auto, |c| format!("{}/db.sqlite3", c.data_folder);
|
database_url: String, false, auto, |c| format!("sqlite://{}", storage::join_path(&c.data_folder, "db.sqlite3"));
|
||||||
/// Icon cache folder
|
/// Icon cache folder
|
||||||
icon_cache_folder: String, false, auto, |c| format!("{}/icon_cache", c.data_folder);
|
icon_cache_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "icon_cache");
|
||||||
/// Attachments folder
|
/// Attachments folder
|
||||||
attachments_folder: String, false, auto, |c| format!("{}/attachments", c.data_folder);
|
attachments_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "attachments");
|
||||||
/// Sends folder
|
/// Sends folder
|
||||||
sends_folder: String, false, auto, |c| format!("{}/sends", c.data_folder);
|
sends_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "sends");
|
||||||
/// Temp folder |> Used for storing temporary file uploads
|
/// Temp folder |> Used for storing temporary file uploads
|
||||||
tmp_folder: String, false, auto, |c| format!("{}/tmp", c.data_folder);
|
tmp_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "tmp");
|
||||||
/// Templates folder
|
/// Templates folder
|
||||||
templates_folder: String, false, auto, |c| format!("{}/templates", c.data_folder);
|
templates_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "templates");
|
||||||
/// Session JWT key
|
/// Session JWT key
|
||||||
rsa_key_filename: String, false, auto, |c| format!("{}/rsa_key", c.data_folder);
|
rsa_key_filename: String, false, auto, |c| storage::join_path(&c.data_folder, "rsa_key");
|
||||||
/// Web vault folder
|
/// Web vault folder
|
||||||
web_vault_folder: String, false, def, "web-vault/".to_string();
|
web_vault_folder: String, false, def, "web-vault/".to_owned();
|
||||||
},
|
},
|
||||||
ws {
|
ws {
|
||||||
/// Enable websocket notifications
|
/// Enable websocket notifications
|
||||||
@@ -531,9 +528,9 @@ make_config! {
|
|||||||
/// Enable push notifications
|
/// Enable push notifications
|
||||||
push_enabled: bool, false, def, false;
|
push_enabled: bool, false, def, false;
|
||||||
/// Push relay uri
|
/// Push relay uri
|
||||||
push_relay_uri: String, false, def, "https://push.bitwarden.com".to_string();
|
push_relay_uri: String, false, def, "https://push.bitwarden.com".to_owned();
|
||||||
/// Push identity uri
|
/// Push identity uri
|
||||||
push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_string();
|
push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_owned();
|
||||||
/// Installation id |> The installation id from https://bitwarden.com/host
|
/// Installation id |> The installation id from https://bitwarden.com/host
|
||||||
push_installation_id: Pass, false, def, String::new();
|
push_installation_id: Pass, false, def, String::new();
|
||||||
/// Installation key |> The installation key from https://bitwarden.com/host
|
/// Installation key |> The installation key from https://bitwarden.com/host
|
||||||
@@ -545,38 +542,38 @@ make_config! {
|
|||||||
job_poll_interval_ms: u64, false, def, 30_000;
|
job_poll_interval_ms: u64, false, def, 30_000;
|
||||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||||
/// Defaults to hourly. Set blank to disable this job.
|
/// Defaults to hourly. Set blank to disable this job.
|
||||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_string();
|
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||||
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string();
|
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_owned();
|
||||||
/// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins.
|
/// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins.
|
||||||
/// Defaults to once every minute. Set blank to disable this job.
|
/// Defaults to once every minute. Set blank to disable this job.
|
||||||
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string();
|
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||||
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors.
|
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors.
|
||||||
/// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job.
|
/// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job.
|
||||||
emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_string();
|
emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_owned();
|
||||||
/// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time.
|
/// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time.
|
||||||
/// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job.
|
/// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job.
|
||||||
emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_string();
|
emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_owned();
|
||||||
/// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table.
|
/// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_string();
|
event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_owned();
|
||||||
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
/// Defaults to every minute. Set blank to disable this job.
|
/// Defaults to every minute. Set blank to disable this job.
|
||||||
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||||
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||||
/// Defaults to once every minute. Set blank to disable this job.
|
/// Defaults to once every minute. Set blank to disable this job.
|
||||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||||
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
|
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string();
|
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned();
|
||||||
},
|
},
|
||||||
|
|
||||||
/// General settings
|
/// General settings
|
||||||
settings {
|
settings {
|
||||||
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://'
|
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://'
|
||||||
/// and port, if it's different than the default. Some server functions don't work correctly without this value
|
/// and port, if it's different than the default. Some server functions don't work correctly without this value
|
||||||
domain: String, true, def, "http://localhost".to_string();
|
domain: String, true, def, "http://localhost".to_owned();
|
||||||
/// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used.
|
/// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used.
|
||||||
domain_set: bool, false, def, false;
|
domain_set: bool, false, def, false;
|
||||||
/// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin)
|
/// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin)
|
||||||
@@ -656,26 +653,37 @@ make_config! {
|
|||||||
admin_token: Pass, true, option;
|
admin_token: Pass, true, option;
|
||||||
|
|
||||||
/// 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_string();
|
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, events are kept indefinitely.
|
||||||
events_days_retain: i64, false, option;
|
events_days_retain: i64, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
client {
|
||||||
|
/// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals
|
||||||
|
client_suppress_onboarding: bool, true, def, false;
|
||||||
|
},
|
||||||
|
|
||||||
/// Advanced settings
|
/// Advanced settings
|
||||||
advanced {
|
advanced {
|
||||||
/// Client IP header |> If not present, the remote IP is used.
|
/// Client IP header |> If not present, the remote IP is used.
|
||||||
/// Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
/// Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||||
ip_header: String, true, def, "X-Real-IP".to_string();
|
ip_header: String, true, def, "X-Real-IP".to_owned();
|
||||||
/// Internal IP header property, used to avoid recomputing each time
|
/// Internal IP header property, used to avoid recomputing each time
|
||||||
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
||||||
|
/// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any
|
||||||
|
/// other address use the remote IP instead, so a client can't spoof the header.
|
||||||
|
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
|
||||||
|
/// running on the same host or container network), the string "all" to accept it from anywhere,
|
||||||
|
/// or a comma separated list of IPs and CIDR ranges.
|
||||||
|
ip_header_trusted_proxies: String, true, def, "local".to_owned();
|
||||||
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
||||||
/// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external
|
/// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external
|
||||||
/// service is set, an icon request to Vaultwarden will return an HTTP redirect to the
|
/// service is set, an icon request to Vaultwarden will return an HTTP redirect to the
|
||||||
/// corresponding icon at the external service.
|
/// corresponding icon at the external service.
|
||||||
icon_service: String, false, def, "internal".to_string();
|
icon_service: String, false, def, "internal".to_owned();
|
||||||
/// _icon_service_url
|
/// _icon_service_url
|
||||||
_icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service);
|
_icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service);
|
||||||
/// _icon_service_csp
|
/// _icon_service_csp
|
||||||
@@ -726,14 +734,14 @@ make_config! {
|
|||||||
/// Enable extended logging
|
/// Enable extended logging
|
||||||
extended_logging: bool, false, def, true;
|
extended_logging: bool, false, def, true;
|
||||||
/// Log timestamp format
|
/// Log timestamp format
|
||||||
log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_string();
|
log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_owned();
|
||||||
/// Enable the log to output to Syslog
|
/// Enable the log to output to Syslog
|
||||||
use_syslog: bool, false, def, false;
|
use_syslog: bool, false, def, false;
|
||||||
/// Log file path
|
/// Log file path
|
||||||
log_file: String, false, option;
|
log_file: String, false, option;
|
||||||
/// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
/// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
||||||
/// For a specific module append it as a comma separated value "info,path::to::module=debug"
|
/// For a specific module append it as a comma separated value "info,path::to::module=debug"
|
||||||
log_level: String, false, def, "info".to_string();
|
log_level: String, false, def, "info".to_owned();
|
||||||
|
|
||||||
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems,
|
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems,
|
||||||
/// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
|
/// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
|
||||||
@@ -771,6 +779,11 @@ make_config! {
|
|||||||
/// 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: u32, false, def, 10;
|
||||||
|
|
||||||
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
|
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
||||||
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
|
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u64, false, def, 300;
|
admin_ratelimit_seconds: u64, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
@@ -804,6 +817,8 @@ make_config! {
|
|||||||
sso_enabled: bool, true, def, false;
|
sso_enabled: bool, true, def, false;
|
||||||
/// Only SSO login |> Disable Email+Master Password login
|
/// Only SSO login |> Disable Email+Master Password login
|
||||||
sso_only: bool, true, def, false;
|
sso_only: bool, true, def, false;
|
||||||
|
/// Allow SSO flow to create account |> You probably want to disable it when using a public provider
|
||||||
|
sso_signups_allowed: bool, true, def, true;
|
||||||
/// Allow email association |> Associate existing non-SSO user based on email
|
/// Allow email association |> Associate existing non-SSO user based on email
|
||||||
sso_signups_match_email: bool, true, def, true;
|
sso_signups_match_email: bool, true, def, true;
|
||||||
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
|
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
|
||||||
@@ -815,7 +830,7 @@ make_config! {
|
|||||||
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
|
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
|
||||||
sso_authority: String, true, def, String::new();
|
sso_authority: String, true, def, String::new();
|
||||||
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
|
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
|
||||||
sso_scopes: String, true, def, "email profile".to_string();
|
sso_scopes: String, true, def, "email profile".to_owned();
|
||||||
/// Authorization request extra parameters
|
/// Authorization request extra parameters
|
||||||
sso_authorize_extra_params: String, true, def, String::new();
|
sso_authorize_extra_params: String, true, def, String::new();
|
||||||
/// Use PKCE during Authorization flow
|
/// Use PKCE during Authorization flow
|
||||||
@@ -883,7 +898,7 @@ make_config! {
|
|||||||
/// From Address
|
/// From Address
|
||||||
smtp_from: String, true, def, String::new();
|
smtp_from: String, true, def, String::new();
|
||||||
/// From Name
|
/// From Name
|
||||||
smtp_from_name: String, true, def, "Vaultwarden".to_string();
|
smtp_from_name: String, true, def, "Vaultwarden".to_owned();
|
||||||
/// Username
|
/// Username
|
||||||
smtp_username: String, true, option;
|
smtp_username: String, true, option;
|
||||||
/// Password
|
/// Password
|
||||||
@@ -929,10 +944,13 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
{
|
{
|
||||||
use crate::db::DbConnType;
|
use crate::db::DbConnType;
|
||||||
let url = &cfg.database_url;
|
let url = &cfg.database_url;
|
||||||
if DbConnType::from_url(url)? == DbConnType::Sqlite && url.contains('/') {
|
if DbConnType::from_url(url)? == DbConnType::Sqlite {
|
||||||
let path = std::path::Path::new(&url);
|
let file_path = url.strip_prefix("sqlite://").unwrap_or(url);
|
||||||
if let Some(parent) = path.parent() {
|
if file_path.contains('/') {
|
||||||
if !parent.is_dir() {
|
let path = std::path::Path::new(file_path);
|
||||||
|
if let Some(parent) = path.parent()
|
||||||
|
&& !parent.is_dir()
|
||||||
|
{
|
||||||
err!(format!(
|
err!(format!(
|
||||||
"SQLite database directory `{}` does not exist or is not a directory",
|
"SQLite database directory `{}` does not exist or is not a directory",
|
||||||
parent.display()
|
parent.display()
|
||||||
@@ -942,6 +960,18 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let trusted_proxies = cfg.ip_header_trusted_proxies.trim();
|
||||||
|
if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") {
|
||||||
|
for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) {
|
||||||
|
if crate::auth::parse_trusted_proxy(entry).is_none() {
|
||||||
|
err!(format!(
|
||||||
|
"Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range",
|
||||||
|
entry.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.password_iterations < 100_000 {
|
if cfg.password_iterations < 100_000 {
|
||||||
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
|
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
|
||||||
}
|
}
|
||||||
@@ -956,13 +986,13 @@ 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!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",));
|
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(log_file) = &cfg.log_file {
|
if let Some(log_file) = &cfg.log_file
|
||||||
if std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() {
|
&& std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err()
|
||||||
err!("Unable to write to log file", log_file);
|
{
|
||||||
}
|
err!("Unable to write to log file", log_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dom = cfg.domain.to_lowercase();
|
let dom = cfg.domain.to_lowercase();
|
||||||
@@ -975,7 +1005,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
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() {
|
||||||
err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed");
|
err!(
|
||||||
|
"ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,11 +1023,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`ORG_CREATION_USERS` contains invalid email addresses");
|
err!("`ORG_CREATION_USERS` contains invalid email addresses");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref token) = cfg.admin_token {
|
if let Some(ref token) = cfg.admin_token
|
||||||
if token.trim().is_empty() && !cfg.disable_admin_token {
|
&& token.trim().is_empty()
|
||||||
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
|
&& !cfg.disable_admin_token
|
||||||
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
|
{
|
||||||
}
|
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
|
||||||
|
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) {
|
if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) {
|
||||||
@@ -1029,37 +1062,41 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let invalid_flags =
|
let invalid_flags = parse_experimental_client_feature_flags(
|
||||||
parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags, FeatureFlagFilter::InvalidOnly);
|
&cfg.experimental_client_feature_flags,
|
||||||
|
&FeatureFlagFilter::InvalidOnly,
|
||||||
|
);
|
||||||
if !invalid_flags.is_empty() {
|
if !invalid_flags.is_empty() {
|
||||||
let feature_flags_error = format!("Unrecognized experimental client feature flags: {:?}.\n\
|
let feature_flags_error = format!(
|
||||||
|
"Unrecognized experimental client feature flags: {invalid_flags:?}.\n\
|
||||||
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
||||||
Supported flags: {:?}\n", invalid_flags, SUPPORTED_FEATURE_FLAGS);
|
Supported flags: {SUPPORTED_FEATURE_FLAGS:?}\n"
|
||||||
|
);
|
||||||
if on_update {
|
if on_update {
|
||||||
err!(feature_flags_error);
|
err!(feature_flags_error);
|
||||||
} else {
|
|
||||||
println!("[WARNING] {feature_flags_error}");
|
|
||||||
}
|
}
|
||||||
|
println!("[WARNING] {feature_flags_error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::items_after_statements, reason = "Keep this close to where it is used")]
|
||||||
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
||||||
|
|
||||||
if let Some(limit) = cfg.user_attachment_limit {
|
if let Some(limit) = cfg.user_attachment_limit
|
||||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||||
err!("`USER_ATTACHMENT_LIMIT` is out of bounds");
|
{
|
||||||
}
|
err!("`USER_ATTACHMENT_LIMIT` is out of bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = cfg.org_attachment_limit {
|
if let Some(limit) = cfg.org_attachment_limit
|
||||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||||
err!("`ORG_ATTACHMENT_LIMIT` is out of bounds");
|
{
|
||||||
}
|
err!("`ORG_ATTACHMENT_LIMIT` is out of bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = cfg.user_send_limit {
|
if let Some(limit) = cfg.user_send_limit
|
||||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||||
err!("`USER_SEND_LIMIT` is out of bounds");
|
{
|
||||||
}
|
err!("`USER_SEND_LIMIT` is out of bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg._enable_duo
|
if cfg._enable_duo
|
||||||
@@ -1087,7 +1124,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
if let Some(yubico_server) = &cfg.yubico_server {
|
if let Some(yubico_server) = &cfg.yubico_server {
|
||||||
let yubico_server = yubico_server.to_lowercase();
|
let yubico_server = yubico_server.to_lowercase();
|
||||||
if !yubico_server.starts_with("https://") {
|
if !yubico_server.starts_with("https://") {
|
||||||
err!("`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL.")
|
err!(
|
||||||
|
"`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1125,11 +1164,8 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
err!(format!("sendmail command at `{path:?}` isn't executable"));
|
||||||
if !metadata.permissions().mode() & 0o111 != 0 {
|
|
||||||
err!(format!("sendmail command at `{path:?}` isn't executable"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1175,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
|
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
|
||||||
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`")
|
err!(
|
||||||
|
"Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,7 +1272,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
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") => {
|
||||||
if let Err(e) = argon2::password_hash::PasswordHash::new(t) {
|
if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) {
|
||||||
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
|
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1300,7 +1338,7 @@ fn extract_url_origin(url: &str) -> String {
|
|||||||
/// All trailing '/' chars are trimmed, even if the path is a lone '/'.
|
/// All trailing '/' chars are trimmed, even if the path is a lone '/'.
|
||||||
fn extract_url_path(url: &str) -> String {
|
fn extract_url_path(url: &str) -> String {
|
||||||
match Url::parse(url) {
|
match Url::parse(url) {
|
||||||
Ok(u) => u.path().trim_end_matches('/').to_string(),
|
Ok(u) => u.path().trim_end_matches('/').to_owned(),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// We already print it in the method above, no need to do it again
|
// We already print it in the method above, no need to do it again
|
||||||
String::new()
|
String::new()
|
||||||
@@ -1310,7 +1348,7 @@ fn extract_url_path(url: &str) -> String {
|
|||||||
|
|
||||||
fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
||||||
if embed_images {
|
if embed_images {
|
||||||
"cid:".to_string()
|
"cid:".to_owned()
|
||||||
} else {
|
} else {
|
||||||
// normalize base_url
|
// normalize base_url
|
||||||
let base_url = domain.trim_end_matches('/');
|
let base_url = domain.trim_end_matches('/');
|
||||||
@@ -1329,10 +1367,10 @@ fn generate_sso_callback_path(domain: &str) -> String {
|
|||||||
fn generate_icon_service_url(icon_service: &str) -> String {
|
fn generate_icon_service_url(icon_service: &str) -> String {
|
||||||
match icon_service {
|
match icon_service {
|
||||||
"internal" => String::new(),
|
"internal" => String::new(),
|
||||||
"bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_string(),
|
"bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_owned(),
|
||||||
"duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_string(),
|
"duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_owned(),
|
||||||
"google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_string(),
|
"google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_owned(),
|
||||||
_ => icon_service.to_string(),
|
_ => icon_service.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,7 +1379,7 @@ fn generate_icon_service_csp(icon_service: &str, icon_service_url: &str) -> Stri
|
|||||||
// We split on the first '{', since that is the variable delimiter for an icon service URL.
|
// We split on the first '{', since that is the variable delimiter for an icon service URL.
|
||||||
// Everything up until the first '{' should be fixed and can be used as an CSP string.
|
// Everything up until the first '{' should be fixed and can be used as an CSP string.
|
||||||
let csp_string = match icon_service_url.split_once('{') {
|
let csp_string = match icon_service_url.split_once('{') {
|
||||||
Some((c, _)) => c.to_string(),
|
Some((c, _)) => c.to_owned(),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1358,96 +1396,12 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls
|
|||||||
println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead.");
|
println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead.");
|
||||||
}
|
}
|
||||||
if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() {
|
if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() {
|
||||||
return "force_tls".to_string();
|
return "force_tls".to_owned();
|
||||||
} else if smtp_ssl.is_some() && !smtp_ssl.unwrap() {
|
} else if smtp_ssl.is_some() && !smtp_ssl.unwrap() {
|
||||||
return "off".to_string();
|
return "off".to_owned();
|
||||||
}
|
}
|
||||||
// Return the default `starttls` in all other cases
|
// Return the default `starttls` in all other cases
|
||||||
"starttls".to_string()
|
"starttls".to_owned()
|
||||||
}
|
|
||||||
|
|
||||||
fn opendal_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
|
|
||||||
// Cache of previously built operators by path
|
|
||||||
static OPERATORS_BY_PATH: LazyLock<dashmap::DashMap<String, opendal::Operator>> =
|
|
||||||
LazyLock::new(dashmap::DashMap::new);
|
|
||||||
|
|
||||||
if let Some(operator) = OPERATORS_BY_PATH.get(path) {
|
|
||||||
return Ok(operator.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let operator = if path.starts_with("s3://") {
|
|
||||||
#[cfg(not(s3))]
|
|
||||||
return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into());
|
|
||||||
|
|
||||||
#[cfg(s3)]
|
|
||||||
opendal_s3_operator_for_path(path)?
|
|
||||||
} else {
|
|
||||||
let builder = opendal::services::Fs::default().root(path);
|
|
||||||
opendal::Operator::new(builder)?.finish()
|
|
||||||
};
|
|
||||||
|
|
||||||
OPERATORS_BY_PATH.insert(path.to_string(), operator.clone());
|
|
||||||
|
|
||||||
Ok(operator)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(s3)]
|
|
||||||
fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
|
|
||||||
use crate::http_client::aws::AwsReqwestConnector;
|
|
||||||
use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig};
|
|
||||||
|
|
||||||
// This is a custom AWS credential loader that uses the official AWS Rust
|
|
||||||
// SDK config crate to load credentials. This ensures maximum compatibility
|
|
||||||
// with AWS credential configurations. For example, OpenDAL doesn't support
|
|
||||||
// AWS SSO temporary credentials yet.
|
|
||||||
struct OpenDALS3CredentialLoader {}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader {
|
|
||||||
async fn load_credential(&self, _client: reqwest::Client) -> anyhow::Result<Option<reqsign::AwsCredential>> {
|
|
||||||
use aws_credential_types::provider::ProvideCredentials as _;
|
|
||||||
use tokio::sync::OnceCell;
|
|
||||||
|
|
||||||
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = OnceCell::const_new();
|
|
||||||
|
|
||||||
let chain = DEFAULT_CREDENTIAL_CHAIN
|
|
||||||
.get_or_init(|| {
|
|
||||||
let reqwest_client = reqwest::Client::builder().build().unwrap();
|
|
||||||
let connector = AwsReqwestConnector {
|
|
||||||
client: reqwest_client,
|
|
||||||
};
|
|
||||||
|
|
||||||
let conf = ProviderConfig::default().with_http_client(connector);
|
|
||||||
|
|
||||||
DefaultCredentialsChain::builder().configure(conf).build()
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let creds = chain.provide_credentials().await?;
|
|
||||||
|
|
||||||
Ok(Some(reqsign::AwsCredential {
|
|
||||||
access_key_id: creds.access_key_id().to_string(),
|
|
||||||
secret_access_key: creds.secret_access_key().to_string(),
|
|
||||||
session_token: creds.session_token().map(|s| s.to_string()),
|
|
||||||
expires_in: creds.expiry().map(|expiration| expiration.into()),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {};
|
|
||||||
|
|
||||||
let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?;
|
|
||||||
|
|
||||||
let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?;
|
|
||||||
|
|
||||||
let builder = opendal::services::S3::default()
|
|
||||||
.customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER))
|
|
||||||
.enable_virtual_host_style()
|
|
||||||
.bucket(bucket)
|
|
||||||
.root(url.path())
|
|
||||||
.default_storage_class("INTELLIGENT_TIERING");
|
|
||||||
|
|
||||||
Ok(opendal::Operator::new(builder)?.finish())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PathType {
|
pub enum PathType {
|
||||||
@@ -1471,31 +1425,39 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
"desktop-ui-migration-milestone-4",
|
"desktop-ui-migration-milestone-4",
|
||||||
// Auth Team
|
// Auth Team
|
||||||
"pm-5594-safari-account-switching",
|
"pm-5594-safari-account-switching",
|
||||||
|
"pm-32413-multi-client-password-management",
|
||||||
// Autofill Team
|
// Autofill Team
|
||||||
|
"undetermined-cipher-scenario-logic",
|
||||||
|
"enable-basic-auth-response",
|
||||||
"ssh-agent",
|
"ssh-agent",
|
||||||
"ssh-agent-v2",
|
"ssh-agent-v2",
|
||||||
// Key Management Team
|
// Key Management Team
|
||||||
"ssh-key-vault-item",
|
"ssh-key-vault-item",
|
||||||
"pm-25373-windows-biometrics-v2",
|
"pm-25373-windows-biometrics-v2",
|
||||||
|
"pm-26340-linux-biometrics-v2",
|
||||||
|
"windows-native-credential-sync",
|
||||||
// Mobile Team
|
// Mobile Team
|
||||||
"anon-addy-self-host-alias",
|
"anon-addy-self-host-alias",
|
||||||
"simple-login-self-host-alias",
|
"simple-login-self-host-alias",
|
||||||
"mutual-tls",
|
"mutual-tls",
|
||||||
"cxp-import-mobile",
|
"cxp-import-mobile",
|
||||||
"cxp-export-mobile",
|
"cxp-export-mobile",
|
||||||
|
"pm-34171-card-scanner",
|
||||||
// Platform Team
|
// Platform Team
|
||||||
"pm-30529-webauthn-related-origins",
|
"pm-30529-webauthn-related-origins",
|
||||||
|
// Vault Team
|
||||||
|
"pm-32009-new-item-types",
|
||||||
];
|
];
|
||||||
|
|
||||||
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 = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||||
|
|
||||||
// Create merged config, config file overwrites env
|
// Create merged config, config file overwrites env
|
||||||
let mut _overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
let builder = _env.merge(&_usr, true, &mut _overrides);
|
let builder = env.merge(&usr, true, &mut overrides);
|
||||||
|
|
||||||
// Fill any missing with defaults
|
// Fill any missing with defaults
|
||||||
let config = builder.build();
|
let config = builder.build();
|
||||||
@@ -1508,9 +1470,9 @@ impl Config {
|
|||||||
rocket_shutdown_handle: None,
|
rocket_shutdown_handle: None,
|
||||||
templates: load_templates(&config.templates_folder),
|
templates: load_templates(&config.templates_folder),
|
||||||
config,
|
config,
|
||||||
_env,
|
_env: env,
|
||||||
_usr,
|
_usr: usr,
|
||||||
_overrides,
|
_overrides: overrides,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1547,17 +1509,20 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Save to file
|
//Save to file
|
||||||
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||||
operator.write(&CONFIG_FILENAME, config_str).await?;
|
operator.write(&CONFIG_FILENAME, config_str).await?;
|
||||||
|
|
||||||
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
crate::api::invalidate_css_cache();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
|
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
|
||||||
let builder = {
|
let builder = {
|
||||||
let usr = &self.inner.read().unwrap()._usr;
|
let usr = &self.inner.read().unwrap()._usr;
|
||||||
let mut _overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
usr.merge(&other, false, &mut _overrides)
|
usr.merge(&other, false, &mut overrides)
|
||||||
};
|
};
|
||||||
self.update_config(builder, false).await
|
self.update_config(builder, false).await
|
||||||
}
|
}
|
||||||
@@ -1580,11 +1545,22 @@ impl Config {
|
|||||||
/// Tests whether signup is allowed for an email address, taking into
|
/// Tests whether signup is allowed for an email address, taking into
|
||||||
/// account the signups_allowed and signups_domains_whitelist settings.
|
/// account the signups_allowed and signups_domains_whitelist settings.
|
||||||
pub fn is_signup_allowed(&self, email: &str) -> bool {
|
pub fn is_signup_allowed(&self, email: &str) -> bool {
|
||||||
if !self.signups_domains_whitelist().is_empty() {
|
if self.signups_domains_whitelist().is_empty() {
|
||||||
|
self.signups_allowed()
|
||||||
|
} else {
|
||||||
// The whitelist setting overrides the signups_allowed setting.
|
// The whitelist setting overrides the signups_allowed setting.
|
||||||
self.is_email_domain_allowed(email)
|
self.is_email_domain_allowed(email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests whether SSO signup is allowed for an email address, taking into
|
||||||
|
/// account the sso_signups_allowed and signups_domains_whitelist settings.
|
||||||
|
pub fn is_sso_signup_allowed(&self, email: &str) -> bool {
|
||||||
|
if self.signups_domains_whitelist().is_empty() {
|
||||||
|
self.sso_signups_allowed()
|
||||||
} else {
|
} else {
|
||||||
self.signups_allowed()
|
// The whitelist setting overrides the signups_allowed setting.
|
||||||
|
self.is_email_domain_allowed(email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1612,7 +1588,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_user_config(&self) -> Result<(), Error> {
|
pub async fn delete_user_config(&self) -> Result<(), Error> {
|
||||||
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||||
operator.delete(&CONFIG_FILENAME).await?;
|
operator.delete(&CONFIG_FILENAME).await?;
|
||||||
|
|
||||||
// Empty user config
|
// Empty user config
|
||||||
@@ -1632,11 +1608,14 @@ impl Config {
|
|||||||
writer._overrides = Vec::new();
|
writer._overrides = Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
crate::api::invalidate_css_cache();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn private_rsa_key(&self) -> String {
|
pub fn private_rsa_key(&self) -> String {
|
||||||
format!("{}.pem", self.rsa_key_filename())
|
storage::with_extension(&self.rsa_key_filename(), "pem")
|
||||||
}
|
}
|
||||||
pub fn mail_enabled(&self) -> bool {
|
pub fn mail_enabled(&self) -> bool {
|
||||||
let inner = &self.inner.read().unwrap().config;
|
let inner = &self.inner.read().unwrap().config;
|
||||||
@@ -1677,15 +1656,11 @@ impl Config {
|
|||||||
PathType::IconCache => self.icon_cache_folder(),
|
PathType::IconCache => self.icon_cache_folder(),
|
||||||
PathType::Attachments => self.attachments_folder(),
|
PathType::Attachments => self.attachments_folder(),
|
||||||
PathType::Sends => self.sends_folder(),
|
PathType::Sends => self.sends_folder(),
|
||||||
PathType::RsaKey => std::path::Path::new(&self.rsa_key_filename())
|
PathType::RsaKey => storage::parent(&self.private_rsa_key())
|
||||||
.parent()
|
.ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))?,
|
||||||
.ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))?
|
|
||||||
.to_str()
|
|
||||||
.ok_or_else(|| std::io::Error::other("Failed to convert RSA key file directory to UTF-8 string"))?
|
|
||||||
.to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
opendal_operator_for_path(&path)
|
storage::operator_for_path(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
||||||
@@ -1709,10 +1684,10 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn shutdown(&self) {
|
pub fn shutdown(&self) {
|
||||||
if let Ok(mut c) = self.inner.write() {
|
if let Ok(mut c) = self.inner.write()
|
||||||
if let Some(handle) = c.rocket_shutdown_handle.take() {
|
&& let Some(handle) = c.rocket_shutdown_handle.take()
|
||||||
handle.notify();
|
{
|
||||||
}
|
handle.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,7 +1704,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
||||||
self.sso_scopes().split_whitespace().map(str::to_string).collect()
|
self.sso_scopes().split_whitespace().map(str::to_owned).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
||||||
@@ -1776,7 +1751,7 @@ where
|
|||||||
reg!("email/email_footer");
|
reg!("email/email_footer");
|
||||||
reg!("email/email_footer_text");
|
reg!("email/email_footer_text");
|
||||||
|
|
||||||
reg!("email/admin_reset_password", ".html");
|
reg!("email/admin_account_recovery", ".html");
|
||||||
reg!("email/change_email_existing", ".html");
|
reg!("email/change_email_existing", ".html");
|
||||||
reg!("email/change_email_invited", ".html");
|
reg!("email/change_email_invited", ".html");
|
||||||
reg!("email/change_email", ".html");
|
reg!("email/change_email", ".html");
|
||||||
@@ -1839,7 +1814,7 @@ fn case_helper<'reg, 'rc>(
|
|||||||
let value = param.value().clone();
|
let value = param.value().clone();
|
||||||
|
|
||||||
if h.params().iter().skip(1).any(|x| x.value() == &value) {
|
if h.params().iter().skip(1).any(|x| x.value() == &value) {
|
||||||
h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or_else(|| Ok(()))
|
h.template().map_or(Ok(()), |t| t.render(r, ctx, rc, out))
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-21
@@ -6,25 +6,23 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use diesel::{
|
use diesel::{
|
||||||
|
Connection, RunQueryDsl,
|
||||||
connection::SimpleConnection,
|
connection::SimpleConnection,
|
||||||
r2d2::{CustomizeConnection, Pool, PooledConnection},
|
r2d2::{CustomizeConnection, Pool, PooledConnection},
|
||||||
Connection, RunQueryDsl,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Request,
|
||||||
http::Status,
|
http::Status,
|
||||||
request::{FromRequest, Outcome},
|
request::{FromRequest, Outcome},
|
||||||
Request,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{Mutex, OwnedSemaphorePermit, Semaphore},
|
sync::{Mutex, OwnedSemaphorePermit, Semaphore},
|
||||||
time::timeout,
|
time::timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{Error, MapResult},
|
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
error::{Error, MapResult},
|
||||||
};
|
};
|
||||||
|
|
||||||
// These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools
|
// These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools
|
||||||
@@ -62,7 +60,7 @@ pub struct DbConnManager {
|
|||||||
impl DbConnManager {
|
impl DbConnManager {
|
||||||
pub fn new(database_url: &str) -> Self {
|
pub fn new(database_url: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
database_url: database_url.to_string(),
|
database_url: database_url.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +222,7 @@ impl DbPool {
|
|||||||
|
|
||||||
// Set a global to determine the database more easily throughout the rest of the code
|
// Set a global to determine the database more easily throughout the rest of the code
|
||||||
if ACTIVE_DB_TYPE.set(conn_type).is_err() {
|
if ACTIVE_DB_TYPE.set(conn_type).is_err() {
|
||||||
error!("Tried to set the active database connection type more than once.")
|
error!("Tried to set the active database connection type more than once.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(DbPool {
|
Ok(DbPool {
|
||||||
@@ -272,22 +270,40 @@ impl DbConnType {
|
|||||||
#[cfg(not(postgresql))]
|
#[cfg(not(postgresql))]
|
||||||
err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled")
|
err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled")
|
||||||
|
|
||||||
//Sqlite
|
// Sqlite (explicit)
|
||||||
} else {
|
} else if url.len() > 7 && &url[..7] == "sqlite:" {
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
return Ok(DbConnType::Sqlite);
|
return Ok(DbConnType::Sqlite);
|
||||||
|
|
||||||
#[cfg(not(sqlite))]
|
#[cfg(not(sqlite))]
|
||||||
err!("`DATABASE_URL` looks like a SQLite URL, but 'sqlite' feature is not enabled")
|
err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist.
|
||||||
|
// This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database.
|
||||||
|
#[cfg(sqlite)]
|
||||||
|
{
|
||||||
|
if std::path::Path::new(url).exists() {
|
||||||
|
return Ok(DbConnType::Sqlite);
|
||||||
|
}
|
||||||
|
err!(format!(
|
||||||
|
"`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \
|
||||||
|
and no existing SQLite database was found at '{url}'. \
|
||||||
|
If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \
|
||||||
|
Otherwise, check your DATABASE_URL for typos or quoting issues."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(sqlite))]
|
||||||
|
err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_init_stmts(&self) -> String {
|
pub fn get_init_stmts(&self) -> String {
|
||||||
let init_stmts = CONFIG.database_conn_init();
|
let init_stmts = CONFIG.database_conn_init();
|
||||||
if !init_stmts.is_empty() {
|
if init_stmts.is_empty() {
|
||||||
init_stmts
|
|
||||||
} else {
|
|
||||||
self.default_init_stmts()
|
self.default_init_stmts()
|
||||||
|
} else {
|
||||||
|
init_stmts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +314,7 @@ impl DbConnType {
|
|||||||
#[cfg(postgresql)]
|
#[cfg(postgresql)]
|
||||||
Self::Postgresql => String::new(),
|
Self::Postgresql => String::new(),
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(),
|
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,12 +405,13 @@ pub fn backup_sqlite() -> Result<String, Error> {
|
|||||||
use diesel::Connection;
|
use diesel::Connection;
|
||||||
|
|
||||||
let db_url = CONFIG.database_url();
|
let db_url = CONFIG.database_url();
|
||||||
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
|
if DbConnType::from_url(&CONFIG.database_url()).is_ok_and(|t| t == DbConnType::Sqlite) {
|
||||||
// Since we do not allow any schema for sqlite database_url's like `file:` or `sqlite:` to be set, we can assume here it isn't
|
// Strip the sqlite:// prefix if present to get the raw file path
|
||||||
// This way we can set a readonly flag on the opening mode without issues.
|
let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url);
|
||||||
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{db_url}?mode=ro"))?;
|
// Open a read-only connection for the backup
|
||||||
|
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{file_path}?mode=ro"))?;
|
||||||
|
|
||||||
let db_path = std::path::Path::new(&db_url).parent().unwrap();
|
let db_path = std::path::Path::new(file_path).parent().unwrap();
|
||||||
let backup_file = db_path
|
let backup_file = db_path
|
||||||
.join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S")))
|
.join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S")))
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
@@ -423,12 +440,12 @@ pub async fn get_sql_server_version(conn: &DbConn) -> String {
|
|||||||
postgresql,mysql {
|
postgresql,mysql {
|
||||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("version();"))
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("version();"))
|
||||||
.get_result::<String>(conn)
|
.get_result::<String>(conn)
|
||||||
.unwrap_or_else(|_| "Unknown".to_string())
|
.unwrap_or_else(|_| "Unknown".to_owned())
|
||||||
}
|
}
|
||||||
sqlite {
|
sqlite {
|
||||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("sqlite_version();"))
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("sqlite_version();"))
|
||||||
.get_result::<String>(conn)
|
.get_result::<String>(conn)
|
||||||
.unwrap_or_else(|_| "Unknown".to_string())
|
.unwrap_or_else(|_| "Unknown".to_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-17
@@ -1,11 +1,13 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{DbConn, schema::archives},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{CipherId, User, UserId};
|
use super::{CipherId, User, UserId};
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::db::schema::archives;
|
|
||||||
use crate::db::DbConn;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
#[diesel(table_name = archives)]
|
#[diesel(table_name = archives)]
|
||||||
@@ -19,13 +21,15 @@ pub struct Archive {
|
|||||||
impl Archive {
|
impl Archive {
|
||||||
// Returns the date the specified cipher was archived
|
// Returns the date the specified cipher was archived
|
||||||
pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
|
pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
archives::table
|
archives::table
|
||||||
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
||||||
.filter(archives::user_uuid.eq(user_uuid))
|
.filter(archives::user_uuid.eq(user_uuid))
|
||||||
.select(archives::archived_at)
|
.select(archives::archived_at)
|
||||||
.first::<NaiveDateTime>(conn).ok()
|
.first::<NaiveDateTime>(conn)
|
||||||
}}
|
.ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves (inserts or updates) an archive record with the provided timestamp
|
// Saves (inserts or updates) an archive record with the provided timestamp
|
||||||
@@ -37,17 +41,20 @@ impl Archive {
|
|||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
diesel::replace_into(archives::table)
|
diesel::insert_into(archives::table)
|
||||||
.values((
|
.values((
|
||||||
archives::user_uuid.eq(user_uuid),
|
archives::user_uuid.eq(user_uuid),
|
||||||
archives::cipher_uuid.eq(cipher_uuid),
|
archives::cipher_uuid.eq(cipher_uuid),
|
||||||
archives::archived_at.eq(archived_at),
|
archives::archived_at.eq(archived_at),
|
||||||
))
|
))
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(archives::archived_at.eq(archived_at))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving archive")
|
.map_res("Error saving archive")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(archives::table)
|
diesel::insert_into(archives::table)
|
||||||
.values((
|
.values((
|
||||||
archives::user_uuid.eq(user_uuid),
|
archives::user_uuid.eq(user_uuid),
|
||||||
@@ -66,26 +73,26 @@ impl Archive {
|
|||||||
// Deletes an archive record for a specific cipher
|
// Deletes an archive record for a specific cipher
|
||||||
pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
archives::table
|
archives::table.filter(archives::user_uuid.eq(user_uuid)).filter(archives::cipher_uuid.eq(cipher_uuid)),
|
||||||
.filter(archives::user_uuid.eq(user_uuid))
|
|
||||||
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
|
||||||
)
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting archive")
|
.map_res("Error deleting archive")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a vec with (cipher_uuid, archived_at)
|
/// Return a vec with (cipher_uuid, archived_at)
|
||||||
/// This is used during a full sync so we only need one query for all archive matches
|
/// This is used during a full sync so we only need one query for all archive matches
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
archives::table
|
archives::table
|
||||||
.filter(archives::user_uuid.eq(user_uuid))
|
.filter(archives::user_uuid.eq(user_uuid))
|
||||||
.select((archives::cipher_uuid, archives::archived_at))
|
.select((archives::cipher_uuid, archives::archived_at))
|
||||||
.load::<(CipherId, NaiveDateTime)>(conn)
|
.load::<(CipherId, NaiveDateTime)>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+50
-51
@@ -1,13 +1,24 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use bigdecimal::{BigDecimal, ToPrimitive};
|
use bigdecimal::{BigDecimal, ToPrimitive};
|
||||||
use derive_more::{AsRef, Deref, Display};
|
use derive_more::{AsRef, Deref, Display};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::time::Duration;
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::EmptyResult,
|
||||||
|
auth::{encode_jwt, generate_file_download_claims},
|
||||||
|
config::PathType,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
schema::{attachments, ciphers},
|
||||||
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
use macros::IdFromParam;
|
||||||
|
|
||||||
use super::{CipherId, OrganizationId, UserId};
|
use super::{CipherId, OrganizationId, UserId};
|
||||||
use crate::db::schema::{attachments, ciphers};
|
|
||||||
use crate::{config::PathType, CONFIG};
|
|
||||||
use macros::IdFromParam;
|
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = attachments)]
|
#[diesel(table_name = attachments)]
|
||||||
@@ -46,7 +57,7 @@ impl Attachment {
|
|||||||
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
|
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||||
|
|
||||||
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
|
if crate::storage::is_fs_operator(&operator) {
|
||||||
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
|
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
|
||||||
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
|
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
|
||||||
} else {
|
} else {
|
||||||
@@ -67,34 +78,20 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::auth::{encode_jwt, generate_file_download_claims};
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl Attachment {
|
impl Attachment {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(attachments::table)
|
diesel::insert_into(attachments::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving attachment")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(attachments::table)
|
|
||||||
.filter(attachments::id.eq(&self.id))
|
|
||||||
.set(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving attachment")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving attachment")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(attachments::table)
|
diesel::insert_into(attachments::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(attachments::id)
|
.on_conflict(attachments::id)
|
||||||
@@ -107,15 +104,15 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
crate::util::retry(||
|
crate::util::retry(
|
||||||
diesel::delete(attachments::table.filter(attachments::id.eq(&self.id)))
|
|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn),
|
||||||
.execute(conn),
|
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_res("Error deleting attachment")
|
.map_res("Error deleting attachment")
|
||||||
}}?;
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||||
let file_path = self.get_file_path();
|
let file_path = self.get_file_path();
|
||||||
@@ -139,25 +136,22 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| attachments::table.filter(attachments::id.eq(id.to_lowercase())).first::<Self>(conn).ok())
|
||||||
attachments::table
|
.await
|
||||||
.filter(attachments::id.eq(id.to_lowercase()))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.filter(attachments::cipher_uuid.eq(cipher_uuid))
|
.filter(attachments::cipher_uuid.eq(cipher_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading attachments")
|
.expect("Error loading attachments")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
let result: Option<BigDecimal> = attachments::table
|
let result: Option<BigDecimal> = attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||||
@@ -168,24 +162,26 @@ impl Attachment {
|
|||||||
match result.map(|r| r.to_i64()) {
|
match result.map(|r| r.to_i64()) {
|
||||||
Some(Some(r)) => r,
|
Some(Some(r)) => r,
|
||||||
Some(None) => i64::MAX,
|
Some(None) => i64::MAX,
|
||||||
None => 0
|
None => 0,
|
||||||
}
|
}
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
let result: Option<BigDecimal> = attachments::table
|
let result: Option<BigDecimal> = attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||||
@@ -196,20 +192,22 @@ impl Attachment {
|
|||||||
match result.map(|r| r.to_i64()) {
|
match result.map(|r| r.to_i64()) {
|
||||||
Some(Some(r)) => r,
|
Some(Some(r)) => r,
|
||||||
Some(None) => i64::MAX,
|
Some(None) => i64::MAX,
|
||||||
None => 0
|
None => 0,
|
||||||
}
|
}
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// This will return all attachments linked to the user or org
|
// This will return all attachments linked to the user or org
|
||||||
@@ -220,7 +218,7 @@ impl Attachment {
|
|||||||
org_uuids: &Vec<OrganizationId>,
|
org_uuids: &Vec<OrganizationId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||||
@@ -228,7 +226,8 @@ impl Attachment {
|
|||||||
.select(attachments::all_columns)
|
.select(attachments::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading attachments")
|
.expect("Error loading attachments")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
use super::{DeviceId, OrganizationId, UserId};
|
|
||||||
use crate::db::schema::auth_requests;
|
|
||||||
use crate::{crypto::ct_eq, util::format_date};
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use macros::UuidFromParam;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
crypto::ct_eq,
|
||||||
|
db::{DbConn, schema::auth_requests},
|
||||||
|
error::MapResult,
|
||||||
|
util::format_date,
|
||||||
|
};
|
||||||
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
|
use super::{DeviceId, OrganizationId, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
|
||||||
#[diesel(table_name = auth_requests)]
|
#[diesel(table_name = auth_requests)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
@@ -74,37 +81,24 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
impl AuthRequest {
|
impl AuthRequest {
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(auth_requests::table)
|
|
||||||
.values(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(auth_requests::table)
|
|
||||||
.filter(auth_requests::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error auth_request")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error auth_request")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(auth_requests::table)
|
diesel::insert_into(auth_requests::table)
|
||||||
.values(&*self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving auth_request")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(auth_requests::table)
|
||||||
|
.values(self)
|
||||||
.on_conflict(auth_requests::uuid)
|
.on_conflict(auth_requests::uuid)
|
||||||
.do_update()
|
.do_update()
|
||||||
.set(&*self)
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving auth_request")
|
.map_res("Error saving auth_request")
|
||||||
}
|
}
|
||||||
@@ -112,31 +106,28 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| auth_requests::table.filter(auth_requests::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||||
auth_requests::table
|
|
||||||
.filter(auth_requests::uuid.eq(uuid))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::uuid.eq(uuid))
|
.filter(auth_requests::uuid.eq(uuid))
|
||||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading auth_requests")
|
.expect("Error loading auth_requests")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user_and_requested_device(
|
pub async fn find_by_user_and_requested_device(
|
||||||
@@ -144,7 +135,7 @@ impl AuthRequest {
|
|||||||
device_uuid: &DeviceId,
|
device_uuid: &DeviceId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||||
.filter(auth_requests::request_device_identifier.eq(device_uuid))
|
.filter(auth_requests::request_device_identifier.eq(device_uuid))
|
||||||
@@ -152,24 +143,27 @@ impl AuthRequest {
|
|||||||
.order_by(auth_requests::creation_date.desc())
|
.order_by(auth_requests::creation_date.desc())
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::creation_date.lt(dt))
|
.filter(auth_requests::creation_date.lt(dt))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading auth_requests")
|
.expect("Error loading auth_requests")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
|
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting auth request")
|
.map_res("Error deleting auth request")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_access_code(&self, access_code: &str) -> bool {
|
pub fn check_access_code(&self, access_code: &str) -> bool {
|
||||||
|
|||||||
+387
-374
File diff suppressed because it is too large.
Load diff
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user