Compare commits

..
Author SHA1 Message Date
Mathijs van Veluw 0a77776a8b Revert "Disable deployments for release env (#7033)"
This reverts commit 8f0e99b875.
2026-04-12 16:57:48 +02:00
155 changed files with 5820 additions and 8929 deletions
+4 -29
View File
@@ -50,11 +50,10 @@
######################### #########################
## Database URL ## Database URL
## When using SQLite, this should use the sqlite:// scheme followed by the path ## When using SQLite, this is the path to the DB file, and it defaults to
## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3. ## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this
## Bare paths without the sqlite:// scheme are supported for backwards compatibility, ## must be set to a local sqlite3 file path.
## but only if the database file already exists. # DATABASE_URL=data/db.sqlite3
# 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
@@ -316,14 +315,6 @@
## 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 ###
######################### #########################
@@ -332,14 +323,6 @@
## 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 `{}`,
@@ -394,7 +377,6 @@
## - "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)
@@ -477,13 +459,6 @@
## 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.
+1
View File
@@ -1,2 +1,3 @@
# Ignore vendored scripts in GitHub stats # Ignore vendored scripts in GitHub stats
src/static/scripts/* linguist-vendored src/static/scripts/* linguist-vendored
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
+12 -13
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# 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,25 +30,24 @@ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
# Test Dockerfiles with hadolint # Test Dockerfiles with hadolint
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian) - name: Run hadolint
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety. run: hadolint docker/Dockerfile.{debian,alpine}
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.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
+17 -21
View File
@@ -38,9 +38,7 @@ jobs:
docker-build: docker-build:
name: Build Vaultwarden containers name: Build Vaultwarden containers
if: ${{ github.repository == 'dani-garcia/vaultwarden' }} if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
environment: environment: release
name: release
deployment: false
permissions: permissions:
packages: write # Needed to upload packages and artifacts packages: write # Needed to upload packages and artifacts
contents: read contents: read
@@ -58,13 +56,13 @@ jobs:
steps: steps:
- name: Initialize QEMU binfmt support - name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# 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 +75,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# 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 +104,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -121,7 +119,7 @@ jobs:
# Login to GitHub Container Registry # Login to GitHub Container Registry
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -137,7 +135,7 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@@ -185,7 +183,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers - name: Bake ${{ matrix.base_image }} containers
id: bake_vw id: bake_vw
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.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 +235,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@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -249,11 +247,9 @@ jobs:
merge-manifests: merge-manifests:
name: Merge manifests name: Merge manifests
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
needs: docker-build needs: docker-build
environment: environment: release
name: release
deployment: false
permissions: permissions:
packages: write # Needed to upload packages and artifacts packages: write # Needed to upload packages and artifacts
attestations: write # Needed to generate an artifact attestation for a build attestations: write # Needed to generate an artifact attestation for a build
@@ -272,7 +268,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -287,7 +283,7 @@ jobs:
# Login to GitHub Container Registry # Login to GitHub Container Registry
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -303,7 +299,7 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +361,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@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +369,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@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +377,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@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
+3 -3
View File
@@ -33,12 +33,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run Trivy vulnerability scanner - name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
env: env:
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2 TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1 TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
@@ -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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0 uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d # v1.45.0
+3 -3
View File
@@ -14,17 +14,17 @@ on:
jobs: jobs:
zizmor: zizmor:
name: Run zizmor name: Run zizmor
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.
+53 -54
View File
@@ -1,59 +1,58 @@
--- ---
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: check-json - id: check-json
- id: check-toml - id: check-toml
- id: mixed-line-ending - id: mixed-line-ending
args: [ "--fix=no" ] args: ["--fix=no"]
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: "(.*js$|.*css$)" exclude: "(.*js$|.*css$)"
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
- id: detect-private-key - id: detect-private-key
- id: check-symlinks - id: check-symlinks
- id: forbid-submodules - id: forbid-submodules
- repo: local
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: 8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
hooks: hooks:
- id: typos - id: fmt
always_run: true name: fmt
description: Format files with cargo fmt.
- repo: local entry: cargo fmt
hooks: language: system
- id: fmt always_run: true
name: fmt pass_filenames: false
description: Format files with cargo fmt. args: ["--", "--check"]
entry: cargo fmt - id: cargo-test
language: system name: cargo test
always_run: true description: Test the package for errors.
pass_filenames: false entry: cargo test
args: [ "--", "--check" ] language: system
- id: cargo-test args: ["--features", "sqlite,mysql,postgresql", "--"]
name: cargo test types_or: [rust, file]
description: Test the package for errors. files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
entry: cargo test pass_filenames: false
language: system - id: cargo-clippy
args: [ "--features", "sqlite,mysql,postgresql", "--" ] name: cargo clippy
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended description: Lint Rust sources
pass_filenames: false entry: cargo clippy
- id: cargo-clippy language: system
name: cargo clippy args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"]
description: Lint Rust sources types_or: [rust, file]
entry: cargo clippy files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
language: system pass_filenames: false
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ] - id: check-docker-templates
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended name: check docker templates
pass_filenames: false description: Check if the Docker templates are updated
- id: check-docker-templates language: system
name: check docker templates entry: sh
description: Check if the Docker templates are updated args:
language: system - "-c"
entry: sh - "cd docker && make"
args: # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- "-c" - repo: https://github.com/crate-ci/typos
- "cd docker && make" rev: 02ea592e44b3a53c302f697cddca7641cd051c3d # v1.45.0
hooks:
- id: typos
-3
View File
@@ -15,7 +15,6 @@ extend-ignore-re = [
"(?i)helo_name", "(?i)helo_name",
"Server name sent during.+HELO", "Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSE",
"COSEKey", "COSEKey",
"COSEAlgorithm", "COSEAlgorithm",
# Ignore this specific string as it's valid # Ignore this specific string as it's valid
@@ -24,6 +23,4 @@ extend-ignore-re = [
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 # https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 # https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
"AuthRequestResponseRecieved", "AuthRequestResponseRecieved",
# Ignore Punycode/IDN tests
"xn--.+"
] ]
Generated
+1105 -1078
View File
File diff suppressed because it is too large Load Diff
+87 -153
View File
@@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
edition = "2024" edition = "2021"
rust-version = "1.95.0" rust-version = "1.92.0"
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
@@ -14,6 +14,7 @@ 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" 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,31 +24,20 @@ publish.workspace = true
[features] [features]
default = [ default = [
# "sqlite", # "sqlite",
# "sqlite_system",
# "mysql", # "mysql",
# "postgresql", # "postgresql",
] ]
# Empty to keep compatibility, prefer to set USE_SYSLOG=true # Empty to keep compatibility, prefer to set USE_SYSLOG=true
enable_syslog = [] enable_syslog = []
# Please enable at least one of these DB backends.
mysql = ["diesel/mysql", "diesel_migrations/mysql"] mysql = ["diesel/mysql", "diesel_migrations/mysql"]
postgresql = ["diesel/postgres", "diesel_migrations/postgres"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"]
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 = [ s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
"opendal/services-s3",
"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"]
@@ -65,176 +55,124 @@ syslog = "7.0.0"
macros = { path = "./macros" } macros = { path = "./macros" }
# Logging # Logging
log = "0.4.33" log = "0.4.29"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
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.5.1" num-derive = "0.4.2"
bigdecimal = "0.4.10" bigdecimal = "0.4.10"
# Web framework # Web framework
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] } rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
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.2.1" dashmap = "6.1.0"
# Async futures # Async futures
futures = "0.3.33" futures = "0.3.32"
tokio = { version = "1.53.1", features = [ tokio = { version = "1.51.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
"fs", tokio-util = { version = "0.7.18", features = ["compat"]}
"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.229", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.149"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } # Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility
diesel_migrations = "2.3.2" diesel = { version = "2.3.7", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.1"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
"as_ref", diesel-derive-newtype = "2.1.2"
"deref",
"display",
"from",
"into",
] }
diesel-derive-newtype = "2.1.3"
# SQLite, statically bundled unless the `sqlite_system` feature is enabled # Bundled/Static SQLite
libsqlite3-sys = { version = "0.37.0", optional = true } libsqlite3-sys = { version = "0.36.0", features = ["bundled"], optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.2" rand = "0.10.1"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.43", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.24.0", features = ["v4"] } uuid = { version = "1.23.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
time = "0.3.55" time = "0.3.47"
# Job scheduler # Job scheduler
job_scheduler_ng = "2.5.0" job_scheduler_ng = "2.4.0"
# Data encoding library Hex/Base32/Base64 # Data encoding library Hex/Base32/Base64
data-encoding = "2.11.1" data-encoding = "2.10.0"
# JWT library # JWT library
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] } jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false }
# TOTP library # TOTP library
totp-lite = "2.0.1" totp-lite = "2.0.1"
# Yubico Library # Yubico Library
yubico_ng = { version = "1.0.0", default-features = false } yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false }
# WebAuthn libraries # WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db # danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration # danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.4"
webauthn-rs-core = "0.5.5" webauthn-rs-core = "0.5.4"
# Handling of URL's for WebAuthn and favicons # Handling of URL's for WebAuthn and favicons
url = "2.5.8" url = "2.5.8"
# Email libraries # Email libraries
lettre = { version = "0.11.23", default-features = false, features = [ lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
# 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.3", features = ["dir_source"] } handlebars = { version = "6.4.0", 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.13.4", default-features = false, features = [ 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}
# Misc hickory-resolver = "0.25.2"
"charset",
"cookies",
"http2",
"json",
"form",
"rustls-no-provider",
"stream",
# Compression
"brotli",
"deflate",
"gzip",
"zstd",
# Proxy
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.1"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.4" html5gum = "0.8.3"
regex = { version = "1.13.1", default-features = false, features = [ regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false }
"perf",
"std",
"unicode-perl",
] }
data-url = "0.3.2" data-url = "0.3.2"
bytes = "1.12.1" bytes = "1.11.1"
svg-hush = "0.9.6" svg-hush = "0.9.6"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
cached = { version = "2.0.2", features = ["async"] } cached = { version = "0.59.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.1"
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.81" openssl = "0.10.76"
# 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.3" pastey = "0.2.1"
governor = "0.10.4" governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.1"
# OIDC for SSO # OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false } openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] }
moka = { version = "0.12.15", features = ["future"] } moka = { version = "0.12.15", features = ["future"] }
# Check client versions for specific features. # Check client versions for specific features.
@@ -242,34 +180,29 @@ 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.52", optional = true, default-features = false, features = ["secure"] } mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true }
which = "8.0.5" which = "8.0.2"
# Argon2 library with support for the PHC format # Argon2 library with support for the PHC format
argon2 = "0.5.3" argon2 = "0.5.3"
# 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.4" rpassword = "7.4.0"
# 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.58.1", default-features = false, features = ["services-fs"] } opendal = { version = "0.55.0", features = ["services-fs"], default-features = false }
# For retrieving AWS credentials, including temporary SSO credentials # For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.10.1", optional = true, default-features = false, features = [ anyhow = { version = "1.0.102", optional = true }
"behavior-version-latest", aws-config = { version = "1.8.15", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
"credentials-process", aws-credential-types = { version = "1.2.14", optional = true }
"rt-tokio", aws-smithy-runtime-api = { version = "1.11.6", optional = true }
"sso", http = { version = "1.4.0", optional = true }
] } reqsign = { version = "0.16.5", optional = true }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.14.0", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.1.0", optional = true }
reqsign-core = { version = "3.2.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
@@ -329,74 +262,75 @@ unsafe_code = "forbid"
non_ascii_idents = "forbid" non_ascii_idents = "forbid"
# Deny # Deny
warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end deprecated_in_future = "deny"
# 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 only so you can still use these during development, but not in the final code # Warn
dbg_macro = "warn" dbg_macro = "warn"
todo = "warn" todo = "warn"
# Ignore/Allow # Ignore/Allow
result_large_err = "allow" result_large_err = "allow"
# Warn on these lint group (Some might be warn by default already though) # Deny
# Will be denied during CI!
complexity = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
perf = { level = "warn", priority = -1 }
style = { level = "warn", priority = -1 }
suspicious = { level = "warn", priority = -1 }
# Deny individual lints
branches_sharing_code = "deny" branches_sharing_code = "deny"
case_sensitive_file_extension_comparisons = "deny"
cast_lossless = "deny"
clone_on_ref_ptr = "deny" clone_on_ref_ptr = "deny"
equatable_if_let = "deny" equatable_if_let = "deny"
excessive_precision = "deny"
filter_map_next = "deny"
float_cmp_const = "deny" float_cmp_const = "deny"
implicit_clone = "deny"
inefficient_to_string = "deny"
iter_on_empty_collections = "deny" iter_on_empty_collections = "deny"
iter_on_single_items = "deny" iter_on_single_items = "deny"
linkedlist = "deny"
macro_use_imports = "deny"
manual_assert = "deny"
manual_instant_elapsed = "deny"
manual_string_new = "deny"
match_wildcard_for_single_variants = "deny"
mem_forget = "deny" mem_forget = "deny"
needless_borrow = "deny"
needless_collect = "deny" needless_collect = "deny"
needless_continue = "deny"
needless_lifetimes = "deny"
option_option = "deny"
redundant_clone = "deny" redundant_clone = "deny"
string_add_assign = "deny"
unnecessary_join = "deny"
unnecessary_self_imports = "deny" unnecessary_self_imports = "deny"
unnested_or_patterns = "deny"
unused_async = "deny"
unused_self = "deny"
useless_let_if_seq = "deny" useless_let_if_seq = "deny"
verbose_file_reads = "deny" verbose_file_reads = "deny"
str_to_string = "deny" zero_sized_map_values = "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
+2 -3
View File
@@ -59,9 +59,8 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
## Usage ## Usage
> [!IMPORTANT] > [!IMPORTANT]
> The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). <br> > The web-vault requires the use a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API).
> That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). <br> > That means it will only work via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS).
> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples).
The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server).
See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags. See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags.
+12 -10
View File
@@ -1,21 +1,22 @@
use std::{env, io::Error, process::Command}; use std::env;
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 // This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros
#[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one. #[cfg(feature = "sqlite")]
println!("cargo:rustc-cfg=sqlite"); println!("cargo:rustc-cfg=sqlite");
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
println!("cargo:rustc-cfg=mysql"); println!("cargo:rustc-cfg=mysql");
#[cfg(feature = "postgresql")] #[cfg(feature = "postgresql")]
println!("cargo:rustc-cfg=postgresql"); println!("cargo:rustc-cfg=postgresql");
#[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))] #[cfg(feature = "s3")]
println!("cargo:rustc-cfg=s3");
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
compile_error!( compile_error!(
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite" "You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
); );
#[cfg(feature = "s3")]
println!("cargo:rustc-cfg=s3");
// Use check-cfg to let cargo know which cfg's we define, // Use check-cfg to let cargo know which cfg's we define,
// and avoid warnings when they are used in the code. // and avoid warnings when they are used in the code.
println!("cargo::rustc-check-cfg=cfg(sqlite)"); println!("cargo::rustc-check-cfg=cfg(sqlite)");
@@ -41,12 +42,13 @@ fn main() {
} }
} }
fn run(args: &[&str]) -> Result<String, Error> { fn run(args: &[&str]) -> Result<String, std::io::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_owned()) Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
} }
/// This method reads info from Git, namely tags, branch, and revision /// This method reads info from Git, namely tags, branch, and revision
@@ -56,7 +58,7 @@ fn run(args: &[&str]) -> Result<String, 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, Error> { fn version_from_git_info() -> Result<String, std::io::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 -1
View File
@@ -2,4 +2,4 @@
# see diesel.rs/guides/configuring-diesel-cli # see diesel.rs/guides/configuring-diesel-cli
[print_schema] [print_schema]
file = "src/db/schema.rs" file = "src/db/schema.rs"
+4 -4
View File
@@ -1,13 +1,13 @@
--- ---
vault_version: "v2026.7.0" vault_version: "v2026.2.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c" vault_image_digest: "sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447"
# 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.97.1 # Rust version to be used rust_version: 1.94.1 # 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.24" # Alpine version to be used alpine_version: "3.23" # Alpine version to be used
# For which platforms/architectures will we try to build images # For which platforms/architectures will we try to build images
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
# Determine the build images per OS/Arch # Determine the build images per OS/Arch
+17 -17
View File
@@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] # [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447
# [docker.io/vaultwarden/web-vault:v2026.7.0] # [docker.io/vaultwarden/web-vault:v2026.2.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 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.97.1 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.94.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.94.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.94.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.94.1 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@@ -57,6 +57,7 @@ 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
@@ -66,11 +67,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=${CARGO_BUILD_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
RUN . /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
@@ -86,7 +87,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 . /env-cargo && \ RUN source /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
@@ -97,13 +98,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 . /env-cargo && \ RUN source /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 ; \
@@ -126,8 +127,7 @@ RUN . /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/alpine:3.23
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 \
+44 -34
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] # [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447
# [docker.io/vaultwarden/web-vault:v2026.7.0] # [docker.io/vaultwarden/web-vault:v2026.2.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 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,8 +36,7 @@ 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.97.1-slim-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.94.1-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@@ -52,7 +51,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 && xx-c-essentials to get `xx-cargo` working # Install clang 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
@@ -60,16 +59,19 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
--no-install-recommends \ --no-install-recommends \
clang \ clang \
git && \ pkg-config \
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 \
pkg-config \ zlib1g-dev && \
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
@@ -81,7 +83,30 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
RUN . /env-cargo && \ # 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
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
@@ -96,15 +121,8 @@ 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 . /env-cargo && \ RUN source /env-cargo && \
# Configure xx-cargo for target pkg-config and Debian transitive library lookup cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
# 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
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
@@ -114,20 +132,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 . /env-cargo && \ RUN source /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
# Configure xx-cargo for target pkg-config and Debian transitive library lookup cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
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 ; \
@@ -150,7 +161,6 @@ RUN . /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" \
+39 -32
View File
@@ -27,16 +27,6 @@
# $ 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
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" %}
@@ -57,7 +47,6 @@ 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
@@ -77,10 +66,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 && xx-c-essentials to get `xx-cargo` working # Install clang 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
@@ -88,16 +77,19 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
--no-install-recommends \ --no-install-recommends \
clang \ clang \
git && \ pkg-config \
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 \
pkg-config \ zlib1g-dev && \
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 %}
@@ -110,14 +102,38 @@ 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 == "alpine" %} {% if base == "debian" %}
# 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=${CARGO_BUILD_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_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 . /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
@@ -137,12 +153,8 @@ 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 . /env-cargo && \ RUN source /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
@@ -152,17 +164,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 . /env-cargo && \ RUN source /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}" && \
{% endif %} 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 ; \
@@ -185,7 +193,6 @@ RUN . /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" \
+2 -2
View File
@@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
quote = "1.0.47" quote = "1.0.45"
syn = "3.0.3" syn = "2.0.117"
[lints] [lints]
workspace = true workspace = true
+4 -5
View File
@@ -1,15 +1,14 @@
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 = parse_macro_input!(input as DeriveInput); let ast = syn::parse(input).unwrap();
impl_derive_uuid_macro(&ast) impl_derive_uuid_macro(&ast)
} }
fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream { fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident; let name = &ast.ident;
let gen_derive = quote! { let gen_derive = quote! {
#[automatically_derived] #[automatically_derived]
@@ -31,12 +30,12 @@ fn impl_derive_uuid_macro(ast: &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 = parse_macro_input!(input as DeriveInput); let ast = syn::parse(input).unwrap();
impl_derive_safestring_macro(&ast) impl_derive_safestring_macro(&ast)
} }
fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream { fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident; let name = &ast.ident;
let gen_derive = quote! { let gen_derive = quote! {
#[automatically_derived] #[automatically_derived]
@@ -1 +0,0 @@
DROP TABLE IF EXISTS archives;
@@ -1,10 +0,0 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL,
cipher_uuid CHAR(36) NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid),
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE,
FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE
);
@@ -1 +0,0 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;
@@ -1 +0,0 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
@@ -1 +0,0 @@
ALTER TABLE sso_auth DROP COLUMN code_response_error;
@@ -1 +0,0 @@
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
@@ -1,6 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
ALTER TABLE users DROP COLUMN v2_upgrade_token;
@@ -1,17 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
DROP TABLE IF EXISTS user_signature_key_pairs;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS archives;
@@ -1,8 +0,0 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
archived_at TIMESTAMP NOT NULL DEFAULT now(),
PRIMARY KEY (user_uuid, cipher_uuid)
);
@@ -1 +0,0 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;
@@ -1 +0,0 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
@@ -1 +0,0 @@
ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error;
@@ -1 +0,0 @@
ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT;
@@ -1,6 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
ALTER TABLE users DROP COLUMN v2_upgrade_token;
@@ -1,16 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
DROP TABLE IF EXISTS user_signature_key_pairs;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS archives;
@@ -1,8 +0,0 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid)
);
@@ -1 +0,0 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;
@@ -1 +0,0 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
@@ -1 +0,0 @@
ALTER TABLE sso_auth DROP COLUMN code_response_error;
@@ -1 +0,0 @@
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
@@ -1,6 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
ALTER TABLE users DROP COLUMN v2_upgrade_token;
@@ -1,16 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
DROP TABLE IF EXISTS user_signature_key_pairs;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
+3 -13
View File
@@ -21,19 +21,11 @@ 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 #
################### ###################
KC_BOOTSTRAP_ADMIN_USERNAME=admin KEYCLOAK_ADMIN=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME} KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080 KC_HTTP_PORT=8080
@@ -47,10 +39,8 @@ 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
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"} DOMAIN=http://localhost:${ROCKET_PORT}
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
+16 -14
View File
@@ -1,8 +1,8 @@
# Integration tests # Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/). This allows running integration tests using [Playwright](https://playwright.dev/).
\
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. It uses its own `test.env` 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's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change. It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
You'll additionally need `nodejs` then run: You will additionally need `nodejs` then run:
```bash ```bash
npm ci --ignore-scripts --allow-git=none --allow-remote=none npm ci --ignore-scripts
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_RUNNING=true npx playwright test PW_KEEP_SERVICE_RUNNNING=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 specific test (It might fail if it has dependency): To run only a specifc 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 "https://127.0.0.1:8000" --ignore-https-errors npx playwright codegen "http://127.0.0.1:8003"
``` ```
## Override web-vault ## Override web-vault
@@ -112,11 +112,12 @@ 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 `https://127.0.0.1:8003/admin/diagnostics` with `admin`. Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
# OpenID Connect test setup # OpenID Connect test setup
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. Additionally this `docker-compose` template allows to run locally Vaultwarden,
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
## Setup ## Setup
@@ -130,17 +131,18 @@ 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 https://127.0.0.1:8080 as user admin of realm master keycloakSetup_1 | Logging into http://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 indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done). Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user
(It is normal for this container to stop once the configuration is done).
Then you can access : Then you can access :
- `Vaultwarden` on https://0.0.0.0:8000 with the default user `test@yopmail.com/test`. - `Vaultwarden` on http://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
@@ -169,7 +171,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) 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)). - `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)).
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`) - All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
## Cleanup ## Cleanup
+2 -2
View File
@@ -17,7 +17,7 @@ done
set -e set -e
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli 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 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 "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli # kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
# ENJOY # ENJOY
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html # Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
+1 -1
View File
@@ -28,7 +28,7 @@ RUN mkdir /playwright
WORKDIR /playwright WORKDIR /playwright
COPY package.json package-lock.json . COPY package.json package-lock.json .
RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox
COPY docker-compose.yml test.env ./ COPY docker-compose.yml test.env ./
COPY compose ./compose COPY compose ./compose
-1
View File
@@ -35,7 +35,6 @@ WORKDIR /
COPY --from=prebuilt /start.sh . COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden . COPY --from=prebuilt /vaultwarden .
COPY --from=build /data ./data
COPY --from=build /web-vault ./web-vault COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"] ENTRYPOINT ["/start.sh"]
-11
View File
@@ -22,14 +22,3 @@ 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")
+6 -9
View File
@@ -24,15 +24,12 @@ 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
@@ -73,7 +70,7 @@ services:
Mysql: Mysql:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_mysql container_name: playwright_mysql
image: mysql:9.7.0 image: mysql:8.4.1
env_file: test.env env_file: test.env
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@@ -85,7 +82,7 @@ services:
Postgres: Postgres:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_postgres container_name: playwright_postgres
image: postgres:18.4 image: postgres:16.3
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}"]
@@ -97,7 +94,7 @@ services:
Maildev: Maildev:
profiles: ["vaultwarden", "maildev"] profiles: ["vaultwarden", "maildev"]
container_name: maildev container_name: maildev
image: timshel/maildev:3.2.19 image: timshel/maildev:3.0.4
ports: ports:
- ${SMTP_PORT}:1025 - ${SMTP_PORT}:1025
- 1080:1080 - 1080:1080
@@ -105,7 +102,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.6.2 image: quay.io/keycloak/keycloak:26.3.4
network_mode: "host" network_mode: "host"
command: command:
- start-dev - start-dev
@@ -115,12 +112,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.6.2 KEYCLOAK_VERSION: 26.3.4
network_mode: "host"
depends_on: depends_on:
- Keycloak - Keycloak
restart: "no" restart: "no"
+1 -1
View File
@@ -1,4 +1,4 @@
import { type FullConfig } from '@playwright/test'; import { firefox, 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';
+13 -2
View File
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
} }
export async function stopVault(force: boolean = false) { export async function stopVault(force: boolean = false) {
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) { if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "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,7 +231,6 @@ 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();
@@ -249,3 +248,15 @@ 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');
}
}
+580 -582
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -8,14 +8,14 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.60.0", "@playwright/test": "1.56.1",
"dotenv": "17.4.2", "dotenv": "17.2.3",
"dotenv-expand": "13.0.0", "dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@3.2.19" "maildev": "npm:@timshel_npm/maildev@3.2.5"
}, },
"dependencies": { "dependencies": {
"mysql2": "3.22.3", "mysql2": "3.15.3",
"otpauth": "9.5.1", "otpauth": "9.4.1",
"pg": "8.21.0" "pg": "8.16.3"
} }
} }
+4 -10
View File
@@ -25,12 +25,10 @@ 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: 240 * 1000, timeout: 120 * 1000,
actionTimeout: 40 * 1000, actionTimeout: 20 * 1000,
navigationTimeout: 40 * 1000, navigationTimeout: 20 * 1000,
expect: { timeout: 40 * 1000 }, expect: { timeout: 20 * 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: {
@@ -39,10 +37,6 @@ 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',
+4 -6
View File
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
##################### #####################
# Playwright Config # # Playwright Config #
##################### #####################
PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false} PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-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 #
################### ###################
KC_BOOTSTRAP_ADMIN_USERNAME=admin KEYCLOAK_ADMIN=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME} KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081 KC_HTTP_PORT=8081
@@ -52,12 +52,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config # # Vaultwarden Config #
###################### ######################
ROCKET_PORT=8003 ROCKET_PORT=8003
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"} DOMAIN=http://localhost:${ROCKET_PORT}
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}
+11 -5
View File
@@ -1,8 +1,6 @@
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();
@@ -18,12 +16,20 @@ 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 orgs.create(test, page, 'New organisation'); await test.step('Create Org', async () => {
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('button', { name: 'New', exact: true }).click(); await page.getByRole('link', { name: 'Collections' }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Collection' }).click(); await page.getByRole('menuitem', { name: 'Collection' }).click();
await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec'); await page.getByLabel('Name (required)').fill('RandomCollec');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec'); await utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
-56
View File
@@ -1,56 +0,0 @@
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);
});
+25 -6
View File
@@ -41,10 +41,13 @@ 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.getByRole('button', { name: "Send email" }).click(); await page.getByText('Verify your account\'s 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");
@@ -75,10 +78,26 @@ 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 logUser(test, page, users.user1, { await test.step('login', async () => {
mailBuffer: emails, await page.goto('/');
mail2fa: true,
}); await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
const code = await retrieveEmailCode(test, page, emails);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Add it later' }).click();
await page.getByRole('link', { name: 'Skip to web app' }).click();
await expect(page).toHaveTitle(/Vaults/);
})
await disableEmail(test, page, users.user1);
emails.close(); emails.close();
}); });
+2 -2
View File
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click(); await page.getByRole('button', { name: 'Log in with master password' }).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}));
+13 -49
View File
@@ -4,7 +4,6 @@ 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();
@@ -21,7 +20,6 @@ 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);
@@ -42,16 +40,6 @@ 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, {
@@ -68,16 +56,18 @@ 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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password); await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password); await page.getByLabel('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 () => {
@@ -100,19 +90,21 @@ 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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password); await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click(); await page.getByRole('button', { name: 'Log in with master password' }).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, { mailBuffer: mail1Buffer }); await logUser(test, page, users.user1, 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);
@@ -121,35 +113,7 @@ test('Confirm invited user', async ({ page }) => {
}); });
test('Organization is visible', async ({ page }) => { test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer }); await logUser(test, page, users.user2, 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.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('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed'));
});
let user2 = {
email: users.user2.email,
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
notNewDevice: true,
});
});
-110
View File
@@ -1,110 +0,0 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
let users = utils.loadEnv();
let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
const context = await browser.newContext();
const page = await context.newPage();
await createAccount(test, page, users.user1);
await context.close();
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Password', async ({ context, page }, testInfo: TestInfo) => {
const label = 'Test Password';
await logUser(test, page, users.user1);
await test.step('Create password entry', async () => {
await page.getByRole('button', { name: 'New item' }).click();
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Item added');
await page.getByRole('button', { name: 'Close' }).click();
});
// Log again
await logUser(test, page, users.user1);
await test.step('Check', async () => {
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
await page.getByTestId('copy-username').click();
await utils.checkNotification(page, 'Username copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
await page.getByTestId('copy-password').click();
await utils.checkNotification(page, 'Password copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
await page.getByRole('button', { name: 'Close' }).click();
});
await test.step('Delete', async () => {
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Item sent to bin');
});
// Log again
await logUser(test, page, users.user1);
await test.step('Deleted', async () => {
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
});
});
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
const label = 'Test SSH key';
await logUser(test, page, users.user1);
const privateKey = await test.step('Create key entry', async () => {
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'SSH key' }).click();
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Item added');
await page.getByRole('button', { name: 'Copy private key' }).click();
await utils.checkNotification(page, 'Private key copied');
return await page.evaluate(() => navigator.clipboard.readText());
});
// Log again
await logUser(test, page, users.user1);
await test.step('Check', async () => {
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
await page.getByRole('button', { name: 'Copy private key' }).click();
await utils.checkNotification(page, 'Private key copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
await page.getByRole('button', { name: 'Close' }).click();
});
await test.step('Delete', async () => {
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Item sent to bin');
});
// Log again
await logUser(test, page, users.user1);
await test.step('Deleted', async () => {
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
})
});
-72
View File
@@ -1,72 +0,0 @@
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();
});
});
+7 -8
View File
@@ -11,11 +11,10 @@ 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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
const secret = await page.getByLabel('Key', { exact: true }).innerText(); const secret = await page.getByLabel('Key').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());
@@ -34,8 +33,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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click() await page.getByLabel('Master password (required)').click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password (required)').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();
@@ -50,7 +49,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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password (required)').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();
}); });
@@ -82,8 +81,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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click() await page.getByLabel('Master password (required)').click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click(); await page.getByRole('button', { name: 'Yes' }).click();
-21
View File
@@ -1,21 +0,0 @@
import { expect, type Browser, Page } from '@playwright/test';
import * as utils from '../../global-utils';
utils.loadEnv();
export async function login(test, page: Page) {
await test.step(`Admin login`, async () => {
await page.goto('/admin');
await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN);
await page.getByRole('button', { name: 'Enter' }).click();
});
}
export async function invite(test, page: Page, email: string) {
await test.step(`Invite user with ${email}`, async () => {
await page.getByRole('link', { name: 'Users' }).click();
await page.getByRole('textbox', { name: 'Enter email' }).fill(email);
await page.getByRole('button', { name: 'Invite' }).click();
await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/);
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
utils.loadEnv(); utils.loadEnv();
test('DB teardown ?', async ({ serviceName }) => { test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) { if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
utils.stopComposeService(serviceName); utils.stopComposeService(serviceName);
} }
}); });
+11 -14
View File
@@ -3,14 +3,11 @@ 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 ${name}`, async () => { await test.step('Create Org', async () => {
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' }); await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
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.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name); await page.getByLabel('Organisation name (required)').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');
@@ -21,7 +18,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 > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click(); await page.locator('org-switcher').getByRole('link', { name: `${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();
@@ -33,11 +30,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 > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click(); await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.getByRole('link', { name: 'Members' }).click(); await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible(); await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
}); });
} }
@@ -45,13 +42,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.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email); await page.getByLabel('Email (required)').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.getByRole('combobox', { name: 'Select collections' }).click(); await page.getByLabel('Select collections').click();
await page.getByLabel('Options List').getByText('Default collection').click(); await page.getByText('Default collection').click();
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click(); await page.getByRole('cell', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited'); await utils.checkNotification(page, 'User(s) invited');
}); });
+1 -1
View File
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv(); utils.loadEnv();
test('Keycloak teardown', async () => { test('Keycloak teardown', async () => {
if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) { if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
console.log("Keep Keycloak running"); console.log("Keep Keycloak running");
} else { } else {
console.log("Keycloak stopping"); console.log("Keycloak stopping");
+17 -8
View File
@@ -15,8 +15,11 @@ 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();
}); });
@@ -30,24 +33,26 @@ 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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password); await page.getByLabel('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.includes("New Device Logged"));
await mailBuffer.expect((m) => m.subject === "Welcome"); await mailBuffer.expect((m) => m.subject === "Welcome");
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
}); });
} }
}); });
@@ -64,14 +69,16 @@ 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();
}); });
@@ -110,12 +117,14 @@ 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 && !options.notNewDevice ){ if( mailBuffer ){
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"));
}); });
+9 -25
View File
@@ -3,7 +3,6 @@ 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 () => {
@@ -18,11 +17,12 @@ 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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password); await page.getByLabel('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,16 +35,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam
}); });
} }
export async function logUser( export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
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);
@@ -52,23 +43,16 @@ export async function logUser(
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page // Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click(); await page.getByRole('button', { name: 'Log in with master password' }).click();
if( options.mail2fa ){ await utils.ignoreExtension(page);
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( options.mailBuffer && !options.notNewDevice ){ if( mailBuffer ){
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
} }
}); });
} }
+9 -52
View File
@@ -1,7 +1,6 @@
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";
@@ -20,7 +19,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: true, SSO_ONLY: false,
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,
}); });
@@ -33,64 +32,22 @@ test.afterAll('Teardown', async ({}) => {
} }
}); });
test('2FA email', async ({ page }) => { test('Create and activate 2FA', 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);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true}); mailBuffer.close();
});
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();
});
+4 -6
View File
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
await page.getByRole('button', { name: 'Other' }).click(); await page.getByRole('button', { name: 'Other' }).click();
// Unlock page // Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click(); await page.getByRole('button', { name: 'Log in with master password' }).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,7 +58,6 @@ 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);
@@ -67,6 +66,7 @@ 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,14 +74,12 @@ 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' })).toHaveCount(1); await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1);
}); });
@@ -67,16 +67,17 @@ 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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password); await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password); await page.getByLabel('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 () => {
@@ -94,7 +95,6 @@ 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,11 +108,13 @@ 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 () => {
+8 -19
View File
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });
test('Activate password policy', async ({ page }) => { test('Enforce 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,27 +61,16 @@ test('Activate 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.');
}); });
});
test('Unlock trigger policyy', async ({ page }) => { await utils.logout(test, page, users.user1);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email); await test.step(`Unlock trigger policy`, async () => {
await page.getByRole('button', { name: /Use single sign-on/ }).click(); await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
await page.getByRole('button', { name: 'Use single sign-on' }).click();
await test.step('Keycloak login', async () => { await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
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('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 page.getByRole('button', { name: 'Unlock' }).click();
});
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
});
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.97.1" channel = "1.94.1"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"
+1 -1
View File
@@ -1,4 +1,4 @@
edition = "2024" edition = "2021"
max_width = 120 max_width = 120
newline_style = "Unix" newline_style = "Unix"
use_small_heuristics = "Off" use_small_heuristics = "Off"
+75 -110
View File
@@ -2,40 +2,39 @@ 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::{Redirect, content::RawHtml as Html}, response::{content::RawHtml as Html, Redirect},
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, unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
}, },
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims}, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
config::ConfigBuilder, config::ConfigBuilder,
db::{ db::{
ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version, 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,
util::{ util::{
FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release, container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
get_display_size, is_running_in_container, parse_experimental_client_feature_flags, is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
}, },
CONFIG, VERSION,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -93,7 +92,8 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
}); });
#[cfg(sqlite)] #[cfg(sqlite)]
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite)); static CAN_BACKUP: LazyLock<bool> =
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);
@@ -199,7 +199,13 @@ 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);
@@ -217,16 +223,10 @@ 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") => {
@@ -306,21 +306,6 @@ 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 {
async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if CONFIG.mail_enabled() {
let org_id: OrganizationId = if CONFIG.sso_enabled() {
FAKE_SSO_IDENTIFIER.into()
} else {
FAKE_ADMIN_UUID.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
} else {
let invitation = Invitation::new(&user.email);
invitation.save(conn).await
}
}
let data: InviteData = data.into_inner(); let data: InviteData = data.into_inner();
if User::find_by_mail(&data.email, &conn).await.is_some() { if User::find_by_mail(&data.email, &conn).await.is_some() {
err_code!("User already exists", Status::Conflict.code) err_code!("User already exists", Status::Conflict.code)
@@ -328,7 +313,18 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
let mut user = User::new(&data.email, None); let mut user = User::new(&data.email, None);
generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if CONFIG.mail_enabled() {
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else {
let invitation = Invitation::new(&user.email);
invitation.save(conn).await
}
}
_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))
@@ -385,7 +381,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_or(String::new(), |u| u.identifier.to_string())); usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
users_json.push(usr); users_json.push(usr);
} }
@@ -468,10 +464,10 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
for device in Device::find_push_devices_by_user(&user.uuid, &conn).await { for device in Device::find_push_devices_by_user(&user.uuid, &conn).await {
match unregister_push_device(device.push_uuid.as_ref()).await { match unregister_push_device(&device.push_uuid).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}"),
} };
} }
} }
@@ -522,12 +518,8 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
} }
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org_id: OrganizationId = if CONFIG.sso_enabled() { let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
FAKE_SSO_IDENTIFIER.into() let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
} else {
FAKE_ADMIN_UUID.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(())
@@ -553,10 +545,9 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32 Some(new_type) => new_type as i32,
} else { None => err!("Invalid type"),
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 {
@@ -643,11 +634,11 @@ async fn has_http_access() -> bool {
} }
} }
use cached::macros::cached; use cached::proc_macro::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(ttl = 600, sync_writes = "default")] #[cached(time = 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,40 +647,42 @@ 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_owned(), _ => "-".to_string(),
}, },
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_owned(), _ => "-".to_string(),
}, },
// 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_owned(), Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
_ => "-".to_owned(), _ => "-".to_string(),
}, },
) )
} else { } else {
("-".to_owned(), "-".to_owned(), "-".to_owned()) ("-".to_string(), "-".to_string(), "-".to_string())
} }
} }
async fn get_ntp_time(has_http_access: bool) -> String { async fn get_ntp_time(has_http_access: bool) -> String {
if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await { if has_http_access {
for line in cf_trace.lines() { if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
if let Some((key, value)) = line.split_once('=') for line in cf_trace.lines() {
&& key == "ts" if let Some((key, value)) = line.split_once('=') {
{ 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;
} }
} }
} }
@@ -716,36 +709,6 @@ 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::*;
@@ -762,7 +725,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_owned(), _ => "Unable to resolve domain name.".to_string(),
}; };
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;
@@ -773,7 +736,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();
@@ -800,7 +763,6 @@ 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,
@@ -863,30 +825,33 @@ 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 Outcome::Success(ip) = ClientIp::from_request(request).await else { let ip = match ClientIp::from_request(request).await {
err_handler!("Error getting Client IP") Outcome::Success(ip) => 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 = if let Some(cookie) = cookies.get(COOKIE_NAME) { let access_token = match cookies.get(COOKIE_NAME) {
cookie.value() Some(cookie) => cookie.value(),
} else { None => {
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"));
} }
} }
+121 -443
View File
@@ -1,38 +1,34 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::db::DbPool;
use chrono::Utc; use chrono::Utc;
use rocket::{ use rocket::serde::json::Json;
http::Status,
request::{FromRequest, Outcome, Request},
serde::json::Json,
};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
CONFIG,
api::{ api::{
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{accept_org_invite, log_user_event, two_factor::email}, core::{accept_org_invite, log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device, master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
JsonResult, Notify, PasswordOrOtpData, UpdateType,
}, },
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
crypto, crypto,
db::{ db::{
DbConn, DbPool,
models::{ models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, User, UserId, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
UserKdfType, UserSignatureKeyPair,
}, },
DbConn,
}, },
mail, mail,
util::{NumberOrString, deser_opt_nonempty_str, format_date}, util::{deser_opt_nonempty_str, format_date, NumberOrString},
CONFIG,
}; };
use super::{ use rocket::{
ciphers::{CipherData, update_cipher_from_data}, http::Status,
sends::{SendData, update_send_from_data}, request::{FromRequest, Outcome, Request},
}; };
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
@@ -42,8 +38,6 @@ pub fn routes() -> Vec<rocket::Route> {
post_profile, post_profile,
put_avatar, put_avatar,
get_public_keys, get_public_keys,
get_account_public_keys,
get_keys,
post_keys, post_keys,
post_password, post_password,
post_set_password, post_set_password,
@@ -60,9 +54,9 @@ pub fn routes() -> Vec<rocket::Route> {
delete_account, delete_account,
revision_date, revision_date,
password_hint, password_hint,
post_prelogin, prelogin,
verify_password, verify_password,
post_api_key, api_key,
rotate_api_key, rotate_api_key,
get_known_device, get_known_device,
get_all_devices, get_all_devices,
@@ -100,14 +94,14 @@ pub struct RegisterData {
email: String, email: String,
#[serde(flatten)] #[serde(flatten)]
compat: RegisterDataCompat, kdf: KDFData,
#[serde(alias = "userSymmetricKey")]
key: String,
#[serde(alias = "userAsymmetricKeys")] #[serde(alias = "userAsymmetricKeys")]
keys: Option<KeysData>, keys: Option<KeysData>,
// Supersedes `keys`, and the only way a v2 account can be registered. master_password_hash: String,
account_keys: Option<AccountKeysData>,
master_password_hint: Option<String>, master_password_hint: Option<String>,
name: Option<String>, name: Option<String>,
@@ -123,70 +117,16 @@ pub struct RegisterData {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct RegisterDataOld { #[serde(rename_all = "camelCase")]
pub struct SetPasswordData {
#[serde(flatten)] #[serde(flatten)]
kdf: KDFData, kdf: KDFData,
#[serde(alias = "userSymmetricKey")]
key: String, key: String,
keys: Option<KeysData>,
#[serde(alias = "masterPasswordHash")]
master_password_hash: String, master_password_hash: String,
} master_password_hint: Option<String>,
org_identifier: Option<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),
}
}
fn hash(&self) -> String {
self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.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, email: &str) -> bool {
let mut unprocessable = false;
*self.fold(
|_| &false,
|rdcu| {
let email = 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)] #[derive(Debug, Deserialize)]
@@ -196,213 +136,18 @@ struct KeysData {
public_key: String, public_key: String,
} }
/// The `accountKeys` payload, which replaces the flat `keys`/`userAsymmetricKeys` object.
///
/// It carries either a "v1" state (just the encryption key pair) or a "v2" one, which adds a
/// signature key pair, a signed public key, and a signed security state. The two deprecated
/// top-level fields are still sent by the SDK alongside the nested ones and are only used as a
/// fallback for clients that don't send `publicKeyEncryptionKeyPair` yet.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs>
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountKeysData {
user_key_encrypted_account_private_key: Option<String>,
account_public_key: Option<String>,
public_key_encryption_key_pair: Option<PublicKeyEncryptionKeyPairData>,
signature_key_pair: Option<SignatureKeyPairData>,
security_state: Option<SecurityStateData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PublicKeyEncryptionKeyPairData {
wrapped_private_key: String,
public_key: String,
signed_public_key: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SignatureKeyPairData {
signature_algorithm: String,
wrapped_signing_key: String,
verifying_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SecurityStateData {
security_state: String,
security_version: i32,
}
pub struct ValidatedAccountKeys {
private_key: String,
public_key: String,
v2: Option<ValidatedV2AccountKeys>,
}
struct ValidatedV2AccountKeys {
signed_public_key: String,
signing_key: String,
verifying_key: String,
signature_algorithm: SignatureAlgorithm,
security_state: String,
security_version: i32,
}
impl AccountKeysData {
/// Checks that the payload describes a complete account cryptographic state.
///
/// The v2 fields have to be all present or all absent: a client that receives a COSE-wrapped
/// private key without the matching signature key pair and security state refuses to unlock the
/// vault, so storing half a state would produce an account nobody can log into.
pub fn validate(self) -> ApiResult<ValidatedAccountKeys> {
let (private_key, public_key, signed_public_key) = if let Some(key_pair) = self.public_key_encryption_key_pair {
(key_pair.wrapped_private_key, key_pair.public_key, key_pair.signed_public_key)
// Older clients only send the deprecated top-level fields, which are always v1.
} else if let (Some(private_key), Some(public_key)) =
(self.user_key_encrypted_account_private_key, self.account_public_key)
{
(private_key, public_key, None)
} else {
err!("The account keys are missing an encryption key pair")
};
let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) {
(Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => {
let Some(signature_algorithm) = SignatureAlgorithm::from_str(&signature_key_pair.signature_algorithm)
else {
err!(format!("Unsupported signature algorithm: {}", signature_key_pair.signature_algorithm))
};
Some(ValidatedV2AccountKeys {
signed_public_key,
signing_key: signature_key_pair.wrapped_signing_key,
verifying_key: signature_key_pair.verifying_key,
signature_algorithm,
security_state: security_state.security_state,
security_version: security_state.security_version,
})
}
(None, None, None) => None,
_ => err!(
"Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent"
),
};
Ok(ValidatedAccountKeys {
private_key,
public_key,
v2,
})
}
}
impl From<KeysData> for ValidatedAccountKeys {
fn from(keys: KeysData) -> Self {
Self {
private_key: keys.encrypted_private_key,
public_key: keys.public_key,
v2: None,
}
}
}
impl ValidatedAccountKeys {
/// Writes the parts of the state that live on the user itself. The user still needs saving, and
/// [`Self::save_signature_key_pair`] still needs calling once it has been.
///
/// Rejects downgrading an account from v2 back to v1.
pub fn apply(&self, user: &mut User) -> EmptyResult {
if user.is_v2() && self.v2.is_none() {
err!("Cannot downgrade an account from v2 to v1 encryption")
}
user.private_key = Some(self.private_key.clone());
user.public_key = Some(self.public_key.clone());
user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone());
user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone());
user.security_version = self.v2.as_ref().map(|v2| v2.security_version);
Ok(())
}
/// Persists the signature key pair. Separate from [`Self::apply`] because the row has a foreign
/// key to the user, so it can only be written once the user exists.
pub async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult {
// Skip if the account is v1, since v1 accounts don't have a signature key pair.
let Some(v2) = &self.v2 else {
return Ok(());
};
let mut key_pair = match UserSignatureKeyPair::find_active_by_user(user_id, conn).await {
Some(mut key_pair) => {
key_pair.signature_algorithm = v2.signature_algorithm as i32;
key_pair.signing_key.clone_from(&v2.signing_key);
key_pair.verifying_key.clone_from(&v2.verifying_key);
key_pair
}
None => UserSignatureKeyPair::new(
user_id.clone(),
v2.signature_algorithm,
v2.signing_key.clone(),
v2.verifying_key.clone(),
),
};
key_pair.save(conn).await
}
}
#[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)]
#[serde(rename_all = "camelCase")]
pub struct SetPasswordData {
#[serde(flatten)]
compat: RegisterDataCompat,
keys: Option<KeysData>,
// Supersedes `keys`, and the only way a v2 account can be initialized here.
account_keys: Option<AccountKeysData>,
master_password_hint: Option<String>,
org_identifier: Option<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_owned()), ht => Some(ht.to_string()),
}, },
} }
} }
fn enforce_password_hint_setting(password_hint: Option<&String>) -> EmptyResult { fn enforce_password_hint_setting(password_hint: &Option<String>) -> EmptyResult {
if password_hint.is_some() && !CONFIG.password_hints_allowed() { if password_hint.is_some() && !CONFIG.password_hints_allowed() {
err!("Password hints have been disabled by the administrator. Remove the hint and try again."); err!("Password hints have been disabled by the administrator. Remove the hint and try again.");
} }
@@ -421,7 +166,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();
@@ -429,10 +174,6 @@ 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.compat.unprocessable(&data.email) {
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 (
@@ -496,16 +237,16 @@ 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 {
&& name.len() > 50 if 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
// can retry without losing their invitation below. // can retry without losing their invitation below.
let password_hint = clean_password_hint(data.master_password_hint.as_ref()); let password_hint = clean_password_hint(&data.master_password_hint);
enforce_password_hint_setting(password_hint.as_ref())?; enforce_password_hint_setting(&password_hint)?;
let mut user = match User::find_by_mail(&email, &conn).await { let mut user = match User::find_by_mail(&email, &conn).await {
Some(user) => { Some(user) => {
@@ -513,8 +254,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.as_ref() { if let Some(token) = data.org_invite_token {
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;
@@ -552,9 +293,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.compat.kdf())?; set_kdf_data(&mut user, &data.kdf)?;
user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?; user.set_password(&data.master_password_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
@@ -562,13 +303,9 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
user.name = name; user.name = name;
} }
let account_keys = match (data.account_keys, data.keys) { if let Some(keys) = data.keys {
(Some(account_keys), _) => Some(account_keys.validate()?), user.private_key = Some(keys.encrypted_private_key);
(None, Some(keys)) => Some(keys.into()), user.public_key = Some(keys.public_key);
(None, None) => None,
};
if let Some(ref account_keys) = account_keys {
account_keys.apply(&mut user)?;
} }
if email_verified { if email_verified {
@@ -592,10 +329,6 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
user.save(&conn).await?; user.save(&conn).await?;
if let Some(account_keys) = account_keys {
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
}
// accept any open emergency access invitations // accept any open emergency access invitations
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() { if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await { for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await {
@@ -618,26 +351,16 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
err!("Account already initialized, cannot set password") err!("Account already initialized, cannot set password")
} }
if data.compat.unprocessable(&user.email) {
err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code);
}
// Check against the password hint setting here so if it fails, // Check against the password hint setting here so if it fails,
// the user can retry without losing their invitation below. // the user can retry without losing their invitation below.
let password_hint = clean_password_hint(data.master_password_hint.as_ref()); let password_hint = clean_password_hint(&data.master_password_hint);
enforce_password_hint_setting(password_hint.as_ref())?; enforce_password_hint_setting(&password_hint)?;
let account_keys = match (data.account_keys, data.keys) { set_kdf_data(&mut user, &data.kdf)?;
(Some(account_keys), _) => Some(account_keys.validate()?),
(None, Some(keys)) => Some(keys.into()),
(None, None) => None,
};
set_kdf_data(&mut user, data.compat.kdf())?;
user.set_password( user.set_password(
&data.compat.hash(), &data.master_password_hash,
Some(data.compat.key()), Some(data.key),
false, false,
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
&conn, &conn,
@@ -645,23 +368,23 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
.await?; .await?;
user.password_hint = password_hint; user.password_hint = password_hint;
if let Some(ref account_keys) = account_keys { if let Some(keys) = data.keys {
account_keys.apply(&mut user)?; user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key);
} }
if let Some(identifier) = data.org_identifier if let Some(identifier) = data.org_identifier {
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER if identifier != crate::sso::FAKE_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
&& identifier != crate::api::admin::FAKE_ADMIN_UUID let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
{ 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() {
@@ -675,10 +398,6 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
user.save(&conn).await?; user.save(&conn).await?;
if let Some(account_keys) = account_keys {
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
}
Ok(Json(json!({ Ok(Json(json!({
"object": "set-password", "object": "set-password",
"captchaBypassToken": "", "captchaBypassToken": "",
@@ -732,10 +451,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 {
&& color.len() != 7 if 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;
@@ -760,60 +479,20 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
}))) })))
} }
#[get("/users/<user_id>/keys")]
async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult {
let user = match User::find_by_uuid(&user_id, &conn).await {
Some(user) if user.public_key.is_some() => user,
Some(_) => err_code!("User has no public_key", Status::NotFound.code),
None => err_code!("User doesn't exist", Status::NotFound.code),
};
Ok(Json(user.public_keys_json(&conn).await))
}
#[get("/accounts/keys")]
async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user;
Ok(Json(json!({
"key": user.akey,
"privateKey": user.private_key,
"publicKey": user.public_key,
"accountKeys": user.account_keys_json(&conn).await,
"object": "keys"
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PostKeysData {
#[serde(flatten)]
keys: Option<KeysData>,
account_keys: Option<AccountKeysData>,
}
#[post("/accounts/keys", data = "<data>")] #[post("/accounts/keys", data = "<data>")]
async fn post_keys(data: Json<PostKeysData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PostKeysData = data.into_inner(); let data: KeysData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
// `accountKeys` supersedes the flat `keys` object when both are sent. user.private_key = Some(data.encrypted_private_key);
let account_keys = match (data.account_keys, data.keys) { user.public_key = Some(data.public_key);
(Some(account_keys), _) => account_keys.validate()?,
(None, Some(keys)) => keys.into(),
(None, None) => err!("No account keys provided"),
};
account_keys.apply(&mut user)?;
user.save(&conn).await?; user.save(&conn).await?;
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
Ok(Json(json!({ Ok(Json(json!({
"key": user.akey,
"privateKey": user.private_key, "privateKey": user.private_key,
"publicKey": user.public_key, "publicKey": user.public_key,
"accountKeys": user.account_keys_json(&conn).await,
"object":"keys" "object":"keys"
}))) })))
} }
@@ -836,8 +515,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid password") err!("Invalid password")
} }
user.password_hint = clean_password_hint(data.master_password_hint.as_ref()); user.password_hint = clean_password_hint(&data.master_password_hint);
enforce_password_hint_setting(user.password_hint.as_ref())?; enforce_password_hint_setting(&user.password_hint)?;
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;
@@ -920,6 +599,10 @@ 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,
@@ -985,6 +668,9 @@ 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 {
@@ -1154,7 +840,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?
} }
} }
@@ -1167,7 +853,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
@@ -1179,7 +865,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
@@ -1192,6 +878,8 @@ 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())
@@ -1202,7 +890,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?
} }
} }
@@ -1332,22 +1020,24 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
err!("Email already in use"); err!("Email already in use");
} }
if let Some(ref val) = user.email_new { match user.email_new {
if val != &data.new_email { Some(ref val) => {
err!("Email change mismatch"); if val != &data.new_email {
err!("Email change mismatch");
}
} }
} else { None => err!("No email change pending"),
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...
if let Some(ref val) = user.email_new_token { match user.email_new_token {
if *val != data.token.into_string() { Some(ref val) => {
err!("Token mismatch"); if *val != data.token.into_string() {
err!("Token mismatch");
}
} }
} else { None => err!("No email change pending"),
err!("No email change pending")
} }
user.verified_at = Some(Utc::now().naive_utc()); user.verified_at = Some(Utc::now().naive_utc());
} else { } else {
@@ -1420,16 +1110,14 @@ struct DeleteRecoverData {
} }
#[post("/accounts/delete-recover", data = "<data>")] #[post("/accounts/delete-recover", data = "<data>")]
async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult { async fn post_delete_recover(data: Json<DeleteRecoverData>, 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 {
&& let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await if 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 {
@@ -1481,7 +1169,6 @@ 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();
@@ -1495,15 +1182,13 @@ struct PasswordHintData {
} }
#[post("/accounts/password-hint", data = "<data>")] #[post("/accounts/password-hint", data = "<data>")]
async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult { async fn password_hint(data: Json<PasswordHintData>, 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;
@@ -1514,9 +1199,9 @@ async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn)
// 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::{RngExt, rngs::SmallRng}; use rand::{rngs::SmallRng, RngExt};
let mut rng: SmallRng = rand::make_rng(); let mut rng: SmallRng = rand::make_rng();
let sleep_ms: u64 = rng.random_range(900..=1100); let sleep_ms = rng.random_range(900..=1100) as u64;
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 {
@@ -1544,11 +1229,11 @@ pub struct PreloginData {
} }
#[post("/accounts/prelogin", data = "<data>")] #[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await _prelogin(data, conn).await
} }
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
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 {
@@ -1561,13 +1246,6 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"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,
})) }))
} }
@@ -1605,7 +1283,9 @@ 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 update_api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult { async fn _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;
@@ -1624,13 +1304,13 @@ async fn update_api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: He
} }
#[post("/accounts/api-key", data = "<data>")] #[post("/accounts/api-key", data = "<data>")]
async fn post_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { async fn api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
update_api_key(data, false, headers, conn).await _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 {
update_api_key(data, true, headers, conn).await _api_key(data, true, headers, conn).await
} }
#[get("/devices/knowndevice")] #[get("/devices/knowndevice")]
@@ -1673,7 +1353,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_owned().into() uuid.to_string().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"));
}; };
@@ -1688,7 +1368,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(DeviceWithAuthRequest::to_json).collect::<Vec<Value>>(); let devices = devices.iter().map(|device| device.to_json()).collect::<Vec<Value>>();
Ok(Json(json!({ Ok(Json(json!({
"data": devices, "data": devices,
@@ -1744,9 +1424,7 @@ 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, ip: ClientIp, conn: DbConn) -> EmptyResult { async fn put_clear_device_token(device_id: DeviceId, 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
@@ -1760,7 +1438,7 @@ async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn)
if let Some(device) = Device::find_by_uuid(&device_id, &conn).await { if let Some(device) = Device::find_by_uuid(&device_id, &conn).await {
Device::clear_push_token_by_uuid(&device_id, &conn).await?; Device::clear_push_token_by_uuid(&device_id, &conn).await?;
unregister_push_device(device.push_uuid.as_ref()).await?; unregister_push_device(&device.push_uuid).await?;
} }
Ok(()) Ok(())
@@ -1768,8 +1446,8 @@ async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn)
// 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, ip: ClientIp, conn: DbConn) -> EmptyResult { async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
put_clear_device_token(device_id, ip, conn).await put_clear_device_token(device_id, conn).await
} }
#[get("/tasks")] #[get("/tasks")]
@@ -2030,6 +1708,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")
} }
} }
+136 -313
View File
@@ -2,30 +2,30 @@ 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::{
Route,
form::{Form, FromForm}, form::{Form, FromForm},
fs::TempFile, Route,
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::{
CONFIG, api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
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, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, MembershipType,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
}, },
DbConn, DbPool,
}, },
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, CONFIG,
}; };
use super::folders::FolderData; use super::folders::FolderData;
@@ -96,10 +96,6 @@ pub fn routes() -> Vec<Route> {
post_collections_update, post_collections_update,
post_collections_admin, post_collections_admin,
put_collections_admin, put_collections_admin,
archive_cipher_put,
archive_cipher_selected,
unarchive_cipher_put,
unarchive_cipher_selected,
] ]
} }
@@ -108,7 +104,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")
} }
} }
@@ -164,7 +160,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
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 the userDecryptionOptions sent in connect/token,
@@ -198,7 +194,6 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"sends": sends_json, "sends": sends_json,
"userDecryption": { "userDecryption": {
"masterPasswordUnlock": master_password_unlock, "masterPasswordUnlock": master_password_unlock,
"v2UpgradeToken": headers.user.v2_upgrade_token_json(),
}, },
"object": "sync" "object": "sync"
}))) })))
@@ -298,7 +293,6 @@ pub struct CipherData {
// when using older client versions, or if the operation doesn't involve // when using older client versions, or if the operation doesn't involve
// updating an existing cipher. // updating an existing cipher.
last_known_revision_date: Option<String>, last_known_revision_date: Option<String>,
archived_date: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -402,34 +396,20 @@ 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 {
&& let Some(dt) = data.last_known_revision_date if let Some(dt) = data.last_known_revision_date {
{ match NaiveDateTime::parse_from_str(&dt, "%+") {
match NaiveDateTime::parse_from_str(&dt, "%+") { // ISO 8601 format
// ISO 8601 format Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"), Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { err!("The client copy of this cipher is out of date. Resync the client and try again.")
err!("The client copy of this cipher is out of date. Resync the client and try again.") }
Ok(_) => (),
} }
Ok(_) => (),
} }
} }
@@ -451,9 +431,7 @@ 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) => {
// A non-empty list of collections implies the caller already validated the user's write if shared_to_collections.is_some()
// 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
{ {
@@ -473,22 +451,25 @@ 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 {
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() if 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 Some(mut saved_att) = Attachment::find_by_id(&id, conn).await else { let mut saved_att = match Attachment::find_by_id(&id, conn).await {
// Warn and continue here. Some(att) => att,
// A missing attachment means it was removed via an other client. None => {
// Also the Desktop Client supports removing attachments and save an update afterwards. // Warn and continue here.
// Bitwarden it self ignores these mismatches server side. // A missing attachment means it was removed via an other client.
warn!("Attachment {id} doesn't exist"); // Also the Desktop Client supports removing attachments and save an update afterwards.
continue; // Bitwarden it self ignores these mismatches server side.
warn!("Attachment {id} doesn't exist");
continue;
}
}; };
if saved_att.cipher_uuid != cipher.uuid { if saved_att.cipher_uuid != cipher.uuid {
@@ -505,6 +486,20 @@ 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,
@@ -514,22 +509,23 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"), _ => err!("Invalid type"),
}; };
let type_data = if let Some(mut data) = type_data_opt { let type_data = match type_data_opt {
// Remove the 'Response' key from the base object. Some(mut data) => {
data.as_object_mut().unwrap().remove("response"); // Remove the 'Response' key from the base object.
// Remove the 'Response' key from every Uri. data.as_object_mut().unwrap().remove("response");
if data["uris"].is_array() { // Remove the 'Response' key from every Uri.
data["uris"] = clean_cipher_data(data["uris"].clone()); if data["uris"].is_array() {
data["uris"] = _clean_cipher_data(data["uris"].clone());
}
data
} }
data None => err!("Data missing"),
} 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);
@@ -538,13 +534,6 @@ 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 NaiveDateTime::parse_from_str(&dt_str, "%+") {
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
}
}
if ut != UpdateType::None { if ut != UpdateType::None {
// Only log events for organizational ciphers // Only log events for organizational ciphers
if let Some(org_id) = &cipher.organization_uuid { if let Some(org_id) = &cipher.organization_uuid {
@@ -611,7 +600,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 { for folder in data.folders.into_iter() {
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 {
@@ -632,7 +621,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).and_then(|i| folders.get(*i).cloned()); let folder_id = relations_map.get(&index).map(|i| folders[*i].clone());
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());
@@ -641,7 +630,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let mut user = headers.user; let mut user = headers.user;
user.update_revision(&conn).await?; user.update_revision(&conn).await?;
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, &conn).await;
Ok(()) Ok(())
} }
@@ -736,10 +725,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 {
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() if 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
@@ -813,16 +802,12 @@ async fn post_collections_update(
err!("Collection cannot be changed") err!("Collection cannot be changed")
} }
let Some(ref org_uuid) = cipher.organization_uuid else {
err!("Cipher is not owned by an organization")
};
let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids); let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids);
let current_collections = let current_collections =
HashSet::<CollectionId>::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); HashSet::<CollectionId>::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await);
for collection in posted_collections.symmetric_difference(&current_collections) { for collection in posted_collections.symmetric_difference(&current_collections) {
match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await {
None => err!("Invalid collection ID provided"), None => err!("Invalid collection ID provided"),
Some(collection) => { Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn).await { if collection.is_writable_by_user(&headers.user.uuid, &conn).await {
@@ -853,7 +838,7 @@ async fn post_collections_update(
log_event( log_event(
EventType::CipherUpdatedCollections as i32, EventType::CipherUpdatedCollections as i32,
&cipher.uuid, &cipher.uuid,
org_uuid, &cipher.organization_uuid.clone().unwrap(),
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@@ -871,7 +856,7 @@ async fn put_collections_admin(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> EmptyResult {
post_collections_admin(cipher_id, data, headers, conn, nt).await post_collections_admin(cipher_id, data, headers, conn, nt).await
} }
@@ -882,7 +867,7 @@ async fn post_collections_admin(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> EmptyResult {
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 {
@@ -893,16 +878,12 @@ async fn post_collections_admin(
err!("Collection cannot be changed") err!("Collection cannot be changed")
} }
let Some(ref org_uuid) = cipher.organization_uuid else {
err!("Cipher is not owned by an organization")
};
let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids); let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids);
let current_collections = let current_collections =
HashSet::<CollectionId>::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); HashSet::<CollectionId>::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await);
for collection in posted_collections.symmetric_difference(&current_collections) { for collection in posted_collections.symmetric_difference(&current_collections) {
match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await {
None => err!("Invalid collection ID provided"), None => err!("Invalid collection ID provided"),
Some(collection) => { Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn).await { if collection.is_writable_by_user(&headers.user.uuid, &conn).await {
@@ -933,7 +914,7 @@ async fn post_collections_admin(
log_event( log_event(
EventType::CipherUpdatedCollections as i32, EventType::CipherUpdatedCollections as i32,
&cipher.uuid, &cipher.uuid,
org_uuid, &cipher.organization_uuid.unwrap(),
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@@ -941,7 +922,7 @@ async fn post_collections_admin(
) )
.await; .await;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?)) Ok(())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -1003,7 +984,7 @@ async fn put_cipher_share_selected(
err!("You must select at least one collection.") err!("You must select at least one collection.")
} }
for cipher in &data.ciphers { for cipher in data.ciphers.iter() {
if cipher.id.is_none() { if cipher.id.is_none() {
err!("Request missing ids field") err!("Request missing ids field")
} }
@@ -1015,15 +996,16 @@ async fn put_cipher_share_selected(
collection_ids: data.collection_ids.clone(), collection_ids: data.collection_ids.clone(),
}; };
if let Some(id) = shared_cipher_data.cipher.id.take() { match shared_cipher_data.cipher.id.take() {
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? Some(id) => {
} else { share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
err!("Request missing ids field") }
None => err!("Request missing ids field"),
}; };
} }
// Multi share actions do not send out a push for each cipher, we need to send a general sync here // Multi share actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await;
Ok(()) Ok(())
} }
@@ -1036,23 +1018,17 @@ async fn share_cipher_by_uuid(
nt: &Notify<'_>, nt: &Notify<'_>,
override_ut: Option<UpdateType>, override_ut: Option<UpdateType>,
) -> JsonResult { ) -> JsonResult {
let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await {
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { Some(cipher) => {
cipher if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
} else { cipher
err!("Cipher is not write accessible") } else {
err!("Cipher is not write accessible")
}
} }
} else { None => err!("Cipher doesn't exist"),
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 {
@@ -1069,7 +1045,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
@@ -1267,10 +1243,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 {
&& size > size_limit if 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 {
@@ -1412,7 +1388,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
} }
@@ -1446,7 +1422,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")]
@@ -1457,42 +1433,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
} }
@@ -1503,7 +1479,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
} }
@@ -1514,7 +1490,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
} }
@@ -1525,7 +1501,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
} }
@@ -1536,7 +1512,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
} }
@@ -1547,7 +1523,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
} }
@@ -1558,18 +1534,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>")]
@@ -1579,7 +1555,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>")]
@@ -1589,7 +1565,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)]
@@ -1610,10 +1586,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 {
&& Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() if 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();
@@ -1642,7 +1618,7 @@ async fn move_cipher_selected(
.await; .await;
} else { } else {
// Multi move actions do not send out a push for each cipher, we need to send a general sync here // Multi move actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await;
} }
if cipher_count != accessible_ciphers_count { if cipher_count != accessible_ciphers_count {
@@ -1694,7 +1670,7 @@ async fn purge_org_vault(
match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await { match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await {
Some(member) if member.atype == MembershipType::Owner => { Some(member) if member.atype == MembershipType::Owner => {
Cipher::delete_all_by_organization(&organization.org_id, &conn).await?; Cipher::delete_all_by_organization(&organization.org_id, &conn).await?;
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, &conn).await;
log_event( log_event(
EventType::OrganizationPurgedVault as i32, EventType::OrganizationPurgedVault as i32,
@@ -1734,41 +1710,11 @@ async fn purge_personal_vault(
} }
user.update_revision(&conn).await?; user.update_revision(&conn).await?;
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, &conn).await;
Ok(()) Ok(())
} }
#[put("/ciphers/<cipher_id>/archive")]
async fn archive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
archive_cipher(&cipher_id, &headers, false, &conn, &nt).await
}
#[put("/ciphers/archive", data = "<data>")]
async fn archive_cipher_selected(
data: Json<CipherIdsData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
archive_multiple_ciphers(data, &headers, &conn, &nt).await
}
#[put("/ciphers/<cipher_id>/unarchive")]
async fn unarchive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
unarchive_cipher(&cipher_id, &headers, false, &conn, &nt).await
}
#[put("/ciphers/unarchive", data = "<data>")]
async fn unarchive_cipher_selected(
data: Json<CipherIdsData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
unarchive_multiple_ciphers(data, &headers, &conn, &nt).await
}
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum CipherDeleteOptions { pub enum CipherDeleteOptions {
SoftSingle, SoftSingle,
@@ -1777,7 +1723,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,
@@ -1843,7 +1789,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,
@@ -1853,18 +1799,18 @@ 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
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await;
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,
@@ -1910,7 +1856,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,
@@ -1920,14 +1866,14 @@ 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,
} }
} }
// Multi move actions do not send out a push for each cipher, we need to send a general sync here // Multi move actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, conn).await;
Ok(Json(json!({ Ok(Json(json!({
"data": ciphers, "data": ciphers,
@@ -1936,7 +1882,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,
@@ -1987,122 +1933,6 @@ async fn delete_cipher_attachment_by_id(
Ok(Json(json!({"cipher":cipher_json}))) Ok(Json(json!({"cipher":cipher_json})))
} }
async fn archive_cipher(
cipher_id: &CipherId,
headers: &Headers,
multi_archive: bool,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else {
err!("Cipher doesn't exist")
};
if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await {
err!("Cipher is not accessible for the current user")
}
cipher.set_archived_at(Utc::now().naive_utc(), &headers.user.uuid, conn).await?;
if !multi_archive {
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
}
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
}
async fn unarchive_cipher(
cipher_id: &CipherId,
headers: &Headers,
multi_unarchive: bool,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else {
err!("Cipher doesn't exist")
};
if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await {
err!("Cipher is not accessible for the current user")
}
cipher.unarchive(&headers.user.uuid, conn).await?;
if !multi_unarchive {
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
}
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
}
async fn archive_multiple_ciphers(
data: Json<CipherIdsData>,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let data = data.into_inner();
let mut ciphers: Vec<Value> = Vec::new();
for cipher_id in data.ids {
match archive_cipher(&cipher_id, headers, true, conn, nt).await {
Ok(json) => ciphers.push(json.into_inner()),
err => return err,
}
}
// Multi archive does not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await;
Ok(Json(json!({
"data": ciphers,
"object": "list",
"continuationToken": null
})))
}
async fn unarchive_multiple_ciphers(
data: Json<CipherIdsData>,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let data = data.into_inner();
let mut ciphers: Vec<Value> = Vec::new();
for cipher_id in data.ids {
match unarchive_cipher(&cipher_id, headers, true, conn, nt).await {
Ok(json) => ciphers.push(json.into_inner()),
err => return err,
}
}
// Multi unarchive does not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await;
Ok(Json(json!({
"data": ciphers,
"object": "list",
"continuationToken": null
})))
}
/// This will hold all the necessary data to improve a full sync of all the ciphers /// This will hold all the necessary data to improve a full sync of all the ciphers
/// It can be used during the `Cipher::to_json()` call. /// It can be used during the `Cipher::to_json()` call.
/// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed. /// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed.
@@ -2112,7 +1942,6 @@ pub struct CipherSyncData {
pub cipher_folders: HashMap<CipherId, FolderId>, pub cipher_folders: HashMap<CipherId, FolderId>,
pub cipher_favorites: HashSet<CipherId>, pub cipher_favorites: HashSet<CipherId>,
pub cipher_collections: HashMap<CipherId, Vec<CollectionId>>, pub cipher_collections: HashMap<CipherId, Vec<CollectionId>>,
pub cipher_archives: HashMap<CipherId, NaiveDateTime>,
pub members: HashMap<OrganizationId, Membership>, pub members: HashMap<OrganizationId, Membership>,
pub user_collections: HashMap<CollectionId, CollectionUser>, pub user_collections: HashMap<CollectionId, CollectionUser>,
pub user_collections_groups: HashMap<CollectionId, CollectionGroup>, pub user_collections_groups: HashMap<CollectionId, CollectionGroup>,
@@ -2129,25 +1958,20 @@ impl CipherSyncData {
pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self { pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self {
let cipher_folders: HashMap<CipherId, FolderId>; let cipher_folders: HashMap<CipherId, FolderId>;
let cipher_favorites: HashSet<CipherId>; let cipher_favorites: HashSet<CipherId>;
let cipher_archives: HashMap<CipherId, NaiveDateTime>;
match sync_type { match sync_type {
// User Sync supports Folders, Favorites, and Archives // User Sync supports Folders and Favorites
CipherSyncType::User => { CipherSyncType::User => {
// Generate a HashMap with the Cipher UUID as key and the Folder UUID as value // Generate a HashMap with the Cipher UUID as key and the Folder UUID as value
cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect(); cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect();
// Generate a HashSet of all the Cipher UUID's which are marked as favorite // Generate a HashSet of all the Cipher UUID's which are marked as favorite
cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect(); cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect();
// Generate a HashMap with the Cipher UUID as key and the archived date time as value
cipher_archives = Archive::find_by_user(user_id, conn).await.into_iter().collect();
} }
// Organization Sync does not support Folders, Favorites, or Archives. // Organization Sync does not support Folders and Favorites.
// 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::new(); cipher_folders = HashMap::with_capacity(0);
cipher_favorites = HashSet::new(); cipher_favorites = HashSet::with_capacity(0);
cipher_archives = HashMap::new();
} }
} }
@@ -2214,7 +2038,6 @@ impl CipherSyncData {
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,
+24 -28
View File
@@ -1,23 +1,23 @@
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use rocket::{Route, serde::json::Json}; use rocket::{serde::json::Json, Route};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
CONFIG,
api::{ api::{
EmptyResult, JsonResult,
core::{CipherSyncData, CipherSyncType}, core::{CipherSyncData, CipherSyncType},
EmptyResult, JsonResult,
}, },
auth::{Headers, decode_emergency_access_invite}, auth::{decode_emergency_access_invite, Headers},
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,14 +89,11 @@ 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()?;
if let Some(emergency_access) = match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await {
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await Some(emergency_access) => Ok(Json(
{
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!"),
)) )),
} else { None => err!("Emergency access not valid."),
err!("Emergency access not valid.")
} }
} }
@@ -139,10 +136,9 @@ async fn post_emergency_access(
err!("Emergency access not valid.") err!("Emergency access not valid.")
}; };
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
new_type as i32 Some(new_type) => new_type as i32,
} else { None => err!("Invalid emergency access type."),
err!("Invalid emergency access type.")
}; };
emergency_access.atype = new_type; emergency_access.atype = new_type;
@@ -209,10 +205,9 @@ 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 = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
new_type as i32 Some(new_type) => new_type as i32,
} else { None => err!("Invalid emergency access type."),
err!("Invalid emergency access type.")
}; };
let grantor_user = headers.user; let grantor_user = headers.user;
@@ -347,11 +342,12 @@ 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 = if let Some(user) = User::find_by_mail(&claims.email, &conn).await { let grantee_user = match User::find_by_mail(&claims.email, &conn).await {
Invitation::take(&claims.email, &conn).await; Some(user) => {
user Invitation::take(&claims.email, &conn).await;
} else { user
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.
@@ -770,7 +766,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")
} }
} }
@@ -829,6 +825,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")
} }
} }
+61 -61
View File
@@ -1,18 +1,18 @@
use std::net::IpAddr; use std::net::IpAddr;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use rocket::{Route, form::FromForm, serde::json::Json}; use rocket::{form::FromForm, serde::json::Json, Route};
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::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId}, models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
DbConn, DbPool,
}, },
util::parse_date, util::parse_date,
CONFIG,
}; };
/// ############################################################################################################### /// ###############################################################################################################
@@ -38,7 +38,9 @@ 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)
@@ -49,10 +51,8 @@ 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(Event::to_json) .map(|e| e.to_json())
.collect() .collect()
} else {
Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -64,21 +64,27 @@ 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 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() {
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await Vec::with_capacity(0)
{
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
} else { } else {
Vec::new() let mut events_json = Vec::with_capacity(0);
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn)
.await
.iter()
.map(|e| e.to_json())
.collect()
}
events_json
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -101,7 +107,9 @@ 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)
@@ -112,10 +120,8 @@ 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(Event::to_json) .map(|e| e.to_json())
.collect() .collect()
} else {
Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -128,8 +134,7 @@ 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.
#[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")] if events_json.len() as i64 == Event::PAGE_SIZE {
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 {
@@ -171,7 +176,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_impl( _log_user_event(
event.r#type, event.r#type,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
@@ -182,11 +187,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
1600..=1699 => { 1600..=1699 => {
// Only allow logging events for an organization the user is actually a member of. if let Some(org_id) = &event.organization_id {
if let Some(org_id) = &event.organization_id _log_event(
&& 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,
@@ -200,24 +202,22 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
} }
} }
_ => { _ => {
// The cipher determines the organization the event is logged to, so make sure the if let Some(cipher_uuid) = &event.cipher_id {
// user can actually access it instead of trusting the provided cipher uuid. if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await {
if let Some(cipher_uuid) = &event.cipher_id if let Some(org_id) = cipher.organization_uuid {
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await _log_event(
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await event.r#type,
&& let Some(org_id) = cipher.organization_uuid cipher_uuid,
{ &org_id,
log_event_impl( &headers.user.uuid,
event.r#type, headers.device.atype,
cipher_uuid, Some(event_date),
&org_id, &headers.ip.ip,
&headers.user.uuid, &conn,
headers.device.atype, )
Some(event_date), .await;
&headers.ip.ip, }
&conn, }
)
.await;
} }
} }
} }
@@ -229,10 +229,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_impl(event_type, user_id, device_type, None, ip, conn).await; _log_user_event(event_type, user_id, device_type, None, ip, conn).await;
} }
async fn log_user_event_impl( async fn _log_user_event(
event_type: i32, event_type: i32,
user_id: &UserId, user_id: &UserId,
device_type: i32, device_type: i32,
@@ -278,11 +278,11 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() { if !CONFIG.org_events_enabled() {
return; return;
} }
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; _log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
} }
#[expect(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn log_event_impl( async fn _log_event(
event_type: i32, event_type: i32,
source_uuid: &str, source_uuid: &str,
org_id: &OrganizationId, org_id: &OrganizationId,
@@ -298,24 +298,24 @@ async fn log_event_impl(
// 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_owned().into()); event.cipher_uuid = Some(source_uuid.to_string().into());
} }
// Collection Events // Collection Events
1300..=1399 => { 1300..=1399 => {
event.collection_uuid = Some(source_uuid.to_owned().into()); event.collection_uuid = Some(source_uuid.to_string().into());
} }
// Group Events // Group Events
1400..=1499 => { 1400..=1499 => {
event.group_uuid = Some(source_uuid.to_owned().into()); event.group_uuid = Some(source_uuid.to_string().into());
} }
// Org User Events // Org User Events
1500..=1599 => { 1500..=1599 => {
event.org_user_uuid = Some(source_uuid.to_owned().into()); event.org_user_uuid = Some(source_uuid.to_string().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_owned().into()); event.policy_uuid = Some(source_uuid.to_string().into());
} }
// Ignore others // Ignore others
_ => {} _ => {}
@@ -338,6 +338,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")
} }
} }
+4 -5
View File
@@ -5,8 +5,8 @@ use crate::{
api::{EmptyResult, JsonResult, Notify, UpdateType}, api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers, auth::Headers,
db::{ db::{
DbConn,
models::{Folder, FolderId}, models::{Folder, FolderId},
DbConn,
}, },
util::deser_opt_nonempty_str, util::deser_opt_nonempty_str,
}; };
@@ -29,10 +29,9 @@ 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 {
if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await { match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
Ok(Json(folder.to_json())) Some(folder) => Ok(Json(folder.to_json())),
} else { _ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
err!("Invalid folder", "Folder does not exist or belongs to another user")
} }
} }
+42 -59
View File
@@ -1,6 +1,4 @@
pub mod accounts; pub mod accounts;
pub mod two_factor;
mod ciphers; mod ciphers;
mod emergency_access; mod emergency_access;
mod events; mod events;
@@ -8,32 +6,17 @@ 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::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers}; pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
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_settings_domains, post_settings_domains, put_settings_domains]; let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_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];
@@ -61,6 +44,25 @@ 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 {
@@ -71,16 +73,14 @@ 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_settings_domains(headers: Headers) -> Json<Value> { fn get_eq_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> {
use serde_json::from_str;
let user = &headers.user; let user = &headers.user;
use serde_json::from_str;
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,39 +110,28 @@ struct EquivDomainData {
} }
#[post("/settings/domains", data = "<data>")] #[post("/settings/domains", data = "<data>")]
async fn post_settings_domains( async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
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_owned()); user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned()); user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
user.save(&conn).await?; user.save(&conn).await?;
nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await; nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
#[put("/settings/domains", data = "<data>")] #[put("/settings/domains", data = "<data>")]
async fn put_settings_domains( async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
data: Json<EquivDomainData>, post_eq_domains(data, headers, conn, nt).await
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
post_settings_domains(data, headers, conn, nt).await
} }
#[get("/hibp/breach?<username>")] #[get("/hibp/breach?<username>")]
@@ -215,11 +204,11 @@ fn config() -> Json<Value> {
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12 // Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31 // Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 // iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
let mut feature_states = parse_experimental_client_feature_flags( let 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_owned(), true); // Add default feature_states here if needed, currently no features are needed by default.
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
@@ -228,17 +217,14 @@ 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": "2026.6.0", "version": "2025.12.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,
@@ -254,10 +240,6 @@ 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",
})) }))
} }
@@ -296,8 +278,9 @@ async fn accept_org_invite(
member.save(conn).await?; member.save(conn).await?;
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else { let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
err!("Organization not found.") Some(org) => org,
None => err!("Organization not found."),
}; };
// User was invited to an organization, so they must be confirmed manually after acceptance // User was invited to an organization, so they must be confirmed manually after acceptance
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
File diff suppressed because it is too large Load Diff
+64 -68
View File
@@ -1,24 +1,23 @@
use std::collections::HashSet;
use chrono::Utc; use chrono::Utc;
use rocket::{ use rocket::{
Request, Route,
request::{FromRequest, Outcome}, request::{FromRequest, Outcome},
serde::json::Json, serde::json::Json,
Request, Route,
}; };
use std::collections::HashSet;
use crate::{ use crate::{
CONFIG,
api::EmptyResult, api::EmptyResult,
auth, auth,
db::{ db::{
DbConn,
models::{ models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
OrganizationApiKey, OrganizationId, User, OrganizationApiKey, OrganizationId, User,
}, },
DbConn,
}, },
mail, mail, CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -84,32 +83,26 @@ 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 mut restored = member.restore(); let 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 = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await { let user = match User::find_by_mail(&user_data.email, &conn).await {
user Some(user) => user, // exists in vaultwarden
} else { None => {
// 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
@@ -117,10 +110,9 @@ 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) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await {
(org.name, org.billing_email) Some(org) => (org.name, org.billing_email),
} else { None => err!("Error looking up organization"),
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()));
@@ -131,33 +123,37 @@ 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() {
&& let Err(e) = if 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 = if let Some(group) = let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await {
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await Some(group) => group.uuid,
{ None => {
group.uuid let mut group = Group::new(
} else { org_id.clone(),
let mut group = group_data.name.clone(),
Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone())); false,
group.save(&conn).await?; Some(group_data.external_id.clone()),
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?;
@@ -178,17 +174,18 @@ 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 {
&& !sync_members.contains(user_external_id) if !sync_members.contains(user_external_id) {
{ if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { // Removing owner, check that there is at least one other confirmed owner
// Removing owner, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
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?;
} }
} }
} }
@@ -205,14 +202,12 @@ 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 = if let Some(a) = headers.get_one("Authorization") { let access_token: &str = match headers.get_one("Authorization") {
if let Some(split) = a.rsplit("Bearer ").next() { Some(a) => match a.rsplit("Bearer ").next() {
split Some(split) => split,
} else { None => err_handler!("No access token provided"),
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 {
@@ -234,13 +229,14 @@ 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 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"),
}; };
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_owned().into(); let org_id: OrganizationId = org_id.to_string().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")
}; };
+56 -94
View File
@@ -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, SendHeaders}, auth::{ClientIp, Headers, Host},
config::PathType, config::PathType,
db::{ db::{
DbConn, DbPool,
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
DbConn, DbPool,
}, },
util::{NumberOrString, save_temp_file}, util::{save_temp_file, NumberOrString},
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,9 +48,7 @@ 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,
@@ -65,7 +63,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")
} }
} }
@@ -80,7 +78,6 @@ 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,
@@ -151,10 +148,6 @@ 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;
@@ -175,7 +168,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(Send::to_json).collect(); let sends_json: Vec<Value> = sends.await.iter().map(|s| s.to_json()).collect();
Json(json!({ Json(json!({
"data": sends_json, "data": sends_json,
@@ -186,10 +179,9 @@ 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 {
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
Ok(Json(send.to_json())) Some(send) => Ok(Json(send.to_json())),
} else { None => err!("Send not found", "Invalid send uuid or does not belong to user"),
err!("Send not found", "Invalid send uuid or does not belong to user")
} }
} }
@@ -318,10 +310,9 @@ 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 = if let Some(m) = &data.file_length { let file_length = match &data.file_length {
m.into_i64()? Some(m) => m.into_i64()?,
} else { _ => err!("Invalid send length"),
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")
@@ -378,7 +369,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>", rank = 2)] #[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
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,
@@ -448,45 +439,41 @@ 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_legacy( async fn post_access(
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 {
&& send.access_count >= max_access_count if send.access_count >= max_access_count {
{ err_code!(SEND_INACCESSIBLE_MSG, 404);
err_code!(SEND_INACCESSIBLE_MSG, 404); }
} }
if !send.is_accessible() { 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)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
@@ -500,17 +487,11 @@ async fn post_access_legacy(
// 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 {
if !send.register_access(&conn).await? { send.access_count += 1;
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
} }
process_access(send, conn, nt).await send.save(&conn).await?;
}
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
@@ -523,47 +504,36 @@ async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult
Ok(Json(send.to_json_access(&conn).await)) Ok(Json(send.to_json_access(&conn).await))
} }
#[post("/sends/access/file/<file_id>", rank = 1)]
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>")] #[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy( async fn post_access_file(
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 {
&& send.access_count >= max_access_count if send.access_count >= max_access_count {
{ err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if let Some(expiration) = send.expiration_date {
if Utc::now().naive_utc() >= expiration {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
if !send.is_accessible() { if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
@@ -575,14 +545,10 @@ async fn post_access_file_legacy(
} }
} }
if !send.register_access(&conn).await? { send.access_count += 1;
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await send.save(&conn).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,
@@ -595,29 +561,29 @@ async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn:
Ok(Json(json!({ Ok(Json(json!({
"object": "send-fileDownload", "object": "send-fileDownload",
"id": file_id, "id": file_id,
"url": download_url(&host, &send.uuid, &file_id).await?, "url": download_url(&host, &send_id, &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 crate::storage::is_fs_operator(&operator) { if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
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_secs(5 * 60)).await?.uri().to_string())
} }
} }
#[get("/sends/<send_id>/<file_id>?<t>")] #[get("/sends/<send_id>/<file_id>?<t>")]
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> { async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
if let Ok(claims) = crate::auth::decode_send(t) if let Ok(claims) = crate::auth::decode_send(t) {
&& claims.sub == format!("{send_id}/{file_id}") if 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
} }
@@ -633,10 +599,6 @@ 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()))
+11 -11
View File
@@ -1,13 +1,14 @@
use data_encoding::BASE32; use data_encoding::BASE32;
use rocket::{Route, serde::json::Json}; use rocket::serde::json::Json;
use rocket::Route;
use crate::{ use crate::{
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData},
auth::{ClientIp, Headers}, auth::{ClientIp, Headers},
crypto, crypto,
db::{ db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType, UserId}, models::{EventType, TwoFactor, TwoFactorType, UserId},
DbConn,
}, },
util::NumberOrString, util::NumberOrString,
}; };
@@ -69,10 +70,9 @@ 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> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
decoded Ok(decoded) => decoded,
} else { _ => err!("Invalid totp secret"),
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::{Sha1, totp_custom}; use totp_lite::{totp_custom, Sha1};
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_owned()), _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()),
}; };
// 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: u64 = (current_timestamp + step * 30i64).cast_unsigned(); let time = (current_timestamp + step * 30i64) as u64;
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.
+17 -16
View File
@@ -1,21 +1,22 @@
use chrono::Utc; use chrono::Utc;
use data_encoding::BASE64; use data_encoding::BASE64;
use rocket::{Route, serde::json::Json}; use rocket::serde::json::Json;
use rocket::Route;
use crate::{ use crate::{
CONFIG,
api::{ api::{
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult,
core::two_factor::generate_recover_code, PasswordOrOtpData,
}, },
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType, User, UserId}, models::{EventType, TwoFactor, TwoFactorType, User, UserId},
DbConn,
}, },
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> {
@@ -81,7 +82,8 @@ enum DuoStatus {
impl DuoStatus { impl DuoStatus {
fn data(self) -> Option<DuoData> { fn data(self) -> Option<DuoData> {
match self { match self {
DuoStatus::Global(data) | DuoStatus::User(data) => Some(data), DuoStatus::Global(data) => Some(data),
DuoStatus::User(data) => Some(data),
DuoStatus::Disabled(_) => None, DuoStatus::Disabled(_) => None,
} }
} }
@@ -180,7 +182,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;
@@ -199,14 +201,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::{Method, header}; use reqwest::{header, Method};
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 dt = Utc::now().to_rfc2822(); let date = Utc::now().to_rfc2822();
let username = &data.ik; let username = &data.ik;
let fields = [&dt, method, &data.host, path, params]; let fields = [&date, 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();
@@ -214,7 +216,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, dt) .header(header::DATE, date)
.send() .send()
.await? .await?
.error_for_status()?; .error_for_status()?;
@@ -354,10 +356,9 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
err!("Invalid ikey") err!("Invalid ikey")
} }
let expire: i64 = if let Ok(e) = expire.parse() { let expire: i64 = match expire.parse() {
e Ok(e) => e,
} else { Err(_) => err!("Invalid expire time"),
err!("Invalid expire time")
}; };
if time >= expire { if time >= expire {
+23 -21
View File
@@ -1,24 +1,23 @@
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::{StatusCode, header}; use reqwest::{header, StatusCode};
use ring::digest::{Digest, SHA512_256, digest}; use ring::digest::{digest, Digest, SHA512_256};
use serde::Serialize; use serde::Serialize;
use url::Url; use std::collections::HashMap;
use crate::{ use crate::{
CONFIG, api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
crypto, crypto,
db::{ db::{
DbConn, DbPool,
models::{DeviceId, EventType, TwoFactorDuoContext}, models::{DeviceId, EventType, TwoFactorDuoContext},
DbConn, DbPool,
}, },
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.
@@ -125,7 +124,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_owned(), aud: url.to_string(),
exp: now + JWT_VALIDITY_SECS, exp: now + JWT_VALIDITY_SECS,
jti: jwt_id, jti: jwt_id,
iat: now, iat: now,
@@ -303,7 +302,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(())
} }
@@ -348,7 +347,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")
} }
} }
@@ -395,7 +394,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);
@@ -439,13 +438,16 @@ 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 Some(ctx) = extract_context(state, conn).await else { let ctx = match extract_context(state, conn).await {
err!( Some(c) => c,
"Error validating duo authentication", None => {
ErrorEvent { err!(
event: EventType::UserFailedLogIn2fa "Error validating duo authentication",
} ErrorEvent {
) event: EventType::UserFailedLogIn2fa
}
)
}
}; };
// Context validation steps // Context validation steps
@@ -474,13 +476,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",
+22 -25
View File
@@ -1,20 +1,20 @@
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use rocket::{Route, serde::json::Json}; use rocket::serde::json::Json;
use rocket::Route;
use crate::{ use crate::{
CONFIG,
api::{ api::{
core::{log_user_event, 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::{
DbConn,
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
DbConn,
}, },
error::{Error, MapResult}, error::{Error, MapResult},
mail, mail, CONFIG,
}; };
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: Option<DeviceId>, device_identifier: DeviceId,
#[serde(alias = "Email")] #[serde(alias = "Email")]
email: Option<String>, email: Option<String>,
#[serde(alias = "MasterPasswordHash")] #[serde(alias = "MasterPasswordHash")]
@@ -91,11 +91,8 @@ 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(device_identifier, &conn).await else { let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.") err!("Username or password is incorrect. Try again.")
}; };
@@ -232,7 +229,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;
@@ -284,9 +281,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 dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
let max_time = CONFIG.email_expiration_time().cast_signed(); let max_time = CONFIG.email_expiration_time() as i64;
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
err!( err!(
"Token has expired", "Token has expired",
ErrorEvent { ErrorEvent {
@@ -342,10 +339,9 @@ 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);
if let Ok(x) = res { match res {
Ok(x) Ok(x) => Ok(x),
} else { Err(_) => err!("Could not decode EmailTokenData from string"),
err!("Could not decode EmailTokenData from string")
} }
} }
} }
@@ -363,17 +359,18 @@ 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_owned(); let mut name = split[1].to_string();
let domain = &split[0]; let domain = &split[0];
let name_size = name.chars().count(); let name_size = name.chars().count();
let new_name = if let 1..=3 = name_size { let new_name = match name_size {
"*".repeat(name_size) 1..=3 => "*".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}")
+21 -14
View File
@@ -1,27 +1,28 @@
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::{Route, serde::json::Json}; use rocket::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::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_event, log_user_event}, core::{log_event, log_user_event},
EmptyResult, JsonResult, PasswordOrOtpData,
}, },
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
DbConn, DbPool,
models::{ models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId, TwoFactorIncomplete, TwoFactorType, User, UserId,
}, },
DbConn, DbPool,
}, },
mail, mail,
util::NumberOrString, util::NumberOrString,
CONFIG,
}; };
pub mod authenticator; pub mod authenticator;
@@ -36,7 +37,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,
@@ -45,7 +46,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
} }
match provider_type { match provider_type {
TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true, TwoFactorType::Authenticator => 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
@@ -58,6 +59,7 @@ 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
@@ -94,7 +96,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();
@@ -118,7 +120,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);
@@ -178,7 +180,9 @@ pub async fn enforce_2fa_policy(
ip: &std::net::IpAddr, ip: &std::net::IpAddr,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await { for member in
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() {
@@ -213,7 +217,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 { for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() {
// 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() {
@@ -247,9 +251,12 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
return; return;
} }
let Ok(conn) = pool.get().await else { let conn = match pool.get().await {
error!("Failed to get DB connection in send_incomplete_2fa_notifications()"); Ok(conn) => conn,
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();
@@ -271,7 +278,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:#?}");
} }
+10 -16
View File
@@ -1,17 +1,16 @@
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds}; use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc};
use rocket::{Route, serde::json::Json}; use rocket::{serde::json::Json, Route};
use crate::{ use crate::{
CONFIG,
api::EmptyResult, api::EmptyResult,
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
DbConn,
models::{TwoFactor, TwoFactorType, UserId}, models::{TwoFactor, TwoFactorType, UserId},
DbConn,
}, },
error::{Error, MapResult}, error::{Error, MapResult},
mail, mail, CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -45,10 +44,9 @@ 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);
if let Ok(x) = res { match res {
Ok(x) Ok(x) => Ok(x),
} else { Err(_) => err!("Could not decode ProtectedActionData from string"),
err!("Could not decode ProtectedActionData from string")
} }
} }
@@ -64,9 +62,7 @@ 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!( err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
"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;
@@ -106,9 +102,7 @@ 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!( err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
"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;
@@ -139,7 +133,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().cast_signed(); let max_time = CONFIG.email_expiration_time() as i64;
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")
+41 -41
View File
@@ -1,33 +1,32 @@
use std::{str::FromStr, sync::LazyLock, time::Duration};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use url::Url;
use uuid::Uuid;
use webauthn_rs::{
Webauthn, WebauthnBuilder,
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
};
use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
RequestAuthenticationExtensions, UserVerificationPolicy,
};
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,
crypto::ct_eq, crypto::ct_eq,
db::{ db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType, UserId}, models::{EventType, TwoFactor, TwoFactorType, UserId},
DbConn,
}, },
error::Error, error::Error,
util::NumberOrString, util::NumberOrString,
CONFIG,
};
use rocket::serde::json::Json;
use rocket::Route;
use serde_json::Value;
use std::str::FromStr;
use std::sync::LazyLock;
use std::time::Duration;
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
use webauthn_rs::{Webauthn, WebauthnBuilder};
use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
RequestAuthenticationExtensions, UserVerificationPolicy,
}; };
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| { static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
@@ -39,7 +38,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed") .expect("Creating WebauthnBuilder failed")
.rp_name(&domain) .rp_name(&domain)
.timeout(Duration::from_mins(1)); .timeout(Duration::from_millis(60000));
webauthn.build().expect("Building Webauthn failed") webauthn.build().expect("Building Webauthn failed")
}); });
@@ -150,7 +149,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_owned()); state["rs"]["policy"] = Value::String("discouraged".to_string());
state["rs"]["extensions"].as_object_mut().unwrap().clear(); state["rs"]["extensions"].as_object_mut().unwrap().clear();
let type_ = TwoFactorType::WebauthnRegisterChallenge; let type_ = TwoFactorType::WebauthnRegisterChallenge;
@@ -266,12 +265,13 @@ 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 = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; Some(tf) => {
tf.delete(&conn).await?; let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
state tf.delete(&conn).await?;
} else { state
err!("Can't recover challenge") }
None => err!("Can't recover challenge"),
}; };
// Verify the credentials with the saved state // Verify the credentials with the saved state
@@ -291,7 +291,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?) TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?)
.save(&conn) .save(&conn)
.await?; .await?;
generate_recover_code(&mut user, &conn).await; _generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
@@ -342,10 +342,9 @@ 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> = if let Ok(d) = serde_json::from_str(&u2f.data) { let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
d Ok(d) => d,
} else { Err(_) => err!("Error parsing U2F data"),
err!("Error parsing U2F data")
}; };
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
@@ -389,10 +388,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_owned()); state["ast"]["policy"] = Value::String("discouraged".to_string());
// 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;
@@ -417,17 +416,18 @@ 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 = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await { let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; Some(tf) => {
tf.delete(conn).await?; let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
state tf.delete(conn).await?;
} else { state
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)?;
+22 -53
View File
@@ -1,56 +1,26 @@
use rocket::{Route, serde::json::Json}; use rocket::serde::json::Json;
use rocket::Route;
use serde_json::Value; use serde_json::Value;
use yubico_ng::{ use yubico::{config::Config, verify_async};
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::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType}, models::{EventType, TwoFactor, TwoFactorType},
DbConn,
}, },
error::{Error, MapResult}, error::{Error, MapResult},
http_client, CONFIG,
}; };
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 {
@@ -75,7 +45,8 @@ 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 {
@@ -93,25 +64,22 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
err!("Yubico support is disabled"); err!("Yubico support is disabled");
} }
if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) { match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
Ok((id, secret)) (Some(id), Some(secret)) => Ok((id, secret)),
} else { _ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
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 mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?; let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
if let Some(yubico_server) = CONFIG.yubico_server() {
config = config.set_api_host(yubico_server); match CONFIG.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>")]
@@ -169,9 +137,10 @@ 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 an error to prevent saving empty keys which would cause users not being able to login anymore. return Ok(Json(json!({
// To remove all keys users should click the `Deactivate all keys` button "enabled": false,
err!("A key is required."); "object": "twoFactorU2f",
})));
} }
// Ensure they are valid OTPs // Ensure they are valid OTPs
@@ -193,7 +162,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;
+111 -106
View File
@@ -6,29 +6,28 @@ use std::{
}; };
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
use futures::{TryFutureExt, stream::StreamExt}; use futures::{stream::StreamExt, TryFutureExt};
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer}; use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
use regex::Regex; use regex::Regex;
use reqwest::{ use reqwest::{
Client, Response,
header::{self, HeaderMap, HeaderValue}, header::{self, HeaderMap, HeaderValue},
Client, Response,
}; };
use rocket::{Route, http::ContentType, response::Redirect}; use rocket::{http::ContentType, response::Redirect, Route};
use svg_hush::{Filter, data_url_filter}; use svg_hush::{data_url_filter, Filter};
use crate::{ use crate::{
CONFIG,
config::PathType, config::PathType,
error::Error, error::Error,
http_client::{CustomHttpClientError, get_reqwest_client_builder, get_valid_host, should_block_host}, http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError},
util::Cached, util::Cached,
CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
if CONFIG.icon_service().as_str() == "internal" { match CONFIG.icon_service().as_str() {
routes![icon_internal] "internal" => routes![icon_internal],
} else { _ => routes![icon_external],
routes![icon_external]
} }
} }
@@ -65,7 +64,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(true) get_reqwest_client_builder()
.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
@@ -82,19 +81,19 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders` // The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
// It is used to prevent sending a specific header which breaks icon downloads. // It is used to prevent sending a specific header which breaks icon downloads.
// If this function needs to be renamed, also adjust the code in `util.rs` // If this function needs to be renamed, also adjust the code in `util.rs`
#[get("/<host>/icon.png")] #[get("/<domain>/icon.png")]
fn icon_external(host: &str) -> Cached<Option<Redirect>> { fn icon_external(domain: &str) -> Cached<Option<Redirect>> {
let Ok(host) = get_valid_host(host) else { if !is_valid_domain(domain) {
warn!("Invalid host: {host}"); warn!("Invalid domain: {domain}");
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
};
if should_block_host(&host).is_err() {
warn!("Blocked address: {host}");
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
} }
let url = CONFIG._icon_service_url().replace("{}", &host.to_string()); if should_block_address(domain) {
warn!("Blocked address: {domain}");
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
}
let url = CONFIG._icon_service_url().replace("{}", domain);
let redir = match CONFIG.icon_redirect_code() { let redir = match CONFIG.icon_redirect_code() {
301 => Some(Redirect::moved(url)), // legacy permanent redirect 301 => Some(Redirect::moved(url)), // legacy permanent redirect
302 => Some(Redirect::found(url)), // legacy temporary redirect 302 => Some(Redirect::found(url)), // legacy temporary redirect
@@ -108,21 +107,12 @@ fn icon_external(host: &str) -> Cached<Option<Redirect>> {
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true) Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
} }
#[get("/<host>/icon.png")] #[get("/<domain>/icon.png")]
async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> { async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
let Ok(host) = get_valid_host(host) else { if !is_valid_domain(domain) {
warn!("Invalid host: {host}"); warn!("Invalid domain: {domain}");
return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(),
true,
);
};
if should_block_host(&host).is_err() {
warn!("Blocked address: {host}");
return Cached::ttl( return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(), CONFIG.icon_cache_negttl(),
@@ -130,7 +120,16 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
); );
} }
match get_icon(&host.to_string()).await { if should_block_address(domain) {
warn!("Blocked address: {domain}");
return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(),
true,
);
}
match get_icon(domain).await {
Some((icon, icon_type)) => { Some((icon, icon_type)) => {
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
} }
@@ -138,6 +137,42 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
} }
} }
/// Returns if the domain provided is valid or not.
///
/// This does some manual checks and makes use of Url to do some basic checking.
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
fn is_valid_domain(domain: &str) -> bool {
const ALLOWED_CHARS: &str = "-.";
// If parsing the domain fails using Url, it will not work with reqwest.
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
debug!("Domain parse error: '{domain}' - {parse_error:?}");
return false;
} else if domain.is_empty()
|| domain.contains("..")
|| domain.starts_with('.')
|| domain.starts_with('-')
|| domain.ends_with('-')
{
debug!(
"Domain validation error: '{domain}' is either empty, contains '..', starts with an '.', starts or ends with a '-'"
);
return false;
} else if domain.len() > 255 {
debug!("Domain validation error: '{domain}' exceeds 255 characters");
return false;
}
for c in domain.chars() {
if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) {
debug!("Domain validation error: '{domain}' contains an invalid character '{c}'");
return false;
}
}
true
}
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
let path = format!("{domain}.png"); let path = format!("{domain}.png");
@@ -148,7 +183,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_owned())); return Some((icon, icon_type.to_string()));
} }
if CONFIG.disable_icon_download() { if CONFIG.disable_icon_download() {
@@ -159,7 +194,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_owned())) Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string()))
} }
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
@@ -184,10 +219,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) {
&& let Ok(buf) = operator.read(path).await if let Ok(buf) = operator.read(path).await {
{ return Some(buf.to_vec());
return Some(buf.to_vec()); }
} }
None None
@@ -281,17 +316,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) {
&& let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) if 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) {
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) { std::str::from_utf8(v).unwrap_or_default()
std::str::from_utf8(v).unwrap_or_default() } else {
} else { ""
"" };
}; let priority = get_icon_priority(full_href.as_str(), sizes);
let priority = get_icon_priority(full_href.as_str(), sizes); icons.push(Icon::new(priority, full_href.to_string()));
icons.push(Icon::new(priority, full_href.to_string())); }
} };
} }
} }
@@ -332,7 +367,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
tld = domain_parts.next_back().unwrap(), tld = domain_parts.next_back().unwrap(),
base = domain_parts.next_back().unwrap() base = domain_parts.next_back().unwrap()
); );
if get_valid_host(&base_domain).is_ok() { if is_valid_domain(&base_domain) {
let sslbase = format!("https://{base_domain}"); let sslbase = format!("https://{base_domain}");
let httpbase = format!("http://{base_domain}"); let httpbase = format!("http://{base_domain}");
debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); debug!("[get_icon_url]: Trying without subdomains '{base_domain}'");
@@ -343,7 +378,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
// When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it.
} else if is_ip.is_err() && domain.matches('.').count() < 2 { } else if is_ip.is_err() && domain.matches('.').count() < 2 {
let www_domain = format!("www.{domain}"); let www_domain = format!("www.{domain}");
if get_valid_host(&www_domain).is_ok() { if is_valid_domain(&www_domain) {
let sslwww = format!("https://{www_domain}"); let sslwww = format!("https://{www_domain}");
let httpwww = format!("http://{www_domain}"); let httpwww = format!("http://{www_domain}");
debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'");
@@ -405,25 +440,9 @@ 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()?)
@@ -511,10 +530,11 @@ 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;
let mut icons = icon_result.iconlist.iter().take(5).peekable(); use data_url::DataUrl;
while let Some(icon) = icons.next() {
for icon in icon_result.iconlist.iter().take(5) {
if icon.href.starts_with("data:image") { if icon.href.starts_with("data:image") {
let Ok(datauri) = data_url::DataUrl::process(&icon.href) else { let Ok(datauri) = 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
@@ -538,25 +558,13 @@ 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 {
debug!("Trying {}", icon.href);
// Make sure all icons are checked before returning error
let res = match get_page_with_referer(&icon.href, &icon_result.referer).await {
Ok(r) => r,
Err(e) if icons.peek().is_none() => return Err(e),
Err(e) if CustomHttpClientError::downcast_ref(&e).is_some() => return Err(e), // If blacklisted stop immediately instead of checking the rest of the icons. see explanation and actual handling inside get_icon()
Err(e) => {
warn!("Unable to download icon: {e:?}");
// Continue to next icon
continue;
}
}; };
} else {
let res = get_page_with_referer(&icon.href, &icon_result.referer).await?;
buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net)
// Check if the icon type is allowed, else try another icon from the list. // Check if the icon type is allowed, else try an icon from the list.
icon_type = get_icon_type(&buffer); icon_type = get_icon_type(&buffer);
if icon_type.is_none() { if icon_type.is_none() {
buffer.clear(); buffer.clear();
@@ -602,25 +610,22 @@ 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)]) {
&& (content.contains("<svg") || content.contains("<SVG")) if content.contains("<svg") || content.contains("<SVG") {
{ return Some("svg+xml");
return Some("svg+xml"); }
} }
None None
} }
// Some details can be found here:
// - https://www.garykessler.net/library/file_sigs_GCK_latest.html
// - https://en.wikipedia.org/wiki/List_of_file_signatures
match bytes { match bytes {
[137, 80, 78, 71, 13, 10, 26, 10, ..] => Some("png"), [137, 80, 78, 71, ..] => Some("png"),
[0, 0, 1, 0, n1, n2, ..] if u16::from_le_bytes([*n1, *n2]) > 0 => Some("x-icon"), // https://en.wikipedia.org/wiki/ICO_(file_format) [0, 0, 1, 0, ..] => Some("x-icon"),
[82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images [82, 73, 70, 70, ..] => Some("webp"),
[255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"), [255, 216, 255, ..] => Some("jpeg"),
[71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"), [71, 73, 70, 56, ..] => Some("gif"),
[66, 77, _, _, _, _, 0, 0, 0, 0, ..] => Some("bmp"), // https://en.wikipedia.org/wiki/BMP_file_format [66, 77, ..] => Some("bmp"),
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg [60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml [60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
_ => None, _ => None,
} }
@@ -748,7 +753,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
} }
} }
_ => (), _ => (),
@@ -821,13 +826,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)
} }
} }
+193 -265
View File
@@ -1,20 +1,18 @@
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::Status,
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::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
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,
@@ -23,29 +21,27 @@ use crate::{
}, },
master_password_policy, master_password_policy,
push::register_push_device, push::register_push_device,
ApiResult, EmptyResult, JsonResult,
}, },
auth, auth,
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims}, auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
crypto,
db::{ db::{
DbConn,
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
TwoFactorType, User, UserId,
}, },
DbConn,
}, },
error::MapResult, error::MapResult,
mail, sso, mail, sso,
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
util, util, CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![
login, login,
post_prelogin, prelogin,
prelogin_password,
identity_register, identity_register,
register_verification_email, register_verification_email,
register_finish, register_finish,
@@ -69,59 +65,45 @@ 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, "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, "client_id cannot be blank")?;
check_is_some(data.password.as_ref(), "password cannot be blank")?; _check_is_some(&data.password, "password cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?; _check_is_some(&data.scope, "scope cannot be blank")?;
check_is_some(data.username.as_ref(), "username cannot be blank")?; _check_is_some(&data.username, "username cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; _check_is_some(&data.device_type, "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).await
} }
"client_credentials" => { "client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; _check_is_some(&data.client_id, "client_id cannot be blank")?;
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; _check_is_some(&data.client_secret, "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?; _check_is_some(&data.scope, "scope cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; _check_is_some(&data.device_type, "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, "client_id cannot be blank")?;
check_is_some(data.code.as_ref(), "code cannot be blank")?; _check_is_some(&data.code, "code cannot be blank")?;
check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?; _check_is_some(&data.code_verifier, "code verifier cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; _check_is_some(&data.device_type, "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).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),
}; };
@@ -140,7 +122,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
} }
} }
} }
@@ -149,14 +131,12 @@ async fn login(
login_result login_result
} }
async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Return Status::Unauthorized to trigger logout
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400) async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant` // Extract token
// See the link below for details let refresh_token = match data.refresh_token {
// https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797 Some(token) => token,
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
let Some(refresh_token) = data.refresh_token else {
err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token")
}; };
// --- // ---
@@ -167,10 +147,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
Err(err) => { Err(err) => {
err_json!( err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
json!({"error": "invalid_grant"}),
format!("Unable to refresh login credentials: {}", err.message())
)
} }
Ok((mut device, auth_tokens)) => { Ok((mut device, auth_tokens)) => {
// Save to update `device.updated_at` to track usage and toggle new status // Save to update `device.updated_at` to track usage and toggle new status
@@ -190,19 +167,19 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
} }
// After exchanging the code we need to check first if 2FA is needed before continuing // After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login( async fn _sso_login(
data: ConnectData, data: ConnectData,
user_id: &mut Option<UserId>, user_id: &mut Option<UserId>,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
client_version: Option<&ClientVersion>, client_version: &Option<ClientVersion>,
) -> JsonResult { ) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?; AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login // Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?; crate::ratelimit::check_limit_login(&ip.ip)?;
let (code, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { let (state, 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 {
@@ -218,7 +195,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(code, code_verifier, conn).await?; let (sso_auth, user_infos) = sso::exchange_code(state, 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,
@@ -246,33 +223,7 @@ async fn sso_login(
} }
) )
} }
Some((user, None)) => match user_infos.email_verified { Some((user, None)) => Some((user, None)),
None if !CONFIG.sso_allow_unknown_email_verification() => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and email verification status is unknown",
user_infos.identifier, user.uuid, user.email
);
err_silent!(
"Email verification status is unknown",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some(false) => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and email is not verified",
user_infos.identifier, user.uuid, user.email
);
err_silent!(
"Email is not verified by the SSO provider",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
_ => Some((user, None)),
},
}, },
Some((user, sso_user)) => Some((user, Some(sso_user))), Some((user, sso_user)) => Some((user, Some(sso_user))),
}; };
@@ -318,7 +269,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.email), format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn event: EventType::UserFailedLogIn
} }
@@ -359,12 +310,12 @@ 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,
ip: &ClientIp, ip: &ClientIp,
client_version: Option<&ClientVersion>, client_version: &Option<ClientVersion>,
) -> JsonResult { ) -> JsonResult {
// Validate scope // Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?; AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -443,9 +394,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().cast_signed() > CONFIG.signups_verify_resend_time() as i64
{ {
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed(); let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
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...
@@ -534,7 +485,18 @@ async fn authenticated_response(
Value::Null Value::Null
}; };
let account_keys = user.account_keys_json(conn).await; let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let mut result = json!({ let mut result = json!({
"access_token": auth_tokens.access_token(), "access_token": auth_tokens.access_token(),
@@ -566,23 +528,23 @@ async fn authenticated_response(
result["TwoFactorToken"] = Value::String(token); result["TwoFactorToken"] = Value::String(token);
} }
info!("User {} logged in successfully. IP: {}", user.email, ip.ip); info!("User {} logged in successfully. IP: {}", user.display_name(), 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,
@@ -674,7 +636,18 @@ async fn user_api_key_login(
Value::Null Value::Null
}; };
let account_keys = user.account_keys_json(conn).await; let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
// Note: No refresh_token is returned. The CLI just repeats the // Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires. // client_credentials login flow when the existing token expires.
@@ -703,13 +676,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_owned().into(); let org_id: OrganizationId = org_id.to_string().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))
}; };
@@ -740,13 +713,14 @@ 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
if let Some(device) = Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
Ok(device) Some(device) => Ok(device),
} else { None => {
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)
}
} }
} }
@@ -755,7 +729,7 @@ async fn twofactor_auth(
data: &ConnectData, data: &ConnectData,
device: &mut Device, device: &mut Device,
ip: &ClientIp, ip: &ClientIp,
client_version: Option<&ClientVersion>, client_version: &Option<ClientVersion>,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<Option<String>> { ) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@@ -772,7 +746,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() {
@@ -780,51 +754,56 @@ 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 if !twofactor_ids.contains(&selected_id) {
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].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 Some(ref twofactor_code) = data.two_factor_token else { let twofactor_code = match data.two_factor_token {
err_json!( Some(ref code) => code,
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, None => {
"2FA token not provided" err_json!(
) _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);
let selected_data = selected_data(selected_twofactor); use crate::crypto::ct_eq;
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) => {
if CONFIG.duo_use_iframe() { match CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow true => {
duo::validate_duo_login(&user.email, twofactor_code, conn).await?; // Legacy iframe prompt flow
} else { duo::validate_duo_login(&user.email, twofactor_code, conn).await?
// OIDC based flow }
duo_oidc::validate_duo_login( false => {
&user.email, // OIDC based flow
twofactor_code, duo_oidc::validate_duo_login(
data.client_id.as_ref().unwrap(), &user.email,
data.device_identifier.as_ref().unwrap(), twofactor_code,
conn, data.client_id.as_ref().unwrap(),
) data.device_identifier.as_ref().unwrap(),
.await?; conn,
)
.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 {
@@ -832,7 +811,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()
&& (crypto::ct_eq(token, twofactor_code) && (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)) => {}
_ => { _ => {
@@ -843,7 +822,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"
) )
} }
@@ -884,15 +863,15 @@ 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,
client_version: Option<&ClientVersion>, client_version: &Option<ClientVersion>,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<Value> { ) -> ApiResult<Value> {
let mut result = json!({ let mut result = json!({
@@ -909,38 +888,42 @@ 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 = if let Some(u) = User::find_by_uuid(user_id, conn).await { let email = match User::find_by_uuid(user_id, conn).await {
u.email Some(u) => u.email,
} else { None => err!("User does not exist"),
err!("User does not exist")
}; };
if CONFIG.duo_use_iframe() { match CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow true => {
let (signature, host) = duo::generate_duo_signature(&email, conn).await?; // Legacy iframe prompt flow
result["TwoFactorProviders2"][provider.to_string()] = json!({ let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
"Host": host, result["TwoFactorProviders2"][provider.to_string()] = json!({
"Signature": signature, "Host": host,
}); "Signature": signature,
} else { })
// OIDC based flow }
let auth_url = duo_oidc::get_duo_auth_url( false => {
&email, // OIDC based flow
data.client_id.as_ref().unwrap(), let auth_url = duo_oidc::get_duo_auth_url(
data.device_identifier.as_ref().unwrap(), &email,
conn, data.client_id.as_ref().unwrap(),
) data.device_identifier.as_ref().unwrap(),
.await?; conn,
)
.await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({ result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url, "AuthUrl": auth_url,
}); })
}
} }
} }
@@ -953,7 +936,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) => {
@@ -971,30 +954,16 @@ 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 */ }
} }
} }
@@ -1002,18 +971,13 @@ async fn json_err_twofactor(
} }
#[post("/accounts/prelogin", data = "<data>")] #[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await _prelogin(data, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, 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,11 +998,8 @@ enum RegisterVerificationResponse {
#[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>,
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
@@ -1055,13 +1016,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.as_ref().is_some_and(|u| u.private_key.is_some()) { if user.filter(|u| u.private_key.is_some()).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::{RngExt, rngs::SmallRng}; use rand::{rngs::SmallRng, RngExt};
let mut rng: SmallRng = rand::make_rng(); let mut rng: SmallRng = rand::make_rng();
let sleep_ms: u64 = rng.random_range(900..=1100); let sleep_ms = rng.random_range(900..=1100) as u64;
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?;
@@ -1077,7 +1038,7 @@ async fn register_verification_email(
#[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
@@ -1136,15 +1097,11 @@ struct ConnectData {
// Needed for authorization code // Needed for authorization code
#[field(name = uncased("code"))] #[field(name = uncased("code"))]
code: Option<OIDCCode>, code: Option<OIDCState>,
#[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)
} }
@@ -1163,32 +1120,33 @@ fn prevalidate() -> JsonResult {
} }
} }
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, mut conn: DbConn) -> ApiResult<Redirect> {
oidcsignin_redirect(state, code, None, cookies, &mut conn).await _oidcsignin_redirect(
state,
OIDCCodeWrapper::Ok {
code,
},
&mut conn,
)
.await
} }
// Bitwarden client appear to only care for code and state // Bitwarden client appear to only care for code and state so we pipe it through
// 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/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141
// 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,
error: String, error: String,
error_description: Option<String>, error_description: Option<String>,
cookies: &CookieJar<'_>,
mut conn: DbConn, mut conn: DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
oidcsignin_redirect( _oidcsignin_redirect(
state.clone(), state,
state.into(), OIDCCodeWrapper::Error {
Some(OIDCCodeResponseError {
error, error,
error_description, error_description,
}), },
cookies,
&mut conn, &mut conn,
) )
.await .await
@@ -1197,32 +1155,18 @@ 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: OIDCCode, code_response: OIDCCodeWrapper,
error: Option<OIDCCodeResponseError>,
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 Some(mut sso_auth) = SsoAuth::find(&state, conn).await else { let mut sso_auth = match SsoAuth::find(&state, conn).await {
err!(format!("Cannot retrieve sso_auth for {state}")) None => err!(format!("Cannot retrieve sso_auth for {state}")),
Some(sso_auth) => sso_auth,
}; };
sso_auth.code_response = Some(code_response);
// Browser-binding check
// 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_owned());
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()) {
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
_ => err!(format!("SSO session binding mismatch for {state}")),
}
cookies
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
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?;
@@ -1232,7 +1176,7 @@ async fn oidcsignin_redirect(
}; };
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("code", &code) .append_pair("code", &state)
.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());
@@ -1268,7 +1212,7 @@ struct AuthorizeData {
// The `redirect_uri` will change depending of the client (web, android, ios ..) // The `redirect_uri` will change depending of the client (web, android, ios ..)
#[get("/connect/authorize?<data..>")] #[get("/connect/authorize?<data..>")]
async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, conn: DbConn) -> ApiResult<Redirect> { async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
let AuthorizeData { let AuthorizeData {
client_id, client_id,
redirect_uri, redirect_uri,
@@ -1282,23 +1226,7 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
err!("Unsupported code challenge method"); err!("Unsupported code challenge method");
} }
// Generate browser-binding token. Stored hashed in DB; raw value handed to the browser as a cookie. let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?;
// Validated on /connect/oidc-signin
let binding_token = data_encoding::BASE64URL_NOPAD.encode(&crypto::get_random_bytes::<32>());
let binding_hash = crypto::sha256_hex(binding_token.as_bytes());
let auth_url =
sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, Some(binding_hash), conn).await?;
cookies.add(
Cookie::build((SSO_BINDING_COOKIE, binding_token))
.path(format!("{}/identity/connect/", CONFIG.domain_path()))
.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
.http_only(true)
.secure(secure.https)
.build(),
);
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }
+5 -8
View File
@@ -30,15 +30,13 @@ 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::{invalidate_css_cache, static_files}, web::static_files,
}; };
use crate::{ use crate::db::{
CONFIG, models::{OrgPolicy, OrgPolicyType, User},
db::{ DbConn,
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>;
@@ -76,7 +74,6 @@ 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 {

Some files were not shown because too many files have changed in this diff Show More