Compare commits

..
3 Commits
140 changed files with 25530 additions and 18826 deletions

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
# Ignore everything
// Ignore everything
*
# Allow what is needed
// Allow what is needed
!.git
!docker/healthcheck.sh
!docker/start.sh
+24 -28
View File
@@ -78,7 +78,7 @@
# DB_CONNECTION_RETRIES=15
## Database timeout
## Timeout in seconds when acquiring database connection
## Timeout when acquiring database connection
# DATABASE_TIMEOUT=30
## Database idle timeout
@@ -173,14 +173,14 @@
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
## Number of days to retain events stored in the database.
## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
# EVENTS_DAYS_RETAIN=
##
## Cron schedule of the job that cleans old auth requests from the auth request.
## Defaults to every minute. Set blank to disable this job.
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
##
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
#
@@ -233,7 +233,7 @@
# USER_SEND_LIMIT=
## Number of days to wait before auto-deleting a trashed item.
## If unset (the default) or 0, trashed items are not auto-deleted.
## If unset (the default), trashed items are not auto-deleted.
## This setting applies globally, so make sure to inform all users of any changes to this setting.
# TRASH_AUTO_DELETE_DAYS=
@@ -316,14 +316,6 @@
## unauthenticated access to potentially sensitive data.
# 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 ###
#########################
@@ -338,8 +330,6 @@
## "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.
## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of
## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service
@@ -392,17 +382,21 @@
##
## The following flags are available:
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
## - "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-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)
## - "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)
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "pm-30529-webauthn-related-origins":
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.
@@ -463,7 +457,7 @@
## Allowed connect-src (Know the risks!)
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
@@ -516,9 +510,6 @@
## Prevent users from logging in directly without going through SSO
# SSO_ONLY=false
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
# SSO_SIGNUPS_ALLOWED=true
## On SSO Signup if a user with a matching email already exists make the association
# SSO_SIGNUPS_MATCH_EMAIL=true
@@ -581,6 +572,11 @@
# DUO_HOST=<API Hostname>
## After that, you should be able to follow the rest of the guide linked above,
## ignoring the fields that ask for the values that you already configured beforehand.
##
## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'.
## Duo no longer supports this, but it still works for some integrations.
## If you aren't sure, leave this alone.
# DUO_USE_IFRAME=false
## Email 2FA settings
## Email token size
@@ -614,7 +610,7 @@
## we allow by default the TOTP code which was valid one step back and one in the future.
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
## You can disable this, so that only the current TOTP Code is allowed.
## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid.
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
# Checkout the repo
- name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching
- name: Rust Caching
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Checkout the repo
- name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# End Checkout the repo
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -32,7 +32,7 @@ jobs:
# Checkout the repo
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# End Checkout the repo
@@ -41,12 +41,12 @@ jobs:
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint
+17 -17
View File
@@ -58,13 +58,13 @@ jobs:
steps:
- name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
platforms: "arm64,arm"
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -77,7 +77,7 @@ jobs:
# Checkout the repo
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# We need fetch-depth of 0 so we also get all the tag metadata
with:
persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -137,7 +137,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -185,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -222,7 +222,7 @@ jobs:
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: ${{ runner.temp }}/digests/*
@@ -237,12 +237,12 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
- name: Upload binaries as artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -264,7 +264,7 @@ jobs:
steps:
- name: Download digests
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ${{ runner.temp }}/digests
pattern: digests-*-${{ matrix.base_image }}
@@ -272,7 +272,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -287,7 +287,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -303,7 +303,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +365,7 @@ jobs:
# Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: 'trivy-results.sarif'
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
security-events: write # To write the security report
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
+5 -4
View File
@@ -18,10 +18,9 @@ repos:
# 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: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
hooks:
- id: typos
always_run: true
- repo: local
hooks:
@@ -39,7 +38,8 @@ repos:
entry: cargo test
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
@@ -47,7 +47,8 @@ repos:
entry: cargo clippy
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false
- id: check-docker-templates
name: check docker templates
Generated
+955 -935
View File
File diff suppressed because it is too large. Load diff
+40 -42
View File
@@ -1,6 +1,6 @@
[workspace.package]
edition = "2024"
rust-version = "1.97.1"
rust-version = "1.94.1"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@@ -12,6 +12,7 @@ members = ["macros"]
name = "vaultwarden"
version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md"
build = "build.rs"
repository.workspace = true
edition.workspace = true
@@ -40,7 +41,6 @@ vendored_openssl = ["openssl/vendored"]
enable_mimalloc = ["dep:mimalloc"]
s3 = [
"opendal/services-s3",
"dep:opendal-http-transport-reqwest",
"dep:aws-config",
"dep:aws-credential-types",
"dep:aws-smithy-runtime-api",
@@ -58,7 +58,6 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
unstable = []
[target."cfg(unix)".dependencies]
nix = { version = "0.31.3", features = ["fs"] }
# Logging
syslog = "7.0.0"
@@ -66,7 +65,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.34"
log = "0.4.33"
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"] }
@@ -76,11 +75,11 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries
num-traits = "0.2.19"
num-derive = "0.5.1"
bigdecimal = "0.4.11"
num-derive = "0.4.2"
bigdecimal = "0.4.10"
# Web framework
rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] }
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
rocket_ws = { version = "0.1.1" }
# WebSockets libraries
@@ -90,8 +89,8 @@ rmpv = "1.3.1" # MessagePack library
dashmap = "6.2.1"
# Async futures
futures = "0.3.34"
tokio = { version = "1.54.1", features = [
futures = "0.3.32"
tokio = { version = "1.52.3", features = [
"fs",
"io-util",
"net",
@@ -100,14 +99,14 @@ tokio = { version = "1.54.1", features = [
"signal",
"time",
] }
tokio-util = { version = "0.7.20", features = ["compat"] }
tokio-util = { version = "0.7.18", features = ["compat"] }
# A generic serialization/deserialization framework
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.10", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -120,36 +119,36 @@ derive_more = { version = "2.1.1", features = [
diesel-derive-newtype = "2.1.3"
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.38.2", optional = true }
libsqlite3-sys = { version = "0.37.0", optional = true }
# Crypto-related libraries
rand = "0.10.3"
rand = "0.10.2"
ring = "0.17.14"
rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.41", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.29.0", features = ["v4"] }
uuid = { version = "1.23.4", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4"
time = "0.3.55"
time = "0.3.53"
# Job scheduler
job_scheduler_ng = "2.5.0"
job_scheduler_ng = "2.4.0"
# Data encoding library Hex/Base32/Base64
data-encoding = "2.11.1"
data-encoding = "2.11.0"
# JWT library
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library
totp-lite = "2.0.1"
# Yubico Library
yubico_ng = { version = "1.0.0", default-features = false }
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] }
# WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db
@@ -162,7 +161,7 @@ webauthn-rs-core = "0.5.5"
url = "2.5.8"
# Email libraries
lettre = { version = "0.11.23", default-features = false, features = [
lettre = { version = "0.11.22", default-features = false, features = [
# Misc
"tracing",
"serde",
@@ -180,10 +179,10 @@ percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9"
# HTML Template library
handlebars = { version = "6.4.4", features = ["dir_source"] }
handlebars = { version = "6.4.2", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.5", default-features = false, features = [
reqwest = { version = "0.13.4", default-features = false, features = [
# Misc
"charset",
"cookies",
@@ -201,24 +200,24 @@ reqwest = { version = "0.13.5", default-features = false, features = [
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.3"
hickory-resolver = "0.26.1"
# Favicon extraction libraries
html5gum = "0.8.4"
regex = { version = "1.13.1", default-features = false, features = [
regex = { version = "1.12.4", default-features = false, features = [
"perf",
"std",
"unicode-perl",
] }
data-url = "0.3.2"
bytes = "1.12.1"
svg-hush = "0.9.7"
svg-hush = "0.9.6"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "4.0.3", features = ["async"] }
cached = { version = "2.0.2", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2"
cookie = "0.18.1"
cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL
@@ -232,11 +231,11 @@ pastey = "0.2.3"
governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.2"
ipnet = "2.12.0"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.16", features = ["future"] }
moka = { version = "0.12.15", features = ["future"] }
# Check client versions for specific features.
semver = "1.0.28"
@@ -245,10 +244,10 @@ semver = "1.0.28"
# 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"] }
which = "8.0.6"
which = "8.0.4"
# Argon2 library with support for the PHC format
argon2 = "0.6.0"
argon2 = "0.5.3"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.5.4"
@@ -257,21 +256,20 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
aws-config = { version = "1.8.18", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.1", optional = true }
aws-smithy-runtime-api = { version = "1.19.1", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true }
aws-credential-types = { version = "1.2.14", optional = true }
aws-smithy-runtime-api = { version = "1.13.0", optional = true }
http = { version = "1.4.2", optional = true }
reqsign-aws-v4 = { version = "3.0.1", optional = true }
reqsign-core = { version = "3.0.1", optional = true }
# Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces
+5 -6
View File
@@ -1,12 +1,11 @@
---
vault_version: "v2026.8.0"
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
# Cross Compile Docker Helper Scripts v1.10.0
vault_version: "v2026.6.2"
vault_image_digest: "sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a"
# 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
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
# The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.96.1 # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images
+15 -16
View File
@@ -19,23 +19,23 @@
# - 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.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.2
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.8.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a
# [docker.io/vaultwarden/web-vault:v2026.6.2]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault
########################## ALPINE BUILD IMAGES ##########################
## 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
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.96.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.96.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.96.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.96.1 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
@@ -66,11 +66,11 @@ RUN USER=root cargo new --bin /app
WORKDIR /app
# 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
cat /env-cargo
RUN . /env-cargo && \
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -86,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN . /env-cargo && \
RUN source /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
find . -not -path "./target*" -delete
@@ -97,13 +97,13 @@ COPY . .
ARG VW_VERSION
# 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
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
# 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}" && \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -126,7 +126,6 @@ RUN . /env-cargo && \
# 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
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
ENV ROCKET_PROFILE="release" \
+14 -42
View File
@@ -19,25 +19,24 @@
# - 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.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.2
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.8.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a
# [docker.io/vaultwarden/web-vault:v2026.6.2]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
## And these bash scripts do not have any significant difference if at all
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
# hadolint ignore=DL3067
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.96.1-slim-trixie AS build
COPY --from=xx / /
ARG TARGETARCH
ARG TARGETVARIANT
@@ -81,7 +80,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app
WORKDIR /app
RUN . /env-cargo && \
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -96,22 +95,9 @@ ARG DB=sqlite,mysql,postgresql
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN . /env-cargo && \
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
RUN source /env-cargo && \
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete
@@ -122,28 +108,15 @@ COPY . .
ARG VW_VERSION
# 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
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
# 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
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -166,7 +139,6 @@ RUN . /env-cargo && \
# 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
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
ENV ROCKET_PROFILE="release" \
+6 -21
View File
@@ -28,21 +28,8 @@
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
#
{% macro xx_cargo_config() -%}
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
{%- endmacro %}
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
@@ -65,7 +52,6 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
{% if base == "debian" %}
# hadolint ignore=DL3067
COPY --from=xx / /
{% endif %}
ARG TARGETARCH
@@ -120,12 +106,12 @@ WORKDIR /app
{% if base == "alpine" %}
# 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
cat /env-cargo
{% endif %}
RUN . /env-cargo && \
RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -145,7 +131,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN . /env-cargo && \
RUN source /env-cargo && \
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
@@ -160,7 +146,7 @@ COPY . .
ARG VW_VERSION
# 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
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
@@ -170,7 +156,7 @@ RUN . /env-cargo && \
{% elif base == "alpine" %}
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 ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -193,7 +179,6 @@ RUN . /env-cargo && \
# 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
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
ENV ROCKET_PROFILE="release" \
-1
View File
@@ -15,7 +15,6 @@ Some unsupported platforms for Debian based images. These are not built and test
- 386 (linux/386)
- ppc64le (linux/ppc64le)
- s390x (linux/s390x)
- riscv64 (linux/riscv64)
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
This ensures the container build process can run binaries from other architectures.<br>
-6
View File
@@ -148,12 +148,6 @@ target "debian-s390x" {
platforms = ["linux/s390x"]
tags = generate_tags("", "-s390x")
}
target "debian-riscv64" {
inherits = ["debian"]
platforms = ["linux/riscv64"]
tags = generate_tags("", "-riscv64")
}
// ==== End of unsupported Debian architecture targets ===
// A Group to build all platforms individually for local testing
+2 -8
View File
@@ -3,23 +3,17 @@
import os
import argparse
import json
import tomllib
import yaml
import jinja2
# Load settings file
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
with open("DockerSettings.yaml", 'r') as yaml_file:
yaml_data = yaml.safe_load(yaml_file)
# Extract the rust_version from the rust-toolchain.toml file
script_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
settings_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.getcwd()),
)
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
args_parser = argparse.ArgumentParser()
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
+2 -2
View File
@@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true
[dependencies]
quote = "1.0.47"
syn = "3.0.9"
quote = "1.0.46"
syn = "2.0.118"
[lints]
workspace = true
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,31 +1,15 @@
SELECT if (
EXISTS(
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
)
,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
) INTO @drop_stmt;
PREPARE drop_stmt FROM @drop_stmt;
EXECUTE drop_stmt;
SELECT if (
EXISTS(
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = '1'
)
,'ALTER TABLE sso_users DROP FOREIGN KEY `1`'
,'SELECT "info: FK sso_users 1 does not exist."'
) INTO @drop_stmt;
PREPARE drop_stmt FROM @drop_stmt;
EXECUTE drop_stmt;
DEALLOCATE PREPARE drop_stmt;
-- Dynamically create DROP FOREIGN KEY
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
-- This checks if the key exists, and if so, will drop it.
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
CONSTRAINT_SCHEMA = DATABASE() AND
TABLE_NAME = 'sso_users' AND
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
'SELECT 1');
PREPARE stmt FROM @drop_sso_fk;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1 +0,0 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -1,7 +0,0 @@
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date
-- columns elsewhere, and to avoid MySQL's implicit session-timezone
-- conversion and 2038 range limit on TIMESTAMP.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = UTC_TIMESTAMP();
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1 +0,0 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -1,9 +0,0 @@
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now():
-- assigning timestamptz now() into a naive TIMESTAMP column casts through
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp
-- column in this schema.
ALTER TABLE org_policies
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc');
Whitespace-only changes.
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
Whitespace-only changes.
@@ -1,6 +0,0 @@
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add
-- the column with a constant placeholder and backfill separately.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP;
+3 -13
View File
@@ -21,19 +21,11 @@ TEST_USER3=test3
TEST_USER3_PASSWORD=${TEST_USER3}
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 #
###################
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1
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_PORT=8000
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
DOMAIN=http://localhost:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
SSO_DEBUG_TOKENS=true
I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true
+17 -15
View File
@@ -1,8 +1,8 @@
# Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/).
\
It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance.
It uses its own `test.env` with different ports to not collide with a running dev instance.
## Install
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
### 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.
You'll additionally need `nodejs` then run:
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
You will additionally need `nodejs` then run:
```bash
npm ci --ignore-scripts --allow-git=none --allow-remote=none
npm ci --ignore-scripts
npx playwright install-deps
npx playwright install firefox
```
@@ -62,10 +62,10 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
### Keep services running
If you want you can keep the DB and Keycloak running (states are not impacted by the tests):
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
```bash
PW_KEEP_SERVICE_RUNNING=true npx playwright test
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
```
### 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
```
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
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
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
@@ -112,11 +112,12 @@ You can check the result running:
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
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
@@ -130,17 +131,18 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash
> 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 | 74af4933-e386-4e64-ba15-a7b61212c45e
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 :
- `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`
- `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).
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_*`)
## Cleanup
+2 -2
View File
@@ -17,7 +17,7 @@ done
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 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:
# docker exec -it keycloakSetup-dev /bin/bash
# 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
# 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
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 compose ./compose
-1
View File
@@ -35,7 +35,6 @@ WORKDIR /
COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden .
COPY --from=build /data ./data
COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"]
-11
View File
@@ -22,14 +22,3 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
mv build /web-vault
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")
+7 -10
View File
@@ -24,15 +24,12 @@ services:
environment:
- ADMIN_TOKEN
- DATABASE_URL
- CLIENT_SUPPRESS_ONBOARDING
- EMAIL_2FA_AUTO_FALLBACK
- I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST
- SMTP_FROM
- SMTP_DEBUG
- SSO_AUTH_ONLY_NOT_SESSION
- SSO_DEBUG_TOKENS
- SSO_ENABLED
- SSO_FRONTEND
@@ -61,7 +58,7 @@ services:
Mariadb:
profiles: ["playwright"]
container_name: playwright_mariadb
image: mariadb:12.2.2
image: mariadb:11.2.4
env_file: test.env
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
@@ -73,7 +70,7 @@ services:
Mysql:
profiles: ["playwright"]
container_name: playwright_mysql
image: mysql:9.7.0
image: mysql:8.4.1
env_file: test.env
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@@ -85,7 +82,7 @@ services:
Postgres:
profiles: ["playwright"]
container_name: playwright_postgres
image: postgres:18.4
image: postgres:16.3
env_file: test.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
@@ -97,7 +94,7 @@ services:
Maildev:
profiles: ["vaultwarden", "maildev"]
container_name: maildev
image: timshel/maildev:3.2.19
image: timshel/maildev:3.0.4
ports:
- ${SMTP_PORT}:1025
- 1080:1080
@@ -105,7 +102,7 @@ services:
Keycloak:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:26.6.2
image: quay.io/keycloak/keycloak:26.3.4
network_mode: "host"
command:
- start-dev
@@ -115,12 +112,12 @@ services:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloakSetup-${ENV:-dev}
image: keycloak_setup-${ENV:-dev}
network_mode: "host"
build:
context: compose/keycloak
dockerfile: Dockerfile
args:
KEYCLOAK_VERSION: 26.6.2
KEYCLOAK_VERSION: 26.3.4
network_mode: "host"
depends_on:
- Keycloak
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 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) {
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}`);
} else {
console.log(`Vaultwarden stopping`);
@@ -231,7 +231,6 @@ export async function checkNotification(page: Page, hasText: string) {
}
export async function cleanLanding(page: Page) {
await page.context().clearCookies();
await page.goto('/', { waitUntil: 'domcontentloaded' });
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();
});
}
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": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.60.0",
"dotenv": "17.4.2",
"dotenv-expand": "13.0.0",
"maildev": "npm:@timshel_npm/maildev@3.2.19"
"@playwright/test": "1.56.1",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@3.2.5"
},
"dependencies": {
"mysql2": "3.22.3",
"otpauth": "9.5.1",
"pg": "8.21.0"
"mysql2": "3.15.3",
"otpauth": "9.4.1",
"pg": "8.16.3"
}
}
+4 -10
View File
@@ -25,12 +25,10 @@ export default defineConfig({
/* Long global timeout for complex tests
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
*/
timeout: 240 * 1000,
actionTimeout: 40 * 1000,
navigationTimeout: 40 * 1000,
expect: { timeout: 40 * 1000 },
"permissions": ["clipboard-read"],
timeout: 120 * 1000,
actionTimeout: 20 * 1000,
navigationTimeout: 20 * 1000,
expect: { timeout: 20 * 1000 },
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
@@ -39,10 +37,6 @@ export default defineConfig({
browserName: 'firefox',
locale: 'en-GB',
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 */
trace: 'on',
+4 -6
View File
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
#####################
# 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
#####################
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
###################
# Keycloak Config #
###################
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081
@@ -52,12 +52,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config #
######################
ROCKET_PORT=8003
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
DOMAIN=http://localhost:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100
ADMIN_TOKEN=admin
CLIENT_SUPPRESS_ONBOARDING=true
SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT}
+11 -5
View File
@@ -1,8 +1,6 @@
import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { createAccount } from './setups/user';
let users = utils.loadEnv();
@@ -18,12 +16,20 @@ test.afterAll('Teardown', async ({}) => {
test('Create', async ({ page }) => {
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 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('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
await page.getByLabel('Name (required)').fill('RandomCollec');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec');
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);
});
-131
View File
@@ -1,131 +0,0 @@
import { test, expect, type Page, type TestInfo, Test } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv();
let mailserver;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailserver = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailserver.listen();
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
if( mailserver ){
await mailserver.close();
}
});
async function emergencyAccess(test: Test, page: Page, user: { name: string }) {
await test.step('Navigate', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Emergency access' }).click();
await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible();
});
}
test('Emergency access', async ({ browser, page }) => {
const context2 = await browser.newContext();
const page2 = await context2.newPage();
const mailBuffer = mailserver.buffer(users.user1.email);
const mailBuffer2 = mailserver.buffer(users.user2.email);
await createAccount(test, page, users.user1);
await createAccount(test, page2, users.user2);
await test.step('Add test2', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Add emergency contact' }).click();
await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email);
await page.getByRole('radio', { name: 'Takeover Can reset your' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
await test.step('Accept', async () => {
const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name);
const pageE = await context2.newPage();
await pageE.setContent(email.html);
const link = await pageE.getByTestId("emergency").getAttribute("href");
await pageE.close();
await page2.goto(link);
await utils.checkNotification(page2, 'Invitation accepted');
});
await test.step('Confirm', async () => {
await emergencyAccess(test, page, users.user1);
await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible();
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await utils.checkNotification(page, users.user2.name + ' confirmed');
await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed");
});
await test.step('Request', async () => {
await emergencyAccess(test, page2, users.user2);
await page2.getByRole('button', { name: 'Options' }).click();
await page2.getByRole('menuitem', { name: 'Request Access' }).click();
await page2.getByRole('button', { name: 'Request Access' }).click();
await utils.checkNotification(page2, 'Emergency access requested');
await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated");
});
await test.step('Approved', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Approve' }).click();
await page.getByRole('button', { name: 'Approve' }).click();
await utils.checkNotification(page, 'Emergency access approved');
await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved");
});
await activateTOTP(test, page, users.user1);
let newPassword = "TotoNewPassword";
await test.step('Access', async () => {
await emergencyAccess(test, page2, users.user2);
await page2.getByRole('button', { name: 'Options' }).click();
await page2.getByRole('menuitem', { name: 'Takeover' }).click();
await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword);
await page2.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page2, 'Password reset for ' + users.user1.name);
});
await test.step('Changed no 2fa', async () => {
users.user1.password = newPassword;
await logUser(test, page, users.user1);
});
await test.step('Reject', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Reject' }).click();
await utils.checkNotification(page, 'Emergency access rejected');
await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected");
});
await test.step('Remove', async () => {
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Removed user ' + users.user2.name);
await expect(page.getByText('You have not added any emergency contacts')).toBeVisible();
});
});
+25 -6
View File
@@ -41,10 +41,13 @@ test('Account creation', async ({ page }) => {
test('Login', async ({ context, page }) => {
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 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');
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
@@ -75,10 +78,26 @@ test('Activate 2fa', async ({ page }) => {
test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, {
mailBuffer: emails,
mail2fa: true,
});
await test.step('login', async () => {
await page.goto('/');
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();
});
+16 -37
View File
@@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa';
import { activateTOTP, disableTOTP } from './setups/2fa';
let users = utils.loadEnv();
let totp;
@@ -31,42 +31,21 @@ test('Authenticator 2fa', async ({ page }) => {
await utils.logout(test, page, users.user1);
await logUser(test, page, users.user1, { totp });
await test.step('login', async () => {
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.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();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaultwarden Web/);
});
await disableTOTP(test, page, users.user1);
});
test('Recovery codes', async ({ context, page }) => {
await logUser(test, page, users.user1);
await activateTOTP(test, page, users.user1);
let recovery = await recoveryCodes(test, page, users.user1);
await utils.logout(test, page, users.user1);
await test.step('login', async () => {
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await expect(page).toHaveTitle(/Vaultwarden Web/);
const newPagePromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Use your recovery code' }).click();
const newPage = await newPagePromise;
const tabs = context.pages();
await tabs[1].bringToFront();
await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible();
await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email);
await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery);
await tabs[1].getByRole('button', { name: 'Submit' }).click();
await expect(tabs[1]).toHaveTitle(/Two-step login/);
});
});
+21 -28
View File
@@ -4,7 +4,6 @@ import { MailDev } from 'maildev';
import * as utils from '../global-utils';
import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv();
@@ -21,7 +20,6 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
EMAIL_2FA_AUTO_FALLBACK: "true",
});
mail1Buffer = mailServer.buffer(users.user1.email);
@@ -47,7 +45,7 @@ test('Invite users', async ({ page }) => {
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('checkbox', { name: 'Require new members' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
@@ -68,16 +66,18 @@ test('invited with new account', async ({ page }) => {
await page.goto(link);
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
// await page.getByLabel('Name').fill(users.user2.name);
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.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
// await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Successfully accepted your invitation');
});
await test.step('Check mails', async () => {
@@ -100,19 +100,21 @@ test('invited with existing account', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(users.user3.password);
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
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 mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
});
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.confirm(test, page, 'Test', users.user2.email);
@@ -121,30 +123,25 @@ test('Confirm invited user', 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 expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await logUser(test, page, users.user1, mail1Buffer);
let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test');
await test.step(`Recover ${users.user2.email}`, async () => {
await test.step(`Rrcover ${users.user2.email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
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, 'Password reset success');
});
let user2 = {
@@ -152,9 +149,5 @@ test('Recover user password', async ({ page }) => {
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
await logUser(test, page, user2, mail2Buffer);
});
-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)
})
});
+8 -8
View File
@@ -21,11 +21,11 @@ test('Send', async ({ browser, page }) => {
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('button', { name: 'New', 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('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();
@@ -46,14 +46,14 @@ test('Send', async ({ browser, page }) => {
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('button', { name: 'New', exact: true }).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('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('textbox', { name: 'Password (required)' }).fill('password');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
@@ -64,7 +64,7 @@ test('Send', async ({ browser, page }) => {
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('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();
+8 -26
View File
@@ -4,24 +4,6 @@ import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils';
export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string {
return await test.step('Recovery code', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.getByRole('button', { name: 'View recovery code' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
const recovery = await page.getByRole('code').innerText();
await page.getByLabel('Close').click();
return recovery;
})
}
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
return await test.step('Activate TOTP 2FA', async () => {
await page.getByRole('button', { name: user.name }).click();
@@ -29,16 +11,16 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).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();
const secret = await page.getByLabel('Key', { exact: true }).innerText();
const secret = await page.getByLabel('Key').innerText();
let totp = new OTPAuth.TOTP({ secret, period: 30 });
await page.getByLabel(/Verification code/).fill(totp.generate());
await page.getByRole('button', { name: 'Turn on' }).click();
await page.getByRole('heading', { name: 'Turned on', exact: true });
await page.getByLabel('Close').click();
return totp;
})
@@ -51,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: 'Two-step login' }).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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
@@ -67,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: 'Two-step login' }).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: 'Send email' }).click();
});
@@ -99,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: 'Two-step login' }).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.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).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();
test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
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';
export async function create(test, page: Page, name: string) {
await test.step(`Create Org ${name}`, async () => {
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
if( await pm_locator.count() > 0 ){
pm_locator.first().click();
}
await test.step('Create Org', async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
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 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 page.locator('a').filter({ hasText: 'Admin Console' }).first().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 page.getByRole('button', { name: 'Toggle collapse Settings' }).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 page.locator('a').filter({ hasText: 'Admin Console' }).first().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 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('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 expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
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('combobox', { name: 'Permission' }).click();
await page.getByText('Edit items', { exact: true }).click();
await page.getByRole('combobox', { name: 'Select collections' }).click();
await page.getByLabel('Options List').getByText('Default collection').click();
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
await page.getByLabel('Select collections').click();
await page.getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
+1 -1
View File
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
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");
} else {
console.log("Keycloak stopping");
+17 -8
View File
@@ -15,8 +15,11 @@ export async function logNewUser(
options: { mailBuffer?: MailBuffer } = {}
) {
await test.step(`Create user ${user.name}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email);
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 expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
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 expect(page).toHaveTitle(/Vaultwarden Web/);
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 ){
let mailBuffer = options.mailBuffer;
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.includes("New Device Logged"));
});
}
});
@@ -64,14 +69,16 @@ export async function logUser(
mailBuffer ?: MailBuffer,
totp?: OTPAuth.TOTP,
mail2fa?: boolean,
notNewDevice?: boolean,
} = {}
) {
let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email);
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 utils.ignoreExtension(page);
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
});
if( mailBuffer && !options.notNewDevice ){
if( mailBuffer ){
await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});
+9 -39
View File
@@ -2,9 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev';
import * as OTPAuth from "otpauth";
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) {
await test.step(`Create user ${user.name}`, async () => {
@@ -19,11 +17,12 @@ export async function createAccount(test, page: Page, user: { email: string, nam
await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created')
await utils.ignoreExtension(page);
// We are now in the default vault page
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
@@ -36,17 +35,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam
});
}
export async function logUser(
test,
page: Page,
user: { email: string, password: string },
options: {
mailBuffer ?: MailBuffer,
mail2fa?: boolean,
notNewDevice?: boolean,
totp?: OTPAuth.TOTP,
} = {}
) {
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Log user ${user.email}`, async () => {
await utils.cleanLanding(page);
@@ -54,35 +43,16 @@ export async function logUser(
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
if( options.mail2fa || options.totp ){
let code;
await test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
if( options.totp ) {
const totp = options.totp;
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
code = totp.generate({timestamp});
} else if( options.mail2fa ){
code = await retrieveEmailCode(test, page, options.mailBuffer);
}
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
});
}
await utils.ignoreExtension(page);
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
if( options.mailBuffer && !options.notNewDevice ){
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
if( mailBuffer ){
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 { MailDev } from 'maildev';
import * as admin from "./setups/admin";
import { logNewUser, logUser } from './setups/sso';
import { activateEmail, disableEmail } from './setups/2fa';
import * as utils from "../global-utils";
@@ -20,7 +19,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: true,
SSO_ONLY: false,
SMTP_HOST: process.env.MAILDEV_HOST,
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);
await logNewUser(test, page, users.user1, {mailBuffer: 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);
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();
// Unlock page
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
@@ -58,7 +58,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
// Landing page
await page.goto('/');
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
// Check that SSO login is available
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);
});
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, {
SSO_ENABLED: false
@@ -74,14 +74,12 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
// Landing page
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// 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);
// Can continue to Master password
await page.getByLabel(/Email address/).fill(users.user1.email);
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 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.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
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 expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
});
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 page.goto(link);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
await test.step('Keycloak login', async () => {
@@ -108,11 +108,13 @@ test('invited with existing account', async ({ page }) => {
await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password);
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 expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
});
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();
});
test('Activate password policy', async ({ page }) => {
test('Enforce password policy', async ({ page }) => {
await logUser(test, page, users.user1);
await orgs.policies(test, page, '/Test');
@@ -61,27 +61,16 @@ test('Activate password policy', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.');
});
});
test('Unlock trigger policyy', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await utils.logout(test, page, users.user1);
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
await test.step(`Unlock trigger policy`, async () => {
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 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('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
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]
channel = "1.99.1"
channel = "1.96.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+20 -59
View File
@@ -201,15 +201,12 @@ fn post_admin_login(
// If the token is invalid, redirect to login page
if validate_token(&data.token) {
// If the token received is valid, generate JWT and save it as a cookie
let claims = match generate_admin_claims() {
Ok(claims) => claims,
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
};
let claims = generate_admin_claims();
let jwt = encode_jwt(&claims);
let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path())
.max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict)
.http_only(true)
.secure(secure.https);
@@ -234,7 +231,7 @@ fn validate_token(token: &str) -> bool {
None => false,
Some(t) if t.starts_with("$argon2") => {
use argon2::password_hash::PasswordVerifier;
match argon2::password_hash::phc::PasswordHash::new(t) {
match argon2::password_hash::PasswordHash::new(t) {
Ok(h) => {
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
@@ -428,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
for membership in memberships {
log_event(
EventType::OrganizationUserDeleted,
EventType::OrganizationUserDeleted as i32,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -449,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
for membership in memberships {
log_event(
EventType::OrganizationUserUnlinkedSso,
EventType::OrganizationUserUnlinkedSso as i32,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -511,7 +508,6 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
user.totp_recover = None;
user.save(&conn).await
@@ -575,7 +571,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
log_event(
EventType::OrganizationUserUpdated,
EventType::OrganizationUserUpdated as i32,
&member_to_edit.uuid,
&data.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -651,7 +647,7 @@ use cached::macros::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
/// 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
#[cached(ttl_secs = 600, sync_writes = "default")]
#[cached(ttl = 600, sync_writes = "default")]
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 has_http_access {
@@ -720,36 +716,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")]
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
use chrono::prelude::*;
@@ -782,8 +748,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
.into_keys()
.collect();
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
let diagnostics_json = json!({
"dns_resolved": dns_resolved,
"current_release": VERSION,
@@ -806,9 +770,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "),
"config_file_status": &CONFIG.get_config_file_status(),
"template_overrides": check_template_overrides().join(", "),
"invalid_env_vars": invalid_env_vars,
"invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH,
"host_os": env::consts::OS,
@@ -913,23 +874,23 @@ mod tests {
fn validate_web_vault_compare() {
// web_vault_compare(active, latest)
// Test normal versions
assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
// Test patched/+build.n versions
// Newer latest version
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
// Equal versions
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
// Newer active version
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
}
}
+115 -276
View File
@@ -21,9 +21,8 @@ use crate::{
DbConn, DbPool,
models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
UserKdfType,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
},
},
mail,
@@ -32,7 +31,7 @@ use crate::{
use super::{
ciphers::{CipherData, update_cipher_from_data},
sends::SendData,
sends::{SendData, update_send_from_data},
};
pub fn routes() -> Vec<rocket::Route> {
@@ -42,13 +41,11 @@ pub fn routes() -> Vec<rocket::Route> {
post_profile,
put_avatar,
get_public_keys,
get_keys,
post_keys,
post_password,
post_set_password,
post_kdf,
post_rotatekey,
post_user_key,
post_sstamp,
post_email_token,
post_email,
@@ -60,6 +57,7 @@ pub fn routes() -> Vec<rocket::Route> {
delete_account,
revision_date,
password_hint,
post_prelogin,
verify_password,
post_api_key,
rotate_api_key,
@@ -93,15 +91,6 @@ pub struct KDFData {
kdf_parallelism: Option<i32>,
}
impl KDFData {
pub(super) fn matches_user(&self, user: &User) -> bool {
self.kdf == user.client_kdf_type
&& self.kdf_iterations == user.client_kdf_iter
&& self.kdf_memory == user.client_kdf_memory
&& self.kdf_parallelism == user.client_kdf_parallelism
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterData {
@@ -115,8 +104,11 @@ pub struct RegisterData {
master_password_hint: Option<String>,
name: Option<String>,
organization_user_id: Option<MembershipId>,
// Used only from the register/finish endpoint
email_verification_token: Option<String>,
accept_emergency_access_id: Option<EmergencyAccessId>,
accept_emergency_access_invite_token: Option<String>,
@@ -169,9 +161,7 @@ struct RegisterDataOld {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterDataCur {
#[serde(alias = "MasterPasswordAuthentication")]
master_password_authentication: MasterPasswordAuthentication,
#[serde(alias = "MasterPasswordUnlock")]
master_password_unlock: MasterPasswordUnlock,
}
@@ -205,12 +195,10 @@ struct KeysData {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordAuthentication {
#[serde(alias = "Kdf")]
kdf: KDFData,
#[serde(alias = "Salt")]
salt: String,
#[serde(alias = "masterPasswordAuthenticationHash", alias = "MasterPasswordAuthenticationHash")]
#[serde(alias = "masterPasswordAuthenticationHash")]
hash: String,
}
@@ -267,11 +255,12 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
false
}
pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
let data: RegisterData = data.into_inner();
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
let mut data: RegisterData = data.into_inner();
let email = data.email.to_lowercase();
let mut name = None;
let mut email_verified = false;
let mut pending_emergency_access = None;
if data.unprocessable() {
@@ -279,65 +268,69 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
}
// First, validate the provided verification tokens
let mut email_verified = match (
&data.email_verification_token,
&data.accept_emergency_access_id,
&data.accept_emergency_access_invite_token,
&data.organization_user_id,
&data.org_invite_token,
) {
// Normal user registration, when email verification is required
(Some(email_verification_token), None, None, None, None) => {
let claims = crate::auth::decode_register_verify(email_verification_token)?;
if claims.sub != data.email {
err!("Email verification token does not match email");
if email_verification {
match (
&data.email_verification_token,
&data.accept_emergency_access_id,
&data.accept_emergency_access_invite_token,
&data.organization_user_id,
&data.org_invite_token,
) {
// Normal user registration, when email verification is required
(Some(email_verification_token), None, None, None, None) => {
let claims = crate::auth::decode_register_verify(email_verification_token)?;
if claims.sub != data.email {
err!("Email verification token does not match email");
}
// During this call we don't get the name, so extract it from the claims
if claims.name.is_some() {
data.name = claims.name;
}
email_verified = claims.verified;
}
// Emergency access registration
(None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => {
if !CONFIG.emergency_access_allowed() {
err!("Emergency access is not enabled.")
}
let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?;
if claims.email != data.email {
err!("Claim email does not match email")
}
if &claims.emer_id != accept_emergency_access_id {
err!("Claim emer_id does not match accept_emergency_access_id")
}
pending_emergency_access = Some((accept_emergency_access_id, claims));
email_verified = true;
}
// Org invite
(None, None, None, Some(organization_user_id), Some(org_invite_token)) => {
let claims = decode_invite(org_invite_token)?;
if claims.email != data.email {
err!("Claim email does not match email")
}
if &claims.member_id != organization_user_id {
err!("Claim org_user_id does not match organization_user_id")
}
email_verified = true;
}
// During this call we don't get the name, so extract it from the claims
name = claims.name;
claims.verified
_ => {
err!("Registration is missing required parameters")
}
}
// Emergency access registration
(None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => {
if !CONFIG.emergency_access_allowed() {
err!("Emergency access is not enabled.")
}
let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?;
if claims.email != data.email {
err!("Claim email does not match email")
}
if &claims.emer_id != accept_emergency_access_id {
err!("Claim emer_id does not match accept_emergency_access_id")
}
pending_emergency_access = Some((accept_emergency_access_id, claims));
true
}
// Org invite
(None, None, None, Some(organization_user_id), Some(org_invite_token)) => {
let claims = decode_invite(org_invite_token)?;
if claims.email != data.email {
err!("Claim email does not match email")
}
if &claims.member_id != organization_user_id {
err!("Claim org_user_id does not match organization_user_id")
}
true
}
_ => {
err!("Registration is missing required parameters")
}
};
}
// 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
if let Some(ref name) = name
if let Some(ref name) = data.name
&& name.len() > 50
{
err!("The field Name must be a string with a maximum length of 50.");
@@ -399,7 +392,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
user.password_hint = password_hint;
// Add extra fields if present
if let Some(name) = name {
if let Some(name) = data.name {
user.name = name;
}
@@ -437,7 +430,8 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
}
Ok(Json(json!({
"object": "registerFinish",
"object": "register",
"captchaBypassToken": "",
})))
}
@@ -446,7 +440,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
let data: SetPasswordData = data.into_inner();
let mut user = headers.user;
if user.private_key.is_some() || !user.password_hash.is_empty() {
if user.private_key.is_some() {
err!("Account already initialized, cannot set password")
}
@@ -493,12 +487,14 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
Membership::accept_user_invitations(&user.uuid, &conn).await?;
}
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
user.save(&conn).await?;
Ok(Json(json!({
"object": "set-password",
"captchaBypassToken": "",
})))
}
@@ -577,34 +573,12 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
})))
}
#[get("/accounts/keys")]
fn get_keys(headers: Headers) -> JsonResult {
let user = headers.user;
// The SDK reads a 404 as the user having no key pair yet
if user.private_key.is_none() || user.public_key.is_none() {
err_code!("User has no key pair", Status::NotFound.code)
}
Ok(Json(json!({
"key": (!user.akey.is_empty()).then_some(&user.akey),
"publicKey": user.public_key,
"privateKey": user.private_key,
"accountKeys": user.account_keys_json(),
"object": "keys",
})))
}
#[post("/accounts/keys", data = "<data>")]
async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: KeysData = data.into_inner();
let mut user = headers.user;
if user.private_key.is_some() || user.public_key.is_some() {
err!("User has existing keypair")
}
user.private_key = Some(data.encrypted_private_key);
user.public_key = Some(data.public_key);
@@ -621,51 +595,29 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
#[serde(rename_all = "camelCase")]
struct ChangePassData {
master_password_hash: String,
new_master_password_hash: String,
master_password_hint: Option<String>,
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
// Outdated values, might still be used by older clients
new_master_password_hash: Option<String>,
key: Option<String>,
key: String,
}
#[post("/accounts/password", data = "<data>")]
async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: ChangePassData = data.into_inner();
let user = headers.user;
let mut user = headers.user;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password")
}
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let (new_master_password_hash, new_key) =
if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) {
if authentication_data.kdf != unlock_data.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
if user.email != authentication_data.salt || user.email != unlock_data.salt {
err!("Invalid master password salt")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key)
} else {
err!("Invalid request!")
};
let mut user = user;
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(user.password_hint.as_ref())?;
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
user.set_password(
&new_master_password_hash,
Some(new_key),
&data.new_master_password_hash,
Some(data.key),
true,
Some(vec![
String::from("post_rotatekey"),
@@ -724,40 +676,27 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct AuthenticationData {
#[serde(alias = "Salt")]
struct AuthenticationData {
salt: String,
#[serde(alias = "Kdf")]
pub(super) kdf: KDFData,
#[serde(alias = "MasterPasswordAuthenticationHash")]
pub(super) master_password_authentication_hash: String,
}
impl AuthenticationData {
pub(super) fn check(&self, user: &User, unlock: &UnlockData) -> EmptyResult {
if self.kdf != unlock.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
if self.salt != user.master_password_salt() || self.salt != unlock.salt {
err!("Invalid master password salt")
}
Ok(())
}
kdf: KDFData,
master_password_authentication_hash: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct UnlockData {
struct UnlockData {
salt: String,
kdf: KDFData,
pub(super) master_key_wrapped_user_key: String,
master_key_wrapped_user_key: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangeKdfData {
#[allow(dead_code)]
new_master_password_hash: String,
#[allow(dead_code)]
key: String,
authentication_data: AuthenticationData,
unlock_data: UnlockData,
master_password_hash: String,
@@ -771,7 +710,13 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
err!("Invalid password")
}
data.authentication_data.check(&headers.user, &data.unlock_data)?;
if data.authentication_data.kdf != data.unlock_data.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt {
err!("Invalid master password salt")
}
let mut user = headers.user;
@@ -1016,20 +961,17 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// Update send data
for send_data in data.account_data.sends {
let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else {
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else {
err!("Send doesn't exist")
};
// Like upstream, only the key changes on rotation
send.akey = send_data.key;
send.save(&conn).await?;
update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?;
}
// Update cipher data
for cipher_data in data.account_data.ciphers {
if cipher_data.organization_id.is_none() {
let Some(saved_cipher) =
cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id))
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
else {
err!("Cipher doesn't exist")
};
@@ -1064,23 +1006,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
save_result
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeyIdData {
user_key_id: KeyId,
}
#[post("/accounts/key-management/user-key-id", data = "<data>")]
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
let mut user = headers.user;
if user.key_id.is_some() {
err_code!("Unexpected data", Status::UnprocessableEntity.code);
}
user.key_id = Some(data.into_inner().user_key_id);
user.save(&conn).await
}
#[post("/accounts/security-stamp", data = "<data>")]
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: PasswordOrOtpData = data.into_inner();
@@ -1395,48 +1320,25 @@ pub struct PreloginData {
email: String,
}
/// Like upstream, an unknown email gets KDF settings picked from a list of common ones by a keyed hash of the email,
/// so they stay the same between requests. The salt stays null, as for existing users.
fn unknown_email_kdf(email: &str) -> (i32, i32, Option<i32>, Option<i32>) {
const PBKDF2: i32 = UserKdfType::Pbkdf2 as i32;
const ARGON2ID: i32 = UserKdfType::Argon2id as i32;
// Same list as upstream, the default is in it twice to give it more weight
const KDF_SETTINGS: [(i32, i32, Option<i32>, Option<i32>); 6] = [
(User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
(User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
(PBKDF2, 100_000, None, None),
(PBKDF2, 5_000, None, None),
(ARGON2ID, 3, Some(64), Some(4)),
(ARGON2ID, 6, Some(32), Some(4)),
];
KDF_SETTINGS[crate::auth::prelogin_kdf_index(email, KDF_SETTINGS.len())]
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
}
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
let data: PreloginData = data.into_inner();
// Normalized once, so the lookup and the settings for an unknown email use the same value
let email = data.email.trim().to_lowercase();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&email, &conn).await {
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism),
None => unknown_email_kdf(&email),
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
};
Ok(Json(json!({
Json(json!({
"kdf": kdf_type,
"kdfIterations": kdf_iter,
"kdfMemory": kdf_mem,
"kdfParallelism": kdf_para,
"kdfSettings": {
"iterations": kdf_iter,
"kdfType": kdf_type,
"memory": kdf_mem,
"parallelism": kdf_para
},
"salt": null,
})))
}))
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
@@ -1667,8 +1569,6 @@ async fn post_auth_request(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let data = data.into_inner();
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
@@ -1681,7 +1581,7 @@ async fn post_auth_request(
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
};
let auth_request = AuthRequest::new(
let mut auth_request = AuthRequest::new(
user.uuid.clone(),
data.device_identifier.clone(),
client_headers.device_type,
@@ -1694,7 +1594,7 @@ async fn post_auth_request(
nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await;
log_user_event(
EventType::UserRequestedDeviceApproval,
EventType::UserRequestedDeviceApproval as i32,
&user.uuid,
client_headers.device_type,
&client_headers.ip.ip,
@@ -1788,7 +1688,7 @@ async fn put_auth_request(
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await;
log_user_event(
EventType::OrganizationUserApprovedAuthRequest,
EventType::OrganizationUserApprovedAuthRequest as i32,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
@@ -1799,7 +1699,7 @@ async fn put_auth_request(
// If denied, there's no reason to keep the request
auth_request.delete(&conn).await?;
log_user_event(
EventType::OrganizationUserRejectedAuthRequest,
EventType::OrganizationUserRejectedAuthRequest as i32,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
@@ -1830,8 +1730,6 @@ async fn get_auth_request_response(
client_headers: ClientHeaders,
conn: DbConn,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
err!("AuthRequest doesn't exist", "User not found")
};
@@ -1905,62 +1803,3 @@ pub async fn purge_auth_requests(pool: DbPool) {
error!("Failed to get DB connection while purging auth requests");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_register_data_current_format() {
// Web and iOS since the master password authentication/unlock split
let data: RegisterData = serde_json::from_str(
r#"{
"email": "user@example.com",
"emailVerificationToken": "token",
"userAsymmetricKeys": {"publicKey": "pub", "encryptedPrivateKey": "priv"},
"masterPasswordAuthentication": {
"kdf": {"kdfType": 0, "iterations": 600000},
"salt": "user@example.com",
"masterPasswordAuthenticationHash": "hash"
},
"masterPasswordUnlock": {
"kdf": {"kdfType": 0, "iterations": 600000},
"salt": "user@example.com",
"masterKeyWrappedUserKey": "key"
}
}"#,
)
.unwrap();
assert!(!data.unprocessable());
assert_eq!(data.hash(), "hash");
assert_eq!(data.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some());
assert_eq!(data.email_verification_token.as_deref(), Some("token"));
}
#[test]
fn test_register_data_flat_format() {
// Android before 2026.9.0
let data: RegisterData = serde_json::from_str(
r#"{
"email": "user@example.com",
"emailVerificationToken": "token",
"masterPasswordHash": "hash",
"masterPasswordHint": null,
"userSymmetricKey": "key",
"userAsymmetricKeys": {"publicKey": "pub", "encryptedPrivateKey": "priv"},
"kdf": 0,
"kdfIterations": 600000
}"#,
)
.unwrap();
assert!(!data.unprocessable());
assert_eq!(data.hash(), "hash");
assert_eq!(data.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some());
}
}
+50 -143
View File
@@ -6,7 +6,6 @@ use rocket::{
Route,
form::{Form, FromForm},
fs::TempFile,
http::Status,
serde::json::Json,
};
use serde_json::Value;
@@ -22,14 +21,14 @@ use crate::{
DbConn, DbPool,
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
};
use super::{folders::FolderData, sends::SIZE_525_MB};
use super::folders::FolderData;
pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be
@@ -162,19 +161,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let policies_json: Vec<Value> =
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
.await
.iter()
.map(OrgPolicy::to_json)
.collect();
let domains_json = if data.exclude_domains {
Value::Null
} else {
api::core::get_eq_domains(&headers, true).into_inner()
};
// This is very similar to the userDecryptionOptions sent in connect/token,
// This is very similar to the the userDecryptionOptions sent in connect/token,
// but as of 2025-12-19 they're both using different casing conventions.
let has_master_password = !headers.user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
@@ -200,13 +193,11 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"folders": folders_json,
"collections": collections_json,
"policies": policies_json,
"policiesNew": policies_new_json,
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"object": "sync"
})))
@@ -269,19 +260,12 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
BankAccount = 6
DriversLicense = 7
Passport = 8
*/
pub r#type: i32,
pub name: String,
@@ -294,9 +278,6 @@ pub struct CipherData {
card: Option<Value>,
identity: Option<Value>,
ssh_key: Option<Value>,
bank_account: Option<Value>,
drivers_license: Option<Value>,
passport: Option<Value>,
favorite: Option<bool>,
reprompt: Option<i32>,
@@ -352,10 +333,6 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
@@ -385,17 +362,6 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
// which results in a warning message being logged. This field isn't
@@ -441,10 +407,10 @@ pub async fn update_cipher_from_data(
// 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 let Some(items) = json_data.as_array_mut() {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
item.remove("response");
}
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
}
@@ -484,12 +450,9 @@ pub async fn update_cipher_from_data(
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"),
Some(member) => {
// A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
// Write access to the cipher itself only counts when it already belongs to this organization.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
if shared_to_collections.is_some()
|| member.has_full_access()
|| (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await)
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
{
cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again.
@@ -545,17 +508,12 @@ pub async fn update_cipher_from_data(
3 => data.card,
4 => data.identity,
5 => data.ssh_key,
6 => data.bank_account,
7 => data.drivers_license,
8 => data.passport,
_ => err!("Invalid type"),
};
let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object.
if let Some(data_obj) = data.as_object_mut() {
data_obj.remove("response");
}
data.as_object_mut().unwrap().remove("response");
// Remove the 'Response' key from every Uri.
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
@@ -577,12 +535,11 @@ pub async fn update_cipher_from_data(
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
match data.archived_date {
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
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}"),
},
None => cipher.unarchive(&headers.user.uuid, conn).await?,
}
}
if ut != UpdateType::None {
@@ -594,8 +551,16 @@ pub async fn update_cipher_from_data(
(_, _) => EventType::CipherUpdated,
};
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
.await;
log_event(
event_type as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
}
nt.send_cipher_update(
ut,
@@ -883,7 +848,7 @@ async fn post_collections_update(
.await;
log_event(
EventType::CipherUpdatedCollections,
EventType::CipherUpdatedCollections as i32,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@@ -903,7 +868,7 @@ async fn put_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
) -> EmptyResult {
post_collections_admin(cipher_id, data, headers, conn, nt).await
}
@@ -914,7 +879,7 @@ async fn post_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
) -> EmptyResult {
let data: CollectionsAdminData = data.into_inner();
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
@@ -963,7 +928,7 @@ async fn post_collections_admin(
.await;
log_event(
EventType::CipherUpdatedCollections,
EventType::CipherUpdatedCollections as i32,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@@ -973,7 +938,7 @@ async fn post_collections_admin(
)
.await;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
Ok(())
}
#[derive(Deserialize)]
@@ -1018,53 +983,14 @@ struct ShareSelectedCipherData {
collection_ids: Vec<CollectionId>,
}
// A bulk share returns cipherMini models, not the cipherDetails models used by
// the single-share route. Keep the encrypted legacy fields and a future v2
// `data` blob, but do not expose details-only fields in this response.
fn cipher_mini_from_json(mut cipher: Value) -> Value {
const FIELDS: &[&str] = &[
"id",
"organizationId",
"type",
"data",
"partialData",
"name",
"notes",
"login",
"card",
"identity",
"secureNote",
"sshKey",
"bankAccount",
"driversLicense",
"passport",
"fields",
"passwordHistory",
"attachments",
"organizationUseTotp",
"revisionDate",
"creationDate",
"deletedDate",
"reprompt",
"key",
];
let Some(object) = cipher.as_object_mut() else {
return json!({ "object": "cipherMini" });
};
object.retain(|field, _| FIELDS.contains(&field.as_str()));
object.insert("object".into(), "cipherMini".into());
cipher
}
#[put("/ciphers/share", data = "<data>")]
async fn put_cipher_share_selected(
data: Json<ShareSelectedCipherData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let data: ShareSelectedCipherData = data.into_inner();
) -> EmptyResult {
let mut data: ShareSelectedCipherData = data.into_inner();
if data.ciphers.is_empty() {
err!("You must select at least one cipher.")
@@ -1080,38 +1006,23 @@ async fn put_cipher_share_selected(
}
}
let mut responses = Vec::with_capacity(data.ciphers.len());
for cipher in data.ciphers {
while let Some(cipher) = data.ciphers.pop() {
let mut shared_cipher_data = ShareCipherData {
cipher,
collection_ids: data.collection_ids.clone(),
};
if let Some(id) = shared_cipher_data.cipher.id.take() {
let response =
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?;
// Like upstream's VisibleToClient, withhold a cipher if sharing it
// removed the user's access to it. Only then include its encrypted
// mini response; the full single-share response stays internal.
if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await
&& shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
{
responses.push(cipher_mini_from_json(response.into_inner()));
}
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
} else {
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
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Json(json!({
"data": responses,
"object": "list",
"continuationToken": null,
})))
Ok(())
}
async fn share_cipher_by_uuid(
@@ -1239,10 +1150,6 @@ async fn post_attachment_v2(
if file_size < 0 {
err!("Attachment size can't be negative")
}
// Matches the "file" upload limit in main.rs, nothing larger can be uploaded
if file_size > SIZE_525_MB {
err!("Max file size is 525 MB.")
}
let attachment_id = crypto::generate_attachment_id();
let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
@@ -1426,7 +1333,7 @@ async fn save_attachment(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherAttachmentCreated,
EventType::CipherAttachmentCreated as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
@@ -1787,7 +1694,7 @@ async fn purge_org_vault(
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
log_event(
EventType::OrganizationPurgedVault,
EventType::OrganizationPurgedVault as i32,
&organization.org_id,
&organization.org_id,
&user.uuid,
@@ -1915,9 +1822,9 @@ async fn delete_cipher_by_uuid(
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|| *delete_options == CipherDeleteOptions::SoftMulti
{
EventType::CipherSoftDeleted
EventType::CipherSoftDeleted as i32
} else {
EventType::CipherDeleted
EventType::CipherDeleted as i32
};
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
@@ -1930,7 +1837,7 @@ async fn delete_cipher_by_uuid(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CipherIdsData {
ids: HashSet<CipherId>,
ids: Vec<CipherId>,
}
async fn delete_multiple_ciphers(
@@ -1986,7 +1893,7 @@ async fn restore_cipher_by_uuid(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherRestored,
EventType::CipherRestored as i32,
&cipher.uuid.clone(),
org_id,
&headers.user.uuid,
@@ -2063,7 +1970,7 @@ async fn delete_cipher_attachment_by_id(
if let Some(ref org_id) = cipher.organization_uuid {
log_event(
EventType::CipherAttachmentDeleted,
EventType::CipherAttachmentDeleted as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
@@ -2241,15 +2148,8 @@ impl CipherSyncData {
}
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
let orgs: Vec<OrganizationId> = members.keys().cloned().collect();
let orgs = Membership::get_orgs_by_user(user_id, conn).await;
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
for attachment in attachments {
@@ -2264,6 +2164,13 @@ impl CipherSyncData {
cipher_collections.entry(cipher).or_default().push(collection);
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
.await
+108 -202
View File
@@ -1,4 +1,4 @@
use chrono::Utc;
use chrono::{TimeDelta, Utc};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
@@ -6,17 +6,14 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult,
core::{
CipherSyncData, CipherSyncType,
accounts::{AuthenticationData, UnlockData},
},
core::{CipherSyncData, CipherSyncType},
},
auth::{Headers, decode_emergency_access_invite},
db::{
DbConn, DbPool,
models::{
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
},
},
mail,
@@ -136,21 +133,19 @@ async fn post_emergency_access(
let data: EmergencyAccessUpdateData = data.into_inner();
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
err!("Invalid emergency access type.")
};
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else {
err!("Emergency access not valid.")
};
emergency_access.atype = new_type as i32;
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
new_type as i32
} else {
err!("Invalid emergency access type.")
};
emergency_access.atype = new_type;
emergency_access.wait_time_days = data.wait_time_days;
if data.key_encrypted.is_some() {
emergency_access.key_encrypted = data.key_encrypted;
@@ -212,11 +207,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let email = data.email.to_lowercase();
let wait_time_days = data.wait_time_days;
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
new_type as i32
} else {
err!("Invalid emergency access type.")
};
@@ -230,7 +225,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("Grantee user does not exist: {}", email.escape_debug()))
err!(format!("Grantee user does not exist: {email}"))
}
if !CONFIG.is_email_domain_allowed(&email) {
@@ -262,8 +257,6 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
err!(format!("Grantee user already invited: {}", &grantee_user.email))
}
let new_type = new_type as i32;
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let mut new_emergency_access =
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
new_emergency_access.save(&conn).await?;
@@ -296,7 +289,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
};
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
err!("The grantee user is already accepted or confirmed for emergency access");
err!("The grantee user is already accepted or confirmed to the organization");
}
let Some(email) = emergency_access.email.clone() else {
@@ -457,24 +450,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
else {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
)
err!("Emergency access not valid.")
};
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
)
}
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
)
err!("Emergency access not valid.")
}
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
@@ -507,17 +487,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
)
err!("Emergency access not valid.")
};
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
)
err!("Emergency access not valid.")
}
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
@@ -641,7 +615,6 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
"kdfMemory": grantor_user.client_kdf_memory,
"kdfParallelism": grantor_user.client_kdf_parallelism,
"keyEncrypted": &emergency_access.key_encrypted,
"salt": grantor_user.master_password_salt(),
"object": "emergencyAccessTakeover",
});
@@ -651,13 +624,8 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EmergencyAccessPasswordData {
// Legacy payload
new_master_password_hash: Option<String>,
key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
new_master_password_hash: String,
key: String,
}
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
@@ -670,6 +638,8 @@ async fn password_emergency_access(
check_emergency_access_enabled()?;
let data: EmergencyAccessPasswordData = data.into_inner();
let new_master_password_hash = &data.new_master_password_hash;
//let key = &data.Key;
let requesting_user = headers.user;
let Some(emergency_access) =
@@ -686,23 +656,8 @@ async fn password_emergency_access(
err!("Grantor user not found.")
};
let (new_master_password_hash, new_key) =
if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) {
authentication_data.check(&grantor_user, &unlock_data)?;
if !authentication_data.kdf.matches_user(&grantor_user) {
err!("KDF settings do not match the grantor account")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key)
} else {
err!("Invalid request!")
};
// change grantor_user password
grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins
@@ -770,71 +725,52 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
return;
}
let Ok(conn) = pool.get().await else {
error!("Failed to get DB connection for emergency time-outs!");
return;
};
if let Ok(conn) = pool.get().await {
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
if emergency_access_list.is_empty() {
debug!("No emergency request timeout to approve");
return;
}
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
match emer.is_recovery_allowed(&now) {
Ok(true) => {} // Recovery is allowed
Ok(false) => continue,
Err(e) => {
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
continue;
}
if emergency_access_list.is_empty() {
debug!("No emergency request timeout to approve");
}
// Get grantor user to send Accepted email
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
continue;
};
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
let recovery_allowed_at =
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
if recovery_allowed_at.le(&now) {
// Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
.await
.expect("Unable to update emergency access status");
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
continue;
};
if CONFIG.mail_enabled() {
// get grantor user to send Accepted email
let grantor_user =
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
// Get grantee user to send Accepted email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// get grantee user to send Accepted email
let grantee_user =
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
.await
.expect("Grantee user not found");
// Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
continue;
}
mail::send_emergency_access_recovery_timed_out(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
)
.await
.expect("Error on sending email");
if CONFIG.mail_enabled() {
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
)
.await
{
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
}
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
{
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)
.await
.expect("Error on sending email");
}
}
}
} else {
error!("Failed to get DB connection while searching emergency request timed out");
}
}
@@ -844,85 +780,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
return;
}
let Ok(conn) = pool.get().await else {
error!("Failed to get DB connection for emergency reminders!");
return;
};
if let Ok(conn) = pool.get().await {
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
if emergency_access_list.is_empty() {
debug!("No emergency request reminder notification to send");
return;
}
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
let Ok(deadline_at) = emer.deadline_at() else {
error!("Invalid deadline for emergency access '{}'", emer.uuid);
continue;
};
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
continue;
};
// Only send reminders during the last 24 hours, else continue
if now < final_reminder_at || now >= deadline_at {
continue;
if emergency_access_list.is_empty() {
debug!("No emergency request reminder notification to send");
}
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
// This is to prevent possible missed reminders, this might cause an extra reminder,
// but that is better then no reminder at all.
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
} else {
now
};
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
// Calculate the day before the recovery will become active
let final_recovery_reminder_at =
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
// Calculate if a day has passed since the previous notification, else no notification has been sent before
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
last_notification_at + TimeDelta::try_days(1).unwrap()
} else {
now
};
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
// Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
emer.update_last_notification_date_and_save(&now, &conn)
.await
.expect("Unable to update emergency access notification date");
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
// Else we can skip the current emergency access this run.
if next_reminder_at.gt(&now) {
continue;
}
// Get grantor user to send Reminder email
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
continue;
};
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
continue;
};
// Get grantee user to send Reminder email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
continue;
}
if CONFIG.mail_enabled()
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
"1", // This notification is only triggered one day before the activation
)
.await
{
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
if CONFIG.mail_enabled() {
// get grantor user to send Accepted email
let grantor_user =
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
// get grantee user to send Accepted email
let grantee_user =
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
.await
.expect("Grantee user not found");
mail::send_emergency_access_recovery_reminder(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
"1", // This notification is only triggered one day before the activation
)
.await
.expect("Error on sending email");
}
}
}
} else {
error!("Failed to get DB connection while searching emergency notification reminder");
}
}
+33 -53
View File
@@ -6,13 +6,12 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult},
api::{EmptyResult, JsonResult},
auth::{AdminHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
},
error::MapResult,
util::parse_date,
};
@@ -30,14 +29,6 @@ struct EventRange {
continuation_token: Option<String>,
}
impl EventRange {
fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> {
let start_date = parse_date(&self.start).map_res("Invalid start date")?;
let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?;
Ok((start_date, end_date))
}
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
@@ -48,7 +39,12 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let (start_date, end_date) = data.date_range()?;
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_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await
@@ -73,7 +69,12 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
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
{
let (start_date, end_date) = data.date_range()?;
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 {
@@ -101,7 +102,12 @@ async fn get_user_events(
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let (start_date, end_date) = data.date_range()?;
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_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await
@@ -162,10 +168,9 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
}
for event in data.iter() {
let event_date = parse_date(&event.date).map_res("Invalid event date")?;
// Only the event types upstream accepts from clients, the rest are written server-side.
let event_date = parse_date(&event.date);
match event.r#type {
t if t == EventType::UserClientExportedVault as i32 => {
1000..=1099 => {
log_user_event_impl(
event.r#type,
&headers.user.uuid,
@@ -176,7 +181,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
)
.await;
}
t if t == EventType::OrganizationClientExportedVault as i32 => {
1600..=1699 => {
// Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
@@ -194,28 +199,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
// Only the vault notification banner click is accepted from clients. The rest of
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
if let Some(org_id) = &event.organization_id
&& let Some(membership) =
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
{
log_event_impl(
event.r#type,
&membership.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
Some(event_date),
&headers.ip.ip,
&conn,
)
.await;
}
}
// Client-side cipher events (viewed, copied, toggled, autofilled).
1107..=1114 | 1117 | 1119..=1132 => {
_ => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id
@@ -236,17 +220,16 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {}
}
}
Ok(())
}
pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
if !CONFIG.org_events_enabled() {
return;
}
log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await;
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
}
async fn log_user_event_impl(
@@ -284,7 +267,7 @@ async fn log_user_event_impl(
}
pub async fn log_event(
event_type: EventType,
event_type: i32,
source_uuid: &str,
org_id: &OrganizationId,
act_user_id: &UserId,
@@ -295,7 +278,7 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() {
return;
}
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
}
#[expect(clippy::too_many_arguments)]
@@ -347,17 +330,14 @@ async fn log_event_impl(
pub async fn event_cleanup_job(pool: DbPool) {
debug!("Start events cleanup job");
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
debug!("events_days_retain is not configured or set to 0, skipping");
if CONFIG.events_days_retain().is_none() {
debug!("events_days_retain is not configured, abort");
return;
}
let Ok(conn) = pool.get().await else {
if let Ok(conn) = pool.get().await {
Event::clean_events(&conn).await.ok();
} else {
error!("Failed to get DB connection while trying to cleanup the events table");
return;
};
if let Err(e) = Event::clean_events(&conn).await {
error!("Failed to cleanup the events table: {e}");
}
}
+2 -9
View File
@@ -228,17 +228,14 @@ fn config() -> Json<Value> {
// Version history:
// - Individual cipher key encryption: 2024.2.0
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2026.6.0",
"version": "2025.12.0",
"gitHash": option_env!("GIT_REV"),
"server": {
"name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden"
},
"settings": {
"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(),
"disableUserRegistration": CONFIG.is_signup_disabled()
},
"environment": {
"vault": domain,
@@ -254,10 +251,6 @@ fn config() -> Json<Value> {
"vapidPublicKey": null
},
"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",
}))
}
+282 -142
View File
@@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive;
use rocket::{Route, http::Status, serde::json::Json};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use crate::{
@@ -15,10 +15,9 @@ use crate::{
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
TwoFactorType, User, UserId,
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
},
},
mail,
@@ -26,13 +25,12 @@ use crate::{
util::{NumberOrString, convert_json_key_lcase_first},
};
use super::accounts::{AuthenticationData, UnlockData};
pub fn routes() -> Vec<Route> {
routes![
get_organization,
create_organization,
delete_organization,
post_delete_organization,
leave_organization,
get_user_collections,
get_org_collections,
@@ -40,10 +38,13 @@ pub fn routes() -> Vec<Route> {
get_org_collection_detail,
get_collection_users,
put_organization,
post_organization,
post_organization_collections,
post_bulk_access_collections,
post_organization_collection_update,
put_organization_collection_update,
delete_organization_collection,
post_organization_collection_delete,
bulk_delete_organization_collections,
post_bulk_collections,
get_org_details,
@@ -58,6 +59,7 @@ pub fn routes() -> Vec<Route> {
get_org_user_mini_details,
get_user,
edit_member,
put_member,
delete_member,
bulk_delete_member,
post_org_import,
@@ -67,6 +69,7 @@ pub fn routes() -> Vec<Route> {
get_master_password_policy,
get_policy,
put_policy,
put_policy_vnext,
get_plans,
post_org_keys,
get_organization_keys,
@@ -82,16 +85,22 @@ pub fn routes() -> Vec<Route> {
post_groups,
get_group,
put_group,
post_group,
get_group_details,
delete_group,
post_delete_group,
bulk_delete_groups,
get_group_members,
put_group_members,
post_delete_group_member,
put_reset_password_enrollment,
get_reset_password_details,
put_reset_password,
put_recover_account,
get_org_export,
post_api_key,
rotate_api_key,
get_billing_metadata,
get_billing_warnings,
get_auto_enroll_status,
get_self_host_billing_metadata,
@@ -123,6 +132,7 @@ struct FullCollectionData {
name: String,
groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>,
}
@@ -235,6 +245,16 @@ async fn delete_organization(
}
}
#[post("/organizations/<org_id>/delete", data = "<data>")]
async fn post_delete_organization(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization(org_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/leave")]
async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
if headers.membership.status != MembershipStatus::Confirmed as i32 {
@@ -249,7 +269,7 @@ async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, c
}
log_event(
EventType::OrganizationUserLeft,
EventType::OrganizationUserLeft as i32,
&membership.uuid,
&org_id,
&headers.user.uuid,
@@ -280,6 +300,16 @@ async fn put_organization(
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
post_organization(org_id, headers, data, conn).await
}
#[post("/organizations/<org_id>", data = "<data>")]
async fn post_organization(
org_id: OrganizationId,
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -297,7 +327,7 @@ async fn put_organization(
org.save(&conn).await?;
log_event(
EventType::OrganizationUpdated,
EventType::OrganizationUpdated as i32,
org_id.as_ref(),
&org_id,
&headers.user.uuid,
@@ -361,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
@@ -385,9 +415,8 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await;
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
// This lists other members for admins, so it must not depend on the membership status
let membership_type: HashMap<MembershipId, i32> =
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
// check if current user has full access to the organization (either directly or via any group)
let has_full_access_to_org = member.has_full_access()
@@ -485,7 +514,7 @@ async fn post_organization_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionCreated,
EventType::CollectionCreated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -568,7 +597,7 @@ async fn post_bulk_access_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated,
EventType::CollectionUpdated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -610,6 +639,17 @@ async fn put_organization_collection_update(
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
post_organization_collection_update(org_id, col_id, headers, data, conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>", data = "<data>", rank = 2)]
async fn post_organization_collection_update(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -634,7 +674,7 @@ async fn put_organization_collection_update(
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated,
EventType::CollectionUpdated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -683,7 +723,7 @@ async fn delete_organization_collection_impl(
err!("Collection not found", "Collection does not exist or does not belong to this organization")
};
log_event(
EventType::CollectionDeleted,
EventType::CollectionDeleted as i32,
&collection.uuid,
org_id,
&headers.user.uuid,
@@ -705,6 +745,16 @@ async fn delete_organization_collection(
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>/delete")]
async fn post_organization_collection_delete(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkCollectionIds {
@@ -768,9 +818,11 @@ async fn get_org_collection_detail(
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
// This lists other members for admins, so it must not depend on the membership status
let membership_type: HashMap<MembershipId, i32> =
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
let membership_type: HashMap<MembershipId, i32> = Membership::find_confirmed_by_org(&org_id, &conn)
.await
.into_iter()
.map(|m| (m.uuid, m.atype))
.collect();
let users: Vec<Value> =
CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn)
@@ -835,11 +887,11 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
@@ -903,14 +955,13 @@ async fn get_members(
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
let mut users_json = Vec::new();
for u in Membership::find_by_org(&org_id, &conn).await {
// The user can be a manager instead of an admin, but we've checked above that they have full access
users_json.push(
u.to_json_details_for_admin(
u.to_json_user_details(
data.include_collections.unwrap_or(false),
data.include_groups.unwrap_or(false),
&conn,
@@ -1024,13 +1075,13 @@ async fn send_invite(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut user_created: bool;
let mut user_created: bool = false;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
let user = match User::find_by_mail(email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {}", email.escape_debug()))
err!(format!("User does not exist: {email}"))
}
if !CONFIG.is_email_domain_allowed(email) {
@@ -1048,7 +1099,7 @@ async fn send_invite(
}
Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
err!(format!("User already in organization: {}", email.escape_debug()))
err!(format!("User already in organization: {email}"))
}
if !CONFIG.mail_enabled() {
@@ -1059,7 +1110,6 @@ async fn send_invite(
member_status = MembershipStatus::Accepted as i32;
}
}
user_created = false;
user
}
};
@@ -1098,7 +1148,7 @@ async fn send_invite(
}
log_event(
EventType::OrganizationUserInvited,
EventType::OrganizationUserInvited as i32,
&new_member.uuid,
&org_id,
&headers.user.uuid,
@@ -1323,9 +1373,7 @@ async fn bulk_confirm_invite(
match data.keys {
Some(keys) => {
for invite in keys {
let Some(member_id) = invite.id else {
err!("Member id is required")
};
let member_id = invite.id.unwrap();
let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(),
@@ -1399,7 +1447,7 @@ async fn confirm_invite_impl(
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
log_event(
EventType::OrganizationUserConfirmed,
EventType::OrganizationUserConfirmed as i32,
&member_to_confirm.uuid,
org_id,
&headers.user.uuid,
@@ -1467,9 +1515,7 @@ async fn get_user(
// In this case, when groups are requested we also need to include collections.
// Else these will not be shown in the interface, and could lead to missing collections when saved.
let include_groups = data.include_groups.unwrap_or(false);
Ok(Json(
user.to_json_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await,
))
Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await))
}
#[derive(Deserialize)]
@@ -1483,6 +1529,17 @@ struct EditUserData {
}
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn put_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
edit_member(org_id, member_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn edit_member(
org_id: OrganizationId,
member_id: MembershipId,
@@ -1580,7 +1637,7 @@ async fn edit_member(
}
log_event(
EventType::OrganizationUserUpdated,
EventType::OrganizationUserUpdated as i32,
&member_to_edit.uuid,
&org_id,
&headers.user.uuid,
@@ -1667,7 +1724,7 @@ async fn delete_member_impl(
}
log_event(
EventType::OrganizationUserRemoved,
EventType::OrganizationUserRemoved as i32,
&member_to_delete.uuid,
org_id,
&headers.user.uuid,
@@ -1736,22 +1793,11 @@ async fn bulk_public_keys(
use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data;
// The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
// mandatory: clients are free to leave them out.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportCollectionData {
name: String,
id: Option<CollectionId>,
external_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportData {
ciphers: Vec<CipherData>,
collections: Vec<ImportCollectionData>,
collections: Vec<FullCollectionData>,
collection_relationships: Vec<RelationsData>,
}
@@ -1777,10 +1823,6 @@ async fn post_org_import(
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
// OrgMemberHeaders also allows invited and accepted members, which are not allowed to import
if headers.membership.status != MembershipStatus::Confirmed as i32 {
err!("You need to be a Member of the Organization to call this endpoint")
}
let data: ImportData = data.into_inner();
// Validate the import before continuing
@@ -1862,7 +1904,7 @@ async fn post_org_import(
#[serde(rename_all = "camelCase")]
struct BulkCollectionsData {
organization_id: OrganizationId,
cipher_ids: HashSet<CipherId>,
cipher_ids: Vec<CipherId>,
collection_ids: HashSet<CollectionId>,
remove_collections: bool,
}
@@ -2102,7 +2144,7 @@ async fn put_policy(
}
log_event(
EventType::OrganizationUserRemoved,
EventType::OrganizationUserRemoved as i32,
&member.uuid,
&org_id,
&headers.user.uuid,
@@ -2128,7 +2170,7 @@ async fn put_policy(
policy.save(&conn).await?;
log_event(
EventType::PolicyUpdated,
EventType::PolicyUpdated as i32,
policy.uuid.as_ref(),
&org_id,
&headers.user.uuid,
@@ -2141,6 +2183,18 @@ async fn put_policy(
Ok(Json(policy.to_json()))
}
// Deprecated with client v2026.5.0
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_policy(org_id, pol_type, data, headers, conn).await
}
#[get("/plans")]
fn get_plans() -> Json<Value> {
// Respond with a minimal json just enough to allow the creation of an new organization.
@@ -2169,6 +2223,12 @@ fn get_plans() -> Json<Value> {
}))
}
#[get("/organizations/<_org_id>/billing/metadata")]
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(empty_data_json())
}
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
Json(json!({
@@ -2188,6 +2248,14 @@ fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHe
}))
}
fn empty_data_json() -> Value {
json!({
"object": "list",
"data": [],
"continuationToken": null
})
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkRevokeMembershipIds {
@@ -2271,7 +2339,7 @@ async fn revoke_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked,
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
&headers.user.uuid,
@@ -2369,7 +2437,7 @@ async fn restore_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRestored,
EventType::OrganizationUserRestored as i32,
&member.uuid,
org_id,
&headers.user.uuid,
@@ -2408,7 +2476,7 @@ async fn get_groups_data(
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@@ -2506,6 +2574,17 @@ impl CollectionData {
}
}
#[post("/organizations/<org_id>/groups/<group_id>", data = "<data>")]
async fn post_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_group(org_id, group_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: OrganizationId,
@@ -2526,7 +2605,7 @@ async fn post_groups(
let group = group_request.to_group(&org_id);
log_event(
EventType::GroupCreated,
EventType::GroupCreated as i32,
&group.uuid,
&org_id,
&headers.user.uuid,
@@ -2567,7 +2646,7 @@ async fn put_group(
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
log_event(
EventType::GroupUpdated,
EventType::GroupUpdated as i32,
&updated_group.uuid,
&org_id,
&headers.user.uuid,
@@ -2600,7 +2679,7 @@ async fn add_update_group(
user_entry.save(conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups,
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
@@ -2615,6 +2694,7 @@ async fn add_update_group(
"id": group.uuid,
"organizationId": group.organizations_uuid,
"name": group.name,
"accessAll": group.access_all,
"externalId": group.external_id,
"object": "group"
})))
@@ -2641,6 +2721,16 @@ async fn get_group_details(
Ok(Json(group.to_json_details(&conn).await))
}
#[post("/organizations/<org_id>/groups/<group_id>/delete")]
async fn post_delete_group(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
}
#[delete("/organizations/<org_id>/groups/<group_id>")]
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
@@ -2664,7 +2754,7 @@ async fn delete_group_impl(
};
log_event(
EventType::GroupDeleted,
EventType::GroupDeleted as i32,
&group.uuid,
org_id,
&headers.user.uuid,
@@ -2742,6 +2832,90 @@ async fn get_group_members(
Ok(Json(json!(group_members)))
}
#[put("/organizations/<org_id>/groups/<group_id>/users", data = "<data>")]
async fn put_group_members(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
data: Json<Vec<MembershipId>>,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
}
let assigned_members = data.into_inner();
let org_memberships = Membership::find_by_org(&org_id, &conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for assigned_member in assigned_members {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
user_entry.save(&conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
}
Ok(())
}
#[post("/organizations/<org_id>/groups/<group_id>/delete-user/<member_id>")]
async fn post_delete_group_member(
org_id: OrganizationId,
group_id: GroupId,
member_id: MembershipId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() {
err!("User could not be found or does not belong to the organization.");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err!("Group could not be found or does not belong to the organization.");
}
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserResetPasswordEnrollmentRequest {
@@ -2753,13 +2927,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
// Legacy payload
new_master_password_hash: Option<String>,
key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
new_master_password_hash: String,
key: String,
#[serde(default)]
reset_master_password: bool,
@@ -2802,6 +2971,24 @@ async fn put_recover_account(
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
}
// Deprecated since `v2026.4.2`
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
@@ -2810,7 +2997,7 @@ async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
req: OrganizationUserRecoverAccountRequest,
reset_request: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@@ -2825,7 +3012,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization")
};
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found")
};
@@ -2838,75 +3025,29 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
let fallback_2fa_email =
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
} else {
false
};
// Check the new password before the email below, so that a rejected request doesn't tell the user
// their password was reset
let new_password = if req.reset_master_password {
let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) =
(req.authentication_data, req.unlock_data)
{
authentication_data.check(&user, &unlock_data)?;
if !authentication_data.kdf.matches_user(&user) {
err!("KDF settings do not match the user account")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (req.new_master_password_hash, req.key) {
(new_master_password_hash, new_key)
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
};
Some((new_master_password_hash, new_key))
} else {
None
};
// Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery(
&user.email,
user.display_name(),
&org.name,
req.reset_master_password,
req.reset_two_factor,
fallback_2fa_email,
)
.await
{
// Sending email before resetting password to ensure working email configuration and the resulting
// user notification. Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await {
err!(format!("Error sending user reset password email: {e:#?}"));
}
if let Some((new_master_password_hash, new_key)) = new_password {
user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
}
if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
}
}
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
if req.reset_master_password {
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
}
if req.reset_two_factor {
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
}
log_event(
EventType::OrganizationUserAdminResetPassword as i32,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(())
}
@@ -2943,7 +3084,6 @@ async fn get_reset_password_details(
"kdfIterations": user.client_kdf_iter,
"kdfMemory": user.client_kdf_memory,
"kdfParallelism": user.client_kdf_parallelism,
"masterPasswordSalt": user.master_password_salt(),
"resetPasswordKey": member.reset_password_key,
"encryptedPrivateKey": org.private_key,
})))
@@ -3026,9 +3166,9 @@ async fn put_reset_password_enrollment(
membership.save(&conn).await?;
let event_type = if membership.reset_password_key.is_some() {
EventType::OrganizationUserResetPasswordEnroll
EventType::OrganizationUserResetPasswordEnroll as i32
} else {
EventType::OrganizationUserResetPasswordWithdraw
EventType::OrganizationUserResetPasswordWithdraw as i32
};
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
@@ -3059,7 +3199,7 @@ async fn api_key(
org_id: &OrganizationId,
data: Json<PasswordOrOtpData>,
rotate: bool,
headers: OwnerHeaders,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != &headers.org_id {
@@ -3068,7 +3208,7 @@ async fn api_key(
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
// Validate the owner users password/otp
// Validate the admin users password/otp
data.validate(&user, true, &conn).await?;
let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
@@ -3096,7 +3236,7 @@ async fn api_key(
async fn post_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, false, headers, conn).await
@@ -3106,7 +3246,7 @@ async fn post_api_key(
async fn rotate_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await
+23 -9
View File
@@ -58,13 +58,25 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0;
let data = data.into_inner();
// Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
for user_data in &data.members {
let mut user_created: bool = false;
if user_data.deleted {
// If user is marked for deletion and it exists, revoke it
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let revoked = member.atype != MembershipType::Owner && member.revoke();
// Only revoke a user if it is not the last confirmed owner
let revoked = if member.atype == MembershipType::Owner
&& member.status == MembershipStatus::Confirmed as i32
{
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't revoke the last owner");
false
} else {
member.revoke()
}
} else {
member.revoke()
};
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
if revoked || ext_modified {
member.save(&conn).await?;
@@ -72,7 +84,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
}
// 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 {
let mut restored = member.atype != MembershipType::Owner && member.restore();
let mut restored = member.restore();
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.
@@ -137,13 +149,9 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
if CONFIG.org_groups_enabled() {
for group_data in &data.groups {
let group_uuid = if let Some(mut group) =
let group_uuid = if let Some(group) =
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
{
if group.name != group_data.name {
group.name = group_data.name.clone();
group.save(&conn).await?;
}
group.uuid
} else {
let mut group =
@@ -172,8 +180,14 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
for member in Membership::find_by_org(&org_id, &conn).await {
if let Some(ref user_external_id) = member.external_id
&& !sync_members.contains(user_external_id)
&& member.atype != MembershipType::Owner
{
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
// Removing owner, check that there is at least one other confirmed owner
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't delete the last owner");
continue;
}
}
member.delete(&conn).await?;
}
}
+200 -26
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{Headers, Host, SendHeaders},
auth::{ClientIp, Headers, Host, SendHeaders},
config::PathType,
db::{
DbConn, DbPool,
@@ -23,7 +23,7 @@ use crate::{
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
let dt = DateTime::UNIX_EPOCH.naive_utc();
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
created_at: dt,
@@ -39,15 +39,18 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
});
// The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
pub(crate) const SIZE_525_MB: i64 = 550_502_400;
const SIZE_525_MB: i64 = 550_502_400;
pub fn routes() -> Vec<rocket::Route> {
routes![
get_sends,
get_send,
post_send,
post_send_file,
post_access,
post_access_legacy,
post_access_file,
post_access_file_legacy,
put_send,
delete_send,
put_remove_password,
@@ -70,7 +73,7 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")]
pub struct SendData {
r#type: i32,
pub key: String,
key: String,
password: Option<String>,
max_access_count: Option<NumberOrString>,
expiration_date: Option<DateTime<Utc>>,
@@ -198,7 +201,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
if data.r#type == SendType::File as i32 {
err!("File sends should use /api/sends/file/v2")
err!("File sends should use /api/sends/file")
}
let mut send = create_send(data, headers.user.uuid)?;
@@ -215,11 +218,93 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
Ok(Json(send.to_json()))
}
#[derive(FromForm)]
struct UploadData<'f> {
model: Json<SendData>,
data: TempFile<'f>,
}
#[derive(FromForm)]
struct UploadDataV2<'f> {
data: TempFile<'f>,
}
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2).
// This method still exists to support older clients, probably need to remove it sometime.
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167
// 2025: This endpoint doesn't seem to exists anymore in the latest version
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
enforce_disable_send_policy(&headers, &conn).await?;
let UploadData {
model,
data,
} = data.into_inner();
let model = model.into_inner();
let Some(size) = data.len().to_i64() else {
err!("Invalid send size");
};
if size < 0 {
err!("Send size can't be negative")
}
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
let size_limit = match CONFIG.user_send_limit() {
Some(0) => err!("File uploads are disabled"),
Some(limit_kb) => {
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
err!("Existing sends overflow")
};
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
err!("Send size overflow");
};
if left <= 0 {
err!("Send storage limit reached! Delete some sends to free up space")
}
i64::clamp(left, 0, SIZE_525_MB)
}
None => SIZE_525_MB,
};
if size > size_limit {
err!("Send storage limit exceeded with this file");
}
let mut send = create_send(model, headers.user.uuid)?;
if send.atype != SendType::File as i32 {
err!("Send content is not a file");
}
let file_id = crate::crypto::generate_send_file_id();
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("id"), Value::String(file_id));
o.insert(String::from("size"), Value::Number(size.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size)));
}
send.data = serde_json::to_string(&data_value)?;
// Save the changes in the database
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendCreate,
&send,
&send.update_users_revision(&conn).await,
&headers.device,
&conn,
)
.await;
Ok(Json(send.to_json()))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
@@ -328,8 +413,8 @@ async fn post_send_file_v2_data(
"Send file name does not match.",
format!(
"Expected file name '{}' got '{}'",
send_data.fileName.escape_debug(),
raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
send_data.fileName,
raw_file_name.dangerous_unsafe_unsanitized_raw()
)
),
_ => err!("Send file name does not match or is not provided."),
@@ -365,16 +450,70 @@ async fn post_send_file_v2_data(
#[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
process_access(send, conn, nt).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendAccessData {
pub password: Option<String>,
}
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
if let Some(expiration) = send.expiration_date
&& 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)
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)),
None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401),
}
}
// Files are incremented during the download
if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
if send.atype == SendType::Text as i32 {
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
}
process_access(send, conn, nt).await
}
@@ -399,27 +538,62 @@ async fn post_access_file(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
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)
}
check_send_file_id(&send, &file_id)?;
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
if send.atype != SendType::File as i32 {
err!("Send is not a file type send.");
// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
host: Host,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
match serde_json::from_str::<SendFileData>(&send.data) {
Ok(data) if &data.id == file_id => Ok(()),
_ => err_code!(SEND_INACCESSIBLE_MSG, 404),
if let Some(expiration) = send.expiration_date
&& 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)
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
}
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
@@ -482,7 +656,7 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
Ok(Json(send.to_json()))
}
async fn update_send_from_data(
pub async fn update_send_from_data(
send: &mut Send,
data: SendData,
headers: &Headers,
+43 -22
View File
@@ -3,7 +3,7 @@ use rocket::{Route, serde::json::Json};
use crate::{
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
auth::{ClientIp, Headers, two_factor},
auth::{ClientIp, Headers},
crypto,
db::{
DbConn,
@@ -20,23 +20,27 @@ pub fn routes() -> Vec<Route> {
#[post("/two-factor/get-authenticator", data = "<data>")]
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, false, &conn).await?;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
let type_ = TwoFactorType::Authenticator as i32;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data),
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
};
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
// It should help prevent TOTP disclosure if someone keeps their vault unlocked.
// Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response.
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94
Ok(Json(json!({
"authenticator": json!({
"enabled": enabled,
"key": key,
}),
"userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled),
"enabled": enabled,
"key": key,
"object": "twoFactorAuthenticator"
})))
}
@@ -45,7 +49,8 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
struct EnableAuthenticatorData {
key: String,
token: NumberOrString,
user_verification_token: String,
master_password_hash: Option<String>,
otp: Option<String>,
}
#[post("/two-factor/authenticator", data = "<data>")]
@@ -56,7 +61,12 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
let mut user = headers.user;
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
// Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
@@ -74,13 +84,12 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({
"authenticator": json!({
"enabled": true,
"key": key,
}),
"enabled": true,
"key": key,
"object": "twoFactorAuthenticator"
})))
}
@@ -116,7 +125,8 @@ pub async fn validate_totp_code(
err!("Invalid TOTP secret")
};
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await {
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
{
Some(tf) => tf,
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
};
@@ -174,25 +184,36 @@ pub async fn validate_totp_code(
#[serde(rename_all = "camelCase")]
struct DisableAuthenticatorData {
key: String,
user_verification_token: String,
master_password_hash: String,
r#type: NumberOrString,
}
#[delete("/two-factor/authenticator", data = "<data>")]
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> EmptyResult {
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user;
let type_ = data.r#type.into_i32()?;
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password");
}
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await {
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
if twofactor.data == data.key {
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
} else {
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
}
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
Ok(())
Ok(Json(json!({
"enabled": false,
"keys": type_,
"object": "twoFactorProvider"
})))
}
+193 -51
View File
@@ -1,13 +1,14 @@
use chrono::Utc;
use data_encoding::BASE64;
use rocket::{Route, serde::json::Json};
use crate::{
CONFIG,
api::{
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
core::log_user_event,
core::two_factor::{VerificationTokenData, generate_recover_code},
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
core::two_factor::generate_recover_code,
},
auth::{Headers, two_factor, two_factor::DuoData},
auth::Headers,
crypto,
db::{
DbConn,
@@ -18,7 +19,55 @@ use crate::{
};
pub fn routes() -> Vec<Route> {
routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
routes![get_duo, activate_duo, activate_duo_put,]
}
#[derive(Serialize, Deserialize)]
struct DuoData {
host: String, // Duo API hostname
ik: String, // client id
sk: String, // client secret
}
impl DuoData {
fn global() -> Option<Self> {
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
(true, Some(host)) => Some(Self {
host,
ik: CONFIG.duo_ikey().unwrap(),
sk: CONFIG.duo_skey().unwrap(),
}),
_ => None,
}
}
fn msg(s: &str) -> Self {
Self {
host: s.into(),
ik: s.into(),
sk: s.into(),
}
}
fn secret() -> Self {
Self::msg("<global_secret>")
}
fn obscure(self) -> Self {
let mut host = self.host;
let mut ik = self.ik;
let mut sk = self.sk;
let digits = 4;
let replaced = "************";
host.replace_range(digits.., replaced);
ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced);
Self {
host,
ik,
sk,
}
}
}
enum DuoStatus {
@@ -47,19 +96,22 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
data.validate(&user, false, &conn).await?;
let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
let data = get_user_duo_data(&user.uuid, &conn).await;
let (enabled, data) = match data {
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
DuoStatus::User(data) => (true, Some(data.obscure())),
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
DuoStatus::Disabled(false) => (false, None),
};
let duo_json = if let Some(data) = duo.as_ref() {
let json = if let Some(data) = data {
json!({
"enabled": enabled,
"host": data.host,
"clientSecret": data.sk,
"clientId": data.ik,
"object": "twoFactorDuo"
})
} else {
json!({
@@ -67,13 +119,11 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
"host": null,
"clientSecret": null,
"clientId": null,
"object": "twoFactorDuo"
})
};
Ok(Json(rocket::serde::json::json!({
"duo": duo_json,
"userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled),
})))
Ok(Json(json))
}
#[derive(Deserialize)]
@@ -82,7 +132,8 @@ struct EnableDuoData {
host: String,
client_secret: String,
client_id: String,
user_verification_token: String,
master_password_hash: Option<String>,
otp: Option<String>,
}
impl From<EnableDuoData> for DuoData {
@@ -109,7 +160,12 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
let data: EnableDuoData = data.into_inner();
let mut user = headers.user;
two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
PasswordOrOtpData {
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
let (data, data_str) = if check_duo_fields_custom(&data) {
let data_req: DuoData = data.into();
@@ -126,15 +182,14 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({
"duo": json!({
"enabled": true,
"host": data.host,
"clientSecret": data.sk,
"clientId": data.ik,
}),
"enabled": true,
"host": data.host,
"clientSecret": data.sk,
"clientId": data.ik,
"object": "twoFactorDuo"
})))
}
@@ -143,29 +198,6 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
activate_duo(data, headers, conn).await
}
#[delete("/two-factor/duo", data = "<data>")]
async fn disable_duo(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await {
// Apply the same transformation than in `get_duo` to check we are disabling the correct one
let duo = match to_user_duo_data(&twofactor) {
DuoStatus::Global(_) => Some(DuoData::secret()),
DuoStatus::User(data) => Some(data.obscure()),
DuoStatus::Disabled(_) => None,
};
two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?;
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
use reqwest::{Method, header};
use std::str::FromStr;
@@ -190,16 +222,21 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
Ok(())
}
const DUO_EXPIRE: i64 = 300;
const APP_EXPIRE: i64 = 3600;
const AUTH_PREFIX: &str = "AUTH";
const DUO_PREFIX: &str = "TX";
const APP_PREFIX: &str = "APP";
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
let type_ = TwoFactorType::Duo as i32;
// If the user doesn't have an entry, disabled
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else {
return DuoStatus::Disabled(DuoData::global().is_some());
};
to_user_duo_data(&twofactor)
}
fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus {
// If the user has the required values, we use those
if let Ok(data) = serde_json::from_str(&twofactor.data) {
return DuoStatus::User(data);
@@ -214,13 +251,118 @@ fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus {
DuoStatus::Disabled(false)
}
// let (ik, sk, host) = get_duo_keys_email();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> {
// let (ik, sk, ak, host) = get_duo_keys();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> {
let data = match User::find_by_mail(email, conn).await {
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
_ => DuoData::global(),
}
.map_res("Can't fetch Duo Keys")?;
Ok((data.ik, data.sk, data.host))
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
}
pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> {
let now = Utc::now().timestamp();
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE);
let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE);
Ok((format!("{duo_sign}:{app_sign}"), host))
}
fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String {
let val = format!("{email}|{ikey}|{expire}");
let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes()));
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
}
pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
let split: Vec<&str> = response.split(':').collect();
if split.len() != 2 {
err!(
"Invalid response length",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
);
}
let auth_sig = split[0];
let app_sig = split[1];
let now = Utc::now().timestamp();
let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?;
let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?;
let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?;
if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) {
err!(
"Error validating duo authentication",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
}
Ok(())
}
fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult<String> {
let split: Vec<&str> = val.split('|').collect();
if split.len() != 3 {
err!("Invalid value length")
}
let u_prefix = split[0];
let u_b64 = split[1];
let u_sig = split[2];
let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}"));
if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) {
err!("Duo signatures don't match")
}
if u_prefix != prefix {
err!("Prefixes don't match")
}
let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else {
err!("Invalid Duo cookie encoding")
};
let Ok(cookie) = String::from_utf8(cookie_vec) else {
err!("Invalid Duo cookie encoding")
};
let cookie_split: Vec<&str> = cookie.split('|').collect();
if cookie_split.len() != 3 {
err!("Invalid cookie length")
}
let username = cookie_split[0];
let u_ikey = cookie_split[1];
let expire = cookie_split[2];
if !crypto::ct_eq(ikey, u_ikey) {
err!("Invalid ikey")
}
let expire: i64 = if let Ok(e) = expire.parse() {
e
} else {
err!("Invalid expire time")
};
if time >= expire {
err!("Expired authorization")
}
Ok(username.into())
}
+2 -2
View File
@@ -383,7 +383,7 @@ pub async fn get_duo_auth_url(
device_identifier: &DeviceId,
conn: &DbConn,
) -> Result<String, Error> {
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
let callback_url = match make_callback_url(client_id) {
Ok(url) => url,
@@ -435,7 +435,7 @@ pub async fn validate_duo_login(
let code = split[0];
let state = split[1];
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
// 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.
+60 -89
View File
@@ -5,26 +5,20 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
core::{log_user_event, two_factor::generate_recover_code},
},
auth::{ClientHeaders, Headers, two_factor},
auth::{ClientHeaders, Headers},
crypto,
db::{
DbConn,
models::{
AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
},
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
},
error::{Error, MapResult},
mail,
};
pub fn routes() -> Vec<Route> {
routes![get_email, send_email_login, send_email, email, disable_email]
routes![get_email, send_email_login, send_email, email,]
}
#[derive(Deserialize)]
@@ -69,19 +63,13 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
err!("Username or password is incorrect. Try again.")
};
if let Some(master_password_hash) = master_password_hash {
// Check password
if !user.check_valid_password(master_password_hash) {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
err!("Username or password is incorrect. Try again.")
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@@ -97,20 +85,6 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
{
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
}
} else if let Some(device_identifier) = &data.device_identifier {
// iOS/Android SSO logins send the email and device id but no password hash,
// so accept a device that has a pending 2FA login for this user
if TwoFactorIncomplete::find_by_user_and_device(&user.uuid, device_identifier, &conn).await.is_none() {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
}
debug!(
"Email 2FA fallback: pending login. Username: {}. Device: {}.",
user.email,
device_identifier.to_string().escape_debug()
);
} else {
err!("No password hash has been submitted.")
}
@@ -122,10 +96,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
};
// 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 {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug())
)
err!("Username or password is incorrect. Try again.")
};
user
@@ -136,8 +107,8 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
/// Generate the token, save the data for later verification and send email to user
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
let mut twofactor =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
let type_ = TwoFactorType::Email as i32;
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
@@ -160,19 +131,18 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
data.validate(&user, false, &conn).await?;
let (enabled, mfa_email) =
if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
let twofactor_data = EmailTokenData::from_json(&x.data)?;
(true, Some(twofactor_data.email))
} else {
(false, None)
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await {
Some(x) => {
let twofactor_data = EmailTokenData::from_json(&x.data)?;
(true, json!(twofactor_data.email))
}
_ => (false, serde_json::value::Value::Null),
};
Ok(Json(rocket::serde::json::json!({
"email": rocket::serde::json::json!({
"enabled": enabled,
"email": mfa_email,
}),
"userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled),
Ok(Json(json!({
"email": mfa_email,
"enabled": enabled,
"object": "twoFactorEmail"
})))
}
@@ -181,36 +151,43 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
struct SendEmailData {
/// Email where 2FA codes will be sent to, can be different than user email account.
email: String,
user_verification_token: String,
master_password_hash: Option<String>,
otp: Option<String>,
}
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
#[post("/two-factor/send-email", data = "<data>")]
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> JsonResult {
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data: SendEmailData = data.into_inner();
let user = headers.user;
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, false, &conn)
.await?;
if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled")
}
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
let type_ = TwoFactorType::Email as i32;
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
tf.delete(&conn).await?;
}
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
let twofactor_data = EmailTokenData::new(data.email, generated_token);
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
let twofactor =
TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
twofactor.save(&conn).await?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
Ok(Json(json!({})))
Ok(())
}
#[derive(Deserialize, Serialize)]
@@ -218,7 +195,8 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
struct EmailData {
email: String,
token: String,
user_verification_token: String,
master_password_hash: Option<String>,
otp: Option<String>,
}
/// Verify email belongs to user and can be used for 2FA email codes.
@@ -227,11 +205,17 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
let data: EmailData = data.into_inner();
let mut user = headers.user;
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
// This is the last step in the verification process, delete the otp directly afterwards
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
.await
.map_res("Two factor not found")?;
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
let mut twofactor =
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?;
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
@@ -250,26 +234,13 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({})))
}
#[delete("/two-factor/email", data = "<data>")]
async fn disable_email(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
let twofactor_data = EmailTokenData::from_json(&twofactor.data)?;
two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?;
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
Ok(Json(json!({
"email": email_data.email,
"enabled": "true",
"object": "twoFactorEmail"
})))
}
/// Validate the email code when used as TwoFactor token mechanism
@@ -281,8 +252,9 @@ pub async fn validate_email_code_str(
conn: &DbConn,
) -> EmptyResult {
let mut email_data = EmailTokenData::from_json(data)?;
let mut twofactor =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
.await
.map_res("Two factor not found")?;
let Some(issued_token) = &email_data.last_token else {
err!(
format!("No token available! IP: {ip}"),
@@ -294,7 +266,7 @@ pub async fn validate_email_code_str(
if !crypto::ct_eq(issued_token, token) {
email_data.add_attempt();
if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
if email_data.attempts >= CONFIG.email_attempts_limit() {
email_data.reset_token();
}
twofactor.data = email_data.to_json();
@@ -313,7 +285,7 @@ pub async fn validate_email_code_str(
twofactor.save(conn).await?;
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
let max_time = i64::from(CONFIG.email_expiration_time());
let max_time = CONFIG.email_expiration_time().cast_signed();
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
err!(
"Token has expired",
@@ -341,10 +313,10 @@ pub struct EmailTokenData {
}
impl EmailTokenData {
pub fn new(email: String, token: Option<String>) -> EmailTokenData {
pub fn new(email: String, token: String) -> EmailTokenData {
EmailTokenData {
email,
last_token: token,
last_token: Some(token),
token_sent: Utc::now().timestamp(),
attempts: 0,
}
@@ -382,8 +354,7 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
if user.verified_at.is_none() {
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
}
// The token is set when the first code is sent
let twofactor_data = EmailTokenData::new(user.email.clone(), None);
let twofactor_data = EmailTokenData::new(user.email.clone(), String::new());
let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json());
twofactor.save(conn).await
}
+71 -34
View File
@@ -7,17 +7,21 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_event, log_user_event},
},
auth::Headers,
crypto,
db::{
DbConn, DbPool,
models::{
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
util::NumberOrString,
};
pub mod authenticator;
@@ -65,7 +69,13 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
}
pub fn routes() -> Vec<Route> {
let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
let mut routes = routes![
get_twofactor,
get_recover,
disable_twofactor,
disable_twofactor_put,
get_device_verification_settings,
];
routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes());
@@ -77,12 +87,6 @@ pub fn routes() -> Vec<Route> {
routes
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerificationTokenData {
user_verification_token: String,
}
#[get("/two-factor")]
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
@@ -122,14 +126,49 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
}
}
/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
/// the providers that are left, and once every provider is gone the 2FA policy applies.
pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DisableTwoFactorData {
master_password_hash: Option<String>,
otp: Option<String>,
r#type: NumberOrString,
}
#[post("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner();
let user = headers.user;
// Delete directly after a valid token has been provided
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
Ok(())
.validate(&user, true, &conn)
.await?;
let type_ = data.r#type.into_i32()?;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
Ok(Json(json!({
"enabled": false,
"type": type_,
"object": "twoFactorProvider"
})))
}
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor_put(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
disable_twofactor(data, headers, conn).await
}
pub async fn enforce_2fa_policy(
@@ -151,7 +190,7 @@ pub async fn enforce_2fa_policy(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked,
EventType::OrganizationUserRevoked as i32,
&member.uuid,
&member.org_uuid,
act_user_id,
@@ -185,8 +224,16 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke();
member.save(conn).await?;
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
.await;
log_event(
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
act_user_id,
device_type,
ip,
conn,
)
.await;
}
}
@@ -196,7 +243,7 @@ pub async fn enforce_2fa_policy_for_org(
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
debug!("Sending notifications for incomplete 2FA logins");
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
return;
}
@@ -206,21 +253,11 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
};
let now = Utc::now().naive_utc();
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
return;
};
let Some(time_before) = now.checked_sub_signed(time_limit) else {
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
return;
};
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
let time_before = now - time_limit;
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
for login in incomplete_logins {
let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
error!("User '{}', not found!", login.user_uuid);
continue;
};
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
info!(
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
user.email, login.ip_address
+7 -7
View File
@@ -72,7 +72,8 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
// Only one Protected Action per user is allowed to take place, delete the previous one
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await {
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await
{
let pa_data = ProtectedActionData::from_json(&pa.data)?;
let elapsed = pa_data.time_since_sent().num_seconds();
let delay = 30;
@@ -124,28 +125,27 @@ pub async fn validate_protected_action_otp(
delete_if_valid: bool,
conn: &DbConn,
) -> EmptyResult {
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn)
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
.await
.map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
pa_data.add_attempt();
pa.data = pa_data.to_json();
// Fail after x attempts if the token has been used too many times.
// Don't delete it, as we use it to keep track of attempts.
if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
if pa_data.attempts >= CONFIG.email_attempts_limit() {
err!("Token has expired")
}
// Check if the token has expired (Using the email 2fa expiration time)
let max_time = i64::from(CONFIG.email_expiration_time());
let max_time = CONFIG.email_expiration_time().cast_signed();
if pa_data.time_since_sent().num_seconds() > max_time {
pa.delete(conn).await?;
err!("Token has expired")
}
if !crypto::ct_eq(&pa_data.token, otp) {
pa_data.add_attempt();
pa.data = pa_data.to_json();
pa.save(conn).await?;
err!("Token is invalid")
}
+70 -110
View File
@@ -1,4 +1,4 @@
use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration};
use std::{str::FromStr, sync::LazyLock, time::Duration};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
@@ -18,18 +18,16 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
core::{log_user_event, two_factor::generate_recover_code},
},
auth::{Headers, two_factor},
auth::Headers,
crypto::ct_eq,
db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType, UserId},
},
error::Error,
util::NumberOrString,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
@@ -47,14 +45,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
});
pub fn routes() -> Vec<Route> {
routes![
get_webauthn,
generate_webauthn_challenge,
activate_webauthn,
activate_webauthn_put,
delete_webauthn,
delete_webauthns
]
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
}
// Some old u2f structs still needed for migrating from u2f to WebAuthn
@@ -128,36 +119,34 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbC
data.validate(&user, false, &conn).await?;
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"webAuthn": json!({
"enabled": enabled,
"keys": registrations_json,
}),
"userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled),
"enabled": enabled,
"keys": registrations_json,
"object": "twoFactorWebAuthn"
})))
}
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
let creds = registrations
data.validate(&user, false, &conn).await?;
let registrations = get_webauthn_registrations(&user.uuid, &conn)
.await?
.1
.into_iter()
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
.collect();
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?;
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
&user.email,
user.display_name(),
Some(creds),
Some(registrations),
)?;
let mut state = serde_json::to_value(&state)?;
@@ -177,19 +166,17 @@ async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers:
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
challenge_value["status"] = "ok".into();
challenge_value["errorMessage"] = "".into();
Ok(Json(json!({
"options": challenge_value
})))
Ok(Json(challenge_value))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnableWebauthnData {
id: i32,
id: NumberOrString, // 1..5
name: String,
device_response: RegisterPublicKeyCredentialCopy,
user_verification_token: String,
master_password_hash: Option<String>,
otp: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -270,14 +257,16 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
let data: EnableWebauthnData = data.into_inner();
let mut user = headers.user;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
// Retrieve and delete the saved challenge state
let state = if let Some(tf) =
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
{
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: PasskeyRegistration = serde_json::from_str(&tf.data)?;
tf.delete(&conn).await?;
state
@@ -288,9 +277,10 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
// Verify the credentials with the saved state
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
// TODO: Check for repeated ID's
registrations.push(WebauthnRegistration {
id: data.id,
id: data.id.into_i32()?,
name: data.name,
migrated: false,
@@ -303,13 +293,13 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
.await?;
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"webAuthn": json!({
"enabled": true,
"keys": registrations.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
"enabled": true,
"keys": keys_json,
"object": "twoFactorU2f"
})))
}
@@ -320,87 +310,57 @@ async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers,
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeleteWebauthnData {
id: i32,
user_verification_token: String,
struct DeleteU2FData {
id: NumberOrString,
master_password_hash: String,
}
#[delete("/two-factor/webauthn", data = "<data>")]
async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
}
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
let id = data.id.into_i32()?;
if !headers.user.check_valid_password(&data.master_password_hash) {
err!("Invalid password");
}
#[delete("/two-factor/webauthn/all", data = "<data>")]
async fn delete_webauthns(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await
}
async fn inner_delete_webauthns(
token: &str,
retain: impl Fn(&WebauthnRegistration) -> bool,
headers: Headers,
conn: &DbConn,
) -> JsonResult {
let user = headers.user;
let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else {
let Some(mut tf) =
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
else {
err!("Webauthn data not found!")
};
let mut keys: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
let keys_id: Vec<i32> = keys.iter().map(|r| r.id).collect();
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
let mut removed: HashSet<Vec<u8>> = HashSet::new();
let mut migrated = false;
keys.retain(|key| {
let retained = retain(key);
if !retained {
removed.insert(key.credential.cred_id().to_vec());
migrated = migrated || key.migrated;
}
retained
});
if removed.is_empty() {
let Some(item_pos) = data.iter().position(|r| r.id == id) else {
err!("Webauthn entry not found")
}
};
if keys.is_empty() {
tf.delete(conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
} else {
tf.data = serde_json::to_string(&keys)?;
tf.save(conn).await?;
drop(tf);
}
let removed_item = data.remove(item_pos);
tf.data = serde_json::to_string(&data)?;
tf.save(&conn).await?;
drop(tf);
// If entry is migrated from u2f, delete the u2f entry as well
if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await {
let Ok(mut data) = serde_json::from_str::<Vec<U2FRegistration>>(&u2f.data) else {
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) {
d
} else {
err!("Error parsing U2F data")
};
data.retain(|old| !removed.contains(&old.reg.key_handle));
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
let new_data_str = serde_json::to_string(&data)?;
if data.is_empty() {
u2f.delete(conn).await?;
} else {
let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str;
u2f.save(conn).await?;
}
u2f.data = new_data_str;
u2f.save(&conn).await?;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?;
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"webAuthn": json!({
"enabled": !keys.is_empty(),
"keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
"enabled": true,
"keys": keys_json,
"object": "twoFactorU2f"
})))
}
@@ -408,7 +368,8 @@ pub async fn get_webauthn_registrations(
user_id: &UserId,
conn: &DbConn,
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
let type_ = TwoFactorType::Webauthn as i32;
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
None => Ok((false, Vec::new())), // If no data, return empty list
}
@@ -455,9 +416,8 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
}
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
let mut state = if let Some(tf) =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
{
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 state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
tf.delete(conn).await?;
state
+56 -97
View File
@@ -1,57 +1,23 @@
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use yubico_ng::{
Verifier, YubicoError,
config::Config,
transport::{AsyncTransport, Response},
};
use yubico::{config::Config, verify_async};
use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
core::{log_user_event, two_factor::generate_recover_code},
},
auth::{Headers, two_factor},
auth::Headers,
db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType},
},
error::{Error, MapResult},
http_client,
};
pub fn routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,]
}
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)?,
})
}
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
}
#[derive(Debug, Deserialize)]
@@ -63,7 +29,8 @@ struct EnableYubikeyData {
key4: Option<String>,
key5: Option<String>,
nfc: bool,
user_verification_token: String,
master_password_hash: Option<String>,
otp: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
@@ -77,7 +44,8 @@ pub struct YubikeyMetadata {
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
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.into_iter().flatten().cloned().collect()
}
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
@@ -105,15 +73,13 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let mut 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);
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
match CONFIG.yubico_server() {
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
None => verify_async(otp, config).await,
}
let client = HttpClientTransport::new()?;
let verifier = Verifier::with_client(config, client)?;
verifier.verify(otp).await.map_res("Failed to verify OTP")
.map_res("Failed to verify OTP")
}
#[post("/two-factor/get-yubikey", data = "<data>")]
@@ -127,47 +93,56 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn:
data.validate(&user, false, &conn).await?;
let user_id = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32;
let (enabled, keys, yubikey_json) =
if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
let enabled = !yubikey_metadata.keys.is_empty();
let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone());
result["enabled"] = Value::Bool(enabled);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
(enabled, yubikey_metadata.keys, result)
} else {
(false, Vec::new(), json!({"enabled": false}))
};
Ok(Json(json!({
"yubiKey": yubikey_json,
"userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
})))
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await;
if let Some(r) = r {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
result["enabled"] = Value::Bool(true);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["object"] = Value::String("twoFactorU2f".to_owned());
Ok(Json(result))
} else {
Ok(Json(json!({
"enabled": false,
"object": "twoFactorU2f",
})))
}
}
#[post("/two-factor/yubikey", data = "<data>")]
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableYubikeyData = data.into_inner();
let yubikeys = parse_yubikeys(&data);
let mut user = headers.user;
if yubikeys.is_empty() {
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
// To remove all keys users should click the `Deactivate all keys` button
err!("A key is required.");
PasswordOrOtpData {
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
// Check if we already have some data
let mut yubikey_data =
if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?;
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?;
yd
} else {
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?;
TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new())
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await {
Some(data) => data,
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
};
let yubikeys = parse_yubikeys(&data);
if yubikeys.is_empty() {
return Ok(Json(json!({
"enabled": false,
"object": "twoFactorU2f",
})));
}
// Ensure they are valid OTPs
for yubikey in &yubikeys {
if yubikey.is_empty() || yubikey.len() == 12 {
@@ -189,12 +164,15 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
result["enabled"] = Value::Bool(true);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
Ok(Json(json!({"yubiKey": result})))
result["object"] = Value::String("twoFactorU2f".to_owned());
Ok(Json(result))
}
#[put("/two-factor/yubikey", data = "<data>")]
@@ -202,32 +180,13 @@ async fn activate_yubikey_put(data: Json<EnableYubikeyData>, headers: Headers, c
activate_yubikey(data, headers, conn).await
}
#[delete("/two-factor/yubikey", data = "<data>")]
async fn delete_yubikeys(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?;
r.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
if response.len() != 44 {
err!("Invalid Yubikey OTP length");
}
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
let Some(response_id) = response.get(..12) else {
err!("Invalid Yubikey OTP");
};
let response_id = &response[..12];
if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
err!("Given Yubikey is not registered");
+6 -6
View File
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
// Generate the cookie store
let cookie_store = Arc::new(Jar::default());
let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
let pool_idle_timeout = Duration::from_secs(10);
// Reuse the client between requests
get_reqwest_client_builder(true)
@@ -85,7 +85,7 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
#[get("/<host>/icon.png")]
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {}", host.escape_debug());
warn!("Invalid host: {host}");
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
};
@@ -113,7 +113,7 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {}", host.escape_debug());
warn!("Invalid host: {host}");
return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(),
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None
}
async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
let meta = operator.stat(path).await?;
let modified =
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
let age = SystemTime::now().duration_since(modified.into())?;
Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
Ok(ttl > 0 && ttl <= age.as_secs())
}
async fn icon_is_negcached(path: &str) -> bool {
@@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
Ok(())
}) {
Ok(_) => {
// Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create
if body.len() >= 67 {
// Check if the icon type is allowed, else try an icon from the list.
icon_type = get_icon_type(&body);
+156 -156
View File
@@ -3,7 +3,7 @@ use num_traits::FromPrimitive;
use rocket::{
Route,
form::{Form, FromForm},
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
http::{Cookie, CookieJar, SameSite},
response::Redirect,
serde::json::Json,
};
@@ -17,14 +17,15 @@ use crate::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event,
two_factor::{
authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey,
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
yubikey,
},
},
identity_master_password_policy,
master_password_policy,
push::register_push_device,
},
auth,
auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims},
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims},
crypto,
db::{
DbConn,
@@ -45,6 +46,7 @@ pub fn routes() -> Vec<Route> {
login,
post_prelogin,
prelogin_password,
identity_register,
register_verification_email,
register_finish,
prevalidate,
@@ -55,7 +57,12 @@ pub fn routes() -> Vec<Route> {
}
#[post("/connect/token", data = "<data>")]
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbConn) -> JsonResult {
async fn login(
data: Form<ConnectData>,
client_header: ClientHeaders,
client_version: Option<ClientVersion>,
conn: DbConn,
) -> JsonResult {
let data: ConnectData = data.into_inner();
let mut user_id: Option<UserId> = None;
@@ -76,13 +83,17 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
password_login(data, &mut user_id, &conn, &client_header.ip).await
password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
}
"client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
}
"authorization_code" if CONFIG.sso_enabled() => {
@@ -94,7 +105,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
sso_login(data, &mut user_id, &conn, &client_header.ip).await
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"send_access" => {
@@ -118,7 +129,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
match &login_result {
Ok(_) => {
log_user_event(
EventType::UserLoggedIn,
EventType::UserLoggedIn as i32,
&user_id,
client_header.device_type,
&client_header.ip.ip,
@@ -128,7 +139,8 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
}
Err(e) => {
if let Some(ev) = e.get_event() {
log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
.await;
}
}
}
@@ -178,7 +190,13 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
}
// After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
async fn sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login
@@ -216,24 +234,6 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
}
)
}
Some((user, None))
if user.private_key.is_none()
&& !CONFIG.sso_signups_allowed()
&& !CONFIG.is_email_domain_allowed(&user.email)
&& !CONFIG.mail_enabled()
&& Invitation::find_by_mail(&user.email, conn).await.is_none() =>
{
error!(
"Login failure ({}), no invitation with email ({}) was found",
user_infos.identifier, user.email
);
err_silent!(
"Missing invitation",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
@@ -281,15 +281,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
// Will trigger 2FA flow if needed
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
None => {
if !CONFIG.is_sso_signup_allowed(&user_infos.email) {
if CONFIG.signups_domains_whitelist().is_empty() {
err!(
"Signups are disabled. You will need an invitation",
ErrorEvent {
event: EventType::UserFailedLogIn
}
);
}
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
err!(
"Email domain not allowed",
ErrorEvent {
@@ -326,7 +318,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
Some((user, _)) if !user.enabled => {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.email),
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@@ -335,7 +327,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
if user.private_key.is_none() {
// User was invited a stub was created
@@ -367,7 +359,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
async fn password_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
// Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -376,14 +374,24 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
// Get the user
let username = data.username.as_ref().unwrap().trim();
let log_username = username.escape_debug();
let Some(mut user) = User::find_by_mail(username, conn).await else {
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip))
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip))
};
// Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let password = data.password.as_ref().unwrap();
// If we get an auth request, we don't check the user's password, but the access code of the auth request
@@ -391,7 +399,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else {
err!(
"Auth request not found. Try again.",
format!("IP: {}. Username: {log_username}.", ip.ip),
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
@@ -403,14 +411,13 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if auth_request.user_uuid != user.uuid
|| !auth_request.approved.unwrap_or(false)
|| auth_request.authentication_date.is_some()
|| request_expired
|| ip.ip.to_string() != auth_request.request_ip
|| !auth_request.check_access_code(password)
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {log_username}.", ip.ip),
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
@@ -419,24 +426,13 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
} else if !user.check_valid_password(password) {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {log_username}.", ip.ip),
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Change the KDF Iterations (only when not logging in with an auth request)
if data.auth_request.is_none() {
kdf_upgrade(&mut user, password, conn).await?;
@@ -447,9 +443,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
if user.last_verifying_at.is_none()
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
> i64::from(CONFIG.signups_verify_resend_time())
> CONFIG.signups_verify_resend_time().cast_signed()
{
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
if resend_limit == 0 || user.login_verify_count < resend_limit {
// 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...
@@ -469,7 +465,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
// We still want the login to fail until they actually verified the email address
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {log_username}.", ip.ip),
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@@ -478,20 +474,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
// Like upstream, an auth request can be used for one login only. Marked after 2FA so a 2FA prompt doesn't use it up.
if let Some(ref auth_request_id) = data.auth_request
&& !AuthRequest::set_authentication_date(auth_request_id, conn).await?
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
@@ -530,7 +513,7 @@ async fn authenticated_response(
// Save to update `device.updated_at` to track usage and toggle new status
device.save(true, conn).await?;
let master_password_policy = identity_master_password_policy(user, conn).await;
let master_password_policy = master_password_policy(user, conn).await;
let has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
@@ -574,6 +557,7 @@ async fn authenticated_response(
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: Same as above
"ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(),
@@ -593,7 +577,7 @@ async fn authenticated_response(
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))
}
@@ -603,14 +587,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
// Validate scope
match data.scope.as_ref() {
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => {
// Like upstream, only the user API key logs in a device
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
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,
_ => err!("Scope not supported"),
}
@@ -734,6 +711,7 @@ async fn user_api_key_login(
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
"ForcePasswordReset": false,
"scope": AuthMethod::UserApiKey.scope(),
"AccountKeys": account_keys,
@@ -799,6 +777,7 @@ async fn twofactor_auth(
data: &ConnectData,
device: &mut Device,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@@ -827,12 +806,17 @@ async fn twofactor_auth(
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
&& !twofactor_ids.contains(&selected_id)
{
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider")
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"Invalid two factor provider"
)
}
// Like upstream, a blank token counts as not provided
let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else {
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided")
let Some(ref twofactor_code) = data.two_factor_token else {
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);
@@ -846,14 +830,20 @@ async fn twofactor_auth(
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::Duo) => {
duo_oidc::validate_duo_login(
&user.email,
twofactor_code,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
if CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow
duo::validate_duo_login(&user.email, twofactor_code, conn).await?;
} else {
// OIDC based flow
duo_oidc::validate_duo_login(
&user.email,
twofactor_code,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
}
}
Some(TwoFactorType::Email) => {
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
@@ -875,42 +865,23 @@ async fn twofactor_auth(
device.save(true, conn).await?;
}
err_json!(
json_err_twofactor(&twofactor_ids, user, data, conn).await?,
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"2FA Remember token not provided or expired"
)
}
}
}
Some(TwoFactorType::RecoveryCode) => {
// Like upstream, spaces and case don't matter
let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase();
if !user.check_valid_recovery_code(&recovery_code) {
err!(
"Two-step token is invalid. Try again.",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
// Check if recovery code is correct
if !user.check_valid_recovery_code(twofactor_code) {
err!("Recovery code is incorrect. Try again.")
}
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
// No device may keep skipping 2FA once every second factor is gone.
// `device` is cleared in memory too, since saving it later would restore its token.
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await;
if CONFIG.mail_enabled()
&& let Err(e) =
mail::send_recover_twofactor(&user.email, &ip.ip.to_string(), &Utc::now().naive_utc()).await
{
error!("Error sending two-step login recovered email: {e:#?}");
}
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
// Remove the recovery code, not needed without twofactors
user.totp_recover = None;
@@ -939,14 +910,21 @@ fn selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
tf.map(|t| t.data).map_res("Two factor doesn't exist")
}
async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult<Value> {
let user_id = &user.uuid;
async fn json_err_twofactor(
providers: &[i32],
user_id: &UserId,
data: &ConnectData,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Value> {
let mut result = json!({
"error" : "invalid_grant",
"error_description" : "Two factor required.",
"TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::<Vec<String>>(),
"TwoFactorProviders2" : {}, // { "0" : null }
"MasterPasswordPolicy": identity_master_password_policy(user, conn).await,
"MasterPasswordPolicy": {
"Object": "masterPasswordPolicy"
}
});
for provider in providers {
@@ -959,21 +937,37 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
}
Some(TwoFactorType::Duo) => {
let auth_url = duo_oidc::get_duo_auth_url(
&user.email,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
let email = if let Some(u) = User::find_by_uuid(user_id, conn).await {
u.email
} else {
err!("User does not exist")
};
result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url,
});
if CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Host": host,
"Signature": signature,
});
} else {
// OIDC based flow
let auth_url = duo_oidc::get_duo_auth_url(
&email,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url,
});
}
}
Some(tf_type @ TwoFactorType::YubiKey) => {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
err!("No YubiKey devices registered")
};
@@ -985,10 +979,23 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
}
Some(tf_type @ TwoFactorType::Email) => {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
err!("No twofactor email registered")
};
// Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`.
let disabled_send = if let Some(cv) = client_version {
let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap();
ver_match.matches(&cv.0)
} else {
false
};
// Send email immediately if email is the only 2FA option.
if providers.len() == 1 && !disabled_send {
email::send_token(user_id, conn).await?;
}
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Email": email::obscure_email(&email_data.email),
@@ -1017,13 +1024,18 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
}
#[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, false, conn).await
}
#[derive(Debug, Deserialize)]
@@ -1039,18 +1051,11 @@ enum RegisterVerificationResponse {
#[response(status = 204)]
NoContent(()),
Token(Json<String>),
PlainToken(String),
}
// Return JSON only when the client explicitly requests it, otherwise return plain text.
fn accepts_json(accept: Option<&Accept>) -> bool {
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
}
#[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email(
data: Json<RegisterVerificationData>,
accept: Option<&Accept>,
ip: ClientIp,
conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> {
@@ -1088,18 +1093,13 @@ async fn register_verification_email(
} else {
// If email verification is not required, return the token directly
// the clients will use this token to finish the registration
Ok(if accepts_json(accept) {
RegisterVerificationResponse::Token(Json(token))
} else {
RegisterVerificationResponse::PlainToken(token)
})
Ok(RegisterVerificationResponse::Token(Json(token)))
}
}
#[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
register(data, conn).await
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, true, conn).await
}
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
@@ -1229,7 +1229,7 @@ async fn oidcsignin_redirect(
let state = sso::decode_state(&base64_state)?;
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else {
err!(format!("Cannot retrieve sso_auth for {}", state.escape_debug()))
err!(format!("Cannot retrieve sso_auth for {state}"))
};
// Browser-binding check
@@ -1238,7 +1238,7 @@ async fn oidcsignin_redirect(
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.escape_debug())),
_ => err!(format!("SSO session binding mismatch for {state}")),
}
cookies
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
+2 -20
View File
@@ -30,7 +30,7 @@ pub use crate::api::{
},
web::catchers as web_catchers,
web::routes as web_routes,
web::{STATIC_FILES, invalidate_css_cache},
web::static_files,
};
use crate::{
CONFIG,
@@ -46,7 +46,7 @@ pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>;
// Common structs representing JSON data received
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PasswordOrOtpData {
#[serde(alias = "MasterPasswordHash")]
@@ -124,21 +124,3 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json
}
// Identity responses use PascalCase keys like upstream, some clients (Android) only read those
async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value {
match master_password_policy(user, conn).await {
Value::Object(policy) => Value::Object(
policy
.into_iter()
.map(|(mut key, value)| {
if let Some(first) = key.get_mut(..1) {
first.make_ascii_uppercase();
}
(key, value)
})
.collect(),
),
policy => policy,
}
}
+9 -34
View File
@@ -1,7 +1,7 @@
use std::{
net::IpAddr,
sync::{Arc, LazyLock},
time::{Duration, Instant},
time::Duration,
};
use chrono::{NaiveDateTime, Utc};
@@ -41,14 +41,6 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscripti
/// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
/// How often a Ping is sent to the client.
const WS_PING_INTERVAL: Duration = Duration::from_secs(15);
/// Close the connection if nothing, not even a Pong, was received from the client for this long.
/// Otherwise half-open connections (client gone without a FIN, e.g. behind a proxy or NAT) are kept forever.
/// Same as the default `ClientTimeoutInterval` of ASP.NET Core SignalR, which the official server uses.
const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> {
@@ -164,16 +156,12 @@ fn websockets_hub<'r>(
rocket_ws::Stream! { ws => {
let mut ws = ws;
let _guard = guard;
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
let mut last_received = Instant::now();
let mut interval = tokio::time::interval(Duration::from_secs(15));
loop {
tokio::select! {
res = ws.next() => {
match res {
Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message {
// Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping),
@@ -207,13 +195,7 @@ fn websockets_hub<'r>(
}
}
_ = interval.tick() => {
// The client stopped responding without closing the connection, drop it
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
break;
}
yield Message::Ping(PING.to_vec());
}
_ = interval.tick() => yield Message::Ping(create_ping())
}
}
}}
@@ -247,16 +229,12 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
rocket_ws::Stream! { ws => {
let mut ws = ws;
let _guard = guard;
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
let mut last_received = Instant::now();
let mut interval = tokio::time::interval(Duration::from_secs(15));
loop {
tokio::select! {
res = ws.next() => {
match res {
Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message {
// Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping),
@@ -290,13 +268,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
}
}
_ = interval.tick() => {
// The client stopped responding without closing the connection, drop it
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
break;
}
yield Message::Ping(PING.to_vec());
}
_ = interval.tick() => yield Message::Ping(create_ping())
}
}
}}
@@ -359,7 +331,6 @@ fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
const RECORD_SEPARATOR: u8 = 0x1e;
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
const PING: [u8; 3] = [0x02, 0x91, 0x06];
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
struct InitialMessage<'a> {
@@ -692,6 +663,10 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
serialize(&value)
}
fn create_ping() -> Vec<u8> {
serialize(&Value::Array(vec![6.into()]))
}
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType {
+36 -97
View File
@@ -1,14 +1,10 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, LazyLock, RwLock},
};
use std::path::{Path, PathBuf};
use rocket::{
Catcher, Route,
fs::NamedFile,
http::ContentType,
response::{Redirect, content::RawHtml as Html},
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
serde::json::Json,
};
use serde_json::Value;
@@ -17,13 +13,12 @@ use crate::{
CONFIG,
api::{ApiResult, EmptyResult, core::now},
auth::decode_file_download,
crypto::sha256_hex,
db::{
DbConn,
models::{AttachmentId, CipherId},
},
error::Error,
util::{Cached, EtagCached},
util::Cached,
};
pub fn routes() -> Vec<Route> {
@@ -68,29 +63,9 @@ fn not_found() -> ApiResult<Html<String>> {
Ok(Html(text))
}
struct CssCache {
css: String,
etag: String,
}
static CSS_CACHE: RwLock<Option<Arc<CssCache>>> = RwLock::new(None);
pub fn invalidate_css_cache() {
*CSS_CACHE.write().unwrap() = None;
}
#[get("/css/vaultwarden.css")]
fn vaultwarden_css() -> EtagCached<String> {
// If reload_templates is false, and we already have the CSS Cached, return this
if !CONFIG.reload_templates()
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
{
return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag);
}
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
fn vaultwarden_css() -> Cached<Css<String>> {
let css_options = json!({
"email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
@@ -137,18 +112,8 @@ fn vaultwarden_css() -> EtagCached<String> {
}
};
let etag = sha256_hex(css.as_bytes());
let cached = Arc::new(CssCache {
css,
etag,
});
if !CONFIG.reload_templates() {
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
}
// Etag Caching will let the browser send us an etag to verify and send new content if needed
EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag)
// Cache for one day should be enough and not too much
Cached::ttl(Css(css), 86_400, false)
}
#[get("/")]
@@ -257,7 +222,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
// This endpoint/function is used during development and development only.
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below!
// NOTE: Do not forget to add any new files added to the `static_files` function below!
#[cfg(debug_assertions)]
#[get("/vw_static/<filename>", rank = 1)]
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
@@ -277,61 +242,35 @@ pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
None
}
pub struct StaticFile {
pub content_type: ContentType,
pub bytes: &'static [u8],
pub etag: String,
}
pub static STATIC_FILES: LazyLock<HashMap<&'static str, StaticFile>> = LazyLock::new(|| {
[
("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]),
("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]),
("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]),
("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]),
("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]),
("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]),
("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]),
("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]),
("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]),
("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]),
("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]),
("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]),
(
"admin_organizations.js",
ContentType::JavaScript,
&include_bytes!("../static/scripts/admin_organizations.js")[..],
),
(
"admin_diagnostics.js",
ContentType::JavaScript,
&include_bytes!("../static/scripts/admin_diagnostics.js")[..],
),
("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]),
("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]),
("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]),
("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]),
("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]),
]
.into_iter()
.map(|(name, content_type, bytes)| {
(
name,
StaticFile {
content_type,
bytes,
etag: sha256_hex(bytes),
},
)
})
.collect()
});
#[get("/vw_static/<filename>", rank = 2)]
pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error> {
if let Some(file) = STATIC_FILES.get(filename) {
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
} else {
err!(format!("Static file not found: {filename}"))
pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> {
match filename {
"404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))),
"mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
"logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
"error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
"hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
"vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))),
"vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))),
"404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))),
"admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))),
"admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))),
"admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))),
"admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))),
"admin_organizations.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js")))
}
"admin_diagnostics.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js")))
}
"bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))),
"bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))),
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
"jquery-4.0.0.slim.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
}
_ => err!(format!("Static file not found: {filename}")),
}
}
+39 -147
View File
@@ -1,6 +1,3 @@
#[path = "auth/two_factor.rs"]
pub mod two_factor;
#[path = "auth/send.rs"]
pub mod send;
pub type SendTokens = send::SendTokens;
@@ -17,7 +14,6 @@ use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive;
use openssl::rsa::Rsa;
use ring::hmac;
use serde::{de::DeserializeOwned, ser::Serialize};
use rocket::{
@@ -27,14 +23,14 @@ use rocket::{
use crate::{
CONFIG,
api::{ApiResult, core::log_event},
api::ApiResult,
config::PathType,
db::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
},
},
error::Error,
@@ -69,7 +65,6 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
static PRELOGIN_KDF_KEY: OnceLock<hmac::Key> = OnceLock::new();
pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error as IoError;
@@ -104,22 +99,9 @@ pub async fn initialize_keys() -> Result<(), Error> {
if PUBLIC_RSA_KEY.set(dec).is_err() {
err!("PUBLIC_RSA_KEY must only be initialized once")
}
// Derived from the RSA key, so it stays the same across restarts without storing another secret
let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf");
if PRELOGIN_KDF_KEY.set(hmac::Key::new(hmac::HMAC_SHA256, prelogin_kdf_key.as_ref())).is_err() {
err!("PRELOGIN_KDF_KEY must only be initialized once")
}
Ok(())
}
/// Returns an index in `0..len` derived from the email, the same for every request
pub fn prelogin_kdf_index(email: &str, len: usize) -> usize {
let tag = hmac::sign(PRELOGIN_KDF_KEY.wait(), email.as_bytes());
let tag = tag.as_ref();
usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) {
Ok(token) => token,
@@ -271,7 +253,7 @@ impl LoginJwtClaims {
premium: true,
name: user.name.clone(),
email: user.email.clone(),
email_verified: user.verified(),
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
// ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
@@ -314,17 +296,6 @@ impl LoginJwtClaims {
}
}
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
};
let Some(exp) = now.checked_add_signed(hours_delta) else {
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
};
Ok(exp)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteJwtClaims {
// Not before
@@ -348,19 +319,19 @@ pub fn generate_invite_claims(
org_id: OrganizationId,
member_id: MembershipId,
invited_by_email: Option<String>,
) -> Result<InviteJwtClaims, Error> {
) -> InviteJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(InviteJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
InviteJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_INVITE_ISSUER.to_string(),
sub: user_id,
email,
org_id,
member_id,
invited_by_email,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -386,19 +357,19 @@ pub fn generate_emergency_access_invite_claims(
emer_id: EmergencyAccessId,
grantor_name: String,
grantor_email: String,
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
) -> EmergencyAccessInviteJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(EmergencyAccessInviteJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
EmergencyAccessInviteJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
sub: user_id,
email,
emer_id,
grantor_name,
grantor_email,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -451,7 +422,7 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId)
let time_now = Utc::now();
FileDownloadClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(1).unwrap()).timestamp(),
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(),
sub: cipher_id,
file_id,
@@ -532,43 +503,36 @@ impl BasicJwtClaims {
}
}
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(BasicJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_DELETE_ISSUER.to_string(),
sub: uuid,
})
}
}
pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(BasicJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
sub: user_id.to_string(),
})
}
}
pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
pub fn generate_admin_claims() -> BasicJwtClaims {
let time_now = Utc::now();
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
Ok(BasicJwtClaims {
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
iss: JWT_ADMIN_ISSUER.to_string(),
sub: "admin_panel".to_owned(),
})
}
}
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
@@ -858,12 +822,6 @@ pub struct AdminHeaders {
pub org_id: OrganizationId,
}
impl AdminHeaders {
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
type Error = &'static str;
@@ -1127,31 +1085,6 @@ fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
}
/// Each proxy appends the address it received the request from to a list header (`X-Forwarded-For: client, proxy1`).
/// Like ASP.NET's ForwardedHeaders, walk the entries of every occurrence of the header from the right and take the
/// first one that isn't a trusted proxy. If they all are, the leftmost one is the client.
fn client_ip_from_header(req: &Request<'_>, header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
let values: Vec<&str> = req.headers().get(header).collect();
client_ip_from_header_values(&values, header, is_trusted)
}
fn client_ip_from_header_values(values: &[&str], header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
let entries: Vec<&str> = values.iter().flat_map(|value| value.split(',')).map(str::trim).collect();
let mut client_ip = None;
for entry in entries.into_iter().rev() {
let Ok(ip) = entry.parse() else {
warn!("'{header}' header is malformed: {entry}");
break;
};
client_ip = Some(ip);
if !is_trusted(ip) {
break;
}
}
client_ip
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp {
type Error = ();
@@ -1160,7 +1093,15 @@ impl<'r> FromRequest<'r> for ClientIp {
let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip)))
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
match ip.find(',') {
Some(idx) => &ip[..idx],
None => ip,
}
.parse()
.map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header()))
.ok()
})
} else {
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
// Log the canonical IP, which is what the user filter will need to match against
@@ -1279,7 +1220,7 @@ impl AuthMethod {
match scope {
None => err!("Missing scope"),
Some(scope) if scope == &method_scope => Ok(method_scope),
Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())),
Some(scope) => err!(format!("Scope ({scope}) not supported")),
}
}
}
@@ -1398,52 +1339,3 @@ pub async fn refresh_tokens(
Ok((device, auth_tokens))
}
#[cfg(test)]
mod tests {
use super::*;
fn client_ip(values: &[&str]) -> Option<IpAddr> {
let is_trusted = |ip: IpAddr| match ip {
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
IpAddr::V6(v6) => v6.is_loopback(),
};
client_ip_from_header_values(values, "X-Forwarded-For", is_trusted)
}
fn ip(s: &str) -> Option<IpAddr> {
Some(s.parse().unwrap())
}
#[test]
fn ip_header_single_value() {
assert_eq!(client_ip(&["203.0.113.5"]), ip("203.0.113.5"));
assert_eq!(client_ip(&["10.0.0.7"]), ip("10.0.0.7"));
assert_eq!(client_ip(&["2001:db8::1"]), ip("2001:db8::1"));
}
#[test]
fn ip_header_uses_rightmost_untrusted_entry() {
// Entries already in the request come before the one the proxy appended
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5"]), ip("203.0.113.5"));
// Trusted proxies on the right are skipped
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5, 10.0.0.2, 127.0.0.1"]), ip("203.0.113.5"));
// All trusted, so the leftmost entry is the client
assert_eq!(client_ip(&["192.168.1.10, 10.0.0.2"]), ip("192.168.1.10"));
}
#[test]
fn ip_header_reads_every_occurrence() {
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5"]), ip("203.0.113.5"));
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5, 10.0.0.2"]), ip("203.0.113.5"));
}
#[test]
fn ip_header_malformed_entries() {
assert_eq!(client_ip(&[]), None);
assert_eq!(client_ip(&[""]), None);
assert_eq!(client_ip(&["203.0.113.5, not-an-ip"]), None);
// Stops at the malformed entry and keeps the closest proxy
assert_eq!(client_ip(&["not-an-ip, 10.0.0.2"]), ip("10.0.0.2"));
}
}
+17 -3
View File
@@ -75,7 +75,7 @@ impl SendTokens {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
};
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
};
@@ -85,8 +85,18 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
if !send.is_accessible() {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
{
return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true);
}
if Utc::now().naive_utc() >= send.deletion_date {
return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true);
}
if send.disabled {
return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true);
}
if send.password_hash.is_some() {
@@ -103,6 +113,10 @@ impl SendTokens {
}
}
if !send.register_access(conn).await? {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self {
access_claims: generate_send_access_claims(&send_id),
})
-281
View File
@@ -1,281 +0,0 @@
use chrono::{TimeDelta, Utc};
use serde::{de::DeserializeOwned, ser::Serialize};
use std::sync::LazyLock;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult},
auth::{decode_jwt, encode_jwt},
db::models::UserId,
};
static JWT_2FA_AUTH_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin()));
#[derive(Serialize, Deserialize)]
pub struct TwoFactorClaims<T> {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: UserId,
pub enabled: bool,
pub claims: T,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthenticatorClaims {
#[serde(rename = "authenticator_key")]
pub key: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DuoClaims {
#[serde(rename = "duo_data")]
pub data: Option<DuoData>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebauthnClaims {
#[serde(rename = "webauthn_keys")]
pub keys: Vec<i32>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct YubikeyClaims {
#[serde(rename = "yubi_keys")]
pub keys: Vec<String>,
}
#[derive(Serialize, Deserialize, PartialEq)]
pub struct DuoData {
pub host: String, // Duo API hostname
pub ik: String, // client id
pub sk: String, // client secret
}
impl DuoData {
pub fn global() -> Option<Self> {
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
(true, Some(host)) => Some(Self {
host,
ik: CONFIG.duo_ikey().unwrap(),
sk: CONFIG.duo_skey().unwrap(),
}),
_ => None,
}
}
pub fn msg(s: &str) -> Self {
Self {
host: s.into(),
ik: s.into(),
sk: s.into(),
}
}
pub fn secret() -> Self {
Self::msg("<global_secret>")
}
pub fn obscure(self) -> Self {
let mut host = self.host;
let mut ik = self.ik;
let mut sk = self.sk;
let digits = 4;
let replaced = "************";
host.replace_range(digits.., replaced);
ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced);
Self {
host,
ik,
sk,
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmailClaims {
pub email: Option<String>,
}
fn token<T: Serialize>(user_id: UserId, enabled: bool, claims: T) -> String {
let time_now = Utc::now();
let claims = TwoFactorClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
iss: JWT_2FA_AUTH_ISSUER.to_string(),
sub: user_id,
enabled,
claims,
};
encode_jwt(&claims)
}
fn validate<T: DeserializeOwned>(token: &str, user_id: &UserId, enabled: bool) -> ApiResult<T> {
match decode_jwt::<TwoFactorClaims<T>>(token, JWT_2FA_AUTH_ISSUER.to_string()) {
Ok(claims) => {
if claims.sub != *user_id {
err!("Invalid verification token: Invalid user");
}
if claims.enabled != enabled {
err!("Invalid verification token: Invalid state");
}
Ok(claims.claims)
}
Err(err) => err!(format!("Failed to decode verification token: {err}")),
}
}
pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String {
token(
user_id,
enabled,
AuthenticatorClaims {
key,
},
)
}
pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult {
let claims = validate::<AuthenticatorClaims>(token, user_id, enabled)?;
if claims.key != key {
err!("Invalid verification token: Invalid key");
}
Ok(())
}
pub fn duo_token(user_id: UserId, data: Option<DuoData>, enabled: bool) -> String {
token(
user_id,
enabled,
DuoClaims {
data,
},
)
}
// When disabling we check that it's the correct data
pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult {
let claims = validate::<DuoClaims>(token, user_id, enabled)?;
if enabled && claims.data.as_ref() != data {
err!("Invalid verification token: Invalid duo data");
}
Ok(())
}
pub fn email_token(user_id: UserId, email: Option<String>, enabled: bool) -> String {
token(
user_id,
enabled,
EmailClaims {
email,
},
)
}
// When disabling we check that it's the correct `email`
pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult {
let claims = validate::<EmailClaims>(token, user_id, enabled)?;
if enabled && claims.email != Some(email) {
err!("Invalid verification token: Invalid email");
}
Ok(())
}
pub fn webauthn_token(user_id: UserId, keys: Vec<i32>, enabled: bool) -> String {
token(
user_id,
enabled,
WebauthnClaims {
keys,
},
)
}
pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult {
let claims = validate::<WebauthnClaims>(token, user_id, enabled)?;
if keys != claims.keys {
err!("Invalid verification token: Invalid keys");
}
Ok(())
}
pub fn yubikey_token(user_id: UserId, keys: Vec<String>, enabled: bool) -> String {
token(
user_id,
enabled,
YubikeyClaims {
keys,
},
)
}
pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec<String>, enabled: bool) -> EmptyResult {
let claims = validate::<YubikeyClaims>(token, user_id, enabled)?;
if *keys != claims.keys {
err!("Invalid verification token: Invalid keys");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, from_value, to_value};
fn token<T: Serialize>(claims: T) -> Value {
to_value(TwoFactorClaims {
nbf: 0,
exp: 0,
iss: String::new(),
sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")),
enabled: false,
claims,
})
.unwrap()
}
#[test]
fn claims_only_parse_as_their_own_provider() {
let tokens = [
token(AuthenticatorClaims {
key: String::from("JBSWY3DPEHPK3PXP"),
}),
token(DuoClaims {
data: None,
}),
token(WebauthnClaims {
keys: vec![1],
}),
token(YubikeyClaims {
keys: Vec::new(),
}),
token(EmailClaims {
email: None,
}),
];
for (issued, token) in tokens.iter().enumerate() {
let parsed = [
from_value::<TwoFactorClaims<AuthenticatorClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<DuoClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<WebauthnClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<YubikeyClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<EmailClaims>>(token.clone()).is_ok(),
];
for (checked, ok) in parsed.into_iter().enumerate() {
assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}");
}
}
}
}
+89 -160
View File
@@ -111,7 +111,6 @@ macro_rules! make_config {
_usr: ConfigBuilder,
_overrides: Vec<&'static str>,
_config_file_status: ConfigFileStatus,
}
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
@@ -260,18 +259,11 @@ macro_rules! make_config {
builder
}
async fn from_file() -> Result<Option<Self>, Error> {
async fn from_file() -> Result<Self, Error> {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes,
// No config file is fine, should not return any error
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
// Any other error might be an issue and should be passed back to the caller
Err(e) => return Err(e.into()),
};
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
let config_bytes = operator.read(&CONFIG_FILENAME).await?;
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
Ok(Some(builder))
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
}
fn clear_non_editable(&mut self) {
@@ -448,8 +440,6 @@ macro_rules! make_config {
"domain_origin",
"domain_path",
"domain",
"duo_ikey",
"duo_host",
"helo_name",
"org_creation_users",
"signups_domains_whitelist",
@@ -461,8 +451,6 @@ macro_rules! make_config {
"sso_authority",
"sso_callback_path",
"sso_client_id",
"yubico_client_id",
"yubico_server",
];
let cfg = {
@@ -495,10 +483,6 @@ macro_rules! make_config {
};
overrides
}
pub fn get_config_file_status(&self) -> ConfigFileStatus {
self.inner.read().unwrap()._config_file_status
}
}
};
}
@@ -555,7 +539,7 @@ make_config! {
jobs {
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
/// Set to 0 to globally disable scheduled jobs.
job_poll_interval_ms: u32, false, def, 30_000;
job_poll_interval_ms: u64, false, def, 30_000;
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
/// Defaults to hourly. Set blank to disable this job.
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
@@ -577,7 +561,7 @@ make_config! {
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
/// Defaults to every minute. Set blank to disable this job.
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned();
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
/// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned();
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
@@ -611,19 +595,19 @@ make_config! {
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
org_attachment_limit: i64, true, option;
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
user_send_limit: i64, true, option;
user_send_limit: i64, true, option;
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
/// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
/// sure to inform all users of any changes to this setting.
trash_auto_delete_days: u16, true, option;
trash_auto_delete_days: i64, true, option;
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
/// where the correct master password was provided but the required 2FA step was not completed,
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
/// This setting applies globally to all users.
incomplete_2fa_time_limit: u16, true, def, 3;
incomplete_2fa_time_limit: i64, true, def, 3;
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
@@ -636,9 +620,9 @@ make_config! {
/// this will prevent logins from succeeding until the address has been verified
signups_verify: bool, true, def, false;
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
signups_verify_resend_time: u32, true, def, 3_600;
signups_verify_resend_time: u64, true, def, 3_600;
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
signups_verify_resend_limit: u16, true, def, 6;
signups_verify_resend_limit: u32, true, def, 6;
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
signups_domains_whitelist: String, true, def, String::new();
/// Enable event logging |> Enables event logging for organizations.
@@ -650,7 +634,7 @@ make_config! {
invitations_allowed: bool, true, def, true;
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
/// email verification token and deletion request token will expire (must be at least 1)
invitation_expiration_hours: u16, false, def, 120;
invitation_expiration_hours: u32, false, def, 120;
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
emergency_access_allowed: bool, true, def, true;
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
@@ -671,13 +655,8 @@ make_config! {
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
/// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
events_days_retain: u16, false, option;
},
client {
/// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals
client_suppress_onboarding: bool, true, def, false;
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
events_days_retain: i64, false, option;
},
/// Advanced settings
@@ -691,8 +670,7 @@ make_config! {
/// other address use the remote IP instead, so a client can't spoof the header.
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
/// running on the same host or container network), the string "all" to accept it from anywhere,
/// or a comma separated list of IPs and CIDR ranges. For a list header like X-Forwarded-For, the
/// client IP is the rightmost address that isn't a trusted proxy, so list every proxy in the chain.
/// or a comma separated list of IPs and CIDR ranges.
ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@@ -710,13 +688,13 @@ make_config! {
/// Temporary redirects are useful while testing different icon services, but once a service
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
/// are currently better supported by the Bitwarden clients.
icon_redirect_code: u16, true, def, 302;
icon_redirect_code: u32, true, def, 302;
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
icon_cache_ttl: u32, true, def, 2_592_000;
icon_cache_ttl: u64, true, def, 2_592_000;
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
icon_cache_negttl: u32, true, def, 259_200;
icon_cache_negttl: u64, true, def, 259_200;
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
icon_download_timeout: u16, true, def, 10;
icon_download_timeout: u64, true, def, 10;
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
icon_blacklist_regex: String, false, option;
@@ -767,11 +745,11 @@ make_config! {
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
db_connection_retries: u32, false, def, 15;
/// Timeout in seconds when acquiring database connection
database_timeout: u32, false, def, 30;
/// Timeout when acquiring database connection
database_timeout: u64, false, def, 30;
/// Timeout in seconds before idle connections to the database are closed
database_idle_timeout: u32, false, def, 600;
database_idle_timeout: u64, false, def, 600;
/// Database connection max pool size
database_max_conns: u32, false, def, 10;
@@ -788,26 +766,26 @@ make_config! {
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
allowed_iframe_ancestors: String, true, def, String::new();
/// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
allowed_connect_src: String, true, def, String::new();
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
login_ratelimit_seconds: u16, false, def, 60;
login_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u16, false, def, 10;
login_ratelimit_max_burst: u32, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u16, false, def, 60;
unauthenticated_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u16, false, def, 300;
admin_ratelimit_seconds: u64, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
admin_ratelimit_max_burst: u16, false, def, 3;
admin_ratelimit_max_burst: u32, false, def, 3;
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
admin_session_lifetime: u32, true, def, 20;
admin_session_lifetime: i64, true, def, 20;
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false;
@@ -834,8 +812,6 @@ make_config! {
sso_enabled: bool, true, def, false;
/// Only SSO login |> Disable Email+Master Password login
sso_only: bool, true, def, false;
/// Allow SSO flow to create account |> You probably want to disable it when using a public provider
sso_signups_allowed: bool, true, def, true;
/// Allow email association |> Associate existing non-SSO user based on email
sso_signups_match_email: bool, true, def, true;
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
@@ -860,8 +836,8 @@ make_config! {
sso_master_password_policy: String, true, option;
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
sso_auth_only_not_session: bool, true, def, false;
/// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u32, true, def, 0;
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false;
},
@@ -882,12 +858,16 @@ make_config! {
duo: _enable_duo {
/// Enabled
_enable_duo: bool, true, def, true;
/// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2)
duo_use_iframe: bool, false, def, false;
/// Client Id
duo_ikey: String, true, option;
/// Client Secret
duo_skey: Pass, true, option;
/// Host
duo_host: String, true, option;
/// Application Key (generated automatically)
_duo_akey: Pass, false, option;
},
/// SMTP Email Settings
@@ -919,11 +899,11 @@ make_config! {
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
smtp_auth_mechanism: String, true, option;
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u16, true, def, 15;
smtp_timeout: u64, true, def, 15;
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
helo_name: String, true, option;
/// Embed images as email attachments.
smtp_embed_images: bool, true, def, true;
smtp_embed_images: bool, true, def, true;
/// _smtp_img_src
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
@@ -941,9 +921,9 @@ make_config! {
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
email_token_size: u8, true, def, 6;
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
email_expiration_time: u32, true, def, 600;
email_expiration_time: u64, true, def, 600;
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
email_attempts_limit: u8, true, def, 3;
email_attempts_limit: u64, true, def, 3;
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
email_2fa_enforce_on_verified_invite: bool, true, def, false;
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
@@ -999,15 +979,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
if cfg.database_min_conns > cfg.database_max_conns {
err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
}
if cfg.database_timeout < 1 {
err!("`DATABASE_TIMEOUT` must be at least 1 second");
}
if cfg.database_idle_timeout < 1 {
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
}
if let Some(log_file) = &cfg.log_file
@@ -1185,8 +1157,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
#[cfg(unix)]
if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() {
err!(format!("sendmail command at `{path:?}` isn't executable"));
{
use std::os::unix::fs::PermissionsExt;
if !metadata.permissions().mode() & 0o111 != 0 {
err!(format!("sendmail command at `{path:?}` isn't executable"));
}
}
}
}
@@ -1290,42 +1265,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression")
}
if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::<Schedule>().is_err() {
err!("`DUO_CONTEXT_PURGE_SCHEDULE` is not a valid cron expression")
}
if !cfg.purge_incomplete_sso_auth.is_empty() && cfg.purge_incomplete_sso_auth.parse::<Schedule>().is_err() {
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
}
if cfg.login_ratelimit_seconds < 1 {
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.login_ratelimit_max_burst < 1 {
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.admin_ratelimit_seconds < 1 {
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.admin_ratelimit_max_burst < 1 {
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.unauthenticated_ratelimit_seconds < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.unauthenticated_ratelimit_max_burst < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
}
if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => {
if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) {
if let Err(e) = argon2::password_hash::PasswordHash::new(t) {
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
}
}
@@ -1471,54 +1414,35 @@ pub enum PathType {
// 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
pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Architecture
"desktop-ui-migration-milestone-1",
"desktop-ui-migration-milestone-2",
"desktop-ui-migration-milestone-3",
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response",
"ssh-agent",
"ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
// Key Management Team
"biometrics-sdk-ipc",
"windows-native-credential-sync",
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
// Mobile Team
"pm-34171-card-scanner",
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
// Platform Team
"pm-30529-webauthn-related-origins",
// Vault Team
"pm-32009-new-item-types",
];
#[derive(Clone, Copy, Serialize)]
pub enum ConfigFileStatus {
NotFound,
Loaded,
Error,
}
impl Config {
pub async fn load() -> Result<Self, Error> {
// Loading from env and file
let env = ConfigBuilder::from_env();
let (usr, config_file_status) = match ConfigBuilder::from_file().await {
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
Err(e) => {
println!(
"[ERROR] Failed to load configuration from `{}`\n\
#####################################################################################\n\
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
# This file is not used until those errors are resolved. #\n\
#####################################################################################\n\
{e:?}\n",
*CONFIG_FILE
);
(ConfigBuilder::default(), ConfigFileStatus::Error)
}
};
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
// Create merged config, config file overwrites env
let mut overrides = Vec::new();
@@ -1538,7 +1462,6 @@ impl Config {
_env: env,
_usr: usr,
_overrides: overrides,
_config_file_status: config_file_status,
}),
})
}
@@ -1572,19 +1495,24 @@ impl Config {
writer.config = config;
writer._usr = builder;
writer._overrides = overrides;
writer._config_file_status = ConfigFileStatus::Loaded;
}
//Save to file
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.write(&CONFIG_FILENAME, config_str).await?;
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(())
}
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = {
let usr = &self.inner.read().unwrap()._usr;
let mut overrides = Vec::new();
usr.merge(&other, false, &mut overrides)
};
self.update_config(builder, false).await
}
/// Tests whether an email's domain is allowed. A domain is allowed if it
/// is in signups_domains_whitelist, or if no whitelist is set (so there
/// are no domain restrictions in effect).
@@ -1611,17 +1539,6 @@ impl Config {
}
}
/// Tests whether SSO signup is allowed for an email address, taking into
/// account the sso_signups_allowed and signups_domains_whitelist settings.
pub fn is_sso_signup_allowed(&self, email: &str) -> bool {
if self.signups_domains_whitelist().is_empty() {
self.sso_signups_allowed()
} else {
// The whitelist setting overrides the signups_allowed setting.
self.is_email_domain_allowed(email)
}
}
// The registration link should be hidden if
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
// - The SSO is activated and password login is disabled.
@@ -1664,12 +1581,8 @@ impl Config {
writer.config = config;
writer._usr = usr;
writer._overrides = Vec::new();
writer._config_file_status = ConfigFileStatus::NotFound;
}
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(())
}
@@ -1681,6 +1594,23 @@ impl Config {
inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail)
}
pub async fn get_duo_akey(&self) -> String {
if let Some(akey) = self._duo_akey() {
akey
} else {
let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64);
// Save the new value
let builder = ConfigBuilder {
_duo_akey: Some(akey_s.clone()),
..Default::default()
};
self.update_config_partial(builder).await.ok();
akey_s
}
}
pub fn is_webauthn_2fa_supported(&self) -> bool {
Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some()
}
@@ -1793,7 +1723,7 @@ where
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/admin_account_recovery", ".html");
reg!("email/admin_reset_password", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html");
@@ -1812,7 +1742,6 @@ where
reg!("email/protected_action", ".html");
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/recover_twofactor", ".html");
reg!("email/register_verify_email", ".html");
reg!("email/send_2fa_removed_from_org", ".html");
reg!("email/send_emergency_access_invite", ".html");
+3 -3
View File
@@ -212,8 +212,8 @@ impl DbPool {
let pool = Pool::builder()
.max_size(max_conns)
.min_idle(Some(CONFIG.database_min_conns()))
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
.connection_timeout(Duration::from_secs(CONFIG.database_timeout()))
.connection_customizer(Box::new(DbConnOptions {
init_stmts: conn_type.get_init_stmts(),
}))
@@ -233,7 +233,7 @@ impl DbPool {
// Get a connection from the pool
pub async fn get(&self) -> Result<DbConn, Error> {
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
let duration = Duration::from_secs(CONFIG.database_timeout());
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
Ok(p) => p.expect("Semaphore should be open"),
Err(_) => {
+3 -6
View File
@@ -41,20 +41,17 @@ impl Archive {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
mysql {
diesel::insert_into(archives::table)
sqlite, mysql {
diesel::replace_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
archives::cipher_uuid.eq(cipher_uuid),
archives::archived_at.eq(archived_at),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(archives::archived_at.eq(archived_at))
.execute(conn)
.map_res("Error saving archive")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
+16 -8
View File
@@ -61,7 +61,7 @@ impl Attachment {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
} else {
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(1)).await?.uri().to_string())
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string())
}
}
@@ -82,16 +82,24 @@ impl Attachment {
impl Attachment {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(attachments::table)
sqlite, mysql {
match diesel::replace_into(attachments::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving attachment")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(attachments::table)
.filter(attachments::id.eq(&self.id))
.set(self)
.execute(conn)
.map_res("Error saving attachment")
}
Err(e) => Err(e.into()),
}.map_res("Error saving attachment")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(attachments::table)
.values(self)
.on_conflict(attachments::id)
+21 -37
View File
@@ -4,7 +4,7 @@ use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::{ApiResult, EmptyResult},
api::EmptyResult,
crypto::ct_eq,
db::{DbConn, schema::auth_requests},
error::MapResult,
@@ -82,23 +82,31 @@ impl AuthRequest {
}
impl AuthRequest {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
mysql {
diesel::insert_into(auth_requests::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
sqlite, mysql {
match diesel::replace_into(auth_requests::table)
.values(&*self)
.execute(conn)
.map_res("Error saving auth_request")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error auth_request")
}
Err(e) => Err(e.into()),
}.map_res("Error auth_request")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(auth_requests::table)
.values(self)
.values(&*self)
.on_conflict(auth_requests::uuid)
.do_update()
.set(self)
.set(&*self)
.execute(conn)
.map_res("Error saving auth_request")
}
@@ -166,30 +174,6 @@ impl AuthRequest {
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting auth requests for user")
})
.await
}
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(uuid))
.filter(auth_requests::authentication_date.is_null())
.set(auth_requests::authentication_date.eq(now))
.execute(conn)
.map(|updated| updated == 1)
.map_res("Error updating auth request")
})
.await
}
pub fn check_access_code(&self, access_code: &str) -> bool {
ct_eq(&self.access_code, access_code)
}
@@ -197,7 +181,7 @@ impl AuthRequest {
pub async fn purge_expired_auth_requests(conn: &DbConn) {
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
for auth_request in Self::find_created_before(&expiry_time, conn).await {
auth_request.delete(conn).await.ok();
}
+41 -59
View File
@@ -42,15 +42,13 @@ pub struct Cipher {
pub key: Option<String>,
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/Vault/Enums/CipherType.cs
// Login = 1,
// SecureNote = 2,
// Card = 3,
// Identity = 4,
// SSHKey = 5
// BankAccount = 6,
// DriversLicense = 7,
// Passport = 8,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
*/
pub atype: i32,
pub name: String,
pub notes: Option<String>,
@@ -308,6 +306,16 @@ impl Cipher {
type_data_json = Value::Null;
}
// Clone the type_data and add some default value.
let mut data_json = type_data_json.clone();
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// data_json should always contain the following keys with every atype
data_json["fields"] = json!(fields_json);
data_json["name"] = json!(self.name);
data_json["notes"] = json!(self.notes);
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
Cow::from(cipher_collections)
@@ -347,6 +355,8 @@ impl Cipher {
"notes": self.notes,
"fields": fields_json,
"data": data_json,
"passwordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated
@@ -355,9 +365,6 @@ impl Cipher {
"card": null,
"identity": null,
"sshKey": null,
"bankAccount": null,
"driversLicense": null,
"passport": null,
});
// These values are only needed for user/default syncs
@@ -397,9 +404,6 @@ impl Cipher {
3 => "card",
4 => "identity",
5 => "sshKey",
6 => "bankAccount",
7 => "driversLicense",
8 => "passport",
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
@@ -440,16 +444,24 @@ impl Cipher {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(ciphers::table)
sqlite, mysql {
match diesel::replace_into(ciphers::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(ciphers::table)
.filter(ciphers::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
}
Err(e) => Err(e.into()),
}.map_res("Error saving cipher")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(ciphers::table)
.values(&*self)
.on_conflict(ciphers::uuid)
@@ -494,22 +506,12 @@ impl Cipher {
/// Purge all ciphers that are old enough to be auto-deleted.
pub async fn purge_trash(conn: &DbConn) {
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
return;
};
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
error!("Unable to calculate auto delete days!");
return;
};
let now = Utc::now().naive_utc();
let Some(dt) = now.checked_sub_signed(days) else {
error!("Unable to calculate trash purge date!");
return;
};
for cipher in Self::find_deleted_before(&dt, conn).await {
cipher.delete(conn).await.ok();
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
let now = Utc::now().naive_utc();
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
for cipher in Self::find_deleted_before(&dt, conn).await {
cipher.delete(conn).await.ok();
}
}
}
@@ -667,27 +669,16 @@ impl Cipher {
}
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
// The user must have a confirmed membership in the cipher's
// organization, since users_collections rows are kept when a
// membership is revoked or not confirmed yet.
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.inner_join(
users_collections::table.on(ciphers_collections::collection_uuid
.eq(users_collections::collection_uuid)
// Only allow collection access via the confirmed membership.
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
.and(users_collections::user_uuid.eq(user_uuid))),
)
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn)
@@ -718,10 +709,6 @@ impl Cipher {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
// Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)
.expect("Error getting group access restrictions")
@@ -1014,7 +1001,6 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1046,7 +1032,6 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1091,7 +1076,6 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1124,7 +1108,6 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1176,7 +1159,6 @@ impl Cipher {
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(ciphers_collections::all_columns)
.distinct()
.load::<(CipherId, CollectionId)>(conn)
+67 -54
View File
@@ -21,7 +21,6 @@ use super::{
User, UserId,
};
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = collections)]
#[diesel(treat_none_as_null = true)]
@@ -72,11 +71,6 @@ impl Collection {
"id": self.uuid,
"organizationId": self.org_uuid,
"name": self.name,
// Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection.
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs
"type": 0,
// This is only used together with MyItems/DefaultUserCollection, which we do not yet support.
"defaultUserCollectionEmail": null,
"object": "collection",
})
}
@@ -168,16 +162,24 @@ impl Collection {
self.update_users_revision(conn).await;
db_run! { conn:
mysql {
diesel::insert_into(collections::table)
sqlite, mysql {
match diesel::replace_into(collections::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving collection")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections::table)
.filter(collections::uuid.eq(&self.uuid))
.set(self)
.execute(conn)
.map_res("Error saving collection")
}
Err(e) => Err(e.into()),
}.map_res("Error saving collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(collections::table)
.values(self)
.on_conflict(collections::uuid)
@@ -367,7 +369,6 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(uuid)
@@ -407,7 +408,6 @@ impl Collection {
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(collections::uuid.eq(uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_collections::collection_uuid.eq(uuid).or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
@@ -432,8 +432,7 @@ impl Collection {
.inner_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_collections::table.on(users_collections::collection_uuid
@@ -479,8 +478,7 @@ impl Collection {
.inner_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_collections::table.on(users_collections::collection_uuid
@@ -531,7 +529,6 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&self.uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(&self.uuid)
@@ -591,7 +588,6 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(&uuid)
@@ -656,10 +652,11 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
users_collections::manage
.eq(true)
.or(collections_groups::manage.eq(true)),
)
.count()
.first::<i64>(conn)
@@ -727,30 +724,53 @@ impl CollectionUser {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
let values = (
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
);
db_run! { conn:
mysql {
diesel::insert_into(users_collections::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
sqlite, mysql {
match diesel::replace_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(users_collections::table)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_collections::collection_uuid.eq(collection_uuid))
.set((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(users_collections::table)
.values(values)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
.do_update()
.set(values)
.set((
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
}
@@ -826,18 +846,10 @@ impl CollectionUser {
.await
}
// Only returns the collections of organizations the user is a confirmed member of
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_collections::table
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(collections::org_uuid)
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(users_collections::all_columns)
.load::<Self>(conn)
.expect("Error loading users_collections")
@@ -893,18 +905,19 @@ impl CollectionCipher {
Self::update_users_revision(collection_uuid, conn).await;
db_run! { conn:
mysql {
diesel::insert_into(ciphers_collections::table)
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
ciphers_collections::collection_uuid.eq(collection_uuid),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to collection")
}
postgresql, sqlite {
postgresql {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more