mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-06-13 05:49:12 +03:00
Compare commits
98 Commits
3cd3d33d00
...
1.35.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cf0c5d67e | |||
| b04ed75f9f | |||
| 0ed8ab68f7 | |||
| dfebee57ec | |||
| bfe420a018 | |||
| e7e4b9a86d | |||
| bb549986e6 | |||
| 39954af96a | |||
| a6b43651ca | |||
| 3f28b583db | |||
| d4f67429d6 | |||
| fc43737868 | |||
| 43df0fb7f4 | |||
| d29cd29f55 | |||
| 2811df2953 | |||
| 8f0e99b875 | |||
| f07a91141a | |||
| 787822854c | |||
| f62a7a66c8 | |||
| 3a1378f469 | |||
| dde63e209e | |||
| 235cf88231 | |||
| c0a78dd55a | |||
| 711bb53d3d | |||
| 650defac75 | |||
| 2b3736802d | |||
| 9c7df6412c | |||
| 065c1f2cd5 | |||
| 1a1d7f578a | |||
| 2b16a05e54 | |||
| c6e9948984 | |||
| ecdb18fcde | |||
| df25d316d6 | |||
| 747286dccd | |||
| e60105411b | |||
| 937857a0bc | |||
| ba55191676 | |||
| c555f7d198 | |||
| 74819b95bd | |||
| da2af3d362 | |||
| 1583fe4af3 | |||
| 36f0620fd1 | |||
| 3cd2d4afe7 | |||
| d09c45bb63 | |||
| feecfb20da | |||
| 347279a12c | |||
| 7f65a254b3 | |||
| cc80f689ed | |||
| 4737192853 | |||
| 0c6817cb4e | |||
| 25a71d913f | |||
| b2cd556f3e | |||
| 4352fffeec | |||
| 8d08697cf8 | |||
| 9f1df42259 | |||
| 1e1f9957cd | |||
| bf37657c08 | |||
| 3e2cef7e8b | |||
| 2af9d21158 | |||
| c4f6c4e63b | |||
| eb2a56aea1 | |||
| a4907f3539 | |||
| 8801b47d80 | |||
| 1ae9dc4119 | |||
| 02377eeac8 | |||
| d9c75508c2 | |||
| 0ab7784b06 | |||
| 5c91058ba0 | |||
| 229b58fe4e | |||
| 061d320c7f | |||
| 2c73c6c2f2 | |||
| b920caf285 | |||
| 57bdab1550 | |||
| b77c01b8bb | |||
| 9cca120fb3 | |||
| 4ad8baf7be | |||
| 8f689d8795 | |||
| 2d91a9460b | |||
| e81e6a5060 | |||
| 76d0856bbe | |||
| f0e79fd391 | |||
| 5981705375 | |||
| 07569a06da | |||
| cb2f5741ac | |||
| c9d527d84f | |||
| 7c7f4f5d4f | |||
| aad1f19b45 | |||
| 35e1a306f3 | |||
| 7f7b412220 | |||
| bb41f64c0a | |||
| 319d982113 | |||
| 95a0c667e4 | |||
| b519832086 | |||
| 2ee40d6105 | |||
| 0182567a62 | |||
| f9751a0a1d | |||
| 9017ca265a | |||
| 8d30285160 |
+24
-12
@@ -183,9 +183,9 @@
|
|||||||
## Defaults to every minute. Set blank to disable this job.
|
## Defaults to every minute. Set blank to disable this job.
|
||||||
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
||||||
#
|
#
|
||||||
## Cron schedule of the job that cleans sso nonce from incomplete flow
|
## Cron schedule of the job that cleans sso auth from incomplete flow
|
||||||
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
||||||
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *"
|
# PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *"
|
||||||
|
|
||||||
########################
|
########################
|
||||||
### General settings ###
|
### General settings ###
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
## Default: 2592000 (30 days)
|
## Default: 2592000 (30 days)
|
||||||
# ICON_CACHE_TTL=2592000
|
# ICON_CACHE_TTL=2592000
|
||||||
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
|
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
|
||||||
## Default: 2592000 (3 days)
|
## Default: 259200 (3 days)
|
||||||
# ICON_CACHE_NEGTTL=259200
|
# ICON_CACHE_NEGTTL=259200
|
||||||
|
|
||||||
## Icon download timeout
|
## Icon download timeout
|
||||||
@@ -372,15 +372,22 @@
|
|||||||
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension.
|
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||||
## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension.
|
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
||||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
|
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
|
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||||
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0)
|
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
||||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
|
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs 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 (Client >= 2025.2.0)
|
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
|
## - "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":
|
||||||
|
## - "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.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
## If sending the email fails the login attempt will fail!!
|
## If sending the email fails the login attempt will fail!!
|
||||||
@@ -471,6 +478,11 @@
|
|||||||
## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
||||||
# ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false
|
# ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false
|
||||||
|
|
||||||
|
## Prefer IPv6 (AAAA) resolving
|
||||||
|
## This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
|
||||||
|
## This could be useful in IPv6 only environments.
|
||||||
|
# DNS_PREFER_IPV6=false
|
||||||
|
|
||||||
#####################################
|
#####################################
|
||||||
### SSO settings (OpenID Connect) ###
|
### SSO settings (OpenID Connect) ###
|
||||||
#####################################
|
#####################################
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
# Ignore vendored scripts in GitHub stats
|
# Ignore vendored scripts in GitHub stats
|
||||||
src/static/scripts/* linguist-vendored
|
src/static/scripts/* linguist-vendored
|
||||||
|
|
||||||
|
|||||||
+48
-35
@@ -1,6 +1,10 @@
|
|||||||
name: Build
|
name: Build
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
@@ -14,6 +18,7 @@ on:
|
|||||||
- "diesel.toml"
|
- "diesel.toml"
|
||||||
- "docker/Dockerfile.j2"
|
- "docker/Dockerfile.j2"
|
||||||
- "docker/DockerSettings.yaml"
|
- "docker/DockerSettings.yaml"
|
||||||
|
- "macros/**"
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@@ -27,13 +32,15 @@ on:
|
|||||||
- "diesel.toml"
|
- "diesel.toml"
|
||||||
- "docker/Dockerfile.j2"
|
- "docker/Dockerfile.j2"
|
||||||
- "docker/DockerSettings.yaml"
|
- "docker/DockerSettings.yaml"
|
||||||
|
- "macros/**"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and Test ${{ matrix.channel }}
|
name: Build and Test ${{ matrix.channel }}
|
||||||
permissions:
|
|
||||||
actions: write
|
|
||||||
contents: read
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
# Make warnings errors, this is to prevent warnings slipping through.
|
# Make warnings errors, this is to prevent warnings slipping through.
|
||||||
@@ -55,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -64,7 +71,6 @@ jobs:
|
|||||||
# Determine rust-toolchain version
|
# Determine rust-toolchain version
|
||||||
- name: Init Variables
|
- name: Init Variables
|
||||||
id: toolchain
|
id: toolchain
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
CHANNEL: ${{ matrix.channel }}
|
CHANNEL: ${{ matrix.channel }}
|
||||||
run: |
|
run: |
|
||||||
@@ -79,32 +85,23 @@ jobs:
|
|||||||
# End Determine rust-toolchain version
|
# End Determine rust-toolchain version
|
||||||
|
|
||||||
|
|
||||||
# Only install the clippy and rustfmt components on the default rust-toolchain
|
- name: "Install toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||||
- name: "Install rust-toolchain version"
|
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master @ Aug 23, 2025, 3:20 AM GMT+2
|
|
||||||
if: ${{ matrix.channel == 'rust-toolchain' }}
|
|
||||||
with:
|
|
||||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
|
||||||
components: clippy, rustfmt
|
|
||||||
# End Uses the rust-toolchain file to determine version
|
|
||||||
|
|
||||||
|
|
||||||
# Install the any other channel to be used for which we do not execute clippy and rustfmt
|
|
||||||
- name: "Install MSRV version"
|
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master @ Aug 23, 2025, 3:20 AM GMT+2
|
|
||||||
if: ${{ matrix.channel != 'rust-toolchain' }}
|
|
||||||
with:
|
|
||||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
|
||||||
# End Install the MSRV channel to be used
|
|
||||||
|
|
||||||
# Set the current matrix toolchain version as default
|
|
||||||
- name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
|
||||||
env:
|
env:
|
||||||
|
CHANNEL: ${{ matrix.channel }}
|
||||||
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
||||||
run: |
|
run: |
|
||||||
# Remove the rust-toolchain.toml
|
# Remove the rust-toolchain.toml
|
||||||
rm rust-toolchain.toml
|
rm rust-toolchain.toml
|
||||||
# Set the default
|
|
||||||
|
# Install the correct toolchain version
|
||||||
|
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --no-self-update
|
||||||
|
|
||||||
|
# If this matrix is the `rust-toolchain` flow, also install rustfmt and clippy
|
||||||
|
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
|
||||||
|
rustup component add --toolchain "${RUST_TOOLCHAIN}" rustfmt clippy
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set as the default toolchain
|
||||||
rustup default "${RUST_TOOLCHAIN}"
|
rustup default "${RUST_TOOLCHAIN}"
|
||||||
|
|
||||||
# Show environment
|
# Show environment
|
||||||
@@ -116,48 +113,60 @@ jobs:
|
|||||||
|
|
||||||
# Enable Rust Caching
|
# Enable Rust Caching
|
||||||
- name: Rust Caching
|
- name: Rust Caching
|
||||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
# Only update when really needed! Use a <year>.<month>[.<inc>] format.
|
# Only update when really needed! Use a <year>.<month>[.<inc>] format.
|
||||||
prefix-key: "v2023.07-rust"
|
prefix-key: "v2025.09-rust"
|
||||||
# End Enable Rust Caching
|
# End Enable Rust Caching
|
||||||
|
|
||||||
# Run cargo tests
|
# Run cargo tests
|
||||||
# First test all features together, afterwards test them separately.
|
# First test all features together, afterwards test them separately.
|
||||||
|
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3"
|
||||||
|
id: test_sqlite_mysql_postgresql_mimalloc_s3
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
run: |
|
||||||
|
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
|
||||||
|
|
||||||
|
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
|
||||||
|
id: test_sqlite_mysql_postgresql_mimalloc
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
run: |
|
||||||
|
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc
|
||||||
|
|
||||||
- name: "test features: sqlite,mysql,postgresql"
|
- name: "test features: sqlite,mysql,postgresql"
|
||||||
id: test_sqlite_mysql_postgresql
|
id: test_sqlite_mysql_postgresql
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features sqlite,mysql,postgresql
|
cargo test --profile ci --features sqlite,mysql,postgresql
|
||||||
|
|
||||||
- name: "test features: sqlite"
|
- name: "test features: sqlite"
|
||||||
id: test_sqlite
|
id: test_sqlite
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features sqlite
|
cargo test --profile ci --features sqlite
|
||||||
|
|
||||||
- name: "test features: mysql"
|
- name: "test features: mysql"
|
||||||
id: test_mysql
|
id: test_mysql
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features mysql
|
cargo test --profile ci --features mysql
|
||||||
|
|
||||||
- name: "test features: postgresql"
|
- name: "test features: postgresql"
|
||||||
id: test_postgresql
|
id: test_postgresql
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features postgresql
|
cargo test --profile ci --features postgresql
|
||||||
# End Run cargo tests
|
# End Run cargo tests
|
||||||
|
|
||||||
|
|
||||||
# Run cargo clippy, and fail on warnings
|
# Run cargo clippy, and fail on warnings
|
||||||
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc"
|
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3"
|
||||||
id: clippy
|
id: clippy
|
||||||
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc
|
cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
|
||||||
# End Run cargo clippy
|
# End Run cargo clippy
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +184,8 @@ jobs:
|
|||||||
- name: "Some checks failed"
|
- name: "Some checks failed"
|
||||||
if: ${{ failure() }}
|
if: ${{ failure() }}
|
||||||
env:
|
env:
|
||||||
|
TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }}
|
||||||
|
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
|
||||||
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
|
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
|
||||||
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
||||||
TEST_MYSQL: ${{ steps.test_mysql.outcome }}
|
TEST_MYSQL: ${{ steps.test_mysql.outcome }}
|
||||||
@@ -186,11 +197,13 @@ jobs:
|
|||||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
name: Check templates
|
name: Check templates
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on: [ push, pull_request ]
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-templates:
|
docker-templates:
|
||||||
name: Validate docker templates
|
name: Validate docker templates
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
name: Hadolint
|
name: Hadolint
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on: [ push, pull_request ]
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
hadolint:
|
hadolint:
|
||||||
name: Validate Dockerfile syntax
|
name: Validate Dockerfile syntax
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -26,29 +32,26 @@ jobs:
|
|||||||
|
|
||||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
||||||
- name: Download hadolint
|
- name: Download hadolint
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
|
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
|
||||||
sudo chmod +x /usr/local/bin/hadolint
|
sudo chmod +x /usr/local/bin/hadolint
|
||||||
env:
|
env:
|
||||||
HADOLINT_VERSION: 2.13.1
|
HADOLINT_VERSION: 2.14.0
|
||||||
# End Download hadolint
|
# End Download hadolint
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|
||||||
# Test Dockerfiles with hadolint
|
# Test Dockerfiles with hadolint
|
||||||
- name: Run hadolint
|
- name: Run hadolint
|
||||||
shell: bash
|
|
||||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
run: hadolint docker/Dockerfile.{debian,alpine}
|
||||||
# End Test Dockerfiles with hadolint
|
# End Test Dockerfiles with hadolint
|
||||||
|
|
||||||
# Test Dockerfiles with docker build checks
|
# Test Dockerfiles with docker build checks
|
||||||
- name: Run docker build check
|
- name: Run docker build check
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
echo "Checking docker/Dockerfile.debian"
|
echo "Checking docker/Dockerfile.debian"
|
||||||
docker build --check . -f docker/Dockerfile.debian
|
docker build --check . -f docker/Dockerfile.debian
|
||||||
|
|||||||
+231
-148
@@ -1,6 +1,12 @@
|
|||||||
name: Release
|
name: Release
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Apply concurrency control only on the upstream repo
|
||||||
|
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
||||||
|
# Don't cancel other runs when creating a tag
|
||||||
|
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -10,56 +16,55 @@ on:
|
|||||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
||||||
- '[1-2].[0-9]+.[0-9]+'
|
- '[1-2].[0-9]+.[0-9]+'
|
||||||
|
|
||||||
concurrency:
|
defaults:
|
||||||
# Apply concurrency control only on the upstream repo
|
run:
|
||||||
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
shell: bash
|
||||||
# Don't cancel other runs when creating a tag
|
|
||||||
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
# A "release" environment must be created in the repository settings
|
||||||
|
# (Settings > Environments > New environment) with the following
|
||||||
|
# variables and secrets configured as needed.
|
||||||
|
#
|
||||||
|
# Variables (only set the ones for registries you want to push to):
|
||||||
|
# DOCKERHUB_REPO: 'index.docker.io/<user>/<repo>'
|
||||||
|
# QUAY_REPO: 'quay.io/<user>/<repo>'
|
||||||
|
# GHCR_REPO: 'ghcr.io/<user>/<repo>'
|
||||||
|
#
|
||||||
|
# Secrets (only required when the corresponding *_REPO variable is set):
|
||||||
|
# DOCKERHUB_REPO => DOCKERHUB_USERNAME, DOCKERHUB_TOKEN
|
||||||
|
# QUAY_REPO => QUAY_USERNAME, QUAY_TOKEN
|
||||||
|
# GITHUB_TOKEN is provided automatically
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Build Vaultwarden containers
|
name: Build Vaultwarden containers
|
||||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||||
|
environment:
|
||||||
|
name: release
|
||||||
|
deployment: false
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write # Needed to upload packages and artifacts
|
||||||
contents: read
|
contents: read
|
||||||
attestations: write
|
attestations: write # Needed to generate an artifact attestation for a build
|
||||||
id-token: write
|
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
|
|
||||||
services:
|
|
||||||
registry:
|
|
||||||
image: registry@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 # v3.0.0
|
|
||||||
ports:
|
|
||||||
- 5000:5000
|
|
||||||
env:
|
env:
|
||||||
SOURCE_COMMIT: ${{ github.sha }}
|
SOURCE_COMMIT: ${{ github.sha }}
|
||||||
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
|
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
|
||||||
# The *_REPO variables need to be configured as repository variables
|
|
||||||
# Append `/settings/variables/actions` to your repo url
|
|
||||||
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>'
|
|
||||||
# Check for Docker hub credentials in secrets
|
|
||||||
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
|
||||||
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>'
|
|
||||||
# Check for Github credentials in secrets
|
|
||||||
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }}
|
|
||||||
# QUAY_REPO needs to be 'quay.io/<user>/<repo>'
|
|
||||||
# Check for Quay.io credentials in secrets
|
|
||||||
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }}
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
arch: ["amd64", "arm64", "arm/v7", "arm/v6"]
|
||||||
base_image: ["debian","alpine"]
|
base_image: ["debian","alpine"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Initialize QEMU binfmt support
|
- name: Initialize QEMU binfmt support
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||||
with:
|
with:
|
||||||
platforms: "arm64,arm"
|
platforms: "arm64,arm"
|
||||||
|
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -72,25 +77,24 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
# We need fetch-depth of 0 so we also get all the tag metadata
|
# We need fetch-depth of 0 so we also get all the tag metadata
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Determine Base Tags and Source Version
|
# Normalize the architecture string for use in paths and cache keys
|
||||||
- name: Determine Base Tags and Source Version
|
- name: Normalize architecture string
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
MATRIX_ARCH: ${{ matrix.arch }}
|
||||||
run: |
|
run: |
|
||||||
# Check which main tag we are going to build determined by ref_type
|
# Replace slashes with nothing to create a safe string for paths/cache keys
|
||||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
NORMALIZED_ARCH="${MATRIX_ARCH//\/}"
|
||||||
echo "BASE_TAGS=latest,${GITHUB_REF#refs/*/}" | tee -a "${GITHUB_ENV}"
|
echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
|
||||||
echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# Determine Source Version
|
||||||
|
- name: Determine Source Version
|
||||||
|
run: |
|
||||||
# Get the Source Version for this release
|
# Get the Source Version for this release
|
||||||
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
||||||
if [[ -n "${GIT_EXACT_TAG}" ]]; then
|
if [[ -n "${GIT_EXACT_TAG}" ]]; then
|
||||||
@@ -99,19 +103,17 @@ jobs:
|
|||||||
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
|
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
|
||||||
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
|
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
|
||||||
fi
|
fi
|
||||||
# End Determine Base Tags
|
|
||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
|
|
||||||
- name: Add registry for DockerHub
|
- name: Add registry for DockerHub
|
||||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||||
run: |
|
run: |
|
||||||
@@ -119,16 +121,15 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
|
|
||||||
- name: Add registry for ghcr.io
|
- name: Add registry for ghcr.io
|
||||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||||
run: |
|
run: |
|
||||||
@@ -136,64 +137,74 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
password: ${{ secrets.QUAY_TOKEN }}
|
password: ${{ secrets.QUAY_TOKEN }}
|
||||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
|
|
||||||
- name: Add registry for Quay.io
|
- name: Add registry for Quay.io
|
||||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||||
run: |
|
run: |
|
||||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
- name: Configure build cache from/to
|
- name: Configure build cache from/to
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||||
BASE_IMAGE: ${{ matrix.base_image }}
|
BASE_IMAGE: ${{ matrix.base_image }}
|
||||||
|
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||||
run: |
|
run: |
|
||||||
#
|
#
|
||||||
# Check if there is a GitHub Container Registry Login and use it for caching
|
# Check if there is a GitHub Container Registry Login and use it for caching
|
||||||
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
|
if [[ -n "${GHCR_REPO}" ]]; then
|
||||||
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}" | tee -a "${GITHUB_ENV}"
|
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||||
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||||
else
|
else
|
||||||
echo "BAKE_CACHE_FROM="
|
echo "BAKE_CACHE_FROM="
|
||||||
echo "BAKE_CACHE_TO="
|
echo "BAKE_CACHE_TO="
|
||||||
fi
|
fi
|
||||||
#
|
#
|
||||||
|
|
||||||
- name: Add localhost registry
|
- name: Generate tags
|
||||||
shell: bash
|
id: tags
|
||||||
|
env:
|
||||||
|
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||||
run: |
|
run: |
|
||||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}"
|
# Convert comma-separated list to newline-separated set commands
|
||||||
|
TAGS=$(echo "${CONTAINER_REGISTRIES}" | tr ',' '\n' | sed "s|.*|*.tags=&|")
|
||||||
|
|
||||||
|
# Output for use in next step
|
||||||
|
{
|
||||||
|
echo "TAGS<<EOF"
|
||||||
|
echo "$TAGS"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@3acf805d94d93a86cce4ca44798a76464a75b88c # v6.9.0
|
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
|
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
|
||||||
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
|
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
|
||||||
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
|
||||||
with:
|
with:
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
|
||||||
source: .
|
source: .
|
||||||
files: docker/docker-bake.hcl
|
files: docker/docker-bake.hcl
|
||||||
targets: "${{ matrix.base_image }}-multi"
|
targets: "${{ matrix.base_image }}-multi"
|
||||||
set: |
|
set: |
|
||||||
*.cache-from=${{ env.BAKE_CACHE_FROM }}
|
*.cache-from=${{ env.BAKE_CACHE_FROM }}
|
||||||
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
||||||
|
*.platform=linux/${{ matrix.arch }}
|
||||||
|
${{ env.TAGS }}
|
||||||
|
*.output=type=local,dest=./output
|
||||||
|
*.output=type=image,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
- name: Extract digest SHA
|
- name: Extract digest SHA
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
|
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
|
||||||
BASE_IMAGE: ${{ matrix.base_image }}
|
BASE_IMAGE: ${{ matrix.base_image }}
|
||||||
@@ -201,105 +212,177 @@ jobs:
|
|||||||
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
|
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
|
||||||
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
env:
|
||||||
|
DIGEST_SHA: ${{ env.DIGEST_SHA }}
|
||||||
|
RUNNER_TEMP: ${{ runner.temp }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "${RUNNER_TEMP}"/digests
|
||||||
|
digest="${DIGEST_SHA}"
|
||||||
|
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Rename binaries to match target platform
|
||||||
|
env:
|
||||||
|
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||||
|
run: |
|
||||||
|
mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}"
|
||||||
|
|
||||||
|
# Upload artifacts to Github Actions and Attest the binaries
|
||||||
|
- name: Attest binaries
|
||||||
|
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||||
|
with:
|
||||||
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
|
- name: Upload binaries as artifacts
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
merge-manifests:
|
||||||
|
name: Merge manifests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: docker-build
|
||||||
|
environment:
|
||||||
|
name: release
|
||||||
|
deployment: false
|
||||||
|
permissions:
|
||||||
|
packages: write # Needed to upload packages and artifacts
|
||||||
|
attestations: write # Needed to generate an artifact attestation for a build
|
||||||
|
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
base_image: ["debian","alpine"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*-${{ matrix.base_image }}
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
# Login to Docker Hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
|
|
||||||
|
- name: Add registry for DockerHub
|
||||||
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
|
env:
|
||||||
|
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||||
|
run: |
|
||||||
|
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# Login to GitHub Container Registry
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
|
|
||||||
|
- name: Add registry for ghcr.io
|
||||||
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
|
env:
|
||||||
|
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||||
|
run: |
|
||||||
|
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# Login to Quay.io
|
||||||
|
- name: Login to Quay.io
|
||||||
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
|
with:
|
||||||
|
registry: quay.io
|
||||||
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
password: ${{ secrets.QUAY_TOKEN }}
|
||||||
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
|
|
||||||
|
- name: Add registry for Quay.io
|
||||||
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
|
env:
|
||||||
|
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||||
|
run: |
|
||||||
|
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# Determine Base Tags
|
||||||
|
- name: Determine Base Tags
|
||||||
|
env:
|
||||||
|
BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}"
|
||||||
|
REF_TYPE: ${{ github.ref_type }}
|
||||||
|
run: |
|
||||||
|
# Check which main tag we are going to build determined by ref_type
|
||||||
|
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||||
|
echo "BASE_TAGS=latest${BASE_IMAGE_TAG},${GITHUB_REF#refs/*/}${BASE_IMAGE_TAG}${BASE_IMAGE_TAG//-/,}" | tee -a "${GITHUB_ENV}"
|
||||||
|
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||||
|
echo "BASE_TAGS=testing${BASE_IMAGE_TAG}" | tee -a "${GITHUB_ENV}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create manifest list, push it and extract digest SHA
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
env:
|
||||||
|
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||||
|
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||||
|
run: |
|
||||||
|
IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}"
|
||||||
|
IFS=',' read -ra TAGS <<< "${BASE_TAGS}"
|
||||||
|
|
||||||
|
TAG_ARGS=()
|
||||||
|
for img in "${IMAGES[@]}"; do
|
||||||
|
for tag in "${TAGS[@]}"; do
|
||||||
|
TAG_ARGS+=("-t" "${img}:${tag}")
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Creating manifest"
|
||||||
|
if ! OUTPUT=$(docker buildx imagetools create \
|
||||||
|
"${TAG_ARGS[@]}" \
|
||||||
|
$(printf "${IMAGES[0]}@sha256:%s " *) 2>&1); then
|
||||||
|
echo "Manifest creation failed"
|
||||||
|
echo "${OUTPUT}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Manifest created successfully"
|
||||||
|
echo "${OUTPUT}"
|
||||||
|
|
||||||
|
# Extract digest SHA for subsequent steps
|
||||||
|
GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||||
|
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
# Attest container images
|
# Attest container images
|
||||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.GHCR_REPO }}
|
subject-name: ${{ vars.GHCR_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.QUAY_REPO }}
|
subject-name: ${{ vars.QUAY_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
|
|
||||||
# Extract the Alpine binaries from the containers
|
|
||||||
- name: Extract binaries
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
|
||||||
BASE_IMAGE: ${{ matrix.base_image }}
|
|
||||||
run: |
|
|
||||||
# Check which main tag we are going to build determined by ref_type
|
|
||||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
|
||||||
EXTRACT_TAG="latest"
|
|
||||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
|
||||||
EXTRACT_TAG="testing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check which base_image was used and append -alpine if needed
|
|
||||||
if [[ "${BASE_IMAGE}" == "alpine" ]]; then
|
|
||||||
EXTRACT_TAG="${EXTRACT_TAG}-alpine"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# After each extraction the image is removed.
|
|
||||||
# This is needed because using different platforms doesn't trigger a new pull/download
|
|
||||||
|
|
||||||
# Extract amd64 binary
|
|
||||||
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp amd64:/vaultwarden vaultwarden-amd64-${BASE_IMAGE}
|
|
||||||
docker rm --force amd64
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Extract arm64 binary
|
|
||||||
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp arm64:/vaultwarden vaultwarden-arm64-${BASE_IMAGE}
|
|
||||||
docker rm --force arm64
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Extract armv7 binary
|
|
||||||
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp armv7:/vaultwarden vaultwarden-armv7-${BASE_IMAGE}
|
|
||||||
docker rm --force armv7
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Extract armv6 binary
|
|
||||||
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp armv6:/vaultwarden vaultwarden-armv6-${BASE_IMAGE}
|
|
||||||
docker rm --force armv6
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Upload artifacts to Github Actions and Attest the binaries
|
|
||||||
- name: "Upload amd64 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-amd64-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Upload arm64 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-arm64-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Upload armv7 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-armv7-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Upload armv6 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-armv6-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Attest artifacts ${{ matrix.base_image }}"
|
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
|
||||||
with:
|
|
||||||
subject-path: vaultwarden-*
|
|
||||||
# End Upload artifacts to Github Actions
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
name: Cleanup
|
name: Cleanup
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -16,7 +20,7 @@ jobs:
|
|||||||
releasecache-cleanup:
|
releasecache-cleanup:
|
||||||
name: Releasecache Cleanup
|
name: Releasecache Cleanup
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write # To be able to cleanup old caches
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
name: Trivy
|
name: Trivy
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -23,20 +27,18 @@ jobs:
|
|||||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||||
name: Trivy Scan
|
name: Trivy Scan
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
security-events: write # To write the security report
|
||||||
actions: read
|
|
||||||
security-events: write
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
- name: Run Trivy vulnerability scanner
|
||||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
env:
|
env:
|
||||||
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
||||||
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
||||||
@@ -48,6 +50,6 @@ jobs:
|
|||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Code Spell Checking
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typos:
|
||||||
|
name: Run typos spell checking
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Checkout the repo
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
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@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
name: Security Analysis with zizmor
|
name: Security Analysis with zizmor
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,22 +11,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
zizmor:
|
zizmor:
|
||||||
name: Run zizmor
|
name: Run zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write # To write the security report
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0
|
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
+56
-48
@@ -1,52 +1,60 @@
|
|||||||
---
|
---
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
args: ["--fix=no"]
|
args: [ "--fix=no" ]
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
exclude: "(.*js$|.*css$)"
|
exclude: "(.*js$|.*css$)"
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- id: check-symlinks
|
- id: check-symlinks
|
||||||
- id: forbid-submodules
|
- id: forbid-submodules
|
||||||
- repo: local
|
|
||||||
|
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||||
|
- repo: https://github.com/crate-ci/typos
|
||||||
|
rev: cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: fmt
|
- id: typos
|
||||||
name: fmt
|
|
||||||
description: Format files with cargo fmt.
|
- repo: local
|
||||||
entry: cargo fmt
|
hooks:
|
||||||
language: system
|
- id: fmt
|
||||||
types: [rust]
|
name: fmt
|
||||||
args: ["--", "--check"]
|
description: Format files with cargo fmt.
|
||||||
- id: cargo-test
|
entry: cargo fmt
|
||||||
name: cargo test
|
language: system
|
||||||
description: Test the package for errors.
|
always_run: true
|
||||||
entry: cargo test
|
pass_filenames: false
|
||||||
language: system
|
args: [ "--", "--check" ]
|
||||||
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--"]
|
- id: cargo-test
|
||||||
types_or: [rust, file]
|
name: cargo test
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
description: Test the package for errors.
|
||||||
pass_filenames: false
|
entry: cargo test
|
||||||
- id: cargo-clippy
|
language: system
|
||||||
name: cargo clippy
|
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||||
description: Lint Rust sources
|
types_or: [ rust, file ]
|
||||||
entry: cargo clippy
|
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||||
language: system
|
pass_filenames: false
|
||||||
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--", "-D", "warnings"]
|
- id: cargo-clippy
|
||||||
types_or: [rust, file]
|
name: cargo clippy
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
description: Lint Rust sources
|
||||||
pass_filenames: false
|
entry: cargo clippy
|
||||||
- id: check-docker-templates
|
language: system
|
||||||
name: check docker templates
|
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||||
description: Check if the Docker templates are updated
|
types_or: [ rust, file ]
|
||||||
language: system
|
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||||
entry: sh
|
pass_filenames: false
|
||||||
args:
|
- id: check-docker-templates
|
||||||
- "-c"
|
name: check docker templates
|
||||||
- "cd docker && make"
|
description: Check if the Docker templates are updated
|
||||||
|
language: system
|
||||||
|
entry: sh
|
||||||
|
args:
|
||||||
|
- "-c"
|
||||||
|
- "cd docker && make"
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
[files]
|
||||||
|
extend-exclude = [
|
||||||
|
".git/",
|
||||||
|
"playwright/",
|
||||||
|
"*.js", # Ignore all JavaScript files
|
||||||
|
"!admin*.js", # Except our own JavaScript files
|
||||||
|
]
|
||||||
|
ignore-hidden = false
|
||||||
|
|
||||||
|
[default]
|
||||||
|
extend-ignore-re = [
|
||||||
|
# We use this in place of the reserved type identifier at some places
|
||||||
|
"typ",
|
||||||
|
# In SMTP it's called HELO, so ignore it
|
||||||
|
"(?i)helo_name",
|
||||||
|
"Server name sent during.+HELO",
|
||||||
|
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
||||||
|
"COSEKey",
|
||||||
|
"COSEAlgorithm",
|
||||||
|
# Ignore this specific string as it's valid
|
||||||
|
"Ensure they are valid OTPs",
|
||||||
|
# This word is misspelled upstream
|
||||||
|
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
|
||||||
|
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
|
||||||
|
"AuthRequestResponseRecieved",
|
||||||
|
]
|
||||||
Generated
+1210
-939
File diff suppressed because it is too large
Load Diff
+98
-70
@@ -1,3 +1,10 @@
|
|||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.93.0"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||||
|
publish = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["macros"]
|
members = ["macros"]
|
||||||
|
|
||||||
@@ -5,15 +12,14 @@ members = ["macros"]
|
|||||||
name = "vaultwarden"
|
name = "vaultwarden"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.87.0"
|
|
||||||
resolver = "2"
|
|
||||||
|
|
||||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "AGPL-3.0-only"
|
|
||||||
publish = false
|
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
resolver = "2"
|
||||||
|
repository.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = [
|
default = [
|
||||||
@@ -49,71 +55,69 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.28"
|
log = "0.4.29"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
tracing = { version = "0.1.41", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
||||||
|
|
||||||
# A `dotenv` implementation for Rust
|
# A `dotenv` implementation for Rust
|
||||||
dotenvy = { version = "0.15.7", default-features = false }
|
dotenvy = { version = "0.15.7", default-features = false }
|
||||||
|
|
||||||
# Lazy initialization
|
|
||||||
once_cell = "1.21.3"
|
|
||||||
|
|
||||||
# Numerical libraries
|
# Numerical libraries
|
||||||
num-traits = "0.2.19"
|
num-traits = "0.2.19"
|
||||||
num-derive = "0.4.2"
|
num-derive = "0.4.2"
|
||||||
bigdecimal = "0.4.9"
|
bigdecimal = "0.4.10"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
||||||
rocket_ws = { version ="0.1.1" }
|
rocket_ws = { version ="0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
rmpv = "1.3.0" # MessagePack library
|
rmpv = "1.3.1" # MessagePack library
|
||||||
|
|
||||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.31"
|
futures = "0.3.32"
|
||||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||||
tokio-util = { version = "0.7.16", features = ["compat"]}
|
tokio-util = { version = "0.7.18", features = ["compat"]}
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.149"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.3.3", features = ["chrono", "r2d2", "numeric"] }
|
# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility
|
||||||
diesel_migrations = "2.3.0"
|
diesel = { version = "2.3.7", features = ["chrono", "r2d2", "numeric"] }
|
||||||
|
diesel_migrations = "2.3.1"
|
||||||
|
|
||||||
derive_more = { version = "2.0.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
||||||
diesel-derive-newtype = "2.1.2"
|
diesel-derive-newtype = "2.1.2"
|
||||||
|
|
||||||
# Bundled/Static SQLite
|
# Bundled/Static SQLite
|
||||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true }
|
libsqlite3-sys = { version = "0.36.0", features = ["bundled"], optional = true }
|
||||||
|
|
||||||
# Crypto-related libraries
|
# Crypto-related libraries
|
||||||
rand = "0.9.2"
|
rand = "0.10.1"
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.18.1", features = ["v4"] }
|
uuid = { version = "1.23.1", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.42", features = ["clock", "serde"], default-features = false }
|
chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
|
||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
time = "0.3.44"
|
time = "0.3.47"
|
||||||
|
|
||||||
# Job scheduler
|
# Job scheduler
|
||||||
job_scheduler_ng = "2.4.0"
|
job_scheduler_ng = "2.4.0"
|
||||||
|
|
||||||
# Data encoding library Hex/Base32/Base64
|
# Data encoding library Hex/Base32/Base64
|
||||||
data-encoding = "2.9.0"
|
data-encoding = "2.10.0"
|
||||||
|
|
||||||
# JWT library
|
# JWT library
|
||||||
jsonwebtoken = "9.3.1"
|
jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false }
|
||||||
|
|
||||||
# TOTP library
|
# TOTP library
|
||||||
totp-lite = "2.0.1"
|
totp-lite = "2.0.1"
|
||||||
@@ -124,61 +128,61 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"
|
|||||||
# WebAuthn libraries
|
# WebAuthn libraries
|
||||||
# danger-allow-state-serialisation is needed to save the state in the db
|
# danger-allow-state-serialisation is needed to save the state in the db
|
||||||
# danger-credential-internals is needed to support U2F to Webauthn migration
|
# danger-credential-internals is needed to support U2F to Webauthn migration
|
||||||
webauthn-rs = { version = "0.5.3", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
webauthn-rs-proto = "0.5.3"
|
webauthn-rs-proto = "0.5.4"
|
||||||
webauthn-rs-core = "0.5.3"
|
webauthn-rs-core = "0.5.4"
|
||||||
|
|
||||||
# Handling of URL's for WebAuthn and favicons
|
# Handling of URL's for WebAuthn and favicons
|
||||||
url = "2.5.7"
|
url = "2.5.8"
|
||||||
|
|
||||||
# Email libraries
|
# Email libraries
|
||||||
lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
||||||
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
||||||
email_address = "0.2.9"
|
email_address = "0.2.9"
|
||||||
|
|
||||||
# HTML Template library
|
# HTML Template library
|
||||||
handlebars = { version = "6.3.2", features = ["dir_source"] }
|
handlebars = { version = "6.4.0", features = ["dir_source"] }
|
||||||
|
|
||||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||||
reqwest = { version = "0.12.24", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.26.0"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.8.0"
|
html5gum = "0.8.3"
|
||||||
regex = { version = "1.12.2", features = ["std", "perf", "unicode-perl"], default-features = false }
|
regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false }
|
||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
bytes = "1.10.1"
|
bytes = "1.11.1"
|
||||||
svg-hush = "0.9.5"
|
svg-hush = "0.9.6"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "0.56.0", features = ["async"] }
|
cached = { version = "0.59.0", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.1"
|
||||||
cookie_store = "0.22.0"
|
cookie_store = "0.22.1"
|
||||||
|
|
||||||
# Used by U2F, JWT and PostgreSQL
|
# Used by U2F, JWT and PostgreSQL
|
||||||
openssl = "0.10.74"
|
openssl = "0.10.78"
|
||||||
|
|
||||||
# CLI argument parsing
|
# CLI argument parsing
|
||||||
pico-args = "0.5.0"
|
pico-args = "0.5.0"
|
||||||
|
|
||||||
# Macro ident concatenation
|
# Macro ident concatenation
|
||||||
pastey = "0.1.1"
|
pastey = "0.2.1"
|
||||||
governor = "0.10.1"
|
governor = "0.10.4"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] }
|
openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] }
|
||||||
mini-moka = "0.10.3"
|
moka = { version = "0.12.15", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
semver = "1.0.27"
|
semver = "1.0.28"
|
||||||
|
|
||||||
# Allow overriding the default memory allocator
|
# Allow overriding the default memory allocator
|
||||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||||
mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true }
|
mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true }
|
||||||
|
|
||||||
which = "8.0.0"
|
which = "8.0.2"
|
||||||
|
|
||||||
# Argon2 library with support for the PHC format
|
# Argon2 library with support for the PHC format
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
@@ -190,14 +194,14 @@ rpassword = "7.4.0"
|
|||||||
grass_compiler = { version = "0.13.4", default-features = false }
|
grass_compiler = { version = "0.13.4", default-features = false }
|
||||||
|
|
||||||
# File are accessed through Apache OpenDAL
|
# File are accessed through Apache OpenDAL
|
||||||
opendal = { version = "0.54.1", features = ["services-fs"], default-features = false }
|
opendal = { version = "0.55.0", features = ["services-fs"], default-features = false }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
anyhow = { version = "1.0.100", optional = true }
|
anyhow = { version = "1.0.102", optional = true }
|
||||||
aws-config = { version = "1.8.8", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||||
aws-credential-types = { version = "1.2.8", optional = true }
|
aws-credential-types = { version = "1.2.14", optional = true }
|
||||||
aws-smithy-runtime-api = { version = "1.9.1", optional = true }
|
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
||||||
http = { version = "1.3.1", optional = true }
|
http = { version = "1.4.0", optional = true }
|
||||||
reqsign = { version = "0.16.5", optional = true }
|
reqsign = { version = "0.16.5", optional = true }
|
||||||
|
|
||||||
# Strip debuginfo from the release builds
|
# Strip debuginfo from the release builds
|
||||||
@@ -207,23 +211,13 @@ reqsign = { version = "0.16.5", optional = true }
|
|||||||
strip = "debuginfo"
|
strip = "debuginfo"
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
debug = false
|
||||||
# A little bit of a speedup
|
|
||||||
[profile.dev]
|
|
||||||
split-debuginfo = "unpacked"
|
|
||||||
|
|
||||||
# Always build argon2 using opt-level 3
|
|
||||||
# This is a huge speed improvement during testing
|
|
||||||
[profile.dev.package.argon2]
|
|
||||||
opt-level = 3
|
|
||||||
|
|
||||||
# Optimize for size
|
# Optimize for size
|
||||||
[profile.release-micro]
|
[profile.release-micro]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
opt-level = "z"
|
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
lto = "fat"
|
opt-level = "z"
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
# Profile for systems with low resources
|
# Profile for systems with low resources
|
||||||
@@ -234,6 +228,32 @@ strip = "symbols"
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
|
|
||||||
|
# Used for profiling and debugging like valgrind or heaptrack
|
||||||
|
# Inherits release to be sure all optimizations have been done
|
||||||
|
[profile.dbg]
|
||||||
|
inherits = "release"
|
||||||
|
strip = "none"
|
||||||
|
split-debuginfo = "off"
|
||||||
|
debug = "full"
|
||||||
|
|
||||||
|
# A little bit of a speedup for generic building
|
||||||
|
[profile.dev]
|
||||||
|
split-debuginfo = "unpacked"
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# Used for CI builds to improve compile time
|
||||||
|
[profile.ci]
|
||||||
|
inherits = "dev"
|
||||||
|
debug = false
|
||||||
|
debug-assertions = false
|
||||||
|
strip = "symbols"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# Always build argon2 using opt-level 3
|
||||||
|
# This is a huge speed improvement during testing
|
||||||
|
[profile.dev.package.argon2]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
# Linting config
|
# Linting config
|
||||||
# https://doc.rust-lang.org/rustc/lints/groups.html
|
# https://doc.rust-lang.org/rustc/lints/groups.html
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
@@ -243,15 +263,16 @@ non_ascii_idents = "forbid"
|
|||||||
|
|
||||||
# Deny
|
# Deny
|
||||||
deprecated_in_future = "deny"
|
deprecated_in_future = "deny"
|
||||||
|
deprecated_safe = { level = "deny", priority = -1 }
|
||||||
future_incompatible = { level = "deny", priority = -1 }
|
future_incompatible = { level = "deny", priority = -1 }
|
||||||
keyword_idents = { level = "deny", priority = -1 }
|
keyword_idents = { level = "deny", priority = -1 }
|
||||||
let_underscore = { level = "deny", priority = -1 }
|
let_underscore = { level = "deny", priority = -1 }
|
||||||
|
nonstandard_style = { level = "deny", priority = -1 }
|
||||||
noop_method_call = "deny"
|
noop_method_call = "deny"
|
||||||
refining_impl_trait = { level = "deny", priority = -1 }
|
refining_impl_trait = { level = "deny", priority = -1 }
|
||||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||||
rust_2021_compatibility = { level = "deny", priority = -1 }
|
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||||
rust_2024_compatibility = { level = "deny", priority = -1 }
|
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||||
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
|
||||||
single_use_lifetimes = "deny"
|
single_use_lifetimes = "deny"
|
||||||
trivial_casts = "deny"
|
trivial_casts = "deny"
|
||||||
trivial_numeric_casts = "deny"
|
trivial_numeric_casts = "deny"
|
||||||
@@ -261,7 +282,8 @@ unused_lifetimes = "deny"
|
|||||||
unused_qualifications = "deny"
|
unused_qualifications = "deny"
|
||||||
variant_size_differences = "deny"
|
variant_size_differences = "deny"
|
||||||
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
||||||
# Building Vaultwarden with Rust v1.85.0 and edition 2024 also works without issues
|
# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues
|
||||||
|
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
||||||
if_let_rescope = "allow"
|
if_let_rescope = "allow"
|
||||||
tail_expr_drop_order = "allow"
|
tail_expr_drop_order = "allow"
|
||||||
|
|
||||||
@@ -275,10 +297,12 @@ todo = "warn"
|
|||||||
result_large_err = "allow"
|
result_large_err = "allow"
|
||||||
|
|
||||||
# Deny
|
# Deny
|
||||||
|
branches_sharing_code = "deny"
|
||||||
case_sensitive_file_extension_comparisons = "deny"
|
case_sensitive_file_extension_comparisons = "deny"
|
||||||
cast_lossless = "deny"
|
cast_lossless = "deny"
|
||||||
clone_on_ref_ptr = "deny"
|
clone_on_ref_ptr = "deny"
|
||||||
equatable_if_let = "deny"
|
equatable_if_let = "deny"
|
||||||
|
excessive_precision = "deny"
|
||||||
filter_map_next = "deny"
|
filter_map_next = "deny"
|
||||||
float_cmp_const = "deny"
|
float_cmp_const = "deny"
|
||||||
implicit_clone = "deny"
|
implicit_clone = "deny"
|
||||||
@@ -292,15 +316,19 @@ manual_instant_elapsed = "deny"
|
|||||||
manual_string_new = "deny"
|
manual_string_new = "deny"
|
||||||
match_wildcard_for_single_variants = "deny"
|
match_wildcard_for_single_variants = "deny"
|
||||||
mem_forget = "deny"
|
mem_forget = "deny"
|
||||||
|
needless_borrow = "deny"
|
||||||
|
needless_collect = "deny"
|
||||||
needless_continue = "deny"
|
needless_continue = "deny"
|
||||||
needless_lifetimes = "deny"
|
needless_lifetimes = "deny"
|
||||||
option_option = "deny"
|
option_option = "deny"
|
||||||
|
redundant_clone = "deny"
|
||||||
string_add_assign = "deny"
|
string_add_assign = "deny"
|
||||||
unnecessary_join = "deny"
|
unnecessary_join = "deny"
|
||||||
unnecessary_self_imports = "deny"
|
unnecessary_self_imports = "deny"
|
||||||
unnested_or_patterns = "deny"
|
unnested_or_patterns = "deny"
|
||||||
unused_async = "deny"
|
unused_async = "deny"
|
||||||
unused_self = "deny"
|
unused_self = "deny"
|
||||||
|
useless_let_if_seq = "deny"
|
||||||
verbose_file_reads = "deny"
|
verbose_file_reads = "deny"
|
||||||
zero_sized_map_values = "deny"
|
zero_sized_map_values = "deny"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
# see diesel.rs/guides/configuring-diesel-cli
|
# see diesel.rs/guides/configuring-diesel-cli
|
||||||
|
|
||||||
[print_schema]
|
[print_schema]
|
||||||
file = "src/db/schema.rs"
|
file = "src/db/schema.rs"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2025.9.1"
|
vault_version: "v2026.3.1"
|
||||||
vault_image_digest: "sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4"
|
vault_image_digest: "sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767"
|
||||||
# Cross Compile Docker Helper Scripts v1.6.1
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
rust_version: 1.89.0 # Rust version to be used
|
rust_version: 1.95.0 # Rust version to be used
|
||||||
debian_version: trixie # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
alpine_version: "3.22" # Alpine version to be used
|
alpine_version: "3.23" # Alpine version to be used
|
||||||
# For which platforms/architectures will we try to build images
|
# For which platforms/architectures will we try to build images
|
||||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||||
# Determine the build images per OS/Arch
|
# Determine the build images per OS/Arch
|
||||||
@@ -17,7 +17,6 @@ build_stage_image:
|
|||||||
platform: "$BUILDPLATFORM"
|
platform: "$BUILDPLATFORM"
|
||||||
alpine:
|
alpine:
|
||||||
image: "build_${TARGETARCH}${TARGETVARIANT}"
|
image: "build_${TARGETARCH}${TARGETVARIANT}"
|
||||||
platform: "linux/amd64" # The Alpine build images only have linux/amd64 images
|
|
||||||
arch_image:
|
arch_image:
|
||||||
amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}"
|
amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}"
|
||||||
arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}"
|
arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}"
|
||||||
|
|||||||
+13
-13
@@ -19,27 +19,27 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2025.9.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.9.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4]
|
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767
|
||||||
# [docker.io/vaultwarden/web-vault:v2025.9.1]
|
# [docker.io/vaultwarden/web-vault:v2026.3.1]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.89.0 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.89.0 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.89.0 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.89.0 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} AS build
|
FROM --platform=$BUILDPLATFORM build_${TARGETARCH}${TARGETVARIANT} AS build
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
@@ -127,7 +127,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.22
|
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
ROCKET_ADDRESS=0.0.0.0 \
|
ROCKET_ADDRESS=0.0.0.0 \
|
||||||
|
|||||||
@@ -19,24 +19,24 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2025.9.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.9.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4]
|
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767
|
||||||
# [docker.io/vaultwarden/web-vault:v2025.9.1]
|
# [docker.io/vaultwarden/web-vault:v2026.3.1]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:15a126ca967cd2efc4c9625fec49f0b972a3f7d7d81d7770bb0a2502d5e4b8a4 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
## And these bash scripts do not have any significant difference if at all
|
## And these bash scripts do not have any significant difference if at all
|
||||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894 AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.89.0-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
@@ -51,7 +51,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
TERM=xterm-256color \
|
TERM=xterm-256color \
|
||||||
CARGO_HOME="/root/.cargo" \
|
CARGO_HOME="/root/.cargo" \
|
||||||
USER="root"
|
USER="root"
|
||||||
|
|
||||||
# Install clang to get `xx-cargo` working
|
# Install clang to get `xx-cargo` working
|
||||||
# Install pkg-config to allow amd64 builds to find all libraries
|
# Install pkg-config to allow amd64 builds to find all libraries
|
||||||
# Install git so build.rs can determine the correct version
|
# Install git so build.rs can determine the correct version
|
||||||
@@ -175,7 +174,7 @@ RUN mkdir /data && \
|
|||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
libmariadb-dev \
|
libmariadb3 \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
openssl && \
|
openssl && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version }}
|
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||||
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version }}
|
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||||
# [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}]
|
# [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||||
# [docker.io/vaultwarden/web-vault:{{ vault_version }}]
|
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||||
|
|
||||||
@@ -36,16 +36,16 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_diges
|
|||||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
|
||||||
{% elif base == "alpine" %}
|
{% elif base == "alpine" %}
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
{% for arch in build_stage_image[base].arch_image %}
|
{% for arch in build_stage_image[base].arch_image %}
|
||||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} AS build
|
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -69,7 +69,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
|
|
||||||
# Install clang to get `xx-cargo` working
|
# Install clang to get `xx-cargo` working
|
||||||
# Install pkg-config to allow amd64 builds to find all libraries
|
# Install pkg-config to allow amd64 builds to find all libraries
|
||||||
# Install git so build.rs can determine the correct version
|
# Install git so build.rs can determine the correct version
|
||||||
@@ -212,7 +211,7 @@ RUN mkdir /data && \
|
|||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
libmariadb-dev \
|
libmariadb3 \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
openssl && \
|
openssl && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
|
|||||||
+2
-2
@@ -116,7 +116,7 @@ docker/bake.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.<br>
|
You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.<br>
|
||||||
This will also append those values to the tag so you can see the builded container when running `docker images`.
|
This will also append those values to the tag so you can see the built container when running `docker images`.
|
||||||
|
|
||||||
You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use.
|
You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use.
|
||||||
```bash
|
```bash
|
||||||
@@ -162,7 +162,7 @@ You can append extra arguments after the target if you want. This can be useful
|
|||||||
|
|
||||||
For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.<br>
|
For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.<br>
|
||||||
|
|
||||||
### Testing podman builded images
|
### Testing podman built images
|
||||||
|
|
||||||
The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that.
|
The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that.
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "macros"
|
name = "macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
repository.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "macros"
|
name = "macros"
|
||||||
@@ -9,8 +13,8 @@ path = "src/lib.rs"
|
|||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.41"
|
quote = "1.0.45"
|
||||||
syn = "2.0.108"
|
syn = "2.0.117"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,2 +1,15 @@
|
|||||||
ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`;
|
-- 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;
|
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_auth;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_auth (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
client_challenge TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
code_response TEXT,
|
||||||
|
auth_response TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_auth;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_auth (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
client_challenge TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
code_response TEXT,
|
||||||
|
auth_response TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_auth;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_auth (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
client_challenge TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
code_response TEXT,
|
||||||
|
auth_response TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -97,9 +97,9 @@ npx playwright codegen "http://127.0.0.1:8003"
|
|||||||
|
|
||||||
## Override web-vault
|
## Override web-vault
|
||||||
|
|
||||||
It is possible to change the `web-vault` used by referencing a different `bw_web_builds` commit.
|
It is possible to change the `web-vault` used by referencing a different `vw_web_builds` commit.
|
||||||
|
|
||||||
Simplest is to set and uncomment `PW_WV_REPO_URL` and `PW_WV_COMMIT_HASH` in the `test.env`.
|
Simplest is to set and uncomment `PW_VW_REPO_URL` and `PW_VW_COMMIT_HASH` in the `test.env`.
|
||||||
Ensure that the image is built with:
|
Ensure that the image is built with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -112,6 +112,8 @@ You can check the result running:
|
|||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
||||||
|
|
||||||
# OpenID Connect test setup
|
# OpenID Connect test setup
|
||||||
|
|
||||||
Additionally this `docker-compose` template allows to run locally Vaultwarden,
|
Additionally this `docker-compose` template allows to run locally Vaultwarden,
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ echo $COMMIT_HASH
|
|||||||
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
||||||
rm -rf /web-vault
|
rm -rf /web-vault
|
||||||
|
|
||||||
mkdir bw_web_builds;
|
mkdir -p vw_web_builds;
|
||||||
cd bw_web_builds;
|
cd vw_web_builds;
|
||||||
|
|
||||||
git -c init.defaultBranch=main init
|
git -c init.defaultBranch=main init
|
||||||
git remote add origin "$REPO_URL"
|
git remote add origin "$REPO_URL"
|
||||||
git fetch --depth 1 origin "$COMMIT_HASH"
|
git fetch --depth 1 origin "$COMMIT_HASH"
|
||||||
git -c advice.detachedHead=false checkout FETCH_HEAD
|
git -c advice.detachedHead=false checkout FETCH_HEAD
|
||||||
|
|
||||||
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2)
|
npm ci --ignore-scripts
|
||||||
./scripts/checkout_web_vault.sh
|
|
||||||
./scripts/build_web_vault.sh
|
|
||||||
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json
|
|
||||||
|
|
||||||
mv ./web-vault/apps/web/build /web-vault
|
cd apps/web
|
||||||
|
npm run dist:oss:selfhost
|
||||||
|
printf '{"version":"%s"}' "$COMMIT_HASH" > build/vw-version.json
|
||||||
|
|
||||||
|
mv build /web-vault
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ services:
|
|||||||
context: compose/warden
|
context: compose/warden
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
REPO_URL: ${PW_WV_REPO_URL:-}
|
REPO_URL: ${PW_VW_REPO_URL:-}
|
||||||
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-}
|
COMMIT_HASH: ${PW_VW_COMMIT_HASH:-}
|
||||||
env_file: ${DC_ENV_FILE:-.env}
|
env_file: ${DC_ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
|
- ADMIN_TOKEN
|
||||||
- DATABASE_URL
|
- DATABASE_URL
|
||||||
- I_REALLY_WANT_VOLATILE_STORAGE
|
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||||
- LOG_LEVEL
|
- LOG_LEVEL
|
||||||
|
|||||||
@@ -221,9 +221,13 @@ export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkNotification(page: Page, hasText: string) {
|
export async function checkNotification(page: Page, hasText: string) {
|
||||||
await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible();
|
await expect(page.locator('bit-toast', { hasText })).toBeVisible();
|
||||||
await page.locator('bit-toast').filter({ hasText }).getByRole('button').click();
|
try {
|
||||||
await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0);
|
await page.locator('bit-toast', { hasText }).getByRole('button', { name: 'Close' }).click({force: true, timeout: 10_000});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Closing notification failed but it should now be invisible (${error})`);
|
||||||
|
}
|
||||||
|
await expect(page.locator('bit-toast', { hasText })).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanLanding(page: Page) {
|
export async function cleanLanding(page: Page) {
|
||||||
@@ -244,3 +248,15 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ignoreExtension(page: Page) {
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
||||||
|
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Extension setup not visible. Continuing');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
Generated
+126
-202
@@ -9,15 +9,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "3.15.0",
|
"mysql2": "3.15.3",
|
||||||
"otpauth": "9.4.1",
|
"otpauth": "9.4.1",
|
||||||
"pg": "8.16.3"
|
"pg": "8.16.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.55.1",
|
"@playwright/test": "1.56.1",
|
||||||
"dotenv": "17.2.2",
|
"dotenv": "17.2.3",
|
||||||
"dotenv-expand": "12.0.3",
|
"dotenv-expand": "12.0.3",
|
||||||
"maildev": "npm:@timshel_npm/maildev@^3.2.3"
|
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
@@ -34,16 +34,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector": {
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
"version": "6.5.6",
|
"version": "6.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz",
|
||||||
"integrity": "sha512-Mj3Hu9ymlsERd7WOsUKNUZnJYL4IZ/I9wVVYgtvOsWYiEFbkQ4G7VRIh2USxTVW4BBDIsLG+gBUgqOqf2Kvqow==",
|
"integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"css-tree": "^3.1.0",
|
"css-tree": "^3.1.0",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"lru-cache": "^11.2.1"
|
"lru-cache": "^11.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/nwsapi": {
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
@@ -144,9 +144,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
"version": "1.0.14",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz",
|
||||||
"integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==",
|
"integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -160,9 +160,6 @@
|
|||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"postcss": "^8.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-tokenizer": {
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
@@ -196,12 +193,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.55.1",
|
"version": "1.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||||
"integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==",
|
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.55.1"
|
"playwright": "1.56.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -249,12 +246,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.5.2",
|
"version": "24.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||||
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
|
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.12.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
@@ -363,9 +360,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/debug": {
|
"node_modules/body-parser/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -637,9 +634,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.2.7",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -660,9 +657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.2",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -952,9 +949,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express/node_modules/debug": {
|
"node_modules/express/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -992,9 +989,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler/node_modules/debug": {
|
"node_modules/finalhandler/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -1340,20 +1337,20 @@
|
|||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "27.0.0",
|
"version": "27.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
|
||||||
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
|
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/dom-selector": "^6.5.4",
|
"@asamuzakjp/dom-selector": "^6.7.2",
|
||||||
"cssstyle": "^5.3.0",
|
"cssstyle": "^5.3.1",
|
||||||
"data-urls": "^6.0.0",
|
"data-urls": "^6.0.0",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.6.0",
|
||||||
"html-encoding-sniffer": "^4.0.0",
|
"html-encoding-sniffer": "^4.0.0",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"http-proxy-agent": "^7.0.2",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"parse5": "^7.3.0",
|
"parse5": "^8.0.0",
|
||||||
"rrweb-cssom": "^0.8.0",
|
"rrweb-cssom": "^0.8.0",
|
||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
@@ -1362,8 +1359,8 @@
|
|||||||
"webidl-conversions": "^8.0.0",
|
"webidl-conversions": "^8.0.0",
|
||||||
"whatwg-encoding": "^3.1.1",
|
"whatwg-encoding": "^3.1.1",
|
||||||
"whatwg-mimetype": "^4.0.0",
|
"whatwg-mimetype": "^4.0.0",
|
||||||
"whatwg-url": "^15.0.0",
|
"whatwg-url": "^15.1.0",
|
||||||
"ws": "^8.18.2",
|
"ws": "^8.18.3",
|
||||||
"xml-name-validator": "^5.0.0"
|
"xml-name-validator": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1426,9 +1423,9 @@
|
|||||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.2.1",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
|
||||||
"integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==",
|
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
@@ -1450,9 +1447,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/maildev": {
|
"node_modules/maildev": {
|
||||||
"name": "@timshel_npm/maildev",
|
"name": "@timshel_npm/maildev",
|
||||||
"version": "3.2.3",
|
"version": "3.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.5.tgz",
|
||||||
"integrity": "sha512-CNxMz4Obw7nL8MZbx4y1YUFeqqAQk+Qwm51tcBV5lRBotMlXKeYhuEcayb1v66nUwq832bUfKF4hyQpJixFZrw==",
|
"integrity": "sha512-suWQu2s2kmO+MXtNJYW9peklznhd+aorIUb4tSNrfaKoEJjDa3vLXTvWf+3cb67o4Yv4Z6nPeKdMTCDZVn/Nyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mailparser": "3.4.6",
|
"@types/mailparser": "3.4.6",
|
||||||
@@ -1461,13 +1458,13 @@
|
|||||||
"commander": "14.0.1",
|
"commander": "14.0.1",
|
||||||
"compression": "1.8.1",
|
"compression": "1.8.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dompurify": "3.2.7",
|
"dompurify": "3.3.0",
|
||||||
"express": "5.1.0",
|
"express": "5.1.0",
|
||||||
"jsdom": "27.0.0",
|
"jsdom": "27.0.1",
|
||||||
"mailparser": "3.7.4",
|
"mailparser": "3.7.5",
|
||||||
"mime": "4.1.0",
|
"mime": "4.1.0",
|
||||||
"nodemailer": "7.0.6",
|
"nodemailer": "7.0.9",
|
||||||
"smtp-server": "3.14.0",
|
"smtp-server": "3.15.0",
|
||||||
"socket.io": "4.8.1",
|
"socket.io": "4.8.1",
|
||||||
"wildstring": "1.0.9"
|
"wildstring": "1.0.9"
|
||||||
},
|
},
|
||||||
@@ -1479,36 +1476,44 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.7.4",
|
"version": "3.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz",
|
||||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
"integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.7.0",
|
||||||
"libmime": "5.3.7",
|
"libmime": "5.3.7",
|
||||||
"linkify-it": "5.0.0",
|
"linkify-it": "5.0.0",
|
||||||
"mailsplit": "5.4.5",
|
"mailsplit": "5.4.6",
|
||||||
"nodemailer": "7.0.4",
|
"nodemailer": "7.0.9",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"tlds": "1.259.0"
|
"tlds": "1.260.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser/node_modules/nodemailer": {
|
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||||
"version": "7.0.4",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailsplit": {
|
"node_modules/mailsplit": {
|
||||||
"version": "5.4.5",
|
"version": "5.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz",
|
||||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
"integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==",
|
||||||
|
"deprecated": "This package has been renamed to @zone-eu/mailsplit. Please update your dependencies.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
@@ -1595,9 +1600,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||||
"integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==",
|
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-ssl-profiles": "^1.1.1",
|
"aws-ssl-profiles": "^1.1.1",
|
||||||
"denque": "^2.1.0",
|
"denque": "^2.1.0",
|
||||||
@@ -1647,25 +1652,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
|
||||||
"version": "3.3.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"nanoid": "bin/nanoid.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.4",
|
"version": "0.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||||
@@ -1676,9 +1662,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||||
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
|
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -1747,9 +1733,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "7.3.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"entities": "^6.0.0"
|
"entities": "^6.0.0"
|
||||||
@@ -1793,13 +1779,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": {
|
"engines": {
|
||||||
"type": "opencollective",
|
"node": ">=16"
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/peberminta": {
|
"node_modules/peberminta": {
|
||||||
@@ -1892,20 +1877,13 @@
|
|||||||
"split2": "^4.1.0"
|
"split2": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
|
||||||
"dev": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.55.1",
|
"version": "1.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||||
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.55.1"
|
"playwright-core": "1.56.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -1918,9 +1896,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.55.1",
|
"version": "1.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||||
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -1929,35 +1907,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
|
||||||
"version": "8.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"nanoid": "^3.3.11",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"source-map-js": "^1.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -2049,34 +1998,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||||
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
|
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "3.1.2",
|
"bytes": "3.1.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"iconv-lite": "0.7.0",
|
"iconv-lite": "0.6.3",
|
||||||
"unpipe": "1.0.0"
|
"unpipe": "1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.8"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
@@ -2105,9 +2038,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router/node_modules/debug": {
|
"node_modules/router/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -2205,9 +2138,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/debug": {
|
"node_modules/send/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -2326,29 +2259,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smtp-server": {
|
"node_modules/smtp-server": {
|
||||||
"version": "3.14.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.15.0.tgz",
|
||||||
"integrity": "sha512-cEw/hdIY+xw1pkbQbQ23hvnm9kNABAsgYB+jJYGkzAynZxJ2VB9aqC6JhB1vpdDnqan7C7AL3qHYRGwz5eD6BQ==",
|
"integrity": "sha512-yv945vk0/xcukSKAoIhGz6GOlcXoCyGQH2w9IlLrTKk3SJiOBH9bcO6tD0ILTZYJsMqRa6OTRZAyqeuLXkv59Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base32.js": "0.1.0",
|
"base32.js": "0.1.0",
|
||||||
"ipv6-normalize": "1.0.1",
|
"ipv6-normalize": "1.0.1",
|
||||||
"nodemailer": "7.0.3",
|
"nodemailer": "7.0.9",
|
||||||
"punycode.js": "2.3.1"
|
"punycode.js": "2.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
|
||||||
"version": "7.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
|
|
||||||
"integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io": {
|
"node_modules/socket.io": {
|
||||||
"version": "4.8.1",
|
"version": "4.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||||
@@ -2564,30 +2488,30 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/tlds": {
|
"node_modules/tlds": {
|
||||||
"version": "1.259.0",
|
"version": "1.260.0",
|
||||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz",
|
||||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
"integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tlds": "bin.js"
|
"tlds": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts": {
|
"node_modules/tldts": {
|
||||||
"version": "7.0.16",
|
"version": "7.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
|
||||||
"integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==",
|
"integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts-core": "^7.0.16"
|
"tldts-core": "^7.0.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"tldts": "bin/cli.js"
|
"tldts": "bin/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts-core": {
|
"node_modules/tldts-core": {
|
||||||
"version": "7.0.16",
|
"version": "7.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz",
|
||||||
"integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==",
|
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
@@ -2644,9 +2568,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.12.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||||
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
|
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.55.1",
|
"@playwright/test": "1.56.1",
|
||||||
"dotenv": "17.2.2",
|
"dotenv": "17.2.3",
|
||||||
"dotenv-expand": "12.0.3",
|
"dotenv-expand": "12.0.3",
|
||||||
"maildev": "npm:@timshel_npm/maildev@^3.2.3"
|
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "3.15.0",
|
"mysql2": "3.15.3",
|
||||||
"otpauth": "9.4.1",
|
"otpauth": "9.4.1",
|
||||||
"pg": "8.16.3"
|
"pg": "8.16.3"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -55,6 +55,7 @@ ROCKET_PORT=8003
|
|||||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
DOMAIN=http://localhost:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
LOGIN_RATELIMIT_MAX_BURST=100
|
LOGIN_RATELIMIT_MAX_BURST=100
|
||||||
|
ADMIN_TOKEN=admin
|
||||||
|
|
||||||
SMTP_SECURITY=off
|
SMTP_SECURITY=off
|
||||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||||
@@ -67,8 +68,8 @@ SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
|||||||
SSO_DEBUG_TOKENS=true
|
SSO_DEBUG_TOKENS=true
|
||||||
|
|
||||||
# Custom web-vault build
|
# Custom web-vault build
|
||||||
# PW_WV_REPO_URL=https://github.com/dani-garcia/bw_web_builds.git
|
# PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git
|
||||||
# PW_WV_COMMIT_HASH=a5f5390895516bce2f48b7baadb6dc399e5fe75a
|
# PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
# Docker MariaDb container#
|
# Docker MariaDb container#
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ test('2fa', async ({ page }) => {
|
|||||||
await page.getByLabel(/Verification code/).fill(code);
|
await page.getByLabel(/Verification code/).fill(code);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
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 expect(page).toHaveTitle(/Vaults/);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -57,15 +57,17 @@ test('invited with new account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
|
||||||
//await page.getByLabel('Name').fill(users.user2.name);
|
//await page.getByLabel('Name').fill(users.user2.name);
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
|
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await utils.checkNotification(page, 'Your new account has been created');
|
await utils.checkNotification(page, 'Your new account has been created');
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// Redirected to the vault
|
// Redirected to the vault
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -91,9 +93,11 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
|||||||
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByLabel('Master password (required)').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
await page.getByRole('button', { name: 'Send email' }).click();
|
||||||
|
|||||||
@@ -33,19 +33,21 @@ export async function logNewUser(
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(user.password);
|
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
if( options.mailBuffer ){
|
if( options.mailBuffer ){
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
await test.step('Check emails', async () => {
|
await test.step('Check emails', async () => {
|
||||||
@@ -115,6 +117,8 @@ export async function logUser(
|
|||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -17,15 +17,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Vault finish Creation
|
// Vault finish Creation
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(user.password);
|
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Your new account has been created')
|
await utils.checkNotification(page, 'Your new account has been created')
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( mailBuffer ){
|
||||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
@@ -45,6 +46,8 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
|||||||
await page.getByLabel('Master password').fill(user.password);
|
await page.getByLabel('Master password').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
|
|||||||
@@ -67,16 +67,17 @@ test('invited with new account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
|
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -107,11 +108,13 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.89.0"
|
channel = "1.95.0"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|||||||
+106
-67
@@ -1,17 +1,16 @@
|
|||||||
use once_cell::sync::Lazy;
|
use std::{env, sync::LazyLock};
|
||||||
use reqwest::Method;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
use rocket::serde::json::Json;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
form::Form,
|
form::Form,
|
||||||
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
response::{content::RawHtml as Html, Redirect},
|
response::{content::RawHtml as Html, Redirect},
|
||||||
|
serde::json::Json,
|
||||||
Catcher, Route,
|
Catcher, Route,
|
||||||
};
|
};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
@@ -24,16 +23,17 @@ use crate::{
|
|||||||
backup_sqlite, get_sql_server_version,
|
backup_sqlite, get_sql_server_version,
|
||||||
models::{
|
models::{
|
||||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||||
MembershipType, OrgPolicy, OrgPolicyErr, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbConnType, ACTIVE_DB_TYPE,
|
DbConn, DbConnType, ACTIVE_DB_TYPE,
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
mail,
|
mail,
|
||||||
|
sso::FAKE_SSO_IDENTIFIER,
|
||||||
util::{
|
util::{
|
||||||
container_base_image, format_naive_datetime_local, get_display_size, get_web_vault_version,
|
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
|
||||||
is_running_in_container, NumberOrString,
|
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
|
||||||
},
|
},
|
||||||
CONFIG, VERSION,
|
CONFIG, VERSION,
|
||||||
};
|
};
|
||||||
@@ -82,7 +82,7 @@ pub fn catchers() -> Vec<Catcher> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static DB_TYPE: Lazy<&str> = Lazy::new(|| match ACTIVE_DB_TYPE.get() {
|
static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
|
||||||
#[cfg(mysql)]
|
#[cfg(mysql)]
|
||||||
Some(DbConnType::Mysql) => "MySQL",
|
Some(DbConnType::Mysql) => "MySQL",
|
||||||
#[cfg(postgresql)]
|
#[cfg(postgresql)]
|
||||||
@@ -93,9 +93,10 @@ static DB_TYPE: Lazy<&str> = Lazy::new(|| match ACTIVE_DB_TYPE.get() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
static CAN_BACKUP: Lazy<bool> = Lazy::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
|
static CAN_BACKUP: LazyLock<bool> =
|
||||||
|
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
|
||||||
#[cfg(not(sqlite))]
|
#[cfg(not(sqlite))]
|
||||||
static CAN_BACKUP: Lazy<bool> = Lazy::new(|| false);
|
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
fn admin_disabled() -> &'static str {
|
fn admin_disabled() -> &'static str {
|
||||||
@@ -157,10 +158,10 @@ fn admin_login(request: &Request<'_>) -> ApiResult<Html<String>> {
|
|||||||
err_code!("Authorization failed.", Status::Unauthorized.code);
|
err_code!("Authorization failed.", Status::Unauthorized.code);
|
||||||
}
|
}
|
||||||
let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||||
render_admin_login(None, Some(redirect))
|
render_admin_login(None, Some(&redirect))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_admin_login(msg: Option<&str>, redirect: Option<String>) -> ApiResult<Html<String>> {
|
fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Html<String>> {
|
||||||
// If there is an error, show it
|
// If there is an error, show it
|
||||||
let msg = msg.map(|msg| format!("Error: {msg}"));
|
let msg = msg.map(|msg| format!("Error: {msg}"));
|
||||||
let json = json!({
|
let json = json!({
|
||||||
@@ -194,14 +195,17 @@ fn post_admin_login(
|
|||||||
if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
|
if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
|
||||||
return Err(AdminResponse::TooManyRequests(render_admin_login(
|
return Err(AdminResponse::TooManyRequests(render_admin_login(
|
||||||
Some("Too many requests, try again later."),
|
Some("Too many requests, try again later."),
|
||||||
redirect,
|
redirect.as_deref(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if !_validate_token(&data.token) {
|
if !_validate_token(&data.token) {
|
||||||
error!("Invalid admin token. IP: {}", ip.ip);
|
error!("Invalid admin token. IP: {}", ip.ip);
|
||||||
Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect)))
|
Err(AdminResponse::Unauthorized(render_admin_login(
|
||||||
|
Some("Invalid admin token, please try again."),
|
||||||
|
redirect.as_deref(),
|
||||||
|
)))
|
||||||
} else {
|
} else {
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = generate_admin_claims();
|
let claims = generate_admin_claims();
|
||||||
@@ -308,11 +312,15 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
|
|||||||
err_code!("User already exists", Status::Conflict.code)
|
err_code!("User already exists", Status::Conflict.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(data.email, None);
|
let mut user = User::new(&data.email, None);
|
||||||
|
|
||||||
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||||
|
FAKE_SSO_IDENTIFIER.into()
|
||||||
|
} else {
|
||||||
|
FAKE_ADMIN_UUID.into()
|
||||||
|
};
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
@@ -469,7 +477,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
|||||||
}
|
}
|
||||||
|
|
||||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
user.reset_security_stamp();
|
user.reset_security_stamp(&conn).await?;
|
||||||
|
|
||||||
user.save(&conn).await
|
user.save(&conn).await
|
||||||
}
|
}
|
||||||
@@ -477,14 +485,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
|||||||
#[post("/users/<user_id>/disable", format = "application/json")]
|
#[post("/users/<user_id>/disable", format = "application/json")]
|
||||||
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let mut user = get_user_or_404(&user_id, &conn).await?;
|
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
user.reset_security_stamp(&conn).await?;
|
||||||
user.reset_security_stamp();
|
|
||||||
user.enabled = false;
|
user.enabled = false;
|
||||||
|
|
||||||
let save_result = user.save(&conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
|
||||||
|
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
|
|
||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +523,11 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||||
|
FAKE_SSO_IDENTIFIER.into()
|
||||||
|
} else {
|
||||||
|
FAKE_ADMIN_UUID.into()
|
||||||
|
};
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
@@ -553,23 +566,9 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member_to_edit.atype = new_type;
|
||||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||||
// It returns different error messages per function.
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||||
if new_type < MembershipType::Admin {
|
|
||||||
match OrgPolicy::is_user_allowed(&member_to_edit.user_uuid, &member_to_edit.org_uuid, true, &conn).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
|
||||||
if CONFIG.email_2fa_auto_fallback() {
|
|
||||||
two_factor::email::find_and_activate_email_2fa(&member_to_edit.user_uuid, &conn).await?;
|
|
||||||
} else {
|
|
||||||
err!("You cannot modify this user to this type because they have not setup 2FA");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
|
||||||
err!("You cannot modify this user to this type because it is a member of an organization which forbids it");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdated as i32,
|
EventType::OrganizationUserUpdated as i32,
|
||||||
@@ -582,7 +581,6 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
member_to_edit.atype = new_type;
|
|
||||||
member_to_edit.save(&conn).await
|
member_to_edit.save(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +647,6 @@ use cached::proc_macro::cached;
|
|||||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||||
/// Any cache will be lost if Vaultwarden is restarted
|
/// Any cache will be lost if Vaultwarden is restarted
|
||||||
use std::time::Duration; // Needed for cached
|
|
||||||
#[cached(time = 600, sync_writes = "default")]
|
#[cached(time = 600, sync_writes = "default")]
|
||||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||||
@@ -701,6 +698,26 @@ async fn get_ntp_time(has_http_access: bool) -> String {
|
|||||||
String::from("Unable to fetch NTP time.")
|
String::from("Unable to fetch NTP time.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
||||||
|
use semver::Version;
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
let active_semver = Version::parse(active).unwrap_or_else(|e| {
|
||||||
|
warn!("Unable to parse active web-vault version '{active}': {e}");
|
||||||
|
Version::parse("2025.1.1").unwrap()
|
||||||
|
});
|
||||||
|
let latest_semver = Version::parse(latest).unwrap_or_else(|e| {
|
||||||
|
warn!("Unable to parse latest web-vault version '{latest}': {e}");
|
||||||
|
Version::parse("2025.1.1").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
match active_semver.cmp(&latest_semver) {
|
||||||
|
Ordering::Less => -1,
|
||||||
|
Ordering::Equal => 0,
|
||||||
|
Ordering::Greater => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/diagnostics")]
|
#[get("/diagnostics")]
|
||||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
@@ -720,32 +737,28 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
_ => "Unable to resolve domain name.".to_string(),
|
_ => "Unable to resolve domain name.".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (latest_release, latest_commit, latest_web_build) = get_release_info(has_http_access).await;
|
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
||||||
|
let active_web_release = get_active_web_release();
|
||||||
|
let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release);
|
||||||
|
|
||||||
let ip_header_name = &ip_header.0.unwrap_or_default();
|
let ip_header_name = &ip_header.0.unwrap_or_default();
|
||||||
|
|
||||||
// Get current running versions
|
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||||
let web_vault_version = get_web_vault_version();
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
|
FeatureFlagFilter::InvalidOnly,
|
||||||
// Check if the running version is newer than the latest stable released version
|
)
|
||||||
let web_vault_pre_release = if let Ok(web_ver_match) = semver::VersionReq::parse(&format!(">{latest_web_build}")) {
|
.into_keys()
|
||||||
web_ver_match.matches(
|
.collect();
|
||||||
&semver::Version::parse(&web_vault_version).unwrap_or_else(|_| semver::Version::parse("2025.1.1").unwrap()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
error!("Unable to parse latest_web_build: '{latest_web_build}'");
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
let diagnostics_json = json!({
|
let diagnostics_json = json!({
|
||||||
"dns_resolved": dns_resolved,
|
"dns_resolved": dns_resolved,
|
||||||
"current_release": VERSION,
|
"current_release": VERSION,
|
||||||
"latest_release": latest_release,
|
"latest_release": latest_vw_release,
|
||||||
"latest_commit": latest_commit,
|
"latest_commit": latest_vw_commit,
|
||||||
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
||||||
"web_vault_version": web_vault_version,
|
"active_web_release": active_web_release,
|
||||||
"latest_web_build": latest_web_build,
|
"latest_web_release": latest_web_release,
|
||||||
"web_vault_pre_release": web_vault_pre_release,
|
"web_vault_compare": web_vault_compare,
|
||||||
"running_within_container": running_within_container,
|
"running_within_container": running_within_container,
|
||||||
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
||||||
"has_http_access": has_http_access,
|
"has_http_access": has_http_access,
|
||||||
@@ -759,6 +772,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
"tz_env": env::var("TZ").unwrap_or_default(),
|
"tz_env": env::var("TZ").unwrap_or_default(),
|
||||||
@@ -825,11 +839,7 @@ impl<'r> FromRequest<'r> for AdminToken {
|
|||||||
_ => err_handler!("Error getting Client IP"),
|
_ => err_handler!("Error getting Client IP"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.disable_admin_token() {
|
if !CONFIG.disable_admin_token() {
|
||||||
Outcome::Success(Self {
|
|
||||||
ip,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let cookies = request.cookies();
|
let cookies = request.cookies();
|
||||||
|
|
||||||
let access_token = match cookies.get(COOKIE_NAME) {
|
let access_token = match cookies.get(COOKIE_NAME) {
|
||||||
@@ -853,10 +863,39 @@ impl<'r> FromRequest<'r> for AdminToken {
|
|||||||
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
|
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
|
||||||
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Outcome::Success(Self {
|
|
||||||
ip,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Outcome::Success(Self {
|
||||||
|
ip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_web_vault_compare() {
|
||||||
|
// web_vault_compare(active, latest)
|
||||||
|
// Test normal versions
|
||||||
|
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!(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!(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!(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-59
@@ -22,7 +22,7 @@ use crate::{
|
|||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
util::{format_date, NumberOrString},
|
util::{deser_opt_nonempty_str, format_date, NumberOrString},
|
||||||
CONFIG,
|
CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@ use rocket::{
|
|||||||
|
|
||||||
pub fn routes() -> Vec<rocket::Route> {
|
pub fn routes() -> Vec<rocket::Route> {
|
||||||
routes![
|
routes![
|
||||||
register,
|
|
||||||
profile,
|
profile,
|
||||||
put_profile,
|
put_profile,
|
||||||
post_profile,
|
post_profile,
|
||||||
@@ -66,6 +65,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
put_device_token,
|
put_device_token,
|
||||||
put_clear_device_token,
|
put_clear_device_token,
|
||||||
post_clear_device_token,
|
post_clear_device_token,
|
||||||
|
get_tasks,
|
||||||
post_auth_request,
|
post_auth_request,
|
||||||
get_auth_request,
|
get_auth_request,
|
||||||
put_auth_request,
|
put_auth_request,
|
||||||
@@ -75,12 +75,16 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Eq, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct KDFData {
|
pub struct KDFData {
|
||||||
|
#[serde(alias = "kdfType")]
|
||||||
kdf: i32,
|
kdf: i32,
|
||||||
|
#[serde(alias = "iterations")]
|
||||||
kdf_iterations: i32,
|
kdf_iterations: i32,
|
||||||
|
#[serde(alias = "memory")]
|
||||||
kdf_memory: Option<i32>,
|
kdf_memory: Option<i32>,
|
||||||
|
#[serde(alias = "parallelism")]
|
||||||
kdf_parallelism: Option<i32>,
|
kdf_parallelism: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +106,6 @@ pub struct RegisterData {
|
|||||||
|
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
organization_user_id: Option<MembershipId>,
|
organization_user_id: Option<MembershipId>,
|
||||||
|
|
||||||
// Used only from the register/finish endpoint
|
// Used only from the register/finish endpoint
|
||||||
@@ -163,11 +166,6 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register", data = "<data>")]
|
|
||||||
async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
|
||||||
_register(data, false, conn).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||||
let mut data: RegisterData = data.into_inner();
|
let mut data: RegisterData = data.into_inner();
|
||||||
let email = data.email.to_lowercase();
|
let email = data.email.to_lowercase();
|
||||||
@@ -285,7 +283,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
|| CONFIG.is_signup_allowed(&email)
|
|| CONFIG.is_signup_allowed(&email)
|
||||||
|| pending_emergency_access.is_some()
|
|| pending_emergency_access.is_some()
|
||||||
{
|
{
|
||||||
User::new(email.clone(), None)
|
User::new(&email, None)
|
||||||
} else {
|
} else {
|
||||||
err!("Registration not allowed or user already exists")
|
err!("Registration not allowed or user already exists")
|
||||||
}
|
}
|
||||||
@@ -295,9 +293,9 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
// Make sure we don't leave a lingering invitation.
|
// Make sure we don't leave a lingering invitation.
|
||||||
Invitation::take(&email, &conn).await;
|
Invitation::take(&email, &conn).await;
|
||||||
|
|
||||||
set_kdf_data(&mut user, data.kdf)?;
|
set_kdf_data(&mut user, &data.kdf)?;
|
||||||
|
|
||||||
user.set_password(&data.master_password_hash, Some(data.key), true, None);
|
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||||
user.password_hint = password_hint;
|
user.password_hint = password_hint;
|
||||||
|
|
||||||
// Add extra fields if present
|
// Add extra fields if present
|
||||||
@@ -358,14 +356,16 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
let password_hint = clean_password_hint(&data.master_password_hint);
|
let password_hint = clean_password_hint(&data.master_password_hint);
|
||||||
enforce_password_hint_setting(&password_hint)?;
|
enforce_password_hint_setting(&password_hint)?;
|
||||||
|
|
||||||
set_kdf_data(&mut user, data.kdf)?;
|
set_kdf_data(&mut user, &data.kdf)?;
|
||||||
|
|
||||||
user.set_password(
|
user.set_password(
|
||||||
&data.master_password_hash,
|
&data.master_password_hash,
|
||||||
Some(data.key),
|
Some(data.key),
|
||||||
false,
|
false,
|
||||||
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
||||||
);
|
&conn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
user.password_hint = password_hint;
|
user.password_hint = password_hint;
|
||||||
|
|
||||||
if let Some(keys) = data.keys {
|
if let Some(keys) = data.keys {
|
||||||
@@ -374,15 +374,13 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(identifier) = data.org_identifier {
|
if let Some(identifier) = data.org_identifier {
|
||||||
if identifier != crate::sso::FAKE_IDENTIFIER {
|
if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
|
||||||
let org = match Organization::find_by_uuid(&identifier.into(), &conn).await {
|
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
||||||
None => err!("Failed to retrieve the associated organization"),
|
err!("Failed to retrieve the associated organization")
|
||||||
Some(org) => org,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await {
|
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
||||||
None => err!("Failed to retrieve the invitation"),
|
err!("Failed to retrieve the invitation")
|
||||||
Some(org) => org,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
accept_org_invite(&user, membership, None, &conn).await?;
|
accept_org_invite(&user, membership, None, &conn).await?;
|
||||||
@@ -401,8 +399,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"Object": "set-password",
|
"object": "set-password",
|
||||||
"CaptchaBypassToken": "",
|
"captchaBypassToken": "",
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,30 +531,21 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
|||||||
String::from("get_public_keys"),
|
String::from("get_public_keys"),
|
||||||
String::from("get_api_webauthn"),
|
String::from("get_api_webauthn"),
|
||||||
]),
|
]),
|
||||||
);
|
&conn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let save_result = user.save(&conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
// Prevent logging out the client where the user requested this endpoint from.
|
// Prevent logging out the client where the user requested this endpoint from.
|
||||||
// If you do logout the user it will causes issues at the client side.
|
// If you do logout the user it will causes issues at the client side.
|
||||||
// Adding the device uuid will prevent this.
|
// Adding the device uuid will prevent this.
|
||||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||||
|
|
||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct ChangeKdfData {
|
|
||||||
#[serde(flatten)]
|
|
||||||
kdf: KDFData,
|
|
||||||
|
|
||||||
master_password_hash: String,
|
|
||||||
new_master_password_hash: String,
|
|
||||||
key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_kdf_data(user: &mut User, data: KDFData) -> EmptyResult {
|
|
||||||
if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 {
|
if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 {
|
||||||
err!("PBKDF2 KDF iterations must be at least 100000.")
|
err!("PBKDF2 KDF iterations must be at least 100000.")
|
||||||
}
|
}
|
||||||
@@ -591,21 +580,65 @@ fn set_kdf_data(user: &mut User, data: KDFData) -> EmptyResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AuthenticationData {
|
||||||
|
salt: String,
|
||||||
|
kdf: KDFData,
|
||||||
|
master_password_authentication_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct UnlockData {
|
||||||
|
salt: String,
|
||||||
|
kdf: KDFData,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/accounts/kdf", data = "<data>")]
|
#[post("/accounts/kdf", data = "<data>")]
|
||||||
async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: ChangeKdfData = data.into_inner();
|
let data: ChangeKdfData = data.into_inner();
|
||||||
let mut user = headers.user;
|
|
||||||
|
|
||||||
if !user.check_valid_password(&data.master_password_hash) {
|
if !headers.user.check_valid_password(&data.master_password_hash) {
|
||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
set_kdf_data(&mut user, data.kdf)?;
|
if data.authentication_data.kdf != data.unlock_data.kdf {
|
||||||
|
err!("KDF settings must be equal for authentication and unlock")
|
||||||
|
}
|
||||||
|
|
||||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
|
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;
|
||||||
|
|
||||||
|
set_kdf_data(&mut user, &data.unlock_data.kdf)?;
|
||||||
|
|
||||||
|
user.set_password(
|
||||||
|
&data.authentication_data.master_password_authentication_hash,
|
||||||
|
Some(data.unlock_data.master_key_wrapped_user_key),
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let save_result = user.save(&conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||||
|
|
||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
@@ -616,6 +649,7 @@ struct UpdateFolderData {
|
|||||||
// There is a bug in 2024.3.x which adds a `null` item.
|
// There is a bug in 2024.3.x which adds a `null` item.
|
||||||
// To bypass this we allow a Option here, but skip it during the updates
|
// To bypass this we allow a Option here, but skip it during the updates
|
||||||
// See: https://github.com/bitwarden/clients/issues/8453
|
// See: https://github.com/bitwarden/clients/issues/8453
|
||||||
|
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||||
id: Option<FolderId>,
|
id: Option<FolderId>,
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
@@ -780,7 +814,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
|
|
||||||
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
|
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
|
||||||
let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
|
let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
|
||||||
let mut existing_emergency_access = EmergencyAccess::find_all_by_grantor_uuid(user_id, &conn).await;
|
let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await;
|
||||||
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
|
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
|
||||||
// We only rotate the reset password key if it is set.
|
// We only rotate the reset password key if it is set.
|
||||||
existing_memberships.retain(|m| m.reset_password_key.is_some());
|
existing_memberships.retain(|m| m.reset_password_key.is_some());
|
||||||
@@ -869,14 +903,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
|
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
);
|
&conn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let save_result = user.save(&conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
// Prevent logging out the client where the user requested this endpoint from.
|
// Prevent logging out the client where the user requested this endpoint from.
|
||||||
// If you do logout the user it will causes issues at the client side.
|
// If you do logout the user it will causes issues at the client side.
|
||||||
// Adding the device uuid will prevent this.
|
// Adding the device uuid will prevent this.
|
||||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||||
|
|
||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
@@ -888,12 +924,13 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
|||||||
|
|
||||||
data.validate(&user, true, &conn).await?;
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
user.reset_security_stamp(&conn).await?;
|
||||||
user.reset_security_stamp();
|
|
||||||
let save_result = user.save(&conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
|
||||||
|
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
|
|
||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,7 +1048,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
|||||||
user.email_new = None;
|
user.email_new = None;
|
||||||
user.email_new_token = None;
|
user.email_new_token = None;
|
||||||
|
|
||||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
|
user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||||
|
|
||||||
let save_result = user.save(&conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
@@ -1162,10 +1199,9 @@ async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResul
|
|||||||
// There is still a timing side channel here in that the code
|
// There is still a timing side channel here in that the code
|
||||||
// paths that send mail take noticeably longer than ones that
|
// paths that send mail take noticeably longer than ones that
|
||||||
// don't. Add a randomized sleep to mitigate this somewhat.
|
// don't. Add a randomized sleep to mitigate this somewhat.
|
||||||
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
use rand::{rngs::SmallRng, RngExt};
|
||||||
let mut rng = SmallRng::from_os_rng();
|
let mut rng: SmallRng = rand::make_rng();
|
||||||
let delta: i32 = 100;
|
let sleep_ms = rng.random_range(900..=1100) as u64;
|
||||||
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -1224,7 +1260,7 @@ struct SecretVerificationRequest {
|
|||||||
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
|
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
|
||||||
if user.password_iterations < CONFIG.password_iterations() {
|
if user.password_iterations < CONFIG.password_iterations() {
|
||||||
user.password_iterations = CONFIG.password_iterations();
|
user.password_iterations = CONFIG.password_iterations();
|
||||||
user.set_password(pwd_hash, None, false, None);
|
user.set_password(pwd_hash, None, false, None, conn).await?;
|
||||||
|
|
||||||
if let Err(e) = user.save(conn).await {
|
if let Err(e) = user.save(conn).await {
|
||||||
error!("Error updating user: {e:#?}");
|
error!("Error updating user: {e:#?}");
|
||||||
@@ -1279,10 +1315,11 @@ async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: D
|
|||||||
|
|
||||||
#[get("/devices/knowndevice")]
|
#[get("/devices/knowndevice")]
|
||||||
async fn get_known_device(device: KnownDevice, conn: DbConn) -> JsonResult {
|
async fn get_known_device(device: KnownDevice, conn: DbConn) -> JsonResult {
|
||||||
let mut result = false;
|
let result = if let Some(user) = User::find_by_mail(&device.email, &conn).await {
|
||||||
if let Some(user) = User::find_by_mail(&device.email, &conn).await {
|
Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &conn).await.is_some()
|
||||||
result = Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &conn).await.is_some();
|
} else {
|
||||||
}
|
false
|
||||||
|
};
|
||||||
Ok(Json(json!(result)))
|
Ok(Json(json!(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1297,6 +1334,11 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
|||||||
|
|
||||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") {
|
let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") {
|
||||||
|
// Bitwarden seems to send padded Base64 strings since 2026.2.1
|
||||||
|
// Since these values are not streamed and Headers are always split by newlines
|
||||||
|
// we can safely ignore padding here and remove any '=' appended.
|
||||||
|
let email_b64 = email_b64.trim_end_matches('=');
|
||||||
|
|
||||||
let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else {
|
let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else {
|
||||||
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
|
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
|
||||||
};
|
};
|
||||||
@@ -1372,7 +1414,7 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
|||||||
}
|
}
|
||||||
|
|
||||||
device.push_token = Some(token);
|
device.push_token = Some(token);
|
||||||
if let Err(e) = device.save(&conn).await {
|
if let Err(e) = device.save(true, &conn).await {
|
||||||
err!(format!("An error occurred while trying to save the device push token: {e}"));
|
err!(format!("An error occurred while trying to save the device push token: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,6 +1450,14 @@ async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResu
|
|||||||
put_clear_device_token(device_id, conn).await
|
put_clear_device_token(device_id, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/tasks")]
|
||||||
|
fn get_tasks(_client_headers: ClientHeaders) -> JsonResult {
|
||||||
|
Ok(Json(json!({
|
||||||
|
"data": [],
|
||||||
|
"object": "list"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct AuthRequestRequest {
|
struct AuthRequestRequest {
|
||||||
@@ -1658,6 +1708,6 @@ pub async fn purge_auth_requests(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
AuthRequest::purge_expired_auth_requests(&conn).await;
|
AuthRequest::purge_expired_auth_requests(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging trashed ciphers")
|
error!("Failed to get DB connection while purging auth requests")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-63
@@ -11,10 +11,10 @@ use rocket::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::auth::ClientVersion;
|
use crate::auth::ClientVersion;
|
||||||
use crate::util::{save_temp_file, NumberOrString};
|
use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
||||||
auth::Headers,
|
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
@@ -86,7 +86,8 @@ pub fn routes() -> Vec<Route> {
|
|||||||
restore_cipher_put_admin,
|
restore_cipher_put_admin,
|
||||||
restore_cipher_selected,
|
restore_cipher_selected,
|
||||||
restore_cipher_selected_admin,
|
restore_cipher_selected_admin,
|
||||||
delete_all,
|
purge_org_vault,
|
||||||
|
purge_personal_vault,
|
||||||
move_cipher_selected,
|
move_cipher_selected,
|
||||||
move_cipher_selected_put,
|
move_cipher_selected_put,
|
||||||
put_collections2_update,
|
put_collections2_update,
|
||||||
@@ -159,7 +160,28 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
let domains_json = if data.exclude_domains {
|
let domains_json = if data.exclude_domains {
|
||||||
Value::Null
|
Value::Null
|
||||||
} else {
|
} else {
|
||||||
api::core::_get_eq_domains(headers, true).into_inner()
|
api::core::_get_eq_domains(&headers, true).into_inner()
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is very similar to the the userDecryptionOptions sent in connect/token,
|
||||||
|
// 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 {
|
||||||
|
json!({
|
||||||
|
"kdf": {
|
||||||
|
"kdfType": headers.user.client_kdf_type,
|
||||||
|
"iterations": headers.user.client_kdf_iter,
|
||||||
|
"memory": headers.user.client_kdf_memory,
|
||||||
|
"parallelism": headers.user.client_kdf_parallelism
|
||||||
|
},
|
||||||
|
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
||||||
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
|
"masterKeyEncryptedUserKey": headers.user.akey,
|
||||||
|
"masterKeyWrappedUserKey": headers.user.akey,
|
||||||
|
"salt": headers.user.email
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -170,6 +192,9 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
"ciphers": ciphers_json,
|
"ciphers": ciphers_json,
|
||||||
"domains": domains_json,
|
"domains": domains_json,
|
||||||
"sends": sends_json,
|
"sends": sends_json,
|
||||||
|
"userDecryption": {
|
||||||
|
"masterPasswordUnlock": master_password_unlock,
|
||||||
|
},
|
||||||
"object": "sync"
|
"object": "sync"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -223,6 +248,7 @@ pub struct CipherData {
|
|||||||
// Id is optional as it is included only in bulk share
|
// Id is optional as it is included only in bulk share
|
||||||
pub id: Option<CipherId>,
|
pub id: Option<CipherId>,
|
||||||
// Folder id is not included in import
|
// Folder id is not included in import
|
||||||
|
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||||
pub folder_id: Option<FolderId>,
|
pub folder_id: Option<FolderId>,
|
||||||
// TODO: Some of these might appear all the time, no need for Option
|
// TODO: Some of these might appear all the time, no need for Option
|
||||||
#[serde(alias = "organizationID")]
|
#[serde(alias = "organizationID")]
|
||||||
@@ -272,6 +298,7 @@ pub struct CipherData {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PartialCipherData {
|
pub struct PartialCipherData {
|
||||||
|
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||||
folder_id: Option<FolderId>,
|
folder_id: Option<FolderId>,
|
||||||
favorite: bool,
|
favorite: bool,
|
||||||
}
|
}
|
||||||
@@ -301,12 +328,6 @@ async fn post_ciphers_create(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut data: ShareCipherData = data.into_inner();
|
let mut data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
// Check if there are one more more collections selected when this cipher is part of an organization.
|
|
||||||
// err if this is not the case before creating an empty cipher.
|
|
||||||
if data.cipher.organization_id.is_some() && data.collection_ids.is_empty() {
|
|
||||||
err!("You must select at least one collection.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// This check is usually only needed in update_cipher_from_data(), but we
|
// This check is usually only needed in update_cipher_from_data(), but we
|
||||||
// need it here as well to avoid creating an empty cipher in the call to
|
// need it here as well to avoid creating an empty cipher in the call to
|
||||||
// cipher.save() below.
|
// cipher.save() below.
|
||||||
@@ -324,7 +345,11 @@ async fn post_ciphers_create(
|
|||||||
// or otherwise), we can just ignore this field entirely.
|
// or otherwise), we can just ignore this field entirely.
|
||||||
data.cipher.last_known_revision_date = None;
|
data.cipher.last_known_revision_date = None;
|
||||||
|
|
||||||
share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await
|
let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await;
|
||||||
|
if res.is_err() {
|
||||||
|
cipher.delete(&conn).await?;
|
||||||
|
}
|
||||||
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when creating a new user-owned cipher.
|
/// Called when creating a new user-owned cipher.
|
||||||
@@ -403,7 +428,7 @@ pub async fn update_cipher_from_data(
|
|||||||
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
|
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
|
||||||
|
|
||||||
if let Some(org_id) = data.organization_id {
|
if let Some(org_id) = data.organization_id {
|
||||||
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
||||||
None => err!("You don't have permission to add item to organization"),
|
None => err!("You don't have permission to add item to organization"),
|
||||||
Some(member) => {
|
Some(member) => {
|
||||||
if shared_to_collections.is_some()
|
if shared_to_collections.is_some()
|
||||||
@@ -693,9 +718,13 @@ async fn put_cipher_partial(
|
|||||||
let data: PartialCipherData = data.into_inner();
|
let data: PartialCipherData = data.into_inner();
|
||||||
|
|
||||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
err!("Cipher doesn't exist")
|
err!("Cipher does not exist")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await {
|
||||||
|
err!("Cipher does not exist", "Cipher is not accessible for the current user")
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id {
|
||||||
if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() {
|
if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() {
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
@@ -1269,7 +1298,7 @@ async fn save_attachment(
|
|||||||
attachment.save(&conn).await.expect("Error saving attachment");
|
attachment.save(&conn).await.expect("Error saving attachment");
|
||||||
}
|
}
|
||||||
|
|
||||||
save_temp_file(PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
||||||
|
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
UpdateType::SyncCipherUpdate,
|
UpdateType::SyncCipherUpdate,
|
||||||
@@ -1542,6 +1571,7 @@ async fn restore_cipher_selected(
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct MoveCipherData {
|
struct MoveCipherData {
|
||||||
|
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||||
folder_id: Option<FolderId>,
|
folder_id: Option<FolderId>,
|
||||||
ids: Vec<CipherId>,
|
ids: Vec<CipherId>,
|
||||||
}
|
}
|
||||||
@@ -1616,9 +1646,51 @@ struct OrganizationIdData {
|
|||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the OrgIdGuard here, to ensure there an organization id present.
|
||||||
|
// If there is no organization id present, it should be forwarded to purge_personal_vault.
|
||||||
|
// This guard needs to be the first argument, else OwnerHeaders will be triggered which will logout the user.
|
||||||
#[post("/ciphers/purge?<organization..>", data = "<data>")]
|
#[post("/ciphers/purge?<organization..>", data = "<data>")]
|
||||||
async fn delete_all(
|
async fn purge_org_vault(
|
||||||
organization: Option<OrganizationIdData>,
|
_org_id_guard: OrgIdGuard,
|
||||||
|
organization: OrganizationIdData,
|
||||||
|
data: Json<PasswordOrOtpData>,
|
||||||
|
headers: OwnerHeaders,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> EmptyResult {
|
||||||
|
if organization.org_id != headers.org_id {
|
||||||
|
err!("Organization not found", "Organization id's do not match");
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
|
let user = headers.user;
|
||||||
|
|
||||||
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
|
match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await {
|
||||||
|
Some(member) if member.atype == MembershipType::Owner => {
|
||||||
|
Cipher::delete_all_by_organization(&organization.org_id, &conn).await?;
|
||||||
|
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
EventType::OrganizationPurgedVault as i32,
|
||||||
|
&organization.org_id,
|
||||||
|
&organization.org_id,
|
||||||
|
&user.uuid,
|
||||||
|
headers.device.atype,
|
||||||
|
&headers.ip.ip,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => err!("You don't have permission to purge the organization vault"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/ciphers/purge", data = "<data>")]
|
||||||
|
async fn purge_personal_vault(
|
||||||
data: Json<PasswordOrOtpData>,
|
data: Json<PasswordOrOtpData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
@@ -1629,52 +1701,18 @@ async fn delete_all(
|
|||||||
|
|
||||||
data.validate(&user, true, &conn).await?;
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
match organization {
|
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
|
||||||
Some(org_data) => {
|
cipher.delete(&conn).await?;
|
||||||
// Organization ID in query params, purging organization vault
|
|
||||||
match Membership::find_by_user_and_org(&user.uuid, &org_data.org_id, &conn).await {
|
|
||||||
None => err!("You don't have permission to purge the organization vault"),
|
|
||||||
Some(member) => {
|
|
||||||
if member.atype == MembershipType::Owner {
|
|
||||||
Cipher::delete_all_by_organization(&org_data.org_id, &conn).await?;
|
|
||||||
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
|
||||||
|
|
||||||
log_event(
|
|
||||||
EventType::OrganizationPurgedVault as i32,
|
|
||||||
&org_data.org_id,
|
|
||||||
&org_data.org_id,
|
|
||||||
&user.uuid,
|
|
||||||
headers.device.atype,
|
|
||||||
&headers.ip.ip,
|
|
||||||
&conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
err!("You don't have permission to purge the organization vault");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// No organization ID in query params, purging user vault
|
|
||||||
// Delete ciphers and their attachments
|
|
||||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
|
|
||||||
cipher.delete(&conn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete folders
|
|
||||||
for f in Folder::find_by_user(&user.uuid, &conn).await {
|
|
||||||
f.delete(&conn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
user.update_revision(&conn).await?;
|
|
||||||
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for f in Folder::find_by_user(&user.uuid, &conn).await {
|
||||||
|
f.delete(&conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.update_revision(&conn).await?;
|
||||||
|
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
@@ -1954,8 +1992,11 @@ impl CipherSyncData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate a HashMap with the Organization UUID as key and the Membership record
|
// Generate a HashMap with the Organization UUID as key and the Membership record
|
||||||
let members: HashMap<OrganizationId, Membership> =
|
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
||||||
Membership::find_by_user(user_id, conn).await.into_iter().map(|m| (m.org_uuid.clone(), m)).collect();
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.org_uuid.clone(), m))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
||||||
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
||||||
|
|||||||
@@ -47,24 +47,11 @@ pub fn routes() -> Vec<Route> {
|
|||||||
|
|
||||||
#[get("/emergency-access/trusted")]
|
#[get("/emergency-access/trusted")]
|
||||||
async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
if !CONFIG.emergency_access_allowed() {
|
let emergency_access_list = if CONFIG.emergency_access_allowed() {
|
||||||
return Json(json!({
|
EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await
|
||||||
"data": [{
|
} else {
|
||||||
"id": "",
|
Vec::new()
|
||||||
"status": 2,
|
};
|
||||||
"type": 0,
|
|
||||||
"waitTimeDays": 0,
|
|
||||||
"granteeId": "",
|
|
||||||
"email": "",
|
|
||||||
"name": "NOTE: Emergency Access is disabled!",
|
|
||||||
"object": "emergencyAccessGranteeDetails",
|
|
||||||
|
|
||||||
}],
|
|
||||||
"object": "list",
|
|
||||||
"continuationToken": null
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await;
|
|
||||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||||
for ea in emergency_access_list {
|
for ea in emergency_access_list {
|
||||||
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
||||||
@@ -245,7 +232,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
invitation.save(&conn).await?;
|
invitation.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(email.clone(), None);
|
let mut user = User::new(&email, None);
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
(user, true)
|
(user, true)
|
||||||
}
|
}
|
||||||
@@ -666,7 +653,7 @@ async fn password_emergency_access(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// change grantor_user password
|
// change grantor_user password
|
||||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None);
|
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||||
grantor_user.save(&conn).await?;
|
grantor_user.save(&conn).await?;
|
||||||
|
|
||||||
// Disable TwoFactor providers since they will otherwise block logins
|
// Disable TwoFactor providers since they will otherwise block logins
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ async fn _log_user_event(
|
|||||||
ip: &IpAddr,
|
ip: &IpAddr,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
let memberships = Membership::find_by_user(user_id, conn).await;
|
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
|
||||||
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
||||||
|
|
||||||
// Upstream saves the event also without any org_id.
|
// Upstream saves the event also without any org_id.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::{
|
|||||||
models::{Folder, FolderId},
|
models::{Folder, FolderId},
|
||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
|
util::deser_opt_nonempty_str,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<rocket::Route> {
|
pub fn routes() -> Vec<rocket::Route> {
|
||||||
@@ -38,6 +39,7 @@ async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> Json
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FolderData {
|
pub struct FolderData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||||
pub id: Option<FolderId>,
|
pub id: Option<FolderId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-39
@@ -53,13 +53,14 @@ use crate::{
|
|||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{
|
db::{
|
||||||
models::{Membership, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyErr, Organization, User},
|
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
mail,
|
mail,
|
||||||
util::parse_experimental_client_feature_flags,
|
util::{parse_experimental_client_feature_flags, FeatureFlagFilter},
|
||||||
|
CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -74,11 +75,11 @@ const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
|||||||
|
|
||||||
#[get("/settings/domains")]
|
#[get("/settings/domains")]
|
||||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
||||||
_get_eq_domains(headers, false)
|
_get_eq_domains(&headers, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json<Value> {
|
fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||||
let user = headers.user;
|
let user = &headers.user;
|
||||||
use serde_json::from_str;
|
use serde_json::from_str;
|
||||||
|
|
||||||
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
||||||
@@ -136,7 +137,7 @@ async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbC
|
|||||||
#[get("/hibp/breach?<username>")]
|
#[get("/hibp/breach?<username>")]
|
||||||
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
||||||
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
||||||
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
|
if let Some(api_key) = CONFIG.hibp_api_key() {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
||||||
);
|
);
|
||||||
@@ -197,19 +198,17 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
|||||||
|
|
||||||
#[get("/config")]
|
#[get("/config")]
|
||||||
fn config() -> Json<Value> {
|
fn config() -> Json<Value> {
|
||||||
let domain = crate::CONFIG.domain();
|
let domain = CONFIG.domain();
|
||||||
// Official available feature flags can be found here:
|
// Official available feature flags can be found here:
|
||||||
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103
|
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
|
||||||
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12
|
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||||
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
|
// 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 (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||||
let mut feature_states =
|
let feature_states = parse_experimental_client_feature_flags(
|
||||||
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
feature_states.insert("duo-redirect".to_string(), true);
|
FeatureFlagFilter::ValidOnly,
|
||||||
feature_states.insert("email-verification".to_string(), true);
|
);
|
||||||
feature_states.insert("unauth-ui-refresh".to_string(), true);
|
// Add default feature_states here if needed, currently no features are needed by default.
|
||||||
feature_states.insert("enable-pm-flight-recorder".to_string(), true);
|
|
||||||
feature_states.insert("mobile-error-reporting".to_string(), true);
|
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
// Note: The clients use this version to handle backwards compatibility concerns
|
// Note: The clients use this version to handle backwards compatibility concerns
|
||||||
@@ -217,14 +216,15 @@ fn config() -> Json<Value> {
|
|||||||
// We should make sure that we keep this updated when we support the new server features
|
// We should make sure that we keep this updated when we support the new server features
|
||||||
// Version history:
|
// Version history:
|
||||||
// - Individual cipher key encryption: 2024.2.0
|
// - Individual cipher key encryption: 2024.2.0
|
||||||
"version": "2025.6.0",
|
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||||
|
"version": "2025.12.0",
|
||||||
"gitHash": option_env!("GIT_REV"),
|
"gitHash": option_env!("GIT_REV"),
|
||||||
"server": {
|
"server": {
|
||||||
"name": "Vaultwarden",
|
"name": "Vaultwarden",
|
||||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"disableUserRegistration": crate::CONFIG.is_signup_disabled()
|
"disableUserRegistration": CONFIG.is_signup_disabled()
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"vault": domain,
|
"vault": domain,
|
||||||
@@ -269,30 +269,15 @@ async fn accept_org_invite(
|
|||||||
err!("User already accepted the invitation");
|
err!("User already accepted the invitation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
|
||||||
// It returns different error messages per function.
|
|
||||||
if member.atype < MembershipType::Admin {
|
|
||||||
match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
|
||||||
if crate::CONFIG.email_2fa_auto_fallback() {
|
|
||||||
two_factor::email::activate_email_2fa(user, conn).await?;
|
|
||||||
} else {
|
|
||||||
err!("You cannot join this organization until you enable two-step login on your user account");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
|
||||||
err!("You cannot join this organization because you are a member of an organization which forbids it");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
member.status = MembershipStatus::Accepted as i32;
|
member.status = MembershipStatus::Accepted as i32;
|
||||||
member.reset_password_key = reset_password_key;
|
member.reset_password_key = reset_password_key;
|
||||||
|
|
||||||
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||||
|
OrgPolicy::check_user_allowed(&member, "join", conn).await?;
|
||||||
|
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
if crate::CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
||||||
Some(org) => org,
|
Some(org) => org,
|
||||||
None => err!("Organization not found."),
|
None => err!("Organization not found."),
|
||||||
|
|||||||
+231
-540
File diff suppressed because it is too large
Load Diff
@@ -94,7 +94,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
Some(user) => user, // exists in vaultwarden
|
Some(user) => user, // exists in vaultwarden
|
||||||
None => {
|
None => {
|
||||||
// User does not exist yet
|
// User does not exist yet
|
||||||
let mut new_user = User::new(user_data.email.clone(), None);
|
let mut new_user = User::new(&user_data.email, None);
|
||||||
new_user.save(&conn).await?;
|
new_user.save(&conn).await?;
|
||||||
|
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
@@ -156,7 +156,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupUser::delete_all_by_group(&group_uuid, &conn).await?;
|
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||||
|
|
||||||
for ext_id in &group_data.member_external_ids {
|
for ext_id in &group_data.member_external_ids {
|
||||||
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
|
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
|
||||||
|
|||||||
+11
-12
@@ -1,13 +1,12 @@
|
|||||||
use std::path::Path;
|
use std::{path::Path, sync::LazyLock, time::Duration};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use once_cell::sync::Lazy;
|
use rocket::{
|
||||||
use rocket::form::Form;
|
form::Form,
|
||||||
use rocket::fs::NamedFile;
|
fs::{NamedFile, TempFile},
|
||||||
use rocket::fs::TempFile;
|
serde::json::Json,
|
||||||
use rocket::serde::json::Json;
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -23,7 +22,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
||||||
static ANON_PUSH_DEVICE: Lazy<Device> = Lazy::new(|| {
|
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
|
||||||
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
|
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
|
||||||
Device {
|
Device {
|
||||||
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
|
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
|
||||||
@@ -274,7 +273,7 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbCo
|
|||||||
|
|
||||||
let file_id = crate::crypto::generate_send_file_id();
|
let file_id = crate::crypto::generate_send_file_id();
|
||||||
|
|
||||||
save_temp_file(PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
|
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
|
||||||
|
|
||||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||||
if let Some(o) = data_value.as_object_mut() {
|
if let Some(o) = data_value.as_object_mut() {
|
||||||
@@ -426,7 +425,7 @@ async fn post_send_file_v2_data(
|
|||||||
|
|
||||||
let file_path = format!("{send_id}/{file_id}");
|
let file_path = format!("{send_id}/{file_id}");
|
||||||
|
|
||||||
save_temp_file(PathType::Sends, &file_path, data.data, false).await?;
|
save_temp_file(&PathType::Sends, &file_path, data.data, false).await?;
|
||||||
|
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendCreate,
|
UpdateType::SyncSendCreate,
|
||||||
@@ -567,9 +566,9 @@ async fn post_access_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
|
||||||
|
|
||||||
if operator.info().scheme() == opendal::Scheme::Fs {
|
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
|
||||||
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
||||||
let token = crate::auth::encode_jwt(&token_claims);
|
let token = crate::auth::encode_jwt(&token_claims);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
|
|||||||
|
|
||||||
let (enabled, key) = match twofactor {
|
let (enabled, key) = match twofactor {
|
||||||
Some(tf) => (true, tf.data),
|
Some(tf) => (true, tf.data),
|
||||||
_ => (false, crypto::encode_random_bytes::<20>(BASE32)),
|
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
|
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use crate::{
|
|||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
core::{log_user_event, two_factor::_generate_recover_code},
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::{ClientHeaders, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
@@ -26,36 +26,80 @@ pub fn routes() -> Vec<Route> {
|
|||||||
struct SendEmailLoginData {
|
struct SendEmailLoginData {
|
||||||
#[serde(alias = "DeviceIdentifier")]
|
#[serde(alias = "DeviceIdentifier")]
|
||||||
device_identifier: DeviceId,
|
device_identifier: DeviceId,
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
#[serde(alias = "Email")]
|
#[serde(alias = "Email")]
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
#[serde(alias = "MasterPasswordHash")]
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
master_password_hash: Option<String>,
|
master_password_hash: Option<String>,
|
||||||
|
auth_request_id: Option<AuthRequestId>,
|
||||||
|
auth_request_access_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User is trying to login and wants to use email 2FA.
|
/// User is trying to login and wants to use email 2FA.
|
||||||
/// Does not require Bearer token
|
/// Does not require Bearer token
|
||||||
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
||||||
async fn send_email_login(data: Json<SendEmailLoginData>, conn: DbConn) -> EmptyResult {
|
async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: ClientHeaders, conn: DbConn) -> EmptyResult {
|
||||||
let data: SendEmailLoginData = data.into_inner();
|
let data: SendEmailLoginData = data.into_inner();
|
||||||
|
|
||||||
use crate::db::models::User;
|
|
||||||
|
|
||||||
// Get the user
|
|
||||||
let Some(user) = User::find_by_device_id(&data.device_identifier, &conn).await else {
|
|
||||||
err!("Cannot find user. Try again.")
|
|
||||||
};
|
|
||||||
|
|
||||||
if !CONFIG._enable_email_2fa() {
|
if !CONFIG._enable_email_2fa() {
|
||||||
err!("Email 2FA is disabled")
|
err!("Email 2FA is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
send_token(&user.uuid, &conn).await?;
|
// Ratelimit the login
|
||||||
|
crate::ratelimit::check_limit_login(&client_headers.ip.ip)?;
|
||||||
|
|
||||||
Ok(())
|
// Get the user
|
||||||
|
let email = match &data.email {
|
||||||
|
Some(email) if !email.is_empty() => Some(email),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let master_password_hash = match &data.master_password_hash {
|
||||||
|
Some(password_hash) if !password_hash.is_empty() => Some(password_hash),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let auth_request_id = match &data.auth_request_id {
|
||||||
|
Some(auth_request_id) if !auth_request_id.is_empty() => Some(auth_request_id),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
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.")
|
||||||
|
};
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
|
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||||
|
err!("AuthRequest doesn't exist", "User not found")
|
||||||
|
};
|
||||||
|
let Some(code) = &data.auth_request_access_code else {
|
||||||
|
err!("no auth request access code")
|
||||||
|
};
|
||||||
|
|
||||||
|
if auth_request.device_type != client_headers.device_type
|
||||||
|
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||||
|
|| !auth_request.check_access_code(code)
|
||||||
|
{
|
||||||
|
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err!("No password hash has been submitted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
user
|
||||||
|
} else {
|
||||||
|
// SSO login only sends device id, so we get the user by the most recently used device
|
||||||
|
let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else {
|
||||||
|
err!("Username or password is incorrect. Try again.")
|
||||||
|
};
|
||||||
|
|
||||||
|
user
|
||||||
|
};
|
||||||
|
|
||||||
|
send_token(&user.uuid, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the token, save the data for later verification and send email to user
|
/// Generate the token, save the data for later verification and send email to user
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
|
use num_traits::FromPrimitive;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -9,12 +11,12 @@ use crate::{
|
|||||||
core::{log_event, log_user_event},
|
core::{log_event, log_user_event},
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, Headers},
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{
|
models::{
|
||||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||||
TwoFactorIncomplete, User, UserId,
|
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
},
|
},
|
||||||
@@ -31,11 +33,47 @@ pub mod protected_actions;
|
|||||||
pub mod webauthn;
|
pub mod webauthn;
|
||||||
pub mod yubikey;
|
pub mod yubikey;
|
||||||
|
|
||||||
|
fn has_global_duo_credentials() -> bool {
|
||||||
|
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DuoProviderData {
|
||||||
|
host: String,
|
||||||
|
ik: String,
|
||||||
|
sk: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
match provider_type {
|
||||||
|
TwoFactorType::Authenticator => true,
|
||||||
|
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||||
|
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||||
|
provider_data
|
||||||
|
.and_then(|raw| serde_json::from_str::<DuoProviderData>(raw).ok())
|
||||||
|
.is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty())
|
||||||
|
|| has_global_duo_credentials()
|
||||||
|
}
|
||||||
|
TwoFactorType::YubiKey => {
|
||||||
|
CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some()
|
||||||
|
}
|
||||||
|
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||||
|
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||||
|
TwoFactorType::RecoveryCode => true,
|
||||||
|
TwoFactorType::U2f
|
||||||
|
| TwoFactorType::U2fRegisterChallenge
|
||||||
|
| TwoFactorType::U2fLoginChallenge
|
||||||
|
| TwoFactorType::EmailVerificationChallenge
|
||||||
|
| TwoFactorType::WebauthnRegisterChallenge
|
||||||
|
| TwoFactorType::WebauthnLoginChallenge
|
||||||
|
| TwoFactorType::ProtectedActions => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut routes = routes![
|
let mut routes = routes![
|
||||||
get_twofactor,
|
get_twofactor,
|
||||||
get_recover,
|
get_recover,
|
||||||
recover,
|
|
||||||
disable_twofactor,
|
disable_twofactor,
|
||||||
disable_twofactor_put,
|
disable_twofactor_put,
|
||||||
get_device_verification_settings,
|
get_device_verification_settings,
|
||||||
@@ -54,7 +92,13 @@ pub fn routes() -> Vec<Route> {
|
|||||||
#[get("/two-factor")]
|
#[get("/two-factor")]
|
||||||
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
|
let twofactors_json: Vec<Value> = twofactors
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tf| {
|
||||||
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
|
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"data": twofactors_json,
|
"data": twofactors_json,
|
||||||
@@ -76,57 +120,9 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct RecoverTwoFactor {
|
|
||||||
master_password_hash: String,
|
|
||||||
email: String,
|
|
||||||
recovery_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/two-factor/recover", data = "<data>")]
|
|
||||||
async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, conn: DbConn) -> JsonResult {
|
|
||||||
let data: RecoverTwoFactor = data.into_inner();
|
|
||||||
|
|
||||||
use crate::db::models::User;
|
|
||||||
|
|
||||||
// Get the user
|
|
||||||
let Some(mut user) = User::find_by_mail(&data.email, &conn).await else {
|
|
||||||
err!("Username or password is incorrect. Try again.")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check password
|
|
||||||
if !user.check_valid_password(&data.master_password_hash) {
|
|
||||||
err!("Username or password is incorrect. Try again.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if recovery code is correct
|
|
||||||
if !user.check_valid_recovery_code(&data.recovery_code) {
|
|
||||||
err!("Recovery code is incorrect. Try again.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove all twofactors from the user
|
|
||||||
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
|
||||||
enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn).await?;
|
|
||||||
|
|
||||||
log_user_event(
|
|
||||||
EventType::UserRecovered2fa as i32,
|
|
||||||
&user.uuid,
|
|
||||||
client_headers.device_type,
|
|
||||||
&client_headers.ip.ip,
|
|
||||||
&conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Remove the recovery code, not needed without twofactors
|
|
||||||
user.totp_recover = None;
|
|
||||||
user.save(&conn).await?;
|
|
||||||
Ok(Json(Value::Object(serde_json::Map::new())))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||||
if user.totp_recover.is_none() {
|
if user.totp_recover.is_none() {
|
||||||
let totp_recover = crypto::encode_random_bytes::<20>(BASE32);
|
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||||
user.totp_recover = Some(totp_recover);
|
user.totp_recover = Some(totp_recover);
|
||||||
user.save(conn).await.ok();
|
user.save(conn).await.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ impl WebauthnRegistration {
|
|||||||
|
|
||||||
#[post("/two-factor/get-webauthn", data = "<data>")]
|
#[post("/two-factor/get-webauthn", data = "<data>")]
|
||||||
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
if !CONFIG.domain_set() {
|
if !CONFIG.is_webauthn_2fa_supported() {
|
||||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
err!("Configured `DOMAIN` is not compatible with Webauthn")
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
@@ -144,7 +144,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
|||||||
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||||
&user.email,
|
&user.email,
|
||||||
&user.name,
|
user.display_name(),
|
||||||
Some(registrations),
|
Some(registrations),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -438,7 +438,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
|||||||
// We need to check for and update the backup_eligible flag when needed.
|
// We need to check for and update the backup_eligible flag when needed.
|
||||||
// Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
|
// Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
|
||||||
// Because of this we check the flag at runtime and update the registrations and state when needed
|
// Because of this we check the flag at runtime and update the registrations and state when needed
|
||||||
check_and_update_backup_eligible(user_id, &rsp, &mut registrations, &mut state, conn).await?;
|
let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?;
|
||||||
|
|
||||||
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
|
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
|
||||||
|
|
||||||
@@ -446,7 +446,8 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
|||||||
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
|
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
|
||||||
// If the cred id matches and the credential is updated, Some(true) is returned
|
// If the cred id matches and the credential is updated, Some(true) is returned
|
||||||
// In those cases, update the record, else leave it alone
|
// In those cases, update the record, else leave it alone
|
||||||
if reg.credential.update_credential(&authentication_result) == Some(true) {
|
let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true);
|
||||||
|
if credential_updated || backup_flags_updated {
|
||||||
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||||
.save(conn)
|
.save(conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -463,13 +464,11 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_and_update_backup_eligible(
|
fn check_and_update_backup_eligible(
|
||||||
user_id: &UserId,
|
|
||||||
rsp: &PublicKeyCredential,
|
rsp: &PublicKeyCredential,
|
||||||
registrations: &mut Vec<WebauthnRegistration>,
|
registrations: &mut Vec<WebauthnRegistration>,
|
||||||
state: &mut PasskeyAuthentication,
|
state: &mut PasskeyAuthentication,
|
||||||
conn: &DbConn,
|
) -> Result<bool, Error> {
|
||||||
) -> EmptyResult {
|
|
||||||
// The feature flags from the response
|
// The feature flags from the response
|
||||||
// For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
|
// For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
|
||||||
const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000;
|
const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000;
|
||||||
@@ -486,16 +485,7 @@ async fn check_and_update_backup_eligible(
|
|||||||
let rsp_id = rsp.raw_id.as_slice();
|
let rsp_id = rsp.raw_id.as_slice();
|
||||||
for reg in &mut *registrations {
|
for reg in &mut *registrations {
|
||||||
if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) {
|
if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) {
|
||||||
// Try to update the key, and if needed also update the database, before the actual state check is done
|
|
||||||
if reg.set_backup_eligible(backup_eligible, backup_state) {
|
if reg.set_backup_eligible(backup_eligible, backup_state) {
|
||||||
TwoFactor::new(
|
|
||||||
user_id.clone(),
|
|
||||||
TwoFactorType::Webauthn,
|
|
||||||
serde_json::to_string(®istrations)?,
|
|
||||||
)
|
|
||||||
.save(conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// We also need to adjust the current state which holds the challenge used to start the authentication verification
|
// We also need to adjust the current state which holds the challenge used to start the authentication verification
|
||||||
// Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
|
// Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
|
||||||
let mut raw_state = serde_json::to_value(&state)?;
|
let mut raw_state = serde_json::to_value(&state)?;
|
||||||
@@ -517,11 +507,12 @@ async fn check_and_update_backup_eligible(
|
|||||||
}
|
}
|
||||||
|
|
||||||
*state = serde_json::from_value(raw_state)?;
|
*state = serde_json::from_value(raw_state)?;
|
||||||
|
return Ok(true);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-26
@@ -1,13 +1,13 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
sync::Arc,
|
sync::{Arc, LazyLock},
|
||||||
time::{Duration, SystemTime},
|
time::{Duration, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bytes::{Bytes, BytesMut};
|
use bytes::{Bytes, BytesMut};
|
||||||
use futures::{stream::StreamExt, TryFutureExt};
|
use futures::{stream::StreamExt, TryFutureExt};
|
||||||
use once_cell::sync::Lazy;
|
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{self, HeaderMap, HeaderValue},
|
header::{self, HeaderMap, HeaderValue},
|
||||||
@@ -16,8 +16,6 @@ use reqwest::{
|
|||||||
use rocket::{http::ContentType, response::Redirect, Route};
|
use rocket::{http::ContentType, response::Redirect, Route};
|
||||||
use svg_hush::{data_url_filter, Filter};
|
use svg_hush::{data_url_filter, Filter};
|
||||||
|
|
||||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::PathType,
|
config::PathType,
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -33,7 +31,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static CLIENT: Lazy<Client> = Lazy::new(|| {
|
static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
||||||
// Generate the default headers
|
// Generate the default headers
|
||||||
let mut default_headers = HeaderMap::new();
|
let mut default_headers = HeaderMap::new();
|
||||||
default_headers.insert(
|
default_headers.insert(
|
||||||
@@ -78,25 +76,25 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build Regex only once since this takes a lot of time.
|
// Build Regex only once since this takes a lot of time.
|
||||||
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
|
static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
|
||||||
|
|
||||||
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
||||||
// It is used to prevent sending a specific header which breaks icon downloads.
|
// It is used to prevent sending a specific header which breaks icon downloads.
|
||||||
// If this function needs to be renamed, also adjust the code in `util.rs`
|
// If this function needs to be renamed, also adjust the code in `util.rs`
|
||||||
#[get("/<domain>/icon.png")]
|
#[get("/<domain>/icon.png")]
|
||||||
fn icon_external(domain: &str) -> Option<Redirect> {
|
fn icon_external(domain: &str) -> Cached<Option<Redirect>> {
|
||||||
if !is_valid_domain(domain) {
|
if !is_valid_domain(domain) {
|
||||||
warn!("Invalid domain: {domain}");
|
warn!("Invalid domain: {domain}");
|
||||||
return None;
|
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_block_address(domain) {
|
if should_block_address(domain) {
|
||||||
warn!("Blocked address: {domain}");
|
warn!("Blocked address: {domain}");
|
||||||
return None;
|
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = CONFIG._icon_service_url().replace("{}", domain);
|
let url = CONFIG._icon_service_url().replace("{}", domain);
|
||||||
match CONFIG.icon_redirect_code() {
|
let redir = match CONFIG.icon_redirect_code() {
|
||||||
301 => Some(Redirect::moved(url)), // legacy permanent redirect
|
301 => Some(Redirect::moved(url)), // legacy permanent redirect
|
||||||
302 => Some(Redirect::found(url)), // legacy temporary redirect
|
302 => Some(Redirect::found(url)), // legacy temporary redirect
|
||||||
307 => Some(Redirect::temporary(url)),
|
307 => Some(Redirect::temporary(url)),
|
||||||
@@ -105,7 +103,8 @@ fn icon_external(domain: &str) -> Option<Redirect> {
|
|||||||
error!("Unexpected redirect code {}", CONFIG.icon_redirect_code());
|
error!("Unexpected redirect code {}", CONFIG.icon_redirect_code());
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/<domain>/icon.png")]
|
#[get("/<domain>/icon.png")]
|
||||||
@@ -143,7 +142,7 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
|||||||
/// This does some manual checks and makes use of Url to do some basic checking.
|
/// This does some manual checks and makes use of Url to do some basic checking.
|
||||||
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
|
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
|
||||||
fn is_valid_domain(domain: &str) -> bool {
|
fn is_valid_domain(domain: &str) -> bool {
|
||||||
const ALLOWED_CHARS: &str = "_-.";
|
const ALLOWED_CHARS: &str = "-.";
|
||||||
|
|
||||||
// If parsing the domain fails using Url, it will not work with reqwest.
|
// If parsing the domain fails using Url, it will not work with reqwest.
|
||||||
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
|
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
|
||||||
@@ -220,7 +219,7 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to read the cached icon, and return it if it exists
|
// Try to read the cached icon, and return it if it exists
|
||||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
|
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||||
if let Ok(buf) = operator.read(path).await {
|
if let Ok(buf) = operator.read(path).await {
|
||||||
return Some(buf.to_vec());
|
return Some(buf.to_vec());
|
||||||
}
|
}
|
||||||
@@ -230,7 +229,7 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::IconCache)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
||||||
let meta = operator.stat(path).await?;
|
let meta = operator.stat(path).await?;
|
||||||
let modified =
|
let modified =
|
||||||
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
||||||
@@ -246,7 +245,7 @@ async fn icon_is_negcached(path: &str) -> bool {
|
|||||||
match expired {
|
match expired {
|
||||||
// No longer negatively cached, drop the marker
|
// No longer negatively cached, drop the marker
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
match CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
|
match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||||
Ok(operator) => {
|
Ok(operator) => {
|
||||||
if let Err(e) = operator.delete(&miss_indicator).await {
|
if let Err(e) = operator.delete(&miss_indicator).await {
|
||||||
error!("Could not remove negative cache indicator for icon {path:?}: {e:?}");
|
error!("Could not remove negative cache indicator for icon {path:?}: {e:?}");
|
||||||
@@ -462,8 +461,8 @@ async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Err
|
|||||||
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
|
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
|
||||||
/// ```
|
/// ```
|
||||||
fn get_icon_priority(href: &str, sizes: &str) -> u8 {
|
fn get_icon_priority(href: &str, sizes: &str) -> u8 {
|
||||||
static PRIORITY_MAP: Lazy<HashMap<&'static str, u8>> =
|
static PRIORITY_MAP: LazyLock<HashMap<&'static str, u8>> =
|
||||||
Lazy::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect());
|
LazyLock::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect());
|
||||||
|
|
||||||
// Check if there is a dimension set
|
// Check if there is a dimension set
|
||||||
let (width, height) = parse_sizes(sizes);
|
let (width, height) = parse_sizes(sizes);
|
||||||
@@ -514,13 +513,11 @@ fn parse_sizes(sizes: &str) -> (u16, u16) {
|
|||||||
|
|
||||||
if !sizes.is_empty() {
|
if !sizes.is_empty() {
|
||||||
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
||||||
None => {}
|
Some(dimensions) if dimensions.len() >= 3 => {
|
||||||
Some(dimensions) => {
|
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
||||||
if dimensions.len() >= 3 {
|
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
||||||
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
|
||||||
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +594,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save_icon(path: &str, icon: Vec<u8>) {
|
async fn save_icon(path: &str, icon: Vec<u8>) {
|
||||||
let operator = match CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
|
let operator = match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||||
Ok(operator) => operator,
|
Ok(operator) => operator,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to get OpenDAL operator while saving icon: {e}");
|
warn!("Failed to get OpenDAL operator while saving icon: {e}");
|
||||||
@@ -798,8 +795,11 @@ impl Emitter for FaviconEmitter {
|
|||||||
fn emit_current_tag(&mut self) -> Option<html5gum::State> {
|
fn emit_current_tag(&mut self) -> Option<html5gum::State> {
|
||||||
self.flush_current_attribute(true);
|
self.flush_current_attribute(true);
|
||||||
self.last_start_tag.clear();
|
self.last_start_tag.clear();
|
||||||
if self.current_token.is_some() && !self.current_token.as_ref().unwrap().closing {
|
match &self.current_token {
|
||||||
self.last_start_tag.extend(&*self.current_token.as_ref().unwrap().tag.name);
|
Some(token) if !token.closing => {
|
||||||
|
self.last_start_tag.extend(&*token.tag.name);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
html5gum::naive_next_state(&self.last_start_tag)
|
html5gum::naive_next_state(&self.last_start_tag)
|
||||||
}
|
}
|
||||||
|
|||||||
+203
-91
@@ -1,8 +1,7 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::Utc;
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
form::{Form, FromForm},
|
form::{Form, FromForm},
|
||||||
http::Status,
|
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Route,
|
Route,
|
||||||
@@ -12,9 +11,12 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
core::{
|
core::{
|
||||||
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
|
accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
|
||||||
log_user_event,
|
log_user_event,
|
||||||
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
|
two_factor::{
|
||||||
|
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
||||||
|
yubikey,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
master_password_policy,
|
master_password_policy,
|
||||||
push::register_push_device,
|
push::register_push_device,
|
||||||
@@ -24,14 +26,14 @@ use crate::{
|
|||||||
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
|
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
|
||||||
db::{
|
db::{
|
||||||
models::{
|
models::{
|
||||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OrganizationApiKey, OrganizationId,
|
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
|
||||||
SsoNonce, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
mail, sso,
|
mail, sso,
|
||||||
sso::{OIDCCode, OIDCState},
|
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
||||||
util, CONFIG,
|
util, CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ async fn login(
|
|||||||
"authorization_code" if CONFIG.sso_enabled() => {
|
"authorization_code" if CONFIG.sso_enabled() => {
|
||||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||||
_check_is_some(&data.code, "code cannot be blank")?;
|
_check_is_some(&data.code, "code cannot be blank")?;
|
||||||
|
_check_is_some(&data.code_verifier, "code verifier cannot be blank")?;
|
||||||
|
|
||||||
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
|
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
|
||||||
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
||||||
@@ -127,12 +130,14 @@ async fn login(
|
|||||||
login_result
|
login_result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return Status::Unauthorized to trigger logout
|
|
||||||
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
// Extract token
|
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
|
||||||
let refresh_token = match data.refresh_token {
|
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
|
||||||
Some(token) => token,
|
// See the link below for details
|
||||||
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
|
// https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797
|
||||||
|
|
||||||
|
let Some(refresh_token) = data.refresh_token else {
|
||||||
|
err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token")
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---
|
// ---
|
||||||
@@ -143,11 +148,14 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
|
|||||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||||
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
|
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
|
err_json!(
|
||||||
|
json!({"error": "invalid_grant"}),
|
||||||
|
format!("Unable to refresh login credentials: {}", err.message())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Ok((mut device, auth_tokens)) => {
|
Ok((mut device, auth_tokens)) => {
|
||||||
// Save to update `device.updated_at` to track usage and toggle new status
|
// Save to update `device.updated_at` to track usage and toggle new status
|
||||||
device.save(conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
let result = json!({
|
let result = json!({
|
||||||
"refresh_token": auth_tokens.refresh_token(),
|
"refresh_token": auth_tokens.refresh_token(),
|
||||||
@@ -175,17 +183,23 @@ async fn _sso_login(
|
|||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
let code = match data.code.as_ref() {
|
let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
|
||||||
None => err!(
|
(None, _) => err!(
|
||||||
"Got no code in OIDC data",
|
"Got no code in OIDC data",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn
|
event: EventType::UserFailedLogIn
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
Some(code) => code,
|
(_, None) => err!(
|
||||||
|
"Got no code verifier in OIDC data",
|
||||||
|
ErrorEvent {
|
||||||
|
event: EventType::UserFailedLogIn
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_infos = sso::exchange_code(code, conn).await?;
|
let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?;
|
||||||
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
|
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
|
||||||
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
|
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
|
||||||
None => None,
|
None => None,
|
||||||
@@ -248,7 +262,7 @@ async fn _sso_login(
|
|||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(user_infos.email, user_infos.user_name);
|
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
||||||
user.verified_at = Some(now);
|
user.verified_at = Some(now);
|
||||||
user.save(conn).await?;
|
user.save(conn).await?;
|
||||||
|
|
||||||
@@ -259,7 +273,7 @@ async fn _sso_login(
|
|||||||
Some((user, _)) if !user.enabled => {
|
Some((user, _)) if !user.enabled => {
|
||||||
err!(
|
err!(
|
||||||
"This user has been disabled",
|
"This user has been disabled",
|
||||||
format!("IP: {}. Username: {}.", ip.ip, user.name),
|
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn
|
event: EventType::UserFailedLogIn
|
||||||
}
|
}
|
||||||
@@ -267,13 +281,14 @@ async fn _sso_login(
|
|||||||
}
|
}
|
||||||
Some((mut user, sso_user)) => {
|
Some((mut user, sso_user)) => {
|
||||||
let mut device = get_device(&data, conn, &user).await?;
|
let mut device = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
|
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
|
||||||
|
|
||||||
if user.private_key.is_none() {
|
if user.private_key.is_none() {
|
||||||
// User was invited a stub was created
|
// User was invited a stub was created
|
||||||
user.verified_at = Some(now);
|
user.verified_at = Some(now);
|
||||||
if let Some(user_name) = user_infos.user_name {
|
if let Some(ref user_name) = user_infos.user_name {
|
||||||
user.name = user_name;
|
user.name = user_name.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
user.save(conn).await?;
|
user.save(conn).await?;
|
||||||
@@ -290,30 +305,13 @@ async fn _sso_login(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// We passed 2FA get full user information
|
|
||||||
let auth_user = sso::redeem(&user_infos.state, conn).await?;
|
|
||||||
|
|
||||||
if sso_user.is_none() {
|
|
||||||
let user_sso = SsoUser {
|
|
||||||
user_uuid: user.uuid.clone(),
|
|
||||||
identifier: user_infos.identifier,
|
|
||||||
};
|
|
||||||
user_sso.save(conn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the user_uuid here to be passed back used for event logging.
|
// Set the user_uuid here to be passed back used for event logging.
|
||||||
*user_id = Some(user.uuid.clone());
|
*user_id = Some(user.uuid.clone());
|
||||||
|
|
||||||
let auth_tokens = sso::create_auth_tokens(
|
// We passed 2FA get auth tokens
|
||||||
&device,
|
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
|
||||||
&user,
|
|
||||||
data.client_id,
|
|
||||||
auth_user.refresh_token,
|
|
||||||
auth_user.access_token,
|
|
||||||
auth_user.expires_in,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _password_login(
|
async fn _password_login(
|
||||||
@@ -435,7 +433,7 @@ async fn _password_login(
|
|||||||
|
|
||||||
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
|
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
|
||||||
|
|
||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn authenticated_response(
|
async fn authenticated_response(
|
||||||
@@ -443,12 +441,12 @@ async fn authenticated_response(
|
|||||||
device: &mut Device,
|
device: &mut Device,
|
||||||
auth_tokens: auth::AuthTokens,
|
auth_tokens: auth::AuthTokens,
|
||||||
twofactor_token: Option<String>,
|
twofactor_token: Option<String>,
|
||||||
now: &NaiveDateTime,
|
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
if CONFIG.mail_enabled() && device.is_new() {
|
if CONFIG.mail_enabled() && device.is_new() {
|
||||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await {
|
let now = Utc::now().naive_utc();
|
||||||
|
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, device).await {
|
||||||
error!("Error sending new device email: {e:#?}");
|
error!("Error sending new device email: {e:#?}");
|
||||||
|
|
||||||
if CONFIG.require_device_email() {
|
if CONFIG.require_device_email() {
|
||||||
@@ -468,10 +466,42 @@ async fn authenticated_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to update `device.updated_at` to track usage and toggle new status
|
// Save to update `device.updated_at` to track usage and toggle new status
|
||||||
device.save(conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
let master_password_policy = 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 {
|
||||||
|
json!({
|
||||||
|
"Kdf": {
|
||||||
|
"KdfType": user.client_kdf_type,
|
||||||
|
"Iterations": user.client_kdf_iter,
|
||||||
|
"Memory": user.client_kdf_memory,
|
||||||
|
"Parallelism": user.client_kdf_parallelism
|
||||||
|
},
|
||||||
|
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
||||||
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
|
"Salt": user.email
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
};
|
||||||
|
|
||||||
|
let account_keys = if user.private_key.is_some() {
|
||||||
|
json!({
|
||||||
|
"publicKeyEncryptionKeyPair": {
|
||||||
|
"wrappedPrivateKey": user.private_key,
|
||||||
|
"publicKey": user.public_key,
|
||||||
|
"Object": "publicKeyEncryptionKeyPair"
|
||||||
|
},
|
||||||
|
"Object": "privateKeys"
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
};
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"access_token": auth_tokens.access_token(),
|
"access_token": auth_tokens.access_token(),
|
||||||
"expires_in": auth_tokens.expires_in(),
|
"expires_in": auth_tokens.expires_in(),
|
||||||
@@ -486,8 +516,10 @@ async fn authenticated_response(
|
|||||||
"ForcePasswordReset": false,
|
"ForcePasswordReset": false,
|
||||||
"MasterPasswordPolicy": master_password_policy,
|
"MasterPasswordPolicy": master_password_policy,
|
||||||
"scope": auth_tokens.scope(),
|
"scope": auth_tokens.scope(),
|
||||||
|
"AccountKeys": account_keys,
|
||||||
"UserDecryptionOptions": {
|
"UserDecryptionOptions": {
|
||||||
"HasMasterPassword": !user.password_hash.is_empty(),
|
"HasMasterPassword": has_master_password,
|
||||||
|
"MasterPasswordUnlock": master_password_unlock,
|
||||||
"Object": "userDecryptionOptions"
|
"Object": "userDecryptionOptions"
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -500,7 +532,7 @@ async fn authenticated_response(
|
|||||||
result["TwoFactorToken"] = Value::String(token);
|
result["TwoFactorToken"] = Value::String(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("User {} logged in successfully. IP: {}", &user.name, ip.ip);
|
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip);
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,10 +617,42 @@ async fn _user_api_key_login(
|
|||||||
let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id);
|
let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id);
|
||||||
|
|
||||||
// Save to update `device.updated_at` to track usage and toggle new status
|
// Save to update `device.updated_at` to track usage and toggle new status
|
||||||
device.save(conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
|
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
|
||||||
|
|
||||||
|
let has_master_password = !user.password_hash.is_empty();
|
||||||
|
let master_password_unlock = if has_master_password {
|
||||||
|
json!({
|
||||||
|
"Kdf": {
|
||||||
|
"KdfType": user.client_kdf_type,
|
||||||
|
"Iterations": user.client_kdf_iter,
|
||||||
|
"Memory": user.client_kdf_memory,
|
||||||
|
"Parallelism": user.client_kdf_parallelism
|
||||||
|
},
|
||||||
|
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
||||||
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
|
"Salt": user.email
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
};
|
||||||
|
|
||||||
|
let account_keys = if user.private_key.is_some() {
|
||||||
|
json!({
|
||||||
|
"publicKeyEncryptionKeyPair": {
|
||||||
|
"wrappedPrivateKey": user.private_key,
|
||||||
|
"publicKey": user.public_key,
|
||||||
|
"Object": "publicKeyEncryptionKeyPair"
|
||||||
|
},
|
||||||
|
"Object": "privateKeys"
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
};
|
||||||
|
|
||||||
// Note: No refresh_token is returned. The CLI just repeats the
|
// Note: No refresh_token is returned. The CLI just repeats the
|
||||||
// client_credentials login flow when the existing token expires.
|
// client_credentials login flow when the existing token expires.
|
||||||
let result = json!({
|
let result = json!({
|
||||||
@@ -603,7 +667,14 @@ async fn _user_api_key_login(
|
|||||||
"KdfMemory": user.client_kdf_memory,
|
"KdfMemory": user.client_kdf_memory,
|
||||||
"KdfParallelism": user.client_kdf_parallelism,
|
"KdfParallelism": user.client_kdf_parallelism,
|
||||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
"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(),
|
"scope": AuthMethod::UserApiKey.scope(),
|
||||||
|
"AccountKeys": account_keys,
|
||||||
|
"UserDecryptionOptions": {
|
||||||
|
"HasMasterPassword": has_master_password,
|
||||||
|
"MasterPasswordUnlock": master_password_unlock,
|
||||||
|
"Object": "userDecryptionOptions"
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
@@ -648,7 +719,12 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult
|
|||||||
// Find device or create new
|
// Find device or create new
|
||||||
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||||
Some(device) => Ok(device),
|
Some(device) => Ok(device),
|
||||||
None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await,
|
None => {
|
||||||
|
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
||||||
|
// save device without updating `device.updated_at`
|
||||||
|
device.save(false, conn).await?;
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,8 +746,27 @@ async fn twofactor_auth(
|
|||||||
|
|
||||||
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?;
|
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?;
|
||||||
|
|
||||||
let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect();
|
let twofactor_ids: Vec<_> = twofactors
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tf| {
|
||||||
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
|
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if twofactor_ids.is_empty() {
|
||||||
|
err!("No enabled and usable two factor providers are available for this account")
|
||||||
|
}
|
||||||
|
|
||||||
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
||||||
|
// Ignore Remember and RecoveryCode Types during this check, these are special
|
||||||
|
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
|
||||||
|
&& !twofactor_ids.contains(&selected_id)
|
||||||
|
{
|
||||||
|
err_json!(
|
||||||
|
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
|
"Invalid two factor provider"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let twofactor_code = match data.two_factor_token {
|
let twofactor_code = match data.two_factor_token {
|
||||||
Some(ref code) => code,
|
Some(ref code) => code,
|
||||||
@@ -688,7 +783,6 @@ async fn twofactor_auth(
|
|||||||
use crate::crypto::ct_eq;
|
use crate::crypto::ct_eq;
|
||||||
|
|
||||||
let selected_data = _selected_data(selected_twofactor);
|
let selected_data = _selected_data(selected_twofactor);
|
||||||
let mut remember = data.two_factor_remember.unwrap_or(0);
|
|
||||||
|
|
||||||
match TwoFactorType::from_i32(selected_id) {
|
match TwoFactorType::from_i32(selected_id) {
|
||||||
Some(TwoFactorType::Authenticator) => {
|
Some(TwoFactorType::Authenticator) => {
|
||||||
@@ -720,13 +814,23 @@ async fn twofactor_auth(
|
|||||||
}
|
}
|
||||||
Some(TwoFactorType::Remember) => {
|
Some(TwoFactorType::Remember) => {
|
||||||
match device.twofactor_remember {
|
match device.twofactor_remember {
|
||||||
Some(ref code) if !CONFIG.disable_2fa_remember() && ct_eq(code, twofactor_code) => {
|
// When a 2FA Remember token is used, check and validate this JWT token, if it is valid, just continue
|
||||||
remember = 1; // Make sure we also return the token here, otherwise it will only remember the first time
|
// If it is invalid we need to trigger the 2FA Login prompt
|
||||||
}
|
Some(ref token)
|
||||||
|
if !CONFIG.disable_2fa_remember()
|
||||||
|
&& (ct_eq(token, twofactor_code)
|
||||||
|
&& auth::decode_2fa_remember(twofactor_code)
|
||||||
|
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
|
||||||
_ => {
|
_ => {
|
||||||
|
// Always delete the current twofactor remember token here if it exists
|
||||||
|
if device.twofactor_remember.is_some() {
|
||||||
|
device.delete_twofactor_remember();
|
||||||
|
// We need to save here, since we send a err_json!() which prevents saving `device` at a later stage
|
||||||
|
device.save(true, conn).await?;
|
||||||
|
}
|
||||||
err_json!(
|
err_json!(
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
"2FA Remember token not provided"
|
"2FA Remember token not provided or expired"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,10 +861,10 @@ async fn twofactor_auth(
|
|||||||
|
|
||||||
TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?;
|
TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?;
|
||||||
|
|
||||||
|
let remember = data.two_factor_remember.unwrap_or(0);
|
||||||
let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 {
|
let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 {
|
||||||
Some(device.refresh_twofactor_remember())
|
Some(device.refresh_twofactor_remember())
|
||||||
} else {
|
} else {
|
||||||
device.delete_twofactor_remember();
|
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Ok(two_factor)
|
Ok(two_factor)
|
||||||
@@ -793,7 +897,7 @@ async fn _json_err_twofactor(
|
|||||||
match TwoFactorType::from_i32(*provider) {
|
match TwoFactorType::from_i32(*provider) {
|
||||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
||||||
|
|
||||||
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => {
|
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
||||||
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
||||||
}
|
}
|
||||||
@@ -893,6 +997,7 @@ struct RegisterVerificationData {
|
|||||||
|
|
||||||
#[derive(rocket::Responder)]
|
#[derive(rocket::Responder)]
|
||||||
enum RegisterVerificationResponse {
|
enum RegisterVerificationResponse {
|
||||||
|
#[response(status = 204)]
|
||||||
NoContent(()),
|
NoContent(()),
|
||||||
Token(Json<String>),
|
Token(Json<String>),
|
||||||
}
|
}
|
||||||
@@ -920,12 +1025,11 @@ async fn register_verification_email(
|
|||||||
let user = User::find_by_mail(&data.email, &conn).await;
|
let user = User::find_by_mail(&data.email, &conn).await;
|
||||||
if user.filter(|u| u.private_key.is_some()).is_some() {
|
if user.filter(|u| u.private_key.is_some()).is_some() {
|
||||||
// There is still a timing side channel here in that the code
|
// There is still a timing side channel here in that the code
|
||||||
// paths that send mail take noticeably longer than ones that
|
// paths that send mail take noticeably longer than ones that don't.
|
||||||
// don't. Add a randomized sleep to mitigate this somewhat.
|
// Add a randomized sleep to mitigate this somewhat.
|
||||||
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
use rand::{rngs::SmallRng, RngExt};
|
||||||
let mut rng = SmallRng::from_os_rng();
|
let mut rng: SmallRng = rand::make_rng();
|
||||||
let delta: i32 = 100;
|
let sleep_ms = rng.random_range(900..=1100) as u64;
|
||||||
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||||
} else {
|
} else {
|
||||||
mail::send_register_verify_email(&data.email, &token).await?;
|
mail::send_register_verify_email(&data.email, &token).await?;
|
||||||
@@ -997,9 +1101,12 @@ struct ConnectData {
|
|||||||
two_factor_remember: Option<i32>,
|
two_factor_remember: Option<i32>,
|
||||||
#[field(name = uncased("authrequest"))]
|
#[field(name = uncased("authrequest"))]
|
||||||
auth_request: Option<AuthRequestId>,
|
auth_request: Option<AuthRequestId>,
|
||||||
|
|
||||||
// Needed for authorization code
|
// Needed for authorization code
|
||||||
#[field(name = uncased("code"))]
|
#[field(name = uncased("code"))]
|
||||||
code: Option<String>,
|
code: Option<OIDCState>,
|
||||||
|
#[field(name = uncased("code_verifier"))]
|
||||||
|
code_verifier: Option<OIDCCodeVerifier>,
|
||||||
}
|
}
|
||||||
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
||||||
if value.is_none() {
|
if value.is_none() {
|
||||||
@@ -1021,14 +1128,13 @@ fn prevalidate() -> JsonResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
||||||
async fn oidcsignin(code: OIDCCode, state: String, conn: DbConn) -> ApiResult<Redirect> {
|
async fn oidcsignin(code: OIDCCode, state: String, mut conn: DbConn) -> ApiResult<Redirect> {
|
||||||
oidcsignin_redirect(
|
_oidcsignin_redirect(
|
||||||
state,
|
state,
|
||||||
|decoded_state| sso::OIDCCodeWrapper::Ok {
|
OIDCCodeWrapper::Ok {
|
||||||
state: decoded_state,
|
|
||||||
code,
|
code,
|
||||||
},
|
},
|
||||||
&conn,
|
&mut conn,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -1040,42 +1146,44 @@ async fn oidcsignin_error(
|
|||||||
state: String,
|
state: String,
|
||||||
error: String,
|
error: String,
|
||||||
error_description: Option<String>,
|
error_description: Option<String>,
|
||||||
conn: DbConn,
|
mut conn: DbConn,
|
||||||
) -> ApiResult<Redirect> {
|
) -> ApiResult<Redirect> {
|
||||||
oidcsignin_redirect(
|
_oidcsignin_redirect(
|
||||||
state,
|
state,
|
||||||
|decoded_state| sso::OIDCCodeWrapper::Error {
|
OIDCCodeWrapper::Error {
|
||||||
state: decoded_state,
|
|
||||||
error,
|
error,
|
||||||
error_description,
|
error_description,
|
||||||
},
|
},
|
||||||
&conn,
|
&mut conn,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// The state was encoded using Base64 to ensure no issue with providers.
|
// The state was encoded using Base64 to ensure no issue with providers.
|
||||||
// iss and scope parameters are needed for redirection to work on IOS.
|
// iss and scope parameters are needed for redirection to work on IOS.
|
||||||
async fn oidcsignin_redirect(
|
// We pass the state as the code to get it back later on.
|
||||||
|
async fn _oidcsignin_redirect(
|
||||||
base64_state: String,
|
base64_state: String,
|
||||||
wrapper: impl FnOnce(OIDCState) -> sso::OIDCCodeWrapper,
|
code_response: OIDCCodeWrapper,
|
||||||
conn: &DbConn,
|
conn: &mut DbConn,
|
||||||
) -> ApiResult<Redirect> {
|
) -> ApiResult<Redirect> {
|
||||||
let state = sso::decode_state(base64_state)?;
|
let state = sso::decode_state(&base64_state)?;
|
||||||
let code = sso::encode_code_claims(wrapper(state.clone()));
|
|
||||||
|
|
||||||
let nonce = match SsoNonce::find(&state, conn).await {
|
let mut sso_auth = match SsoAuth::find(&state, conn).await {
|
||||||
Some(n) => n,
|
None => err!(format!("Cannot retrieve sso_auth for {state}")),
|
||||||
None => err!(format!("Failed to retrieve redirect_uri with {state}")),
|
Some(sso_auth) => sso_auth,
|
||||||
};
|
};
|
||||||
|
sso_auth.code_response = Some(code_response);
|
||||||
|
sso_auth.updated_at = Utc::now().naive_utc();
|
||||||
|
sso_auth.save(conn).await?;
|
||||||
|
|
||||||
let mut url = match url::Url::parse(&nonce.redirect_uri) {
|
let mut url = match url::Url::parse(&sso_auth.redirect_uri) {
|
||||||
Ok(url) => url,
|
Ok(url) => url,
|
||||||
Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", nonce.redirect_uri)),
|
Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", sso_auth.redirect_uri)),
|
||||||
};
|
};
|
||||||
|
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("code", &code)
|
.append_pair("code", &state)
|
||||||
.append_pair("state", &state)
|
.append_pair("state", &state)
|
||||||
.append_pair("scope", &AuthMethod::Sso.scope())
|
.append_pair("scope", &AuthMethod::Sso.scope())
|
||||||
.append_pair("iss", &CONFIG.domain());
|
.append_pair("iss", &CONFIG.domain());
|
||||||
@@ -1098,10 +1206,8 @@ struct AuthorizeData {
|
|||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
scope: Option<String>,
|
scope: Option<String>,
|
||||||
state: OIDCState,
|
state: OIDCState,
|
||||||
#[allow(unused)]
|
code_challenge: OIDCCodeChallenge,
|
||||||
code_challenge: Option<String>,
|
code_challenge_method: String,
|
||||||
#[allow(unused)]
|
|
||||||
code_challenge_method: Option<String>,
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
response_mode: Option<String>,
|
response_mode: Option<String>,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
@@ -1118,10 +1224,16 @@ async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
|
|||||||
client_id,
|
client_id,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
state,
|
state,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method,
|
||||||
..
|
..
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?;
|
if code_challenge_method != "S256" {
|
||||||
|
err!("Unsupported code challenge method");
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?;
|
||||||
|
|
||||||
Ok(Redirect::temporary(String::from(auth_url)))
|
Ok(Redirect::temporary(String::from(auth_url)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub type EmptyResult = ApiResult<()>;
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct PasswordOrOtpData {
|
struct PasswordOrOtpData {
|
||||||
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
master_password_hash: Option<String>,
|
master_password_hash: Option<String>,
|
||||||
otp: Option<String>,
|
otp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-24
@@ -1,11 +1,14 @@
|
|||||||
use std::{net::IpAddr, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{Arc, LazyLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use rmpv::Value;
|
use rmpv::Value;
|
||||||
use rocket::{futures::StreamExt, Route};
|
use rocket::{futures::StreamExt, Route};
|
||||||
use tokio::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
use rocket_ws::{Message, WebSocket};
|
use rocket_ws::{Message, WebSocket};
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{ClientIp, WsAccessTokenHeader},
|
auth::{ClientIp, WsAccessTokenHeader},
|
||||||
@@ -16,15 +19,13 @@ use crate::{
|
|||||||
Error, CONFIG,
|
Error, CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
||||||
|
|
||||||
pub static WS_USERS: Lazy<Arc<WebSocketUsers>> = Lazy::new(|| {
|
|
||||||
Arc::new(WebSocketUsers {
|
Arc::new(WebSocketUsers {
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
pub static WS_ANONYMOUS_SUBSCRIPTIONS: Lazy<Arc<AnonymousWebSocketSubscriptions>> = Lazy::new(|| {
|
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
||||||
Arc::new(AnonymousWebSocketSubscriptions {
|
Arc::new(AnonymousWebSocketSubscriptions {
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
@@ -35,7 +36,7 @@ use super::{
|
|||||||
push_send_update, push_user_update,
|
push_send_update, push_user_update,
|
||||||
};
|
};
|
||||||
|
|
||||||
static NOTIFICATIONS_DISABLED: Lazy<bool> = Lazy::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
@@ -109,8 +110,7 @@ fn websockets_hub<'r>(
|
|||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
header_token: WsAccessTokenHeader,
|
header_token: WsAccessTokenHeader,
|
||||||
) -> Result<rocket_ws::Stream!['r], Error> {
|
) -> Result<rocket_ws::Stream!['r], Error> {
|
||||||
let addr = ip.ip;
|
info!("Accepting Rocket WS connection from {}", ip.ip);
|
||||||
info!("Accepting Rocket WS connection from {addr}");
|
|
||||||
|
|
||||||
let token = if let Some(token) = data.access_token {
|
let token = if let Some(token) = data.access_token {
|
||||||
token
|
token
|
||||||
@@ -133,7 +133,7 @@ fn websockets_hub<'r>(
|
|||||||
users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx));
|
users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx));
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, addr))
|
(rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -189,8 +189,7 @@ fn websockets_hub<'r>(
|
|||||||
#[allow(tail_expr_drop_order)]
|
#[allow(tail_expr_drop_order)]
|
||||||
#[get("/anonymous-hub?<token..>")]
|
#[get("/anonymous-hub?<token..>")]
|
||||||
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
||||||
let addr = ip.ip;
|
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
||||||
info!("Accepting Anonymous Rocket WS connection from {addr}");
|
|
||||||
|
|
||||||
let (mut rx, guard) = {
|
let (mut rx, guard) = {
|
||||||
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
||||||
@@ -200,7 +199,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
subscriptions.map.insert(token.clone(), tx);
|
subscriptions.map.insert(token.clone(), tx);
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, addr))
|
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -257,11 +256,11 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
// Websockets server
|
// Websockets server
|
||||||
//
|
//
|
||||||
|
|
||||||
fn serialize(val: Value) -> Vec<u8> {
|
fn serialize(val: &Value) -> Vec<u8> {
|
||||||
use rmpv::encode::write_value;
|
use rmpv::encode::write_value;
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
write_value(&mut buf, &val).expect("Error encoding MsgPack");
|
write_value(&mut buf, val).expect("Error encoding MsgPack");
|
||||||
|
|
||||||
// Add size bytes at the start
|
// Add size bytes at the start
|
||||||
// Extracted from BinaryMessageFormat.js
|
// Extracted from BinaryMessageFormat.js
|
||||||
@@ -359,15 +358,16 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
|
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||||
let data = create_update(
|
let data = create_update(
|
||||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||||
UpdateType::LogOut,
|
UpdateType::LogOut,
|
||||||
acting_device_id.clone(),
|
acting_device_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
@@ -375,7 +375,7 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.push_enabled() {
|
if CONFIG.push_enabled() {
|
||||||
push_logout(user, acting_device_id.clone(), conn).await;
|
push_logout(user, acting_device, conn).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ impl AnonymousWebSocketSubscriptions {
|
|||||||
let data = create_anonymous_update(
|
let data = create_anonymous_update(
|
||||||
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
|
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
|
||||||
UpdateType::AuthRequestResponse,
|
UpdateType::AuthRequestResponse,
|
||||||
user_id.clone(),
|
user_id,
|
||||||
);
|
);
|
||||||
self.send_update(auth_request_id, &data).await;
|
self.send_update(auth_request_id, &data).await;
|
||||||
}
|
}
|
||||||
@@ -588,16 +588,19 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
|
|||||||
])]),
|
])]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
serialize(value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: UserId) -> Vec<u8> {
|
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: &UserId) -> Vec<u8> {
|
||||||
use rmpv::Value as V;
|
use rmpv::Value as V;
|
||||||
|
|
||||||
let value = V::Array(vec![
|
let value = V::Array(vec![
|
||||||
1.into(),
|
1.into(),
|
||||||
V::Map(vec![]),
|
V::Map(vec![]),
|
||||||
V::Nil,
|
V::Nil,
|
||||||
|
// This word is misspelled, but upstream has this too
|
||||||
|
// https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
|
||||||
|
// https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
|
||||||
"AuthRequestResponseRecieved".into(),
|
"AuthRequestResponseRecieved".into(),
|
||||||
V::Array(vec![V::Map(vec![
|
V::Array(vec![V::Map(vec![
|
||||||
("Type".into(), (ut as i32).into()),
|
("Type".into(), (ut as i32).into()),
|
||||||
@@ -606,11 +609,11 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
])]),
|
])]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
serialize(value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ping() -> Vec<u8> {
|
fn create_ping() -> Vec<u8> {
|
||||||
serialize(Value::Array(vec![6.into()]))
|
serialize(&Value::Array(vec![6.into()]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
|
|||||||
+11
-11
@@ -1,3 +1,8 @@
|
|||||||
|
use std::{
|
||||||
|
sync::LazyLock,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
Method,
|
Method,
|
||||||
@@ -8,7 +13,7 @@ use tokio::sync::RwLock;
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, UpdateType},
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId},
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
@@ -16,9 +21,6 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthPushToken {
|
struct AuthPushToken {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
@@ -32,7 +34,7 @@ struct LocalAuthPushToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_auth_api_token() -> ApiResult<String> {
|
async fn get_auth_api_token() -> ApiResult<String> {
|
||||||
static API_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| {
|
static API_TOKEN: LazyLock<RwLock<LocalAuthPushToken>> = LazyLock::new(|| {
|
||||||
RwLock::new(LocalAuthPushToken {
|
RwLock::new(LocalAuthPushToken {
|
||||||
access_token: String::new(),
|
access_token: String::new(),
|
||||||
valid_until: Instant::now(),
|
valid_until: Instant::now(),
|
||||||
@@ -126,7 +128,7 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe
|
|||||||
err!(format!("An error occurred while proceeding registration of a device: {e}"));
|
err!(format!("An error occurred while proceeding registration of a device: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = device.save(conn).await {
|
if let Err(e) = device.save(true, conn).await {
|
||||||
err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}"));
|
err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,15 +188,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
|
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
|
|
||||||
|
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"organizationId": (),
|
"organizationId": (),
|
||||||
"deviceId": acting_device_id,
|
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
||||||
"identifier": acting_device_id,
|
"identifier": acting_device.map(|d| &d.uuid),
|
||||||
"type": UpdateType::LogOut as i32,
|
"type": UpdateType::LogOut as i32,
|
||||||
"payload": {
|
"payload": {
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
|
|||||||
+5
-3
@@ -60,11 +60,13 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
|||||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||||
"mail_enabled": CONFIG.mail_enabled(),
|
"mail_enabled": CONFIG.mail_enabled(),
|
||||||
"sends_allowed": CONFIG.sends_allowed(),
|
"sends_allowed": CONFIG.sends_allowed(),
|
||||||
|
"remember_2fa_disabled": CONFIG.disable_2fa_remember(),
|
||||||
|
"password_hints_allowed": CONFIG.password_hints_allowed(),
|
||||||
"signup_disabled": CONFIG.is_signup_disabled(),
|
"signup_disabled": CONFIG.is_signup_disabled(),
|
||||||
"sso_enabled": CONFIG.sso_enabled(),
|
"sso_enabled": CONFIG.sso_enabled(),
|
||||||
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
|
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
|
||||||
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
|
||||||
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
|
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
|
||||||
|
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {
|
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {
|
||||||
@@ -238,8 +240,8 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
|
|||||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||||
"jquery-3.7.1.slim.js" => {
|
"jquery-4.0.0.slim.js" => {
|
||||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.7.1.slim.js")))
|
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
|
||||||
}
|
}
|
||||||
_ => err!(format!("Static file not found: {filename}")),
|
_ => err!(format!("Static file not found: {filename}")),
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-45
@@ -1,12 +1,15 @@
|
|||||||
// JWT Handling
|
use std::{
|
||||||
|
env,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{LazyLock, OnceLock},
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use once_cell::sync::{Lazy, OnceCell};
|
|
||||||
use openssl::rsa::Rsa;
|
use openssl::rsa::Rsa;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::ser::Serialize;
|
use serde::ser::Serialize;
|
||||||
use std::{env, net::IpAddr};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::ApiResult,
|
api::ApiResult,
|
||||||
@@ -22,27 +25,31 @@ use crate::{
|
|||||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||||
|
|
||||||
// Limit when BitWarden consider the token as expired
|
// Limit when BitWarden consider the token as expired
|
||||||
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap());
|
pub static BW_EXPIRATION: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_minutes(5).unwrap());
|
||||||
|
|
||||||
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap());
|
pub static DEFAULT_REFRESH_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_days(30).unwrap());
|
||||||
pub static MOBILE_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(90).unwrap());
|
pub static MOBILE_REFRESH_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_days(90).unwrap());
|
||||||
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
pub static DEFAULT_ACCESS_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_hours(2).unwrap());
|
||||||
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
static JWT_HEADER: LazyLock<Header> = LazyLock::new(|| Header::new(JWT_ALGORITHM));
|
||||||
|
|
||||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
pub static JWT_LOGIN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||||
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
static JWT_INVITE_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
||||||
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: LazyLock<String> =
|
||||||
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
LazyLock::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
||||||
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
static JWT_DELETE_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
||||||
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
static JWT_VERIFYEMAIL_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
||||||
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
static JWT_ADMIN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
||||||
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
|
static JWT_SEND_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|send", CONFIG.domain_origin()));
|
||||||
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
|
static JWT_ORG_API_KEY_ISSUER: LazyLock<String> =
|
||||||
static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin()));
|
LazyLock::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
|
||||||
static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
|
static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
|
||||||
|
LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin()));
|
||||||
|
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
|
||||||
|
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
|
||||||
|
static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin()));
|
||||||
|
|
||||||
static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
||||||
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
|
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||||
|
|
||||||
pub async fn initialize_keys() -> Result<(), Error> {
|
pub async fn initialize_keys() -> Result<(), Error> {
|
||||||
use std::io::Error;
|
use std::io::Error;
|
||||||
@@ -54,7 +61,7 @@ pub async fn initialize_keys() -> Result<(), Error> {
|
|||||||
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
|
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::RsaKey).map_err(Error::other)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?;
|
||||||
|
|
||||||
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
||||||
Ok(buffer) => Some(buffer),
|
Ok(buffer) => Some(buffer),
|
||||||
@@ -154,6 +161,10 @@ pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error
|
|||||||
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
|
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_2fa_remember(token: &str) -> Result<TwoFactorRememberClaims, Error> {
|
||||||
|
decode_jwt(token, JWT_2FA_REMEMBER_ISSUER.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LoginJwtClaims {
|
pub struct LoginJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -434,6 +445,31 @@ pub fn generate_register_verify_claims(email: String, name: Option<String>, veri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct TwoFactorRememberClaims {
|
||||||
|
// Not before
|
||||||
|
pub nbf: i64,
|
||||||
|
// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
// Issuer
|
||||||
|
pub iss: String,
|
||||||
|
// Subject
|
||||||
|
pub sub: DeviceId,
|
||||||
|
// UserId
|
||||||
|
pub user_uuid: UserId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> TwoFactorRememberClaims {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
TwoFactorRememberClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
||||||
|
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||||
|
sub: device_uuid,
|
||||||
|
user_uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BasicJwtClaims {
|
pub struct BasicJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -457,7 +493,7 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_verify_email_claims(user_id: UserId) -> BasicJwtClaims {
|
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
BasicJwtClaims {
|
BasicJwtClaims {
|
||||||
@@ -668,10 +704,9 @@ pub struct OrgHeaders {
|
|||||||
|
|
||||||
impl OrgHeaders {
|
impl OrgHeaders {
|
||||||
fn is_member(&self) -> bool {
|
fn is_member(&self) -> bool {
|
||||||
// NOTE: we don't care about MembershipStatus at the moment because this is only used
|
// Only allow not revoked members, we can not use the Confirmed status here
|
||||||
// where an invited, accepted or confirmed user is expected if this ever changes or
|
// as some endpoints can be triggered by invited users during joining
|
||||||
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly
|
self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User
|
||||||
self.membership_type >= MembershipType::User
|
|
||||||
}
|
}
|
||||||
fn is_confirmed_and_admin(&self) -> bool {
|
fn is_confirmed_and_admin(&self) -> bool {
|
||||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
||||||
@@ -684,6 +719,36 @@ impl OrgHeaders {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||||
|
// but there are cases where it is a query value.
|
||||||
|
// First check the path, if this is not a valid uuid, try the query values.
|
||||||
|
fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
|
||||||
|
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||||
|
Some(org_id)
|
||||||
|
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||||
|
Some(org_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special Guard to ensure that there is an organization id present
|
||||||
|
// If there is no org id trigger the Outcome::Forward.
|
||||||
|
// This is useful for endpoints which work for both organization and personal vaults, like purge.
|
||||||
|
pub struct OrgIdGuard;
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for OrgIdGuard {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
match get_org_id(request) {
|
||||||
|
Some(_) => Outcome::Success(OrgIdGuard),
|
||||||
|
None => Outcome::Forward(rocket::http::Status::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for OrgHeaders {
|
impl<'r> FromRequest<'r> for OrgHeaders {
|
||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
@@ -691,18 +756,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = try_outcome!(Headers::from_request(request).await);
|
let headers = try_outcome!(Headers::from_request(request).await);
|
||||||
|
|
||||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
// Extract the org_id from the request
|
||||||
// but there are cases where it is a query value.
|
let url_org_id = get_org_id(request);
|
||||||
// First check the path, if this is not a valid uuid, try the query values.
|
|
||||||
let url_org_id: Option<OrganizationId> = {
|
|
||||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
|
||||||
Some(org_id.clone())
|
|
||||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
|
||||||
Some(org_id.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match url_org_id {
|
match url_org_id {
|
||||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||||
@@ -820,7 +875,7 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
|||||||
_ => err_handler!("Error getting DB"),
|
_ => err_handler!("Error getting DB"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !Collection::can_access_collection(&headers.membership, &col_id, &conn).await {
|
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
||||||
err_handler!("The current user isn't a manager for this collection")
|
err_handler!("The current user isn't a manager for this collection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -902,8 +957,8 @@ impl ManagerHeaders {
|
|||||||
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
|
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
|
||||||
err!("Collection Id is malformed!");
|
err!("Collection Id is malformed!");
|
||||||
}
|
}
|
||||||
if !Collection::can_access_collection(&h.membership, col_id, conn).await {
|
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await {
|
||||||
err!("You don't have access to all collections!");
|
err!("Collection not found", "The current user isn't a manager for this collection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,8 +1259,20 @@ pub async fn refresh_tokens(
|
|||||||
) -> ApiResult<(Device, AuthTokens)> {
|
) -> ApiResult<(Device, AuthTokens)> {
|
||||||
let refresh_claims = match decode_refresh(refresh_token) {
|
let refresh_claims = match decode_refresh(refresh_token) {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
|
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
|
||||||
err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
||||||
|
|
||||||
|
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
|
||||||
|
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
|
||||||
|
// check expiration or issuer, so they're not included here.
|
||||||
|
RefreshJwtClaims {
|
||||||
|
nbf: 0,
|
||||||
|
exp: 0,
|
||||||
|
iss: String::new(),
|
||||||
|
sub: AuthMethod::Password,
|
||||||
|
device_token: refresh_token.into(),
|
||||||
|
token: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(claims) => claims,
|
Ok(claims) => claims,
|
||||||
};
|
};
|
||||||
@@ -1217,7 +1284,7 @@ pub async fn refresh_tokens(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save to update `updated_at`.
|
// Save to update `updated_at`.
|
||||||
device.save(conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
||||||
None => err!("Impossible to find user"),
|
None => err!("Impossible to find user"),
|
||||||
|
|||||||
+268
-139
@@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
env::consts::EXE_SUFFIX,
|
env::consts::EXE_SUFFIX,
|
||||||
|
fmt,
|
||||||
process::exit,
|
process::exit,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
@@ -8,15 +9,18 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use job_scheduler_ng::Schedule;
|
use job_scheduler_ng::Schedule;
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
|
use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::Error,
|
error::Error,
|
||||||
util::{get_env, get_env_bool, get_web_vault_version, is_valid_email, parse_experimental_client_feature_flags},
|
util::{
|
||||||
|
get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags,
|
||||||
|
FeatureFlagFilter,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
|
static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
|
||||||
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
|
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
|
||||||
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
|
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
|
||||||
});
|
});
|
||||||
@@ -33,7 +37,7 @@ static CONFIG_FILENAME: LazyLock<String> = LazyLock::new(|| {
|
|||||||
|
|
||||||
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
pub static CONFIG: LazyLock<Config> = LazyLock::new(|| {
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap_or_else(|e| {
|
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap_or_else(|e| {
|
||||||
println!("Error loading config:\n {e:?}\n");
|
println!("Error loading config:\n {e:?}\n");
|
||||||
@@ -55,6 +59,41 @@ pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
|||||||
pub type Pass = String;
|
pub type Pass = String;
|
||||||
|
|
||||||
macro_rules! make_config {
|
macro_rules! make_config {
|
||||||
|
// Support string print
|
||||||
|
( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option<String> with "***"
|
||||||
|
( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***"
|
||||||
|
( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value(&$value).unwrap() }; // Optional other or string, we convert to json
|
||||||
|
( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { $value.as_str().into() }; // Required string value, we convert to json
|
||||||
|
( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to json
|
||||||
|
|
||||||
|
// Group or empty string
|
||||||
|
( @show ) => { "" };
|
||||||
|
( @show $lit:literal ) => { $lit };
|
||||||
|
|
||||||
|
// Wrap the optionals in an Option type
|
||||||
|
( @type $ty:ty, option) => { Option<$ty> };
|
||||||
|
( @type $ty:ty, $id:ident) => { $ty };
|
||||||
|
|
||||||
|
// Generate the values depending on none_action
|
||||||
|
( @build $value:expr, $config:expr, option, ) => { $value };
|
||||||
|
( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) };
|
||||||
|
( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{
|
||||||
|
match $value {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
|
||||||
|
f($config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{
|
||||||
|
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
|
||||||
|
f($config)
|
||||||
|
}};
|
||||||
|
|
||||||
|
( @getenv $name:expr, bool ) => { get_env_bool($name) };
|
||||||
|
( @getenv $name:expr, $ty:ident ) => { get_env($name) };
|
||||||
|
|
||||||
($(
|
($(
|
||||||
$(#[doc = $groupdoc:literal])?
|
$(#[doc = $groupdoc:literal])?
|
||||||
$group:ident $(: $group_enabled:ident)? {
|
$group:ident $(: $group_enabled:ident)? {
|
||||||
@@ -74,10 +113,103 @@ macro_rules! make_config {
|
|||||||
_env: ConfigBuilder,
|
_env: ConfigBuilder,
|
||||||
_usr: ConfigBuilder,
|
_usr: ConfigBuilder,
|
||||||
|
|
||||||
_overrides: Vec<String>,
|
_overrides: Vec<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
||||||
|
// This deserialize doesn't care if there are keys missing, or if there are duplicate keys
|
||||||
|
// In case of duplicate keys (which should never be possible unless manually edited), the last value is used!
|
||||||
|
// Main reason for this is removing the `visit_seq` function, which causes a lot of code generation not needed or used for this struct.
|
||||||
|
impl<'de> Deserialize<'de> for ConfigBuilder {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
const FIELDS: &[&str] = &[
|
||||||
|
$($(
|
||||||
|
stringify!($name),
|
||||||
|
)+)+
|
||||||
|
];
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
enum Field {
|
||||||
|
$($(
|
||||||
|
$name,
|
||||||
|
)+)+
|
||||||
|
__ignore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Field {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct FieldVisitor;
|
||||||
|
|
||||||
|
impl Visitor<'_> for FieldVisitor {
|
||||||
|
type Value = Field;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("ConfigBuilder field identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Field, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
match value {
|
||||||
|
$($(
|
||||||
|
stringify!($name) => Ok(Field::$name),
|
||||||
|
)+)+
|
||||||
|
_ => Ok(Field::__ignore),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_identifier(FieldVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConfigBuilderVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for ConfigBuilderVisitor {
|
||||||
|
type Value = ConfigBuilder;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("struct ConfigBuilder")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: MapAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut builder = ConfigBuilder::default();
|
||||||
|
while let Some(key) = map.next_key()? {
|
||||||
|
match key {
|
||||||
|
$($(
|
||||||
|
Field::$name => {
|
||||||
|
if builder.$name.is_some() {
|
||||||
|
return Err(de::Error::duplicate_field(stringify!($name)));
|
||||||
|
}
|
||||||
|
builder.$name = map.next_value()?;
|
||||||
|
}
|
||||||
|
)+)+
|
||||||
|
Field::__ignore => {
|
||||||
|
let _ = map.next_value::<de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_struct("ConfigBuilder", FIELDS, ConfigBuilderVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Serialize)]
|
||||||
pub struct ConfigBuilder {
|
pub struct ConfigBuilder {
|
||||||
$($(
|
$($(
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -86,7 +218,6 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigBuilder {
|
impl ConfigBuilder {
|
||||||
#[allow(clippy::field_reassign_with_default)]
|
|
||||||
fn from_env() -> Self {
|
fn from_env() -> Self {
|
||||||
let env_file = get_env("ENV_FILE").unwrap_or_else(|| String::from(".env"));
|
let env_file = get_env("ENV_FILE").unwrap_or_else(|| String::from(".env"));
|
||||||
match dotenvy::from_path(&env_file) {
|
match dotenvy::from_path(&env_file) {
|
||||||
@@ -148,14 +279,14 @@ macro_rules! make_config {
|
|||||||
|
|
||||||
/// Merges the values of both builders into a new builder.
|
/// Merges the values of both builders into a new builder.
|
||||||
/// If both have the same element, `other` wins.
|
/// If both have the same element, `other` wins.
|
||||||
fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<String>) -> Self {
|
fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<&str>) -> Self {
|
||||||
let mut builder = self.clone();
|
let mut builder = self.clone();
|
||||||
$($(
|
$($(
|
||||||
if let v @Some(_) = &other.$name {
|
if let v @Some(_) = &other.$name {
|
||||||
builder.$name = v.clone();
|
builder.$name = v.clone();
|
||||||
|
|
||||||
if self.$name.is_some() {
|
if self.$name.is_some() {
|
||||||
overrides.push(pastey::paste!(stringify!([<$name:upper>])).into());
|
overrides.push(pastey::paste!(stringify!([<$name:upper>])));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)+)+
|
)+)+
|
||||||
@@ -196,6 +327,32 @@ macro_rules! make_config {
|
|||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct ConfigItems { $($( $name: make_config! {@type $ty, $none_action}, )+)+ }
|
struct ConfigItems { $($( $name: make_config! {@type $ty, $none_action}, )+)+ }
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ElementDoc {
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ElementData {
|
||||||
|
editable: bool,
|
||||||
|
name: &'static str,
|
||||||
|
value: serde_json::Value,
|
||||||
|
default: serde_json::Value,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
r#type: &'static str,
|
||||||
|
doc: ElementDoc,
|
||||||
|
overridden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct GroupData {
|
||||||
|
group: &'static str,
|
||||||
|
grouptoggle: &'static str,
|
||||||
|
groupdoc: &'static str,
|
||||||
|
elements: Vec<ElementData>,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
impl Config {
|
impl Config {
|
||||||
$($(
|
$($(
|
||||||
@@ -207,11 +364,12 @@ macro_rules! make_config {
|
|||||||
|
|
||||||
pub fn prepare_json(&self) -> serde_json::Value {
|
pub fn prepare_json(&self) -> serde_json::Value {
|
||||||
let (def, cfg, overridden) = {
|
let (def, cfg, overridden) = {
|
||||||
|
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
||||||
let inner = &self.inner.read().unwrap();
|
let inner = &self.inner.read().unwrap();
|
||||||
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
fn _get_form_type(rust_type: &str) -> &'static str {
|
fn _get_form_type(rust_type: &'static str) -> &'static str {
|
||||||
match rust_type {
|
match rust_type {
|
||||||
"Pass" => "password",
|
"Pass" => "password",
|
||||||
"String" => "text",
|
"String" => "text",
|
||||||
@@ -220,48 +378,36 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_doc(doc: &str) -> serde_json::Value {
|
fn _get_doc(doc_str: &'static str) -> ElementDoc {
|
||||||
let mut split = doc.split("|>").map(str::trim);
|
let mut split = doc_str.split("|>").map(str::trim);
|
||||||
|
ElementDoc {
|
||||||
// We do not use the json!() macro here since that causes a lot of macro recursion.
|
name: split.next().unwrap_or_default(),
|
||||||
// This slows down compile time and it also causes issues with rust-analyzer
|
description: split.next().unwrap_or_default(),
|
||||||
serde_json::Value::Object({
|
}
|
||||||
let mut doc_json = serde_json::Map::new();
|
|
||||||
doc_json.insert("name".into(), serde_json::to_value(split.next()).unwrap());
|
|
||||||
doc_json.insert("description".into(), serde_json::to_value(split.next()).unwrap());
|
|
||||||
doc_json
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We do not use the json!() macro here since that causes a lot of macro recursion.
|
let data: Vec<GroupData> = vec![
|
||||||
// This slows down compile time and it also causes issues with rust-analyzer
|
$( // This repetition is for each group
|
||||||
serde_json::Value::Array(<[_]>::into_vec(Box::new([
|
GroupData {
|
||||||
$(
|
group: stringify!($group),
|
||||||
serde_json::Value::Object({
|
grouptoggle: stringify!($($group_enabled)?),
|
||||||
let mut group = serde_json::Map::new();
|
groupdoc: (make_config! { @show $($groupdoc)? }),
|
||||||
group.insert("group".into(), (stringify!($group)).into());
|
|
||||||
group.insert("grouptoggle".into(), (stringify!($($group_enabled)?)).into());
|
|
||||||
group.insert("groupdoc".into(), (make_config! { @show $($groupdoc)? }).into());
|
|
||||||
|
|
||||||
group.insert("elements".into(), serde_json::Value::Array(<[_]>::into_vec(Box::new([
|
elements: vec![
|
||||||
$(
|
$( // This repetition is for each element within a group
|
||||||
serde_json::Value::Object({
|
ElementData {
|
||||||
let mut element = serde_json::Map::new();
|
editable: $editable,
|
||||||
element.insert("editable".into(), ($editable).into());
|
name: stringify!($name),
|
||||||
element.insert("name".into(), (stringify!($name)).into());
|
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
|
||||||
element.insert("value".into(), serde_json::to_value(cfg.$name).unwrap());
|
default: serde_json::to_value(&def.$name).unwrap_or_default(),
|
||||||
element.insert("default".into(), serde_json::to_value(def.$name).unwrap());
|
r#type: _get_form_type(stringify!($ty)),
|
||||||
element.insert("type".into(), (_get_form_type(stringify!($ty))).into());
|
doc: _get_doc(concat!($($doc),+)),
|
||||||
element.insert("doc".into(), (_get_doc(concat!($($doc),+))).into());
|
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
|
||||||
element.insert("overridden".into(), (overridden.contains(&pastey::paste!(stringify!([<$name:upper>])).into())).into());
|
},
|
||||||
element
|
)+], // End of elements repetition
|
||||||
}),
|
},
|
||||||
)+
|
)+]; // End of groups repetition
|
||||||
]))));
|
serde_json::to_value(data).unwrap()
|
||||||
group
|
|
||||||
}),
|
|
||||||
)+
|
|
||||||
])))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_support_json(&self) -> serde_json::Value {
|
pub fn get_support_json(&self) -> serde_json::Value {
|
||||||
@@ -269,8 +415,8 @@ macro_rules! make_config {
|
|||||||
// Pass types will always be masked and no need to put them in the list.
|
// Pass types will always be masked and no need to put them in the list.
|
||||||
// Besides Pass, only String types will be masked via _privacy_mask.
|
// Besides Pass, only String types will be masked via _privacy_mask.
|
||||||
const PRIVACY_CONFIG: &[&str] = &[
|
const PRIVACY_CONFIG: &[&str] = &[
|
||||||
"allowed_iframe_ancestors",
|
|
||||||
"allowed_connect_src",
|
"allowed_connect_src",
|
||||||
|
"allowed_iframe_ancestors",
|
||||||
"database_url",
|
"database_url",
|
||||||
"domain_origin",
|
"domain_origin",
|
||||||
"domain_path",
|
"domain_path",
|
||||||
@@ -278,16 +424,18 @@ macro_rules! make_config {
|
|||||||
"helo_name",
|
"helo_name",
|
||||||
"org_creation_users",
|
"org_creation_users",
|
||||||
"signups_domains_whitelist",
|
"signups_domains_whitelist",
|
||||||
|
"_smtp_img_src",
|
||||||
|
"smtp_from_name",
|
||||||
"smtp_from",
|
"smtp_from",
|
||||||
"smtp_host",
|
"smtp_host",
|
||||||
"smtp_username",
|
"smtp_username",
|
||||||
"_smtp_img_src",
|
|
||||||
"sso_client_id",
|
|
||||||
"sso_authority",
|
"sso_authority",
|
||||||
"sso_callback_path",
|
"sso_callback_path",
|
||||||
|
"sso_client_id",
|
||||||
];
|
];
|
||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
|
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
||||||
let inner = &self.inner.read().unwrap();
|
let inner = &self.inner.read().unwrap();
|
||||||
inner.config.clone()
|
inner.config.clone()
|
||||||
};
|
};
|
||||||
@@ -317,13 +465,21 @@ macro_rules! make_config {
|
|||||||
serde_json::Value::Object({
|
serde_json::Value::Object({
|
||||||
let mut json = serde_json::Map::new();
|
let mut json = serde_json::Map::new();
|
||||||
$($(
|
$($(
|
||||||
json.insert(stringify!($name).into(), make_config! { @supportstr $name, cfg.$name, $ty, $none_action });
|
json.insert(String::from(stringify!($name)), make_config! { @supportstr $name, cfg.$name, $ty, $none_action });
|
||||||
)+)+;
|
)+)+;
|
||||||
|
// Loop through all privacy sensitive keys and mask them
|
||||||
|
for mask_key in PRIVACY_CONFIG {
|
||||||
|
if let Some(value) = json.get_mut(*mask_key) {
|
||||||
|
if let Some(s) = value.as_str() {
|
||||||
|
*value = _privacy_mask(s).into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
json
|
json
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_overrides(&self) -> Vec<String> {
|
pub fn get_overrides(&self) -> Vec<&'static str> {
|
||||||
let overrides = {
|
let overrides = {
|
||||||
let inner = &self.inner.read().unwrap();
|
let inner = &self.inner.read().unwrap();
|
||||||
inner._overrides.clone()
|
inner._overrides.clone()
|
||||||
@@ -332,55 +488,6 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Support string print
|
|
||||||
( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value($value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option<String> with "***"
|
|
||||||
( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***"
|
|
||||||
( @supportstr $name:ident, $value:expr, String, option ) => { // Optional other value, we return as is or convert to string to apply the privacy config
|
|
||||||
if PRIVACY_CONFIG.contains(&stringify!($name)) {
|
|
||||||
serde_json::to_value($value.as_ref().map(|x| _privacy_mask(x) )).unwrap()
|
|
||||||
} else {
|
|
||||||
serde_json::to_value($value).unwrap()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { // Required other value, we return as is or convert to string to apply the privacy config
|
|
||||||
if PRIVACY_CONFIG.contains(&stringify!($name)) {
|
|
||||||
_privacy_mask(&$value).into()
|
|
||||||
} else {
|
|
||||||
($value).into()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value($value).unwrap() }; // Optional other value, we return as is or convert to string to apply the privacy config
|
|
||||||
( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to string to apply the privacy config
|
|
||||||
|
|
||||||
// Group or empty string
|
|
||||||
( @show ) => { "" };
|
|
||||||
( @show $lit:literal ) => { $lit };
|
|
||||||
|
|
||||||
// Wrap the optionals in an Option type
|
|
||||||
( @type $ty:ty, option) => { Option<$ty> };
|
|
||||||
( @type $ty:ty, $id:ident) => { $ty };
|
|
||||||
|
|
||||||
// Generate the values depending on none_action
|
|
||||||
( @build $value:expr, $config:expr, option, ) => { $value };
|
|
||||||
( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) };
|
|
||||||
( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{
|
|
||||||
match $value {
|
|
||||||
Some(v) => v,
|
|
||||||
None => {
|
|
||||||
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
|
|
||||||
f($config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{
|
|
||||||
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
|
|
||||||
f($config)
|
|
||||||
}};
|
|
||||||
|
|
||||||
( @getenv $name:expr, bool ) => { get_env_bool($name) };
|
|
||||||
( @getenv $name:expr, $ty:ident ) => { get_env($name) };
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//STRUCTURE:
|
//STRUCTURE:
|
||||||
@@ -460,9 +567,9 @@ make_config! {
|
|||||||
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||||
/// Defaults to once every minute. Set blank to disable this job.
|
/// Defaults to once every minute. Set blank to disable this job.
|
||||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||||
/// Purge incomplete SSO nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete SSO login.
|
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string();
|
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string();
|
||||||
},
|
},
|
||||||
|
|
||||||
/// General settings
|
/// General settings
|
||||||
@@ -685,6 +792,10 @@ make_config! {
|
|||||||
/// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
|
/// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
|
||||||
/// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
/// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
||||||
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
|
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
|
||||||
|
|
||||||
|
/// Prefer IPv6 (AAAA) resolving |> This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
|
||||||
|
/// This could be useful in IPv6 only environments.
|
||||||
|
dns_prefer_ipv6: bool, true, def, false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/// OpenID Connect SSO settings
|
/// OpenID Connect SSO settings
|
||||||
@@ -812,7 +923,7 @@ make_config! {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
||||||
// Validate connection URL is valid and DB feature is enabled
|
// Validate connection URL is valid and DB feature is enabled
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
{
|
{
|
||||||
@@ -918,32 +1029,17 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103
|
let invalid_flags =
|
||||||
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12
|
parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags, FeatureFlagFilter::InvalidOnly);
|
||||||
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
|
|
||||||
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
|
||||||
//
|
|
||||||
// NOTE: Move deprecated flags to the utils::parse_experimental_client_feature_flags() DEPRECATED_FLAGS const!
|
|
||||||
const KNOWN_FLAGS: &[&str] = &[
|
|
||||||
// Autofill Team
|
|
||||||
"inline-menu-positioning-improvements",
|
|
||||||
"inline-menu-totp",
|
|
||||||
"ssh-agent",
|
|
||||||
// Key Management Team
|
|
||||||
"ssh-key-vault-item",
|
|
||||||
// Tools
|
|
||||||
"export-attachments",
|
|
||||||
// Mobile Team
|
|
||||||
"anon-addy-self-host-alias",
|
|
||||||
"simple-login-self-host-alias",
|
|
||||||
"mutual-tls",
|
|
||||||
];
|
|
||||||
let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags);
|
|
||||||
let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect();
|
|
||||||
if !invalid_flags.is_empty() {
|
if !invalid_flags.is_empty() {
|
||||||
err!(format!("Unrecognized experimental client feature flags: {invalid_flags:?}.\n\n\
|
let feature_flags_error = format!("Unrecognized experimental client feature flags: {:?}.\n\
|
||||||
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
||||||
Supported flags: {KNOWN_FLAGS:?}"));
|
Supported flags: {:?}\n", invalid_flags, SUPPORTED_FEATURE_FLAGS);
|
||||||
|
if on_update {
|
||||||
|
err!(feature_flags_error);
|
||||||
|
} else {
|
||||||
|
println!("[WARNING] {feature_flags_error}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
||||||
@@ -1216,12 +1312,16 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
|||||||
if embed_images {
|
if embed_images {
|
||||||
"cid:".to_string()
|
"cid:".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{domain}/vw_static/")
|
// normalize base_url
|
||||||
|
let base_url = domain.trim_end_matches('/');
|
||||||
|
format!("{base_url}/vw_static/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_sso_callback_path(domain: &str) -> String {
|
fn generate_sso_callback_path(domain: &str) -> String {
|
||||||
format!("{domain}/identity/connect/oidc-signin")
|
// normalize base_url
|
||||||
|
let base_url = domain.trim_end_matches('/');
|
||||||
|
format!("{base_url}/identity/connect/oidc-signin")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the correct URL for the icon service.
|
/// Generate the correct URL for the icon service.
|
||||||
@@ -1358,6 +1458,35 @@ pub enum PathType {
|
|||||||
RsaKey,
|
RsaKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Official available feature flags can be found here:
|
||||||
|
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
|
||||||
|
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||||
|
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
|
||||||
|
// 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",
|
||||||
|
// Autofill Team
|
||||||
|
"ssh-agent",
|
||||||
|
"ssh-agent-v2",
|
||||||
|
// Key Management Team
|
||||||
|
"ssh-key-vault-item",
|
||||||
|
"pm-25373-windows-biometrics-v2",
|
||||||
|
// Mobile Team
|
||||||
|
"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",
|
||||||
|
];
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load() -> Result<Self, Error> {
|
pub async fn load() -> Result<Self, Error> {
|
||||||
// Loading from env and file
|
// Loading from env and file
|
||||||
@@ -1371,7 +1500,7 @@ impl Config {
|
|||||||
// Fill any missing with defaults
|
// Fill any missing with defaults
|
||||||
let config = builder.build();
|
let config = builder.build();
|
||||||
if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) {
|
if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) {
|
||||||
validate_config(&config)?;
|
validate_config(&config, false)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
@@ -1407,7 +1536,7 @@ impl Config {
|
|||||||
let env = &self.inner.read().unwrap()._env;
|
let env = &self.inner.read().unwrap()._env;
|
||||||
env.merge(&builder, false, &mut overrides).build()
|
env.merge(&builder, false, &mut overrides).build()
|
||||||
};
|
};
|
||||||
validate_config(&config)?;
|
validate_config(&config, true)?;
|
||||||
|
|
||||||
// Save both the user and the combined config
|
// Save both the user and the combined config
|
||||||
{
|
{
|
||||||
@@ -1518,7 +1647,7 @@ impl Config {
|
|||||||
if let Some(akey) = self._duo_akey() {
|
if let Some(akey) = self._duo_akey() {
|
||||||
akey
|
akey
|
||||||
} else {
|
} else {
|
||||||
let akey_s = crate::crypto::encode_random_bytes::<64>(data_encoding::BASE64);
|
let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64);
|
||||||
|
|
||||||
// Save the new value
|
// Save the new value
|
||||||
let builder = ConfigBuilder {
|
let builder = ConfigBuilder {
|
||||||
@@ -1542,7 +1671,7 @@ impl Config {
|
|||||||
token.is_some() && !token.unwrap().trim().is_empty()
|
token.is_some() && !token.unwrap().trim().is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn opendal_operator_for_path_type(&self, path_type: PathType) -> Result<opendal::Operator, Error> {
|
pub fn opendal_operator_for_path_type(&self, path_type: &PathType) -> Result<opendal::Operator, Error> {
|
||||||
let path = match path_type {
|
let path = match path_type {
|
||||||
PathType::Data => self.data_folder(),
|
PathType::Data => self.data_folder(),
|
||||||
PathType::IconCache => self.icon_cache_folder(),
|
PathType::IconCache => self.icon_cache_folder(),
|
||||||
@@ -1735,8 +1864,8 @@ fn to_json<'reg, 'rc>(
|
|||||||
|
|
||||||
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
|
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
|
||||||
// The default is based upon the version since this feature is added.
|
// The default is based upon the version since this feature is added.
|
||||||
static WEB_VAULT_VERSION: Lazy<semver::Version> = Lazy::new(|| {
|
static WEB_VAULT_VERSION: LazyLock<semver::Version> = LazyLock::new(|| {
|
||||||
let vault_version = get_web_vault_version();
|
let vault_version = get_active_web_release();
|
||||||
// Use a single regex capture to extract version components
|
// Use a single regex capture to extract version components
|
||||||
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||||
re.captures(&vault_version)
|
re.captures(&vault_version)
|
||||||
@@ -1751,7 +1880,7 @@ static WEB_VAULT_VERSION: Lazy<semver::Version> = Lazy::new(|| {
|
|||||||
|
|
||||||
// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then.
|
// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then.
|
||||||
// The default is based upon the version since this feature is added.
|
// The default is based upon the version since this feature is added.
|
||||||
static VW_VERSION: Lazy<semver::Version> = Lazy::new(|| {
|
static VW_VERSION: LazyLock<semver::Version> = LazyLock::new(|| {
|
||||||
let vw_version = crate::VERSION.unwrap_or("1.32.5");
|
let vw_version = crate::VERSION.unwrap_or("1.32.5");
|
||||||
// Use a single regex capture to extract version components
|
// Use a single regex capture to extract version components
|
||||||
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||||
|
|||||||
+4
-4
@@ -48,20 +48,20 @@ pub fn get_random_bytes<const N: usize>() -> [u8; N] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encode random bytes using the provided function.
|
/// Encode random bytes using the provided function.
|
||||||
pub fn encode_random_bytes<const N: usize>(e: Encoding) -> String {
|
pub fn encode_random_bytes<const N: usize>(e: &Encoding) -> String {
|
||||||
e.encode(&get_random_bytes::<N>())
|
e.encode(&get_random_bytes::<N>())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a random string over a specified alphabet.
|
/// Generates a random string over a specified alphabet.
|
||||||
pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
|
pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
|
||||||
// Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
|
// Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
|
||||||
use rand::Rng;
|
use rand::RngExt;
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
|
|
||||||
(0..num_chars)
|
(0..num_chars)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
let i = rng.random_range(0..alphabet.len());
|
let i = rng.random_range(0..alphabet.len());
|
||||||
alphabet[i] as char
|
char::from(alphabet[i])
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ pub fn get_random_string_alphanum(num_chars: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_id<const N: usize>() -> String {
|
pub fn generate_id<const N: usize>() -> String {
|
||||||
encode_random_bytes::<N>(HEXLOWER)
|
encode_random_bytes::<N>(&HEXLOWER)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_send_file_id() -> String {
|
pub fn generate_send_file_id() -> String {
|
||||||
|
|||||||
+47
-11
@@ -337,6 +337,46 @@ macro_rules! db_run {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write all ToSql<Text, DB> and FromSql<Text, DB> given a serializable/deserializable type.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! impl_FromToSqlText {
|
||||||
|
($name:ty) => {
|
||||||
|
#[cfg(mysql)]
|
||||||
|
impl ToSql<Text, diesel::mysql::Mysql> for $name {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result {
|
||||||
|
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(postgresql)]
|
||||||
|
impl ToSql<Text, diesel::pg::Pg> for $name {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
|
||||||
|
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(sqlite)]
|
||||||
|
impl ToSql<Text, diesel::sqlite::Sqlite> for $name {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {
|
||||||
|
serde_json::to_string(self).map_err(Into::into).map(|str| {
|
||||||
|
out.set_value(str);
|
||||||
|
diesel::serialize::IsNull::No
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<DB: diesel::backend::Backend> FromSql<Text, DB> for $name
|
||||||
|
where
|
||||||
|
String: FromSql<Text, DB>,
|
||||||
|
{
|
||||||
|
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
||||||
|
<String as FromSql<Text, DB>>::from_sql(bytes)
|
||||||
|
.and_then(|str| serde_json::from_str(&str).map_err(Into::into))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
// Reexport the models, needs to be after the macros are defined so it can access them
|
// Reexport the models, needs to be after the macros are defined so it can access them
|
||||||
@@ -347,7 +387,6 @@ pub mod models;
|
|||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
pub fn backup_sqlite() -> Result<String, Error> {
|
pub fn backup_sqlite() -> Result<String, Error> {
|
||||||
use diesel::Connection;
|
use diesel::Connection;
|
||||||
use std::{fs::File, io::Write};
|
|
||||||
|
|
||||||
let db_url = CONFIG.database_url();
|
let db_url = CONFIG.database_url();
|
||||||
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
|
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
|
||||||
@@ -361,16 +400,13 @@ pub fn backup_sqlite() -> Result<String, Error> {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
|
|
||||||
match File::create(backup_file.clone()) {
|
diesel::sql_query("VACUUM INTO ?")
|
||||||
Ok(mut f) => {
|
.bind::<diesel::sql_types::Text, _>(&backup_file)
|
||||||
let serialized_db = conn.serialize_database_to_buffer();
|
.execute(&mut conn)
|
||||||
f.write_all(serialized_db.as_slice()).expect("Error writing SQLite backup");
|
.map(|_| ())
|
||||||
Ok(backup_file)
|
.map_res("VACUUM INTO failed")?;
|
||||||
}
|
|
||||||
Err(e) => {
|
Ok(backup_file)
|
||||||
err_silent!(format!("Unable to save SQLite backup: {e:?}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
|
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::Attachments)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||||
|
|
||||||
if operator.info().scheme() == opendal::Scheme::Fs {
|
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
|
||||||
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
|
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
|
||||||
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
|
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
|
||||||
} else {
|
} else {
|
||||||
@@ -117,7 +117,7 @@ impl Attachment {
|
|||||||
.map_res("Error deleting attachment")
|
.map_res("Error deleting attachment")
|
||||||
}}?;
|
}}?;
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::Attachments)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||||
let file_path = self.get_file_path();
|
let file_path = self.get_file_path();
|
||||||
|
|
||||||
if let Err(e) = operator.delete(&file_path).await {
|
if let Err(e) = operator.delete(&file_path).await {
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
||||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(5).unwrap(); //after 5 minutes, clients reject the request
|
// 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::try_minutes(15).unwrap();
|
||||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||||
auth_request.delete(conn).await.ok();
|
auth_request.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-21
@@ -559,7 +559,7 @@ impl Cipher {
|
|||||||
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
||||||
return cached_member.has_full_access();
|
return cached_member.has_full_access();
|
||||||
}
|
}
|
||||||
} else if let Some(member) = Membership::find_by_user_and_org(user_uuid, org_uuid, conn).await {
|
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||||
return member.has_full_access();
|
return member.has_full_access();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -668,10 +668,12 @@ impl Cipher {
|
|||||||
ciphers::table
|
ciphers::table
|
||||||
.filter(ciphers::uuid.eq(&self.uuid))
|
.filter(ciphers::uuid.eq(&self.uuid))
|
||||||
.inner_join(ciphers_collections::table.on(
|
.inner_join(ciphers_collections::table.on(
|
||||||
ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
|
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
|
||||||
|
))
|
||||||
.inner_join(users_collections::table.on(
|
.inner_join(users_collections::table.on(
|
||||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||||
.and(users_collections::user_uuid.eq(user_uuid))))
|
.and(users_collections::user_uuid.eq(user_uuid))
|
||||||
|
))
|
||||||
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||||
.load::<(bool, bool, bool)>(conn)
|
.load::<(bool, bool, bool)>(conn)
|
||||||
.expect("Error getting user access restrictions")
|
.expect("Error getting user access restrictions")
|
||||||
@@ -697,6 +699,9 @@ impl Cipher {
|
|||||||
.inner_join(users_organizations::table.on(
|
.inner_join(users_organizations::table.on(
|
||||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||||
))
|
))
|
||||||
|
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
|
))
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
|
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
|
||||||
.load::<(bool, bool, bool)>(conn)
|
.load::<(bool, bool, bool)>(conn)
|
||||||
@@ -795,28 +800,28 @@ impl Cipher {
|
|||||||
let mut query = ciphers::table
|
let mut query = ciphers::table
|
||||||
.left_join(ciphers_collections::table.on(
|
.left_join(ciphers_collections::table.on(
|
||||||
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
|
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
|
||||||
))
|
))
|
||||||
.left_join(users_organizations::table.on(
|
.left_join(users_organizations::table.on(
|
||||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
|
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid))
|
.and(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
))
|
))
|
||||||
.left_join(users_collections::table.on(
|
.left_join(users_collections::table.on(
|
||||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||||
// Ensure that users_collections::user_uuid is NULL for unconfirmed users.
|
// Ensure that users_collections::user_uuid is NULL for unconfirmed users.
|
||||||
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))
|
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))
|
||||||
))
|
))
|
||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
// Ensure that group and membership belong to the same org
|
||||||
))
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
|
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
||||||
collections_groups::groups_uuid.eq(groups::uuid)
|
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||||
)
|
))
|
||||||
))
|
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
|
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
|
||||||
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
|
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
|
||||||
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
|
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
|
||||||
@@ -986,7 +991,9 @@ impl Cipher {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
||||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||||
@@ -1047,7 +1054,9 @@ impl Cipher {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
||||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||||
@@ -1115,8 +1124,8 @@ impl Cipher {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
|
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
|
||||||
|
|||||||
+19
-14
@@ -191,7 +191,7 @@ impl Collection {
|
|||||||
self.update_users_revision(conn).await;
|
self.update_users_revision(conn).await;
|
||||||
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
|
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
|
||||||
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
|
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
|
||||||
CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?;
|
CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?;
|
||||||
|
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
|
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
|
||||||
@@ -239,8 +239,8 @@ impl Collection {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||||
@@ -355,8 +355,8 @@ impl Collection {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||||
@@ -422,8 +422,8 @@ impl Collection {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||||
@@ -484,8 +484,8 @@ impl Collection {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||||
@@ -513,7 +513,8 @@ impl Collection {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
|
let uuid = uuid.to_string();
|
||||||
let user_uuid = user_uuid.to_string();
|
let user_uuid = user_uuid.to_string();
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
collections::table
|
collections::table
|
||||||
@@ -530,17 +531,17 @@ impl Collection {
|
|||||||
.left_join(groups_users::table.on(
|
.left_join(groups_users::table.on(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||||
collections_groups::collections_uuid.eq(collections::uuid)
|
collections_groups::collections_uuid.eq(collections::uuid)
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
.filter(collections::uuid.eq(&self.uuid))
|
.filter(collections::uuid.eq(&uuid))
|
||||||
.filter(
|
.filter(
|
||||||
users_collections::collection_uuid.eq(&self.uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection
|
users_collections::collection_uuid.eq(&uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||||
)).or(
|
)).or(
|
||||||
@@ -558,6 +559,10 @@ impl Collection {
|
|||||||
.unwrap_or(0) != 0
|
.unwrap_or(0) != 0
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
|
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
|
|||||||
+50
-35
@@ -1,6 +1,6 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
|
||||||
use data_encoding::{BASE64, BASE64URL};
|
use data_encoding::BASE64URL;
|
||||||
use derive_more::{Display, From};
|
use derive_more::{Display, From};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -35,6 +35,30 @@ pub struct Device {
|
|||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Device {
|
impl Device {
|
||||||
|
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uuid,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
|
||||||
|
user_uuid,
|
||||||
|
name,
|
||||||
|
atype,
|
||||||
|
|
||||||
|
push_uuid: Some(PushId(get_uuid())),
|
||||||
|
push_token: None,
|
||||||
|
refresh_token: Device::generate_refresh_token(),
|
||||||
|
twofactor_remember: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn generate_refresh_token() -> String {
|
||||||
|
crypto::encode_random_bytes::<64>(&BASE64URL)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_json(&self) -> Value {
|
pub fn to_json(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"id": self.uuid,
|
"id": self.uuid,
|
||||||
@@ -48,10 +72,13 @@ impl Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_twofactor_remember(&mut self) -> String {
|
pub fn refresh_twofactor_remember(&mut self) -> String {
|
||||||
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
|
use crate::auth::{encode_jwt, generate_2fa_remember_claims};
|
||||||
self.twofactor_remember = Some(twofactor_remember.clone());
|
|
||||||
|
|
||||||
twofactor_remember
|
let two_factor_remember_claim = generate_2fa_remember_claims(self.uuid.clone(), self.user_uuid.clone());
|
||||||
|
let two_factor_remember_string = encode_jwt(&two_factor_remember_claim);
|
||||||
|
self.twofactor_remember = Some(two_factor_remember_string.clone());
|
||||||
|
|
||||||
|
two_factor_remember_string
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_twofactor_remember(&mut self) {
|
pub fn delete_twofactor_remember(&mut self) {
|
||||||
@@ -110,38 +137,21 @@ impl DeviceWithAuthRequest {
|
|||||||
}
|
}
|
||||||
use crate::db::DbConn;
|
use crate::db::DbConn;
|
||||||
|
|
||||||
use crate::api::{ApiResult, EmptyResult};
|
use crate::api::EmptyResult;
|
||||||
use crate::error::MapResult;
|
use crate::error::MapResult;
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl Device {
|
impl Device {
|
||||||
pub async fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32, conn: &DbConn) -> ApiResult<Device> {
|
pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult {
|
||||||
let now = Utc::now().naive_utc();
|
if update_time {
|
||||||
|
self.updated_at = Utc::now().naive_utc();
|
||||||
|
}
|
||||||
|
|
||||||
let device = Self {
|
|
||||||
uuid,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
|
|
||||||
user_uuid,
|
|
||||||
name,
|
|
||||||
atype,
|
|
||||||
|
|
||||||
push_uuid: Some(PushId(get_uuid())),
|
|
||||||
push_token: None,
|
|
||||||
refresh_token: crypto::encode_random_bytes::<64>(BASE64URL),
|
|
||||||
twofactor_remember: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
device.inner_save(conn).await.map(|()| device)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn inner_save(&self, conn: &DbConn) -> EmptyResult {
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
sqlite, mysql {
|
||||||
crate::util::retry(||
|
crate::util::retry(||
|
||||||
diesel::replace_into(devices::table)
|
diesel::replace_into(devices::table)
|
||||||
.values(self)
|
.values(&*self)
|
||||||
.execute(conn),
|
.execute(conn),
|
||||||
10,
|
10,
|
||||||
).map_res("Error saving device")
|
).map_res("Error saving device")
|
||||||
@@ -149,10 +159,10 @@ impl Device {
|
|||||||
postgresql {
|
postgresql {
|
||||||
crate::util::retry(||
|
crate::util::retry(||
|
||||||
diesel::insert_into(devices::table)
|
diesel::insert_into(devices::table)
|
||||||
.values(self)
|
.values(&*self)
|
||||||
.on_conflict((devices::uuid, devices::user_uuid))
|
.on_conflict((devices::uuid, devices::user_uuid))
|
||||||
.do_update()
|
.do_update()
|
||||||
.set(self)
|
.set(&*self)
|
||||||
.execute(conn),
|
.execute(conn),
|
||||||
10,
|
10,
|
||||||
).map_res("Error saving device")
|
).map_res("Error saving device")
|
||||||
@@ -160,12 +170,6 @@ impl Device {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should only be called after user has passed authentication
|
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
|
||||||
self.updated_at = Utc::now().naive_utc();
|
|
||||||
self.inner_save(conn).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
||||||
@@ -261,6 +265,17 @@ impl Device {
|
|||||||
.unwrap_or(0) != 0
|
.unwrap_or(0) != 0
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
|
// Generate a new token per device.
|
||||||
|
// We cannot do a single UPDATE with one value because each device needs a unique token.
|
||||||
|
let devices = Self::find_by_user(user_uuid, conn).await;
|
||||||
|
for mut device in devices {
|
||||||
|
device.refresh_token = Device::generate_refresh_token();
|
||||||
|
device.save(false, conn).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Display)]
|
#[derive(Display)]
|
||||||
|
|||||||
@@ -340,6 +340,16 @@ impl EmergencyAccess {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
|
db_run! { conn: {
|
||||||
|
emergency_access::table
|
||||||
|
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||||
|
.filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32))
|
||||||
|
.load::<Self>(conn)
|
||||||
|
.expect("Error loading emergency_access")
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
|
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
|
||||||
if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email {
|
if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email {
|
||||||
err!("User email does not match invite.");
|
err!("User email does not match invite.");
|
||||||
|
|||||||
+62
-20
@@ -1,6 +1,6 @@
|
|||||||
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
||||||
use crate::api::EmptyResult;
|
use crate::api::EmptyResult;
|
||||||
use crate::db::schema::{collections_groups, groups, groups_users, users_organizations};
|
use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations};
|
||||||
use crate::db::DbConn;
|
use crate::db::DbConn;
|
||||||
use crate::error::MapResult;
|
use crate::error::MapResult;
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
@@ -81,7 +81,7 @@ impl Group {
|
|||||||
// If both read_only and hide_passwords are false, then manage should be true
|
// If both read_only and hide_passwords are false, then manage should be true
|
||||||
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
||||||
// Or an entry with everything to false
|
// Or an entry with everything to false
|
||||||
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn)
|
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
@@ -191,7 +191,7 @@ impl Group {
|
|||||||
|
|
||||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
for group in Self::find_by_organization(org_uuid, conn).await {
|
for group in Self::find_by_organization(org_uuid, conn).await {
|
||||||
group.delete(conn).await?;
|
group.delete(org_uuid, conn).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -246,8 +246,8 @@ impl Group {
|
|||||||
.inner_join(users_organizations::table.on(
|
.inner_join(users_organizations::table.on(
|
||||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||||
))
|
))
|
||||||
.inner_join(groups::table.on(
|
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
))
|
))
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(groups::access_all.eq(true))
|
.filter(groups::access_all.eq(true))
|
||||||
@@ -276,9 +276,9 @@ impl Group {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
CollectionGroup::delete_all_by_group(&self.uuid, conn).await?;
|
CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
||||||
GroupUser::delete_all_by_group(&self.uuid, conn).await?;
|
GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
||||||
|
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
|
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
|
||||||
@@ -306,8 +306,8 @@ impl Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CollectionGroup {
|
impl CollectionGroup {
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
|
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
|
||||||
for group_user in group_users {
|
for group_user in group_users {
|
||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
@@ -365,10 +365,19 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
collections_groups::table
|
collections_groups::table
|
||||||
|
.inner_join(groups::table.on(
|
||||||
|
groups::uuid.eq(collections_groups::groups_uuid)
|
||||||
|
))
|
||||||
|
.inner_join(collections::table.on(
|
||||||
|
collections::uuid.eq(collections_groups::collections_uuid)
|
||||||
|
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
||||||
|
))
|
||||||
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
||||||
|
.filter(collections::org_uuid.eq(org_uuid))
|
||||||
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading collection groups")
|
.expect("Error loading collection groups")
|
||||||
}}
|
}}
|
||||||
@@ -383,6 +392,13 @@ impl CollectionGroup {
|
|||||||
.inner_join(users_organizations::table.on(
|
.inner_join(users_organizations::table.on(
|
||||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||||
))
|
))
|
||||||
|
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
|
))
|
||||||
|
.inner_join(collections::table.on(
|
||||||
|
collections::uuid.eq(collections_groups::collections_uuid)
|
||||||
|
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
||||||
|
))
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.select(collections_groups::all_columns)
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
@@ -394,14 +410,20 @@ impl CollectionGroup {
|
|||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
collections_groups::table
|
collections_groups::table
|
||||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||||
|
.inner_join(collections::table.on(
|
||||||
|
collections::uuid.eq(collections_groups::collections_uuid)
|
||||||
|
))
|
||||||
|
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(collections::org_uuid))
|
||||||
|
))
|
||||||
.select(collections_groups::all_columns)
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading collection groups")
|
.expect("Error loading collection groups")
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
|
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
|
||||||
for group_user in group_users {
|
for group_user in group_users {
|
||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
@@ -415,8 +437,8 @@ impl CollectionGroup {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
|
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
|
||||||
for group_user in group_users {
|
for group_user in group_users {
|
||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
@@ -429,10 +451,14 @@ impl CollectionGroup {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_collection(
|
||||||
|
collection_uuid: &CollectionId,
|
||||||
|
org_uuid: &OrganizationId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
|
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
|
||||||
for collection_assigned_to_group in collection_assigned_to_groups {
|
for collection_assigned_to_group in collection_assigned_to_groups {
|
||||||
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await;
|
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, org_uuid, conn).await;
|
||||||
for group_user in group_users {
|
for group_user in group_users {
|
||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
@@ -494,10 +520,19 @@ impl GroupUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
groups_users::table
|
groups_users::table
|
||||||
|
.inner_join(groups::table.on(
|
||||||
|
groups::uuid.eq(groups_users::groups_uuid)
|
||||||
|
))
|
||||||
|
.inner_join(users_organizations::table.on(
|
||||||
|
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||||
|
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))
|
||||||
|
))
|
||||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||||
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
|
.select(groups_users::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading group users")
|
.expect("Error loading group users")
|
||||||
}}
|
}}
|
||||||
@@ -522,6 +557,13 @@ impl GroupUser {
|
|||||||
.inner_join(collections_groups::table.on(
|
.inner_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||||
))
|
))
|
||||||
|
.inner_join(groups::table.on(
|
||||||
|
groups::uuid.eq(groups_users::groups_uuid)
|
||||||
|
))
|
||||||
|
.inner_join(collections::table.on(
|
||||||
|
collections::uuid.eq(collections_groups::collections_uuid)
|
||||||
|
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
||||||
|
))
|
||||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||||
.count()
|
.count()
|
||||||
@@ -575,8 +617,8 @@ impl GroupUser {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
|
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
|
||||||
for group_user in group_users {
|
for group_user in group_users {
|
||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mod group;
|
|||||||
mod org_policy;
|
mod org_policy;
|
||||||
mod organization;
|
mod organization;
|
||||||
mod send;
|
mod send;
|
||||||
mod sso_nonce;
|
mod sso_auth;
|
||||||
mod two_factor;
|
mod two_factor;
|
||||||
mod two_factor_duo_context;
|
mod two_factor_duo_context;
|
||||||
mod two_factor_incomplete;
|
mod two_factor_incomplete;
|
||||||
@@ -27,7 +27,7 @@ pub use self::event::{Event, EventType};
|
|||||||
pub use self::favorite::Favorite;
|
pub use self::favorite::Favorite;
|
||||||
pub use self::folder::{Folder, FolderCipher, FolderId};
|
pub use self::folder::{Folder, FolderCipher, FolderId};
|
||||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||||
pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyId, OrgPolicyType};
|
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||||
pub use self::organization::{
|
pub use self::organization::{
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||||
OrganizationId,
|
OrganizationId,
|
||||||
@@ -36,7 +36,7 @@ pub use self::send::{
|
|||||||
id::{SendFileId, SendId},
|
id::{SendFileId, SendId},
|
||||||
Send, SendType,
|
Send, SendType,
|
||||||
};
|
};
|
||||||
pub use self::sso_nonce::SsoNonce;
|
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth};
|
||||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||||
|
|||||||
+35
-33
@@ -2,10 +2,12 @@ use derive_more::{AsRef, From};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::api::core::two_factor;
|
||||||
use crate::api::EmptyResult;
|
use crate::api::EmptyResult;
|
||||||
use crate::db::schema::{org_policies, users_organizations};
|
use crate::db::schema::{org_policies, users_organizations};
|
||||||
use crate::db::DbConn;
|
use crate::db::DbConn;
|
||||||
use crate::error::MapResult;
|
use crate::error::MapResult;
|
||||||
|
use crate::CONFIG;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
||||||
@@ -40,6 +42,10 @@ pub enum OrgPolicyType {
|
|||||||
// FreeFamiliesSponsorshipPolicy = 13,
|
// FreeFamiliesSponsorshipPolicy = 13,
|
||||||
RemoveUnlockWithPin = 14,
|
RemoveUnlockWithPin = 14,
|
||||||
RestrictedItemTypes = 15,
|
RestrictedItemTypes = 15,
|
||||||
|
UriMatchDefaults = 16,
|
||||||
|
// AutotypeDefaultSetting = 17, // Not supported yet
|
||||||
|
// AutoConfirm = 18, // Not supported (not implemented yet)
|
||||||
|
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
||||||
@@ -58,14 +64,6 @@ pub struct ResetPasswordDataModel {
|
|||||||
pub auto_enroll_enabled: bool,
|
pub auto_enroll_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type OrgPolicyResult = Result<(), OrgPolicyErr>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum OrgPolicyErr {
|
|
||||||
TwoFactorMissing,
|
|
||||||
SingleOrgEnforced,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl OrgPolicy {
|
impl OrgPolicy {
|
||||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
||||||
@@ -271,7 +269,7 @@ impl OrgPolicy {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||||
if user.atype < MembershipType::Admin {
|
if user.atype < MembershipType::Admin {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -280,31 +278,35 @@ impl OrgPolicy {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_user_allowed(
|
pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult {
|
||||||
user_uuid: &UserId,
|
if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) {
|
||||||
org_uuid: &OrganizationId,
|
// Enforce TwoFactor/TwoStep login
|
||||||
exclude_current_org: bool,
|
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await
|
||||||
conn: &DbConn,
|
{
|
||||||
) -> OrgPolicyResult {
|
if p.enabled && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() {
|
||||||
// Enforce TwoFactor/TwoStep login
|
if CONFIG.email_2fa_auto_fallback() {
|
||||||
if TwoFactor::find_by_user(user_uuid, conn).await.is_empty() {
|
two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?;
|
||||||
match Self::find_by_org_and_type(org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
|
} else {
|
||||||
Some(p) if p.enabled => {
|
err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid));
|
||||||
return Err(OrgPolicyErr::TwoFactorMissing);
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce Single Organization Policy of other organizations user is a member of
|
// Check if the user is part of another Organization with SingleOrg activated
|
||||||
// This check here needs to exclude this current org-id, else an accepted user can not be confirmed.
|
if Self::is_applicable_to_user(&m.user_uuid, OrgPolicyType::SingleOrg, Some(&m.org_uuid), conn).await {
|
||||||
let exclude_org = if exclude_current_org {
|
err!(format!(
|
||||||
Some(org_uuid)
|
"Cannot {} because another organization policy forbids it (membership {})",
|
||||||
} else {
|
action, m.uuid
|
||||||
None
|
));
|
||||||
};
|
}
|
||||||
if Self::is_applicable_to_user(user_uuid, OrgPolicyType::SingleOrg, exclude_org, conn).await {
|
|
||||||
return Err(OrgPolicyErr::SingleOrgEnforced);
|
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await {
|
||||||
|
if p.enabled
|
||||||
|
&& Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0
|
||||||
|
{
|
||||||
|
err!(format!("Cannot {} because the organization policy forbids being part of other organization (membership {})", action, m.uuid));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -330,7 +332,7 @@ impl OrgPolicy {
|
|||||||
for policy in
|
for policy in
|
||||||
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
|
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
|
||||||
{
|
{
|
||||||
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||||
if user.atype < MembershipType::Admin {
|
if user.atype < MembershipType::Admin {
|
||||||
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
|
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
|
||||||
Ok(opts) => {
|
Ok(opts) => {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ impl PartialOrd<MembershipType> for i32 {
|
|||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Organization {
|
impl Organization {
|
||||||
pub fn new(name: String, billing_email: String, private_key: Option<String>, public_key: Option<String>) -> Self {
|
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
|
||||||
let billing_email = billing_email.to_lowercase();
|
let billing_email = billing_email.to_lowercase();
|
||||||
Self {
|
Self {
|
||||||
uuid: OrganizationId(crate::util::get_uuid()),
|
uuid: OrganizationId(crate::util::get_uuid()),
|
||||||
@@ -514,7 +514,8 @@ impl Membership {
|
|||||||
"familySponsorshipValidUntil": null,
|
"familySponsorshipValidUntil": null,
|
||||||
"familySponsorshipToDelete": null,
|
"familySponsorshipToDelete": null,
|
||||||
"accessSecretsManager": false,
|
"accessSecretsManager": false,
|
||||||
"limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations
|
// limit collection creation to managers with access_all permission to prevent issues
|
||||||
|
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
|
||||||
"limitCollectionDeletion": true,
|
"limitCollectionDeletion": true,
|
||||||
"limitItemDeletion": false,
|
"limitItemDeletion": false,
|
||||||
"allowAdminAccessToAllCollectionItems": true,
|
"allowAdminAccessToAllCollectionItems": true,
|
||||||
@@ -883,10 +884,15 @@ impl Membership {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
pub async fn count_accepted_and_confirmed_by_user(
|
||||||
|
user_uuid: &UserId,
|
||||||
|
excluded_org: &OrganizationId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> i64 {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
.filter(users_organizations::org_uuid.ne(excluded_org))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)))
|
.filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)))
|
||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
@@ -1068,7 +1074,9 @@ impl Membership {
|
|||||||
.left_join(collections_groups::table.on(
|
.left_join(collections_groups::table.on(
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||||
))
|
))
|
||||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||||
|
))
|
||||||
.left_join(ciphers_collections::table.on(
|
.left_join(ciphers_collections::table.on(
|
||||||
ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))
|
ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -46,6 +46,16 @@ pub enum SendType {
|
|||||||
File = 1,
|
File = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SendAuthType {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
// Send requires email OTP verification
|
||||||
|
Email = 0, // Not yet supported by Vaultwarden
|
||||||
|
// Send requires a password
|
||||||
|
Password = 1,
|
||||||
|
// Send requires no auth
|
||||||
|
None = 2,
|
||||||
|
}
|
||||||
|
|
||||||
impl Send {
|
impl Send {
|
||||||
pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self {
|
pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
@@ -145,6 +155,7 @@ impl Send {
|
|||||||
"maxAccessCount": self.max_access_count,
|
"maxAccessCount": self.max_access_count,
|
||||||
"accessCount": self.access_count,
|
"accessCount": self.access_count,
|
||||||
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
|
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
|
||||||
|
"authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 },
|
||||||
"disabled": self.disabled,
|
"disabled": self.disabled,
|
||||||
"hideEmail": self.hide_email,
|
"hideEmail": self.hide_email,
|
||||||
|
|
||||||
@@ -225,7 +236,7 @@ impl Send {
|
|||||||
self.update_users_revision(conn).await;
|
self.update_users_revision(conn).await;
|
||||||
|
|
||||||
if self.atype == SendType::File as i32 {
|
if self.atype == SendType::File as i32 {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
|
||||||
operator.remove_all(&self.uuid).await.ok();
|
operator.remove_all(&self.uuid).await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::api::EmptyResult;
|
||||||
|
use crate::db::schema::sso_auth;
|
||||||
|
use crate::db::{DbConn, DbPool};
|
||||||
|
use crate::error::MapResult;
|
||||||
|
use crate::sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION};
|
||||||
|
|
||||||
|
use diesel::deserialize::FromSql;
|
||||||
|
use diesel::expression::AsExpression;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::serialize::{Output, ToSql};
|
||||||
|
use diesel::sql_types::Text;
|
||||||
|
|
||||||
|
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
pub enum OIDCCodeWrapper {
|
||||||
|
Ok {
|
||||||
|
code: OIDCCode,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
error: String,
|
||||||
|
error_description: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_FromToSqlText!(OIDCCodeWrapper);
|
||||||
|
|
||||||
|
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
pub struct OIDCAuthenticatedUser {
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub access_token: String,
|
||||||
|
pub expires_in: Option<Duration>,
|
||||||
|
pub identifier: OIDCIdentifier,
|
||||||
|
pub email: String,
|
||||||
|
pub email_verified: Option<bool>,
|
||||||
|
pub user_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_FromToSqlText!(OIDCAuthenticatedUser);
|
||||||
|
|
||||||
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
|
#[diesel(table_name = sso_auth)]
|
||||||
|
#[diesel(treat_none_as_null = true)]
|
||||||
|
#[diesel(primary_key(state))]
|
||||||
|
pub struct SsoAuth {
|
||||||
|
pub state: OIDCState,
|
||||||
|
pub client_challenge: OIDCCodeChallenge,
|
||||||
|
pub nonce: String,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub code_response: Option<OIDCCodeWrapper>,
|
||||||
|
pub auth_response: Option<OIDCAuthenticatedUser>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local methods
|
||||||
|
impl SsoAuth {
|
||||||
|
pub fn new(state: OIDCState, client_challenge: OIDCCodeChallenge, nonce: String, redirect_uri: String) -> Self {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
SsoAuth {
|
||||||
|
state,
|
||||||
|
client_challenge,
|
||||||
|
nonce,
|
||||||
|
redirect_uri,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
code_response: None,
|
||||||
|
auth_response: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database methods
|
||||||
|
impl SsoAuth {
|
||||||
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
|
db_run! { conn:
|
||||||
|
mysql {
|
||||||
|
diesel::insert_into(sso_auth::table)
|
||||||
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving SSO auth")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(sso_auth::table)
|
||||||
|
.values(self)
|
||||||
|
.on_conflict(sso_auth::state)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving SSO auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
|
||||||
|
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
|
||||||
|
db_run! { conn: {
|
||||||
|
sso_auth::table
|
||||||
|
.filter(sso_auth::state.eq(state))
|
||||||
|
.filter(sso_auth::created_at.ge(oldest))
|
||||||
|
.first::<Self>(conn)
|
||||||
|
.ok()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
|
db_run! {conn: {
|
||||||
|
diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error deleting sso_auth")
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
|
||||||
|
debug!("Purging expired sso_auth");
|
||||||
|
if let Ok(conn) = pool.get().await {
|
||||||
|
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
|
||||||
|
db_run! { conn: {
|
||||||
|
diesel::delete(sso_auth::table.filter(sso_auth::created_at.lt(oldest)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error deleting expired SSO nonce")
|
||||||
|
}}
|
||||||
|
} else {
|
||||||
|
err!("Failed to get DB connection while purging expired sso_auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::db::schema::sso_nonce;
|
|
||||||
use crate::db::{DbConn, DbPool};
|
|
||||||
use crate::error::MapResult;
|
|
||||||
use crate::sso::{OIDCState, NONCE_EXPIRATION};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
|
||||||
#[diesel(table_name = sso_nonce)]
|
|
||||||
#[diesel(primary_key(state))]
|
|
||||||
pub struct SsoNonce {
|
|
||||||
pub state: OIDCState,
|
|
||||||
pub nonce: String,
|
|
||||||
pub verifier: Option<String>,
|
|
||||||
pub redirect_uri: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local methods
|
|
||||||
impl SsoNonce {
|
|
||||||
pub fn new(state: OIDCState, nonce: String, verifier: Option<String>, redirect_uri: String) -> Self {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
|
|
||||||
SsoNonce {
|
|
||||||
state,
|
|
||||||
nonce,
|
|
||||||
verifier,
|
|
||||||
redirect_uri,
|
|
||||||
created_at: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Database methods
|
|
||||||
impl SsoNonce {
|
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
|
||||||
db_run! { conn:
|
|
||||||
sqlite, mysql {
|
|
||||||
diesel::replace_into(sso_nonce::table)
|
|
||||||
.values(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving SSO nonce")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(sso_nonce::table)
|
|
||||||
.values(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving SSO nonce")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(state: &OIDCState, conn: &DbConn) -> EmptyResult {
|
|
||||||
db_run! { conn: {
|
|
||||||
diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state)))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error deleting SSO nonce")
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
|
|
||||||
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
|
|
||||||
db_run! { conn: {
|
|
||||||
sso_nonce::table
|
|
||||||
.filter(sso_nonce::state.eq(state))
|
|
||||||
.filter(sso_nonce::created_at.ge(oldest))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
|
|
||||||
debug!("Purging expired sso_nonce");
|
|
||||||
if let Ok(conn) = pool.get().await {
|
|
||||||
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
|
|
||||||
db_run! { conn: {
|
|
||||||
diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest)))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error deleting expired SSO nonce")
|
|
||||||
}}
|
|
||||||
} else {
|
|
||||||
err!("Failed to get DB connection while purging expired sso_nonce")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ pub struct TwoFactor {
|
|||||||
pub last_used: i64,
|
pub last_used: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(num_derive::FromPrimitive)]
|
#[derive(num_derive::FromPrimitive)]
|
||||||
pub enum TwoFactorType {
|
pub enum TwoFactorType {
|
||||||
Authenticator = 0,
|
Authenticator = 0,
|
||||||
|
|||||||
+31
-16
@@ -1,4 +1,4 @@
|
|||||||
use crate::db::schema::{devices, invitations, sso_users, users};
|
use crate::db::schema::{invitations, sso_users, twofactor_incomplete, users};
|
||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
@@ -10,8 +10,7 @@ use super::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
crypto,
|
crypto,
|
||||||
db::models::DeviceId,
|
db::{models::DeviceId, DbConn},
|
||||||
db::DbConn,
|
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
sso::OIDCIdentifier,
|
sso::OIDCIdentifier,
|
||||||
util::{format_date, get_uuid, retry},
|
util::{format_date, get_uuid, retry},
|
||||||
@@ -106,7 +105,7 @@ impl User {
|
|||||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||||
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
|
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
|
||||||
|
|
||||||
pub fn new(email: String, name: Option<String>) -> Self {
|
pub fn new(email: &str, name: Option<String>) -> Self {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let email = email.to_lowercase();
|
let email = email.to_lowercase();
|
||||||
|
|
||||||
@@ -186,13 +185,14 @@ impl User {
|
|||||||
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
||||||
/// After these 2 minutes this stamp will expire.
|
/// After these 2 minutes this stamp will expire.
|
||||||
///
|
///
|
||||||
pub fn set_password(
|
pub async fn set_password(
|
||||||
&mut self,
|
&mut self,
|
||||||
password: &str,
|
password: &str,
|
||||||
new_key: Option<String>,
|
new_key: Option<String>,
|
||||||
reset_security_stamp: bool,
|
reset_security_stamp: bool,
|
||||||
allow_next_route: Option<Vec<String>>,
|
allow_next_route: Option<Vec<String>>,
|
||||||
) {
|
conn: &DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32);
|
self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32);
|
||||||
|
|
||||||
if let Some(route) = allow_next_route {
|
if let Some(route) = allow_next_route {
|
||||||
@@ -204,12 +204,15 @@ impl User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if reset_security_stamp {
|
if reset_security_stamp {
|
||||||
self.reset_security_stamp()
|
self.reset_security_stamp(conn).await?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_security_stamp(&mut self) {
|
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
self.security_stamp = get_uuid();
|
self.security_stamp = get_uuid();
|
||||||
|
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||||
@@ -232,6 +235,15 @@ impl User {
|
|||||||
pub fn reset_stamp_exception(&mut self) {
|
pub fn reset_stamp_exception(&mut self) {
|
||||||
self.stamp_exception = None;
|
self.stamp_exception = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
// default to email if name is empty
|
||||||
|
if !&self.name.is_empty() {
|
||||||
|
&self.name
|
||||||
|
} else {
|
||||||
|
&self.email
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
@@ -387,15 +399,18 @@ impl User {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_device_id(device_uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
if let Some(user_uuid) = db_run! ( conn: {
|
||||||
users::table
|
twofactor_incomplete::table
|
||||||
.inner_join(devices::table.on(devices::user_uuid.eq(users::uuid)))
|
.filter(twofactor_incomplete::device_uuid.eq(device_uuid))
|
||||||
.filter(devices::uuid.eq(device_uuid))
|
.order_by(twofactor_incomplete::login_time.desc())
|
||||||
.select(users::all_columns)
|
.select(twofactor_incomplete::user_uuid)
|
||||||
.first::<Self>(conn)
|
.first::<UserId>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
}) {
|
||||||
|
return Self::find_by_uuid(&user_uuid, conn).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option<SsoUser>)> {
|
pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option<SsoUser>)> {
|
||||||
|
|||||||
+5
-2
@@ -256,12 +256,15 @@ table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
sso_nonce (state) {
|
sso_auth (state) {
|
||||||
state -> Text,
|
state -> Text,
|
||||||
|
client_challenge -> Text,
|
||||||
nonce -> Text,
|
nonce -> Text,
|
||||||
verifier -> Nullable<Text>,
|
|
||||||
redirect_uri -> Text,
|
redirect_uri -> Text,
|
||||||
|
code_response -> Nullable<Text>,
|
||||||
|
auth_response -> Nullable<Text>,
|
||||||
created_at -> Timestamp,
|
created_at -> Timestamp,
|
||||||
|
updated_at -> Timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
-47
@@ -3,6 +3,7 @@
|
|||||||
//
|
//
|
||||||
use crate::db::models::EventType;
|
use crate::db::models::EventType;
|
||||||
use crate::http_client::CustomHttpClientError;
|
use crate::http_client::CustomHttpClientError;
|
||||||
|
use serde::ser::{Serialize, SerializeStruct, Serializer};
|
||||||
use std::error::Error as StdError;
|
use std::error::Error as StdError;
|
||||||
|
|
||||||
macro_rules! make_error {
|
macro_rules! make_error {
|
||||||
@@ -73,7 +74,7 @@ make_error! {
|
|||||||
Empty(Empty): _no_source, _serialize,
|
Empty(Empty): _no_source, _serialize,
|
||||||
// Used to represent err! calls
|
// Used to represent err! calls
|
||||||
Simple(String): _no_source, _api_error,
|
Simple(String): _no_source, _api_error,
|
||||||
Compact(Compact): _no_source, _api_error_small,
|
Compact(Compact): _no_source, _compact_api_error,
|
||||||
|
|
||||||
// Used in our custom http client to handle non-global IPs and blocked domains
|
// Used in our custom http client to handle non-global IPs and blocked domains
|
||||||
CustomHttpClient(CustomHttpClientError): _has_source, _api_error,
|
CustomHttpClient(CustomHttpClientError): _has_source, _api_error,
|
||||||
@@ -130,6 +131,10 @@ impl Error {
|
|||||||
(usr_msg, log_msg.into()).into()
|
(usr_msg, log_msg.into()).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_msg<M: Into<String> + Clone>(usr_msg: M) -> Self {
|
||||||
|
(usr_msg.clone(), usr_msg.into()).into()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
Empty {}.into()
|
Empty {}.into()
|
||||||
}
|
}
|
||||||
@@ -196,38 +201,97 @@ fn _no_source<T, S>(_: T) -> Option<S> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _serialize(e: &impl serde::Serialize, _msg: &str) -> String {
|
fn _serialize(e: &impl Serialize, _msg: &str) -> String {
|
||||||
serde_json::to_string(e).unwrap()
|
serde_json::to_string(e).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _api_error(_: &impl std::any::Any, msg: &str) -> String {
|
/// This will serialize the default ApiErrorResponse
|
||||||
let json = json!({
|
/// It will add the needed fields which are mostly empty or have multiple copies of the message
|
||||||
"message": msg,
|
/// This is more efficient than having a larger struct and use the Serialize derive
|
||||||
"error": "",
|
/// It also prevents using `json!()` calls to create the final output
|
||||||
"error_description": "",
|
impl Serialize for ApiErrorResponse<'_> {
|
||||||
"validationErrors": {"": [ msg ]},
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
"errorModel": {
|
where
|
||||||
"message": msg,
|
S: Serializer,
|
||||||
"object": "error"
|
{
|
||||||
},
|
#[derive(serde::Serialize)]
|
||||||
"exceptionMessage": null,
|
struct ErrorModel<'a> {
|
||||||
"exceptionStackTrace": null,
|
message: &'a str,
|
||||||
"innerExceptionMessage": null,
|
object: &'static str,
|
||||||
"object": "error"
|
}
|
||||||
});
|
|
||||||
_serialize(&json, "")
|
let mut state = serializer.serialize_struct("ApiErrorResponse", 9)?;
|
||||||
|
|
||||||
|
state.serialize_field("message", self.0.message)?;
|
||||||
|
|
||||||
|
let mut validation_errors = std::collections::HashMap::with_capacity(1);
|
||||||
|
validation_errors.insert("", vec![self.0.message]);
|
||||||
|
state.serialize_field("validationErrors", &validation_errors)?;
|
||||||
|
|
||||||
|
let error_model = ErrorModel {
|
||||||
|
message: self.0.message,
|
||||||
|
object: "error",
|
||||||
|
};
|
||||||
|
state.serialize_field("errorModel", &error_model)?;
|
||||||
|
|
||||||
|
state.serialize_field("error", "")?;
|
||||||
|
state.serialize_field("error_description", "")?;
|
||||||
|
state.serialize_field("exceptionMessage", &None::<()>)?;
|
||||||
|
state.serialize_field("exceptionStackTrace", &None::<()>)?;
|
||||||
|
state.serialize_field("innerExceptionMessage", &None::<()>)?;
|
||||||
|
state.serialize_field("object", "error")?;
|
||||||
|
|
||||||
|
state.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _api_error_small(_: &impl std::any::Any, msg: &str) -> String {
|
/// This will serialize the smaller CompactApiErrorResponse
|
||||||
let json = json!({
|
/// It will add the needed fields which are mostly empty
|
||||||
"message": msg,
|
/// This is more efficient than having a larger struct and use the Serialize derive
|
||||||
"validationErrors": null,
|
/// It also prevents using `json!()` calls to create the final output
|
||||||
"exceptionMessage": null,
|
impl Serialize for CompactApiErrorResponse<'_> {
|
||||||
"exceptionStackTrace": null,
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
"innerExceptionMessage": null,
|
where
|
||||||
"object": "error"
|
S: Serializer,
|
||||||
});
|
{
|
||||||
_serialize(&json, "")
|
let mut state = serializer.serialize_struct("CompactApiErrorResponse", 6)?;
|
||||||
|
|
||||||
|
state.serialize_field("message", self.0.message)?;
|
||||||
|
state.serialize_field("validationErrors", &None::<()>)?;
|
||||||
|
state.serialize_field("exceptionMessage", &None::<()>)?;
|
||||||
|
state.serialize_field("exceptionStackTrace", &None::<()>)?;
|
||||||
|
state.serialize_field("innerExceptionMessage", &None::<()>)?;
|
||||||
|
state.serialize_field("object", "error")?;
|
||||||
|
|
||||||
|
state.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main API Error struct template
|
||||||
|
/// This struct which we can be used by both ApiErrorResponse and CompactApiErrorResponse
|
||||||
|
/// is small and doesn't contain unneeded empty fields. This is more memory efficient, but also less code to compile
|
||||||
|
struct ApiErrorMsg<'a> {
|
||||||
|
message: &'a str,
|
||||||
|
}
|
||||||
|
/// Default API Error response struct
|
||||||
|
/// The custom serialization adds all other needed fields
|
||||||
|
struct ApiErrorResponse<'a>(ApiErrorMsg<'a>);
|
||||||
|
/// Compact API Error response struct used for some newer error responses
|
||||||
|
/// The custom serialization adds all other needed fields
|
||||||
|
struct CompactApiErrorResponse<'a>(ApiErrorMsg<'a>);
|
||||||
|
|
||||||
|
fn _api_error(_: &impl std::any::Any, msg: &str) -> String {
|
||||||
|
let response = ApiErrorMsg {
|
||||||
|
message: msg,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&ApiErrorResponse(response)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _compact_api_error(_: &impl std::any::Any, msg: &str) -> String {
|
||||||
|
let response = ApiErrorMsg {
|
||||||
|
message: msg,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&CompactApiErrorResponse(response)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -258,34 +322,41 @@ impl Responder<'_, 'static> for Error {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! err {
|
macro_rules! err {
|
||||||
($kind:ident, $msg:expr) => {{
|
($kind:ident, $msg:expr) => {{
|
||||||
error!("{}", $msg);
|
let msg = $msg;
|
||||||
return Err($crate::error::Error::new($msg, $msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {})));
|
error!("{msg}");
|
||||||
|
return Err($crate::error::Error::new_msg(msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {})));
|
||||||
}};
|
}};
|
||||||
($msg:expr) => {{
|
($msg:expr) => {{
|
||||||
error!("{}", $msg);
|
let msg = $msg;
|
||||||
return Err($crate::error::Error::new($msg, $msg));
|
error!("{msg}");
|
||||||
|
return Err($crate::error::Error::new_msg(msg));
|
||||||
}};
|
}};
|
||||||
($msg:expr, ErrorEvent $err_event:tt) => {{
|
($msg:expr, ErrorEvent $err_event:tt) => {{
|
||||||
error!("{}", $msg);
|
let msg = $msg;
|
||||||
return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event));
|
error!("{msg}");
|
||||||
|
return Err($crate::error::Error::new_msg(msg).with_event($crate::error::ErrorEvent $err_event));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr) => {{
|
($usr_msg:expr, $log_value:expr) => {{
|
||||||
error!("{}. {}", $usr_msg, $log_value);
|
let usr_msg = $usr_msg;
|
||||||
return Err($crate::error::Error::new($usr_msg, $log_value));
|
let log_value = $log_value;
|
||||||
|
error!("{usr_msg}. {log_value}");
|
||||||
|
return Err($crate::error::Error::new(usr_msg, log_value));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{
|
($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{
|
||||||
error!("{}. {}", $usr_msg, $log_value);
|
let usr_msg = $usr_msg;
|
||||||
return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event));
|
let log_value = $log_value;
|
||||||
|
error!("{usr_msg}. {log_value}");
|
||||||
|
return Err($crate::error::Error::new(usr_msg, log_value).with_event($crate::error::ErrorEvent $err_event));
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! err_silent {
|
macro_rules! err_silent {
|
||||||
($msg:expr) => {{
|
($msg:expr) => {{
|
||||||
return Err($crate::error::Error::new($msg, $msg));
|
return Err($crate::error::Error::new_msg($msg));
|
||||||
}};
|
}};
|
||||||
($msg:expr, ErrorEvent $err_event:tt) => {{
|
($msg:expr, ErrorEvent $err_event:tt) => {{
|
||||||
return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event));
|
return Err($crate::error::Error::new_msg($msg).with_event($crate::error::ErrorEvent $err_event));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr) => {{
|
($usr_msg:expr, $log_value:expr) => {{
|
||||||
return Err($crate::error::Error::new($usr_msg, $log_value));
|
return Err($crate::error::Error::new($usr_msg, $log_value));
|
||||||
@@ -298,12 +369,15 @@ macro_rules! err_silent {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! err_code {
|
macro_rules! err_code {
|
||||||
($msg:expr, $err_code:expr) => {{
|
($msg:expr, $err_code:expr) => {{
|
||||||
error!("{}", $msg);
|
let msg = $msg;
|
||||||
return Err($crate::error::Error::new($msg, $msg).with_code($err_code));
|
error!("{msg}");
|
||||||
|
return Err($crate::error::Error::new_msg(msg).with_code($err_code));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr, $err_code:expr) => {{
|
($usr_msg:expr, $log_value:expr, $err_code:expr) => {{
|
||||||
error!("{}. {}", $usr_msg, $log_value);
|
let usr_msg = $usr_msg;
|
||||||
return Err($crate::error::Error::new($usr_msg, $log_value).with_code($err_code));
|
let log_value = $log_value;
|
||||||
|
error!("{usr_msg}. {log_value}");
|
||||||
|
return Err($crate::error::Error::new(usr_msg, log_value).with_code($err_code));
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +385,7 @@ macro_rules! err_code {
|
|||||||
macro_rules! err_discard {
|
macro_rules! err_discard {
|
||||||
($msg:expr, $data:expr) => {{
|
($msg:expr, $data:expr) => {{
|
||||||
std::io::copy(&mut $data.open(), &mut std::io::sink()).ok();
|
std::io::copy(&mut $data.open(), &mut std::io::sink()).ok();
|
||||||
return Err($crate::error::Error::new($msg, $msg));
|
return Err($crate::error::Error::new_msg($msg));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr, $data:expr) => {{
|
($usr_msg:expr, $log_value:expr, $data:expr) => {{
|
||||||
std::io::copy(&mut $data.open(), &mut std::io::sink()).ok();
|
std::io::copy(&mut $data.open(), &mut std::io::sink()).ok();
|
||||||
@@ -336,7 +410,9 @@ macro_rules! err_handler {
|
|||||||
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
|
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr) => {{
|
($usr_msg:expr, $log_value:expr) => {{
|
||||||
error!(target: "auth", "Unauthorized Error: {}. {}", $usr_msg, $log_value);
|
let usr_msg = $usr_msg;
|
||||||
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $usr_msg));
|
let log_value = $log_value;
|
||||||
|
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
||||||
|
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg));
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-29
@@ -2,12 +2,11 @@ use std::{
|
|||||||
fmt,
|
fmt,
|
||||||
net::{IpAddr, SocketAddr},
|
net::{IpAddr, SocketAddr},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, LazyLock, Mutex},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver};
|
use hickory_resolver::{net::runtime::TokioRuntimeProvider, TokioResolver};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
dns::{Name, Resolve, Resolving},
|
dns::{Name, Resolve, Resolving},
|
||||||
@@ -25,9 +24,10 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::
|
|||||||
err!("Invalid host");
|
err!("Invalid host");
|
||||||
};
|
};
|
||||||
|
|
||||||
should_block_host(host)?;
|
should_block_host(&host)?;
|
||||||
|
|
||||||
static INSTANCE: Lazy<Client> = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client"));
|
static INSTANCE: LazyLock<Client> =
|
||||||
|
LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client"));
|
||||||
|
|
||||||
Ok(INSTANCE.request(method, url))
|
Ok(INSTANCE.request(method, url))
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder {
|
|||||||
return attempt.error("Invalid host");
|
return attempt.error("Invalid host");
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = should_block_host(host) {
|
if let Err(e) = should_block_host(&host) {
|
||||||
return attempt.error(e);
|
return attempt.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,11 +100,11 @@ fn should_block_address_regex(domain_or_ip: &str) -> bool {
|
|||||||
is_match
|
is_match
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_block_host(host: Host<&str>) -> Result<(), CustomHttpClientError> {
|
fn should_block_host(host: &Host<&str>) -> Result<(), CustomHttpClientError> {
|
||||||
let (ip, host_str): (Option<IpAddr>, String) = match host {
|
let (ip, host_str): (Option<IpAddr>, String) = match host {
|
||||||
Host::Ipv4(ip) => (Some(ip.into()), ip.to_string()),
|
Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()),
|
||||||
Host::Ipv6(ip) => (Some(ip.into()), ip.to_string()),
|
Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()),
|
||||||
Host::Domain(d) => (None, d.to_string()),
|
Host::Domain(d) => (None, (*d).to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(ip) = ip {
|
if let Some(ip) = ip {
|
||||||
@@ -179,37 +179,40 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
|||||||
|
|
||||||
impl CustomDnsResolver {
|
impl CustomDnsResolver {
|
||||||
fn instance() -> Arc<Self> {
|
fn instance() -> Arc<Self> {
|
||||||
static INSTANCE: Lazy<Arc<CustomDnsResolver>> = Lazy::new(CustomDnsResolver::new);
|
static INSTANCE: LazyLock<Arc<CustomDnsResolver>> = LazyLock::new(CustomDnsResolver::new);
|
||||||
Arc::clone(&*INSTANCE)
|
Arc::clone(&*INSTANCE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new() -> Arc<Self> {
|
fn new() -> Arc<Self> {
|
||||||
match TokioResolver::builder(TokioConnectionProvider::default()) {
|
TokioResolver::builder(TokioRuntimeProvider::default())
|
||||||
Ok(builder) => {
|
.and_then(|mut builder| {
|
||||||
let resolver = builder.build();
|
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
|
||||||
Arc::new(Self::Hickory(Arc::new(resolver)))
|
// This might cause issues on IPv4 only systems or containers
|
||||||
}
|
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
|
||||||
Err(e) => {
|
if !CONFIG.dns_prefer_ipv6() {
|
||||||
warn!("Error creating Hickory resolver, falling back to default: {e:?}");
|
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
|
||||||
Arc::new(Self::Default())
|
}
|
||||||
}
|
builder.build()
|
||||||
}
|
})
|
||||||
|
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
|
||||||
|
.map(|resolver| Arc::new(Self::Hickory(Arc::new(resolver))))
|
||||||
|
.unwrap_or_else(|_| Arc::new(Self::Default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note that we get an iterator of addresses, but we only grab the first one for convenience
|
// Note that we get an iterator of addresses, but we only grab the first one for convenience
|
||||||
async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> {
|
async fn resolve_domain(&self, name: &str) -> Result<Vec<SocketAddr>, BoxError> {
|
||||||
pre_resolve(name)?;
|
pre_resolve(name)?;
|
||||||
|
|
||||||
let result = match self {
|
let results: Vec<SocketAddr> = match self {
|
||||||
Self::Default() => tokio::net::lookup_host(name).await?.next(),
|
Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(),
|
||||||
Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)),
|
Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(addr) = &result {
|
for addr in &results {
|
||||||
post_resolve(name, addr.ip())?;
|
post_resolve(name, addr.ip())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +242,11 @@ impl Resolve for CustomDnsResolver {
|
|||||||
let this = self.clone();
|
let this = self.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let name = name.as_str();
|
let name = name.as_str();
|
||||||
let result = this.resolve_domain(name).await?;
|
let results = this.resolve_domain(name).await?;
|
||||||
Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter()))
|
if results.is_empty() {
|
||||||
|
warn!("Unable to resolve {name} to any valid IP address");
|
||||||
|
}
|
||||||
|
Ok::<reqwest::dns::Addrs, _>(Box::new(results.into_iter()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -184,7 +184,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_verify_email_claims(user_id.clone());
|
let claims = generate_verify_email_claims(user_id);
|
||||||
let verify_email_token = encode_jwt(&claims);
|
let verify_email_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -235,7 +235,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_verify_email_claims(user_id.clone());
|
let claims = generate_verify_email_claims(user_id);
|
||||||
let verify_email_token = encode_jwt(&claims);
|
let verify_email_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -302,10 +302,10 @@ pub async fn send_invite(
|
|||||||
.append_pair("organizationUserId", &member_id)
|
.append_pair("organizationUserId", &member_id)
|
||||||
.append_pair("token", &invite_token);
|
.append_pair("token", &invite_token);
|
||||||
|
|
||||||
if CONFIG.sso_enabled() {
|
if CONFIG.sso_enabled() && CONFIG.sso_only() {
|
||||||
query_params.append_pair("orgUserHasExistingUser", "false");
|
|
||||||
query_params.append_pair("orgSsoIdentifier", &org_id);
|
query_params.append_pair("orgSsoIdentifier", &org_id);
|
||||||
} else if user.private_key.is_some() {
|
}
|
||||||
|
if user.private_key.is_some() {
|
||||||
query_params.append_pair("orgUserHasExistingUser", "true");
|
query_params.append_pair("orgUserHasExistingUser", "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -705,7 +705,7 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult {
|
async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult {
|
||||||
let smtp_from = &CONFIG.smtp_from();
|
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
||||||
|
|
||||||
let body = if CONFIG.smtp_embed_images() {
|
let body = if CONFIG.smtp_embed_images() {
|
||||||
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
|
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
|
||||||
@@ -727,9 +727,9 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text:
|
|||||||
};
|
};
|
||||||
|
|
||||||
let email = Message::builder()
|
let email = Message::builder()
|
||||||
.message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.split('@').collect::<Vec<&str>>()[1])))
|
.message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.domain())))
|
||||||
.to(Mailbox::new(None, Address::from_str(address)?))
|
.to(Mailbox::new(None, Address::from_str(address)?))
|
||||||
.from(Mailbox::new(Some(CONFIG.smtp_from_name()), Address::from_str(smtp_from)?))
|
.from(Mailbox::new(Some(CONFIG.smtp_from_name()), smtp_from))
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.multipart(body)?;
|
.multipart(body)?;
|
||||||
|
|
||||||
|
|||||||
+44
-13
@@ -126,7 +126,7 @@ fn parse_args() {
|
|||||||
exit(0);
|
exit(0);
|
||||||
} else if pargs.contains(["-v", "--version"]) {
|
} else if pargs.contains(["-v", "--version"]) {
|
||||||
config::SKIP_CONFIG_VALIDATION.store(true, Ordering::Relaxed);
|
config::SKIP_CONFIG_VALIDATION.store(true, Ordering::Relaxed);
|
||||||
let web_vault_version = util::get_web_vault_version();
|
let web_vault_version = util::get_active_web_release();
|
||||||
println!("Vaultwarden {version}");
|
println!("Vaultwarden {version}");
|
||||||
println!("Web-Vault {web_vault_version}");
|
println!("Web-Vault {web_vault_version}");
|
||||||
exit(0);
|
exit(0);
|
||||||
@@ -246,8 +246,8 @@ fn init_logging() -> Result<log::LevelFilter, Error> {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.collect::<Vec<&str>>()
|
.collect::<Vec<&str>>()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|s| match s.split('=').collect::<Vec<&str>>()[..] {
|
.flat_map(|s| match s.split_once('=') {
|
||||||
[log, lvl_str] => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)),
|
Some((log, lvl_str)) => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -448,7 +448,7 @@ async fn check_data_folder() {
|
|||||||
|
|
||||||
if data_folder.starts_with("s3://") {
|
if data_folder.starts_with("s3://") {
|
||||||
if let Err(e) = CONFIG
|
if let Err(e) = CONFIG
|
||||||
.opendal_operator_for_path_type(PathType::Data)
|
.opendal_operator_for_path_type(&PathType::Data)
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
error!("Failed to create S3 operator for data folder '{data_folder}': {e:?}");
|
error!("Failed to create S3 operator for data folder '{data_folder}': {e:?}");
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -558,6 +558,12 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
let basepath = &CONFIG.domain_path();
|
let basepath = &CONFIG.domain_path();
|
||||||
|
|
||||||
let mut config = rocket::Config::from(rocket::Config::figment());
|
let mut config = rocket::Config::from(rocket::Config::figment());
|
||||||
|
|
||||||
|
// We install our own signal handlers below; disable Rocket's built-in handlers
|
||||||
|
config.shutdown.ctrlc = false;
|
||||||
|
#[cfg(unix)]
|
||||||
|
config.shutdown.signals.clear();
|
||||||
|
|
||||||
config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into();
|
config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into();
|
||||||
config.cli_colors = false; // Make sure Rocket does not color any values for logging.
|
config.cli_colors = false; // Make sure Rocket does not color any values for logging.
|
||||||
config.limits = Limits::new()
|
config.limits = Limits::new()
|
||||||
@@ -589,11 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
|
|
||||||
CONFIG.set_rocket_shutdown_handle(instance.shutdown());
|
CONFIG.set_rocket_shutdown_handle(instance.shutdown());
|
||||||
|
|
||||||
tokio::spawn(async move {
|
spawn_shutdown_signal_handler();
|
||||||
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
|
|
||||||
info!("Exiting Vaultwarden!");
|
|
||||||
CONFIG.shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(all(unix, sqlite))]
|
#[cfg(all(unix, sqlite))]
|
||||||
{
|
{
|
||||||
@@ -621,6 +623,35 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn spawn_shutdown_signal_handler() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::signal::unix::signal;
|
||||||
|
|
||||||
|
let mut sigint = signal(SignalKind::interrupt()).expect("Error setting SIGINT handler");
|
||||||
|
let mut sigterm = signal(SignalKind::terminate()).expect("Error setting SIGTERM handler");
|
||||||
|
let mut sigquit = signal(SignalKind::quit()).expect("Error setting SIGQUIT handler");
|
||||||
|
|
||||||
|
let signal_name = tokio::select! {
|
||||||
|
_ = sigint.recv() => "SIGINT",
|
||||||
|
_ = sigterm.recv() => "SIGTERM",
|
||||||
|
_ = sigquit.recv() => "SIGQUIT",
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Received {signal_name}, initiating graceful shutdown");
|
||||||
|
CONFIG.shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn spawn_shutdown_signal_handler() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
|
||||||
|
info!("Received Ctrl-C, initiating graceful shutdown");
|
||||||
|
CONFIG.shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn schedule_jobs(pool: db::DbPool) {
|
fn schedule_jobs(pool: db::DbPool) {
|
||||||
if CONFIG.job_poll_interval_ms() == 0 {
|
if CONFIG.job_poll_interval_ms() == 0 {
|
||||||
info!("Job scheduler disabled.");
|
info!("Job scheduler disabled.");
|
||||||
@@ -699,10 +730,10 @@ fn schedule_jobs(pool: db::DbPool) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge sso nonce from incomplete flow (default to daily at 00h20).
|
// Purge sso auth from incomplete flow (default to daily at 00h20).
|
||||||
if !CONFIG.purge_incomplete_sso_nonce().is_empty() {
|
if !CONFIG.purge_incomplete_sso_auth().is_empty() {
|
||||||
sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || {
|
sched.add(Job::new(CONFIG.purge_incomplete_sso_auth().parse().unwrap(), || {
|
||||||
runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone()));
|
runtime.spawn(db::models::SsoAuth::delete_expired(pool.clone()));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -1,5 +1,4 @@
|
|||||||
use once_cell::sync::Lazy;
|
use std::{net::IpAddr, num::NonZeroU32, sync::LazyLock, time::Duration};
|
||||||
use std::{net::IpAddr, num::NonZeroU32, time::Duration};
|
|
||||||
|
|
||||||
use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter};
|
use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter};
|
||||||
|
|
||||||
@@ -7,13 +6,13 @@ use crate::{Error, CONFIG};
|
|||||||
|
|
||||||
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
||||||
|
|
||||||
static LIMITER_LOGIN: Lazy<Limiter> = Lazy::new(|| {
|
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
|
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
|
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
||||||
});
|
});
|
||||||
|
|
||||||
static LIMITER_ADMIN: Lazy<Limiter> = Lazy::new(|| {
|
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
|
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
|
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
||||||
|
|||||||
+150
-139
@@ -1,32 +1,28 @@
|
|||||||
use chrono::Utc;
|
use std::{sync::LazyLock, time::Duration};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
|
||||||
use regex::Regex;
|
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use mini_moka::sync::Cache;
|
use chrono::Utc;
|
||||||
use once_cell::sync::Lazy;
|
use derive_more::{AsRef, Deref, Display, From, Into};
|
||||||
|
use regex::Regex;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::ApiResult,
|
api::ApiResult,
|
||||||
auth,
|
auth,
|
||||||
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
|
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
|
||||||
db::{
|
db::{
|
||||||
models::{Device, SsoNonce, User},
|
models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User},
|
||||||
DbConn,
|
DbConn,
|
||||||
},
|
},
|
||||||
sso_client::Client,
|
sso_client::Client,
|
||||||
CONFIG,
|
CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC";
|
pub static FAKE_SSO_IDENTIFIER: &str = "vaultwarden-dummy-oidc-identifier";
|
||||||
|
|
||||||
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
|
static SSO_JWT_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin()));
|
||||||
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
|
|
||||||
|
|
||||||
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin()));
|
pub static SSO_AUTH_EXPIRATION: LazyLock<chrono::Duration> =
|
||||||
|
LazyLock::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
|
||||||
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
|
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Clone,
|
Clone,
|
||||||
@@ -48,6 +44,47 @@ pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeD
|
|||||||
#[from(forward)]
|
#[from(forward)]
|
||||||
pub struct OIDCCode(String);
|
pub struct OIDCCode(String);
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone,
|
||||||
|
Debug,
|
||||||
|
Default,
|
||||||
|
DieselNewType,
|
||||||
|
FromForm,
|
||||||
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
Hash,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
AsRef,
|
||||||
|
Deref,
|
||||||
|
Display,
|
||||||
|
From,
|
||||||
|
Into,
|
||||||
|
)]
|
||||||
|
#[deref(forward)]
|
||||||
|
#[into(owned)]
|
||||||
|
pub struct OIDCCodeChallenge(String);
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone,
|
||||||
|
Debug,
|
||||||
|
Default,
|
||||||
|
DieselNewType,
|
||||||
|
FromForm,
|
||||||
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
Hash,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
AsRef,
|
||||||
|
Deref,
|
||||||
|
Display,
|
||||||
|
Into,
|
||||||
|
)]
|
||||||
|
#[deref(forward)]
|
||||||
|
#[into(owned)]
|
||||||
|
pub struct OIDCCodeVerifier(String);
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Clone,
|
Clone,
|
||||||
Debug,
|
Debug,
|
||||||
@@ -92,40 +129,6 @@ pub fn encode_ssotoken_claims() -> String {
|
|||||||
auth::encode_jwt(&claims)
|
auth::encode_jwt(&claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub enum OIDCCodeWrapper {
|
|
||||||
Ok {
|
|
||||||
state: OIDCState,
|
|
||||||
code: OIDCCode,
|
|
||||||
},
|
|
||||||
Error {
|
|
||||||
state: OIDCState,
|
|
||||||
error: String,
|
|
||||||
error_description: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct OIDCCodeClaims {
|
|
||||||
// Expiration time
|
|
||||||
pub exp: i64,
|
|
||||||
// Issuer
|
|
||||||
pub iss: String,
|
|
||||||
|
|
||||||
pub code: OIDCCodeWrapper,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_code_claims(code: OIDCCodeWrapper) -> String {
|
|
||||||
let time_now = Utc::now();
|
|
||||||
let claims = OIDCCodeClaims {
|
|
||||||
exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(),
|
|
||||||
iss: SSO_JWT_ISSUER.to_string(),
|
|
||||||
code,
|
|
||||||
};
|
|
||||||
|
|
||||||
auth::encode_jwt(&claims)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
struct BasicTokenClaims {
|
struct BasicTokenClaims {
|
||||||
iat: Option<i64>,
|
iat: Option<i64>,
|
||||||
@@ -133,6 +136,12 @@ struct BasicTokenClaims {
|
|||||||
exp: i64,
|
exp: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BasicTokenClaimsValidation {
|
||||||
|
exp: u64,
|
||||||
|
iss: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl BasicTokenClaims {
|
impl BasicTokenClaims {
|
||||||
fn nbf(&self) -> i64 {
|
fn nbf(&self) -> i64 {
|
||||||
self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp())
|
self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp())
|
||||||
@@ -140,18 +149,28 @@ impl BasicTokenClaims {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
|
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
|
||||||
let mut validation = jsonwebtoken::Validation::default();
|
// We need to manually validate this token, since `insecure_decode` does not do this
|
||||||
validation.set_issuer(&[CONFIG.sso_authority()]);
|
match jsonwebtoken::dangerous::insecure_decode::<BasicTokenClaimsValidation>(token) {
|
||||||
validation.insecure_disable_signature_validation();
|
Ok(btcv) => {
|
||||||
validation.validate_aud = false;
|
let now = jsonwebtoken::get_current_timestamp();
|
||||||
|
let validate_claim = btcv.claims;
|
||||||
|
// Validate the exp in the claim with a leeway of 60 seconds, same as jsonwebtoken does
|
||||||
|
if validate_claim.exp < now - 60 {
|
||||||
|
err_silent!(format!("Expired Signature for base token claim from {token_name}"))
|
||||||
|
}
|
||||||
|
if validate_claim.iss.ne(&CONFIG.sso_authority()) {
|
||||||
|
err_silent!(format!("Invalid Issuer for base token claim from {token_name}"))
|
||||||
|
}
|
||||||
|
|
||||||
match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) {
|
// All is validated and ok, lets decode again using the wanted struct
|
||||||
Ok(btc) => Ok(btc.claims),
|
let btc = jsonwebtoken::dangerous::insecure_decode::<BasicTokenClaims>(token).unwrap();
|
||||||
|
Ok(btc.claims)
|
||||||
|
}
|
||||||
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")),
|
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_state(base64_state: String) -> ApiResult<OIDCState> {
|
pub fn decode_state(base64_state: &str) -> ApiResult<OIDCState> {
|
||||||
let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) {
|
let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) {
|
||||||
Ok(vec) => match String::from_utf8(vec) {
|
Ok(vec) => match String::from_utf8(vec) {
|
||||||
Ok(valid) => OIDCState(valid),
|
Ok(valid) => OIDCState(valid),
|
||||||
@@ -163,9 +182,14 @@ pub fn decode_state(base64_state: String) -> ApiResult<OIDCState> {
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `nonce` allow to protect against replay attacks
|
|
||||||
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
|
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
|
||||||
pub async fn authorize_url(state: OIDCState, client_id: &str, raw_redirect_uri: &str, conn: DbConn) -> ApiResult<Url> {
|
pub async fn authorize_url(
|
||||||
|
state: OIDCState,
|
||||||
|
client_challenge: OIDCCodeChallenge,
|
||||||
|
client_id: &str,
|
||||||
|
raw_redirect_uri: &str,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> ApiResult<Url> {
|
||||||
let redirect_uri = match client_id {
|
let redirect_uri = match client_id {
|
||||||
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
|
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
|
||||||
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(),
|
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(),
|
||||||
@@ -179,8 +203,8 @@ pub async fn authorize_url(state: OIDCState, client_id: &str, raw_redirect_uri:
|
|||||||
_ => err!(format!("Unsupported client {client_id}")),
|
_ => err!(format!("Unsupported client {client_id}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?;
|
let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri).await?;
|
||||||
nonce.save(&conn).await?;
|
sso_auth.save(&conn).await?;
|
||||||
Ok(auth_url)
|
Ok(auth_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,78 +234,45 @@ impl OIDCIdentifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AuthenticatedUser {
|
|
||||||
pub refresh_token: Option<String>,
|
|
||||||
pub access_token: String,
|
|
||||||
pub expires_in: Option<Duration>,
|
|
||||||
pub identifier: OIDCIdentifier,
|
|
||||||
pub email: String,
|
|
||||||
pub email_verified: Option<bool>,
|
|
||||||
pub user_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UserInformation {
|
|
||||||
pub state: OIDCState,
|
|
||||||
pub identifier: OIDCIdentifier,
|
|
||||||
pub email: String,
|
|
||||||
pub email_verified: Option<bool>,
|
|
||||||
pub user_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn decode_code_claims(code: &str, conn: &DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
|
|
||||||
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) {
|
|
||||||
Ok(code_claims) => match code_claims.code {
|
|
||||||
OIDCCodeWrapper::Ok {
|
|
||||||
state,
|
|
||||||
code,
|
|
||||||
} => Ok((code, state)),
|
|
||||||
OIDCCodeWrapper::Error {
|
|
||||||
state,
|
|
||||||
error,
|
|
||||||
error_description,
|
|
||||||
} => {
|
|
||||||
if let Err(err) = SsoNonce::delete(&state, conn).await {
|
|
||||||
error!("Failed to delete database sso_nonce using {state}: {err}")
|
|
||||||
}
|
|
||||||
err!(format!(
|
|
||||||
"SSO authorization failed: {error}, {}",
|
|
||||||
error_description.as_ref().unwrap_or(&String::new())
|
|
||||||
))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => err!(format!("Failed to decode code wrapper: {err}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// During the 2FA flow we will
|
// During the 2FA flow we will
|
||||||
// - retrieve the user information and then only discover he needs 2FA.
|
// - retrieve the user information and then only discover he needs 2FA.
|
||||||
// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged.
|
// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged.
|
||||||
// The `nonce` will ensure that the user is authorized only once.
|
// The `SsoAuth` will ensure that the user is authorized only once.
|
||||||
// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`.
|
pub async fn exchange_code(
|
||||||
pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserInformation> {
|
state: &OIDCState,
|
||||||
|
client_verifier: OIDCCodeVerifier,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> {
|
||||||
use openidconnect::OAuth2TokenResponse;
|
use openidconnect::OAuth2TokenResponse;
|
||||||
|
|
||||||
let (code, state) = decode_code_claims(wrapped_code, conn).await?;
|
let mut sso_auth = match SsoAuth::find(state, conn).await {
|
||||||
|
None => err!(format!("Invalid state cannot retrieve sso auth")),
|
||||||
|
Some(sso_auth) => sso_auth,
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(authenticated_user) = AC_CACHE.get(&state) {
|
if let Some(authenticated_user) = sso_auth.auth_response.clone() {
|
||||||
return Ok(UserInformation {
|
return Ok((sso_auth, authenticated_user));
|
||||||
state,
|
|
||||||
identifier: authenticated_user.identifier,
|
|
||||||
email: authenticated_user.email,
|
|
||||||
email_verified: authenticated_user.email_verified,
|
|
||||||
user_name: authenticated_user.user_name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nonce = match SsoNonce::find(&state, conn).await {
|
let code = match sso_auth.code_response.clone() {
|
||||||
None => err!(format!("Invalid state cannot retrieve nonce")),
|
Some(OIDCCodeWrapper::Ok {
|
||||||
Some(nonce) => nonce,
|
code,
|
||||||
|
}) => code.clone(),
|
||||||
|
Some(OIDCCodeWrapper::Error {
|
||||||
|
error,
|
||||||
|
error_description,
|
||||||
|
}) => {
|
||||||
|
sso_auth.delete(conn).await?;
|
||||||
|
err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new())))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sso_auth.delete(conn).await?;
|
||||||
|
err!("Missing authorization provider return");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = Client::cached().await?;
|
let client = Client::cached().await?;
|
||||||
let (token_response, id_claims) = client.exchange_code(code, nonce).await?;
|
let (token_response, id_claims) = client.exchange_code(code, client_verifier, &sso_auth).await?;
|
||||||
|
|
||||||
let user_info = client.user_info(token_response.access_token().to_owned()).await?;
|
let user_info = client.user_info(token_response.access_token().to_owned()).await?;
|
||||||
|
|
||||||
@@ -301,7 +292,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
|
|||||||
|
|
||||||
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject());
|
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject());
|
||||||
|
|
||||||
let authenticated_user = AuthenticatedUser {
|
let authenticated_user = OIDCAuthenticatedUser {
|
||||||
refresh_token: refresh_token.cloned(),
|
refresh_token: refresh_token.cloned(),
|
||||||
access_token: token_response.access_token().secret().clone(),
|
access_token: token_response.access_token().secret().clone(),
|
||||||
expires_in: token_response.expires_in(),
|
expires_in: token_response.expires_in(),
|
||||||
@@ -312,29 +303,49 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
|
|||||||
};
|
};
|
||||||
|
|
||||||
debug!("Authenticated user {authenticated_user:?}");
|
debug!("Authenticated user {authenticated_user:?}");
|
||||||
|
sso_auth.auth_response = Some(authenticated_user.clone());
|
||||||
|
sso_auth.updated_at = Utc::now().naive_utc();
|
||||||
|
sso_auth.save(conn).await?;
|
||||||
|
|
||||||
AC_CACHE.insert(state.clone(), authenticated_user);
|
Ok((sso_auth, authenticated_user))
|
||||||
|
|
||||||
Ok(UserInformation {
|
|
||||||
state,
|
|
||||||
identifier,
|
|
||||||
email,
|
|
||||||
email_verified,
|
|
||||||
user_name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// User has passed 2FA flow we can delete `nonce` and clear the cache.
|
// User has passed 2FA flow we can delete auth info from database
|
||||||
pub async fn redeem(state: &OIDCState, conn: &DbConn) -> ApiResult<AuthenticatedUser> {
|
pub async fn redeem(
|
||||||
if let Err(err) = SsoNonce::delete(state, conn).await {
|
device: &Device,
|
||||||
error!("Failed to delete database sso_nonce using {state}: {err}")
|
user: &User,
|
||||||
|
client_id: Option<String>,
|
||||||
|
sso_user: Option<SsoUser>,
|
||||||
|
sso_auth: SsoAuth,
|
||||||
|
auth_user: OIDCAuthenticatedUser,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> ApiResult<AuthTokens> {
|
||||||
|
sso_auth.delete(conn).await?;
|
||||||
|
|
||||||
|
if sso_user.is_none() {
|
||||||
|
let user_sso = SsoUser {
|
||||||
|
user_uuid: user.uuid.clone(),
|
||||||
|
identifier: auth_user.identifier.clone(),
|
||||||
|
};
|
||||||
|
user_sso.save(conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(au) = AC_CACHE.get(state) {
|
if !CONFIG.sso_auth_only_not_session() {
|
||||||
AC_CACHE.invalidate(state);
|
let now = Utc::now();
|
||||||
Ok(au)
|
|
||||||
|
let (ap_nbf, ap_exp) =
|
||||||
|
match (decode_token_claims("access_token", &auth_user.access_token), auth_user.expires_in) {
|
||||||
|
(Ok(ap), _) => (ap.nbf(), ap.exp),
|
||||||
|
(Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()),
|
||||||
|
_ => err!("Non jwt access_token and empty expires_in"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let access_claims =
|
||||||
|
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now);
|
||||||
|
|
||||||
|
_create_auth_tokens(device, auth_user.refresh_token, access_claims, auth_user.access_token)
|
||||||
} else {
|
} else {
|
||||||
err!("Failed to retrieve user info from sso cache")
|
Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+58
-43
@@ -1,25 +1,25 @@
|
|||||||
use regex::Regex;
|
use std::{borrow::Cow, sync::LazyLock, time::Duration};
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use mini_moka::sync::Cache;
|
use openidconnect::{core::*, reqwest, *};
|
||||||
use once_cell::sync::Lazy;
|
use regex::Regex;
|
||||||
use openidconnect::core::*;
|
use url::Url;
|
||||||
use openidconnect::reqwest;
|
|
||||||
use openidconnect::*;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{ApiResult, EmptyResult},
|
api::{ApiResult, EmptyResult},
|
||||||
db::models::SsoNonce,
|
db::models::SsoAuth,
|
||||||
sso::{OIDCCode, OIDCState},
|
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
||||||
CONFIG,
|
CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
static CLIENT_CACHE_KEY: Lazy<String> = Lazy::new(|| "sso-client".to_string());
|
static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_string());
|
||||||
static CLIENT_CACHE: Lazy<Cache<String, Client>> = Lazy::new(|| {
|
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
||||||
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build()
|
moka::sync::Cache::builder()
|
||||||
|
.max_capacity(1)
|
||||||
|
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration()))
|
||||||
|
.build()
|
||||||
});
|
});
|
||||||
|
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
|
||||||
|
LazyLock::new(|| moka::future::Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(30)).build());
|
||||||
|
|
||||||
/// OpenID Connect Core client.
|
/// OpenID Connect Core client.
|
||||||
pub type CustomClient = openidconnect::Client<
|
pub type CustomClient = openidconnect::Client<
|
||||||
@@ -42,6 +42,8 @@ pub type CustomClient = openidconnect::Client<
|
|||||||
EndpointSet,
|
EndpointSet,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
pub type RefreshTokenResponse = (Option<String>, String, Option<Duration>);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
@@ -111,7 +113,11 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
|
// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
|
||||||
pub async fn authorize_url(state: OIDCState, redirect_uri: String) -> ApiResult<(Url, SsoNonce)> {
|
pub async fn authorize_url(
|
||||||
|
state: OIDCState,
|
||||||
|
client_challenge: OIDCCodeChallenge,
|
||||||
|
redirect_uri: String,
|
||||||
|
) -> ApiResult<(Url, SsoAuth)> {
|
||||||
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
|
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
|
||||||
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
|
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
|
||||||
|
|
||||||
@@ -126,22 +132,21 @@ impl Client {
|
|||||||
.add_scopes(scopes)
|
.add_scopes(scopes)
|
||||||
.add_extra_params(CONFIG.sso_authorize_extra_params_vec());
|
.add_extra_params(CONFIG.sso_authorize_extra_params_vec());
|
||||||
|
|
||||||
let verifier = if CONFIG.sso_pkce() {
|
if CONFIG.sso_pkce() {
|
||||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
auth_req = auth_req
|
||||||
auth_req = auth_req.set_pkce_challenge(pkce_challenge);
|
.add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into())
|
||||||
Some(pkce_verifier.into_secret())
|
.add_extra_param("code_challenge_method", "S256");
|
||||||
} else {
|
}
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let (auth_url, _, nonce) = auth_req.url();
|
let (auth_url, _, nonce) = auth_req.url();
|
||||||
Ok((auth_url, SsoNonce::new(state, nonce.secret().clone(), verifier, redirect_uri)))
|
Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn exchange_code(
|
pub async fn exchange_code(
|
||||||
&self,
|
&self,
|
||||||
code: OIDCCode,
|
code: OIDCCode,
|
||||||
nonce: SsoNonce,
|
client_verifier: OIDCCodeVerifier,
|
||||||
|
sso_auth: &SsoAuth,
|
||||||
) -> ApiResult<(
|
) -> ApiResult<(
|
||||||
StandardTokenResponse<
|
StandardTokenResponse<
|
||||||
IdTokenFields<
|
IdTokenFields<
|
||||||
@@ -159,17 +164,21 @@ impl Client {
|
|||||||
|
|
||||||
let mut exchange = self.core_client.exchange_code(oidc_code);
|
let mut exchange = self.core_client.exchange_code(oidc_code);
|
||||||
|
|
||||||
|
let verifier = PkceCodeVerifier::new(client_verifier.into());
|
||||||
if CONFIG.sso_pkce() {
|
if CONFIG.sso_pkce() {
|
||||||
match nonce.verifier {
|
exchange = exchange.set_pkce_verifier(verifier);
|
||||||
None => err!(format!("Missing verifier in the DB nonce table")),
|
} else {
|
||||||
Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret.clone())),
|
let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
|
||||||
|
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
|
||||||
|
err!(format!("PKCE client challenge failed"))
|
||||||
|
// Might need to notify admin ? how ?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match exchange.request_async(&self.http_client).await {
|
match exchange.request_async(&self.http_client).await {
|
||||||
Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)),
|
Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)),
|
||||||
Ok(token_response) => {
|
Ok(token_response) => {
|
||||||
let oidc_nonce = Nonce::new(nonce.nonce);
|
let oidc_nonce = Nonce::new(sso_auth.nonce.clone());
|
||||||
|
|
||||||
let id_token = match token_response.extra_fields().id_token() {
|
let id_token = match token_response.extra_fields().id_token() {
|
||||||
None => err!("Token response did not contain an id_token"),
|
None => err!("Token response did not contain an id_token"),
|
||||||
@@ -228,23 +237,29 @@ impl Client {
|
|||||||
verifier
|
verifier
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn exchange_refresh_token(
|
pub async fn exchange_refresh_token(refresh_token: String) -> ApiResult<RefreshTokenResponse> {
|
||||||
refresh_token: String,
|
let client = Client::cached().await?;
|
||||||
) -> ApiResult<(Option<String>, String, Option<Duration>)> {
|
|
||||||
|
REFRESH_CACHE
|
||||||
|
.get_with(refresh_token.clone(), async move { client._exchange_refresh_token(refresh_token).await })
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn _exchange_refresh_token(&self, refresh_token: String) -> Result<RefreshTokenResponse, String> {
|
||||||
let rt = RefreshToken::new(refresh_token);
|
let rt = RefreshToken::new(refresh_token);
|
||||||
|
|
||||||
let client = Client::cached().await?;
|
match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await {
|
||||||
let token_response =
|
Err(err) => {
|
||||||
match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await {
|
error!("Request to exchange_refresh_token endpoint failed: {err}");
|
||||||
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
|
Err(format!("Request to exchange_refresh_token endpoint failed: {err}"))
|
||||||
Ok(token_response) => token_response,
|
}
|
||||||
};
|
Ok(token_response) => Ok((
|
||||||
|
token_response.refresh_token().map(|token| token.secret().clone()),
|
||||||
Ok((
|
token_response.access_token().secret().clone(),
|
||||||
token_response.refresh_token().map(|token| token.secret().clone()),
|
token_response.expires_in(),
|
||||||
token_response.access_token().secret().clone(),
|
)),
|
||||||
token_response.expires_in(),
|
}
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,8 @@
|
|||||||
"microsoftstore.com",
|
"microsoftstore.com",
|
||||||
"xbox.com",
|
"xbox.com",
|
||||||
"azure.com",
|
"azure.com",
|
||||||
"windowsazure.com"
|
"windowsazure.com",
|
||||||
|
"cloud.microsoft"
|
||||||
],
|
],
|
||||||
"excluded": false
|
"excluded": false
|
||||||
},
|
},
|
||||||
@@ -971,5 +972,13 @@
|
|||||||
"pinterest.se"
|
"pinterest.se"
|
||||||
],
|
],
|
||||||
"excluded": false
|
"excluded": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 91,
|
||||||
|
"domains": [
|
||||||
|
"twitter.com",
|
||||||
|
"x.com"
|
||||||
|
],
|
||||||
|
"excluded": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+17
@@ -58,3 +58,20 @@ img {
|
|||||||
.abbr-badge {
|
.abbr-badge {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-icon,
|
||||||
|
.theme-icon-active {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 1.75em;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon svg,
|
||||||
|
.theme-icon-active svg {
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
min-width: 1.25em;
|
||||||
|
min-height: 1.25em;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
Vendored
+12
-3
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser */
|
/* eslint-env es2017, browser */
|
||||||
/* exported BASE_URL, _post */
|
/* exported BASE_URL, _post _delete */
|
||||||
|
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
// If the base URL is `https://vaultwarden.example.com/base/path/admin/`,
|
// If the base URL is `https://vaultwarden.example.com/base/path/admin/`,
|
||||||
@@ -106,7 +106,11 @@ const showActiveTheme = (theme, focus = false) => {
|
|||||||
const themeSwitcherText = document.querySelector("#bd-theme-text");
|
const themeSwitcherText = document.querySelector("#bd-theme-text");
|
||||||
const activeThemeIcon = document.querySelector(".theme-icon-active use");
|
const activeThemeIcon = document.querySelector(".theme-icon-active use");
|
||||||
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
|
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
|
||||||
const svgOfActiveBtn = btnToActive.querySelector("span use").textContent;
|
if (!btnToActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btnIconUse = btnToActive ? btnToActive.querySelector("[data-theme-icon-use]") : null;
|
||||||
|
const iconHref = btnIconUse ? btnIconUse.getAttribute("href") || btnIconUse.getAttribute("xlink:href") : null;
|
||||||
|
|
||||||
document.querySelectorAll("[data-bs-theme-value]").forEach(element => {
|
document.querySelectorAll("[data-bs-theme-value]").forEach(element => {
|
||||||
element.classList.remove("active");
|
element.classList.remove("active");
|
||||||
@@ -115,7 +119,12 @@ const showActiveTheme = (theme, focus = false) => {
|
|||||||
|
|
||||||
btnToActive.classList.add("active");
|
btnToActive.classList.add("active");
|
||||||
btnToActive.setAttribute("aria-pressed", "true");
|
btnToActive.setAttribute("aria-pressed", "true");
|
||||||
activeThemeIcon.textContent = svgOfActiveBtn;
|
|
||||||
|
if (iconHref && activeThemeIcon) {
|
||||||
|
activeThemeIcon.setAttribute("href", iconHref);
|
||||||
|
activeThemeIcon.setAttribute("xlink:href", iconHref);
|
||||||
|
}
|
||||||
|
|
||||||
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
|
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
|
||||||
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
|
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -29,7 +29,7 @@ function isValidIp(ip) {
|
|||||||
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkVersions(platform, installed, latest, commit=null, pre_release=false) {
|
function checkVersions(platform, installed, latest, commit=null, compare_order=0) {
|
||||||
if (installed === "-" || latest === "-") {
|
if (installed === "-" || latest === "-") {
|
||||||
document.getElementById(`${platform}-failed`).classList.remove("d-none");
|
document.getElementById(`${platform}-failed`).classList.remove("d-none");
|
||||||
return;
|
return;
|
||||||
@@ -37,7 +37,7 @@ function checkVersions(platform, installed, latest, commit=null, pre_release=fal
|
|||||||
|
|
||||||
// Only check basic versions, no commit revisions
|
// Only check basic versions, no commit revisions
|
||||||
if (commit === null || installed.indexOf("-") === -1) {
|
if (commit === null || installed.indexOf("-") === -1) {
|
||||||
if (platform === "web" && pre_release === true) {
|
if (platform === "web" && compare_order === 1) {
|
||||||
document.getElementById(`${platform}-prerelease`).classList.remove("d-none");
|
document.getElementById(`${platform}-prerelease`).classList.remove("d-none");
|
||||||
} else if (installed == latest) {
|
} else if (installed == latest) {
|
||||||
document.getElementById(`${platform}-success`).classList.remove("d-none");
|
document.getElementById(`${platform}-success`).classList.remove("d-none");
|
||||||
@@ -83,7 +83,7 @@ async function generateSupportString(event, dj) {
|
|||||||
let supportString = "### Your environment (Generated via diagnostics page)\n\n";
|
let supportString = "### Your environment (Generated via diagnostics page)\n\n";
|
||||||
|
|
||||||
supportString += `* Vaultwarden version: v${dj.current_release}\n`;
|
supportString += `* Vaultwarden version: v${dj.current_release}\n`;
|
||||||
supportString += `* Web-vault version: v${dj.web_vault_version}\n`;
|
supportString += `* Web-vault version: v${dj.active_web_release}\n`;
|
||||||
supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`;
|
supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`;
|
||||||
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`;
|
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`;
|
||||||
supportString += `* Database type: ${dj.db_type}\n`;
|
supportString += `* Database type: ${dj.db_type}\n`;
|
||||||
@@ -109,6 +109,9 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += "* Websocket Check: disabled\n";
|
supportString += "* Websocket Check: disabled\n";
|
||||||
}
|
}
|
||||||
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
|
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
|
||||||
|
if (dj.invalid_feature_flags != "") {
|
||||||
|
supportString += `* Invalid feature flags: true\n`;
|
||||||
|
}
|
||||||
|
|
||||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||||
"headers": { "Accept": "application/json" }
|
"headers": { "Accept": "application/json" }
|
||||||
@@ -128,6 +131,10 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dj.invalid_feature_flags != "") {
|
||||||
|
supportString += `\n**Invalid feature flags:** ${dj.invalid_feature_flags}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
// Add http response check messages if they exists
|
// Add http response check messages if they exists
|
||||||
if (httpResponseCheck === false) {
|
if (httpResponseCheck === false) {
|
||||||
supportString += "\n**Failed HTTP Checks:**\n";
|
supportString += "\n**Failed HTTP Checks:**\n";
|
||||||
@@ -208,9 +215,9 @@ function initVersionCheck(dj) {
|
|||||||
}
|
}
|
||||||
checkVersions("server", serverInstalled, serverLatest, serverLatestCommit);
|
checkVersions("server", serverInstalled, serverLatest, serverLatestCommit);
|
||||||
|
|
||||||
const webInstalled = dj.web_vault_version;
|
const webInstalled = dj.active_web_release;
|
||||||
const webLatest = dj.latest_web_build;
|
const webLatest = dj.latest_web_release;
|
||||||
checkVersions("web", webInstalled, webLatest, null, dj.web_vault_pre_release);
|
checkVersions("web", webInstalled, webLatest, null, dj.web_vault_compare);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkDns(dns_resolved) {
|
function checkDns(dns_resolved) {
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser, jquery */
|
/* eslint-env es2017, browser, jquery */
|
||||||
/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
|
/* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */
|
||||||
|
|
||||||
function deleteUser(event) {
|
function deleteUser(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
+2
-5
@@ -1,5 +1,5 @@
|
|||||||
/*!
|
/*!
|
||||||
* Bootstrap v5.3.7 (https://getbootstrap.com/)
|
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||||
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
*/
|
*/
|
||||||
@@ -647,7 +647,7 @@
|
|||||||
* Constants
|
* Constants
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VERSION = '5.3.7';
|
const VERSION = '5.3.8';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class definition
|
* Class definition
|
||||||
@@ -3690,9 +3690,6 @@
|
|||||||
this._element.setAttribute('aria-expanded', 'false');
|
this._element.setAttribute('aria-expanded', 'false');
|
||||||
Manipulator.removeDataAttribute(this._menu, 'popper');
|
Manipulator.removeDataAttribute(this._menu, 'popper');
|
||||||
EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget);
|
EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget);
|
||||||
|
|
||||||
// Explicitly return focus to the trigger element
|
|
||||||
this._element.focus();
|
|
||||||
}
|
}
|
||||||
_getConfig(config) {
|
_getConfig(config) {
|
||||||
config = super._getConfig(config);
|
config = super._getConfig(config);
|
||||||
|
|||||||
Vendored
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
@charset "UTF-8";
|
@charset "UTF-8";
|
||||||
/*!
|
/*!
|
||||||
* Bootstrap v5.3.7 (https://getbootstrap.com/)
|
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||||
* Copyright 2011-2025 The Bootstrap Authors
|
* Copyright 2011-2025 The Bootstrap Authors
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
*/
|
*/
|
||||||
@@ -547,6 +547,10 @@ legend + * {
|
|||||||
-webkit-appearance: textfield;
|
-webkit-appearance: textfield;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
[type=search]::-webkit-search-cancel-button {
|
||||||
|
cursor: pointer;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
/* rtl:raw:
|
/* rtl:raw:
|
||||||
[type="tel"],
|
[type="tel"],
|
||||||
@@ -6208,6 +6212,7 @@ textarea.form-control-lg {
|
|||||||
.spinner-grow,
|
.spinner-grow,
|
||||||
.spinner-border {
|
.spinner-border {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
width: var(--bs-spinner-width);
|
width: var(--bs-spinner-width);
|
||||||
height: var(--bs-spinner-height);
|
height: var(--bs-spinner-height);
|
||||||
vertical-align: var(--bs-spinner-vertical-align);
|
vertical-align: var(--bs-spinner-vertical-align);
|
||||||
|
|||||||
Vendored
+59
-59
@@ -4,20 +4,21 @@
|
|||||||
*
|
*
|
||||||
* To rebuild or modify this file with the latest versions of the included
|
* To rebuild or modify this file with the latest versions of the included
|
||||||
* software please visit:
|
* software please visit:
|
||||||
* https://datatables.net/download/#bs5/dt-2.3.2
|
* https://datatables.net/download/#bs5/dt-2.3.7
|
||||||
*
|
*
|
||||||
* Included libraries:
|
* Included libraries:
|
||||||
* DataTables 2.3.2
|
* DataTables 2.3.7
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--dt-row-selected: 13, 110, 253;
|
--dt-row-selected: 13, 110, 253;
|
||||||
--dt-row-selected-text: 255, 255, 255;
|
--dt-row-selected-text: 255, 255, 255;
|
||||||
--dt-row-selected-link: 9, 10, 11;
|
--dt-row-selected-link: 228, 228, 228;
|
||||||
--dt-row-stripe: 0, 0, 0;
|
--dt-row-stripe: 0, 0, 0;
|
||||||
--dt-row-hover: 0, 0, 0;
|
--dt-row-hover: 0, 0, 0;
|
||||||
--dt-column-ordering: 0, 0, 0;
|
--dt-column-ordering: 0, 0, 0;
|
||||||
--dt-header-align-items: center;
|
--dt-header-align-items: center;
|
||||||
|
--dt-header-vertical-align: middle;
|
||||||
--dt-html-background: white;
|
--dt-html-background: white;
|
||||||
}
|
}
|
||||||
:root.dark {
|
:root.dark {
|
||||||
@@ -87,42 +88,42 @@ table.dataTable thead > tr > th:active,
|
|||||||
table.dataTable thead > tr > td:active {
|
table.dataTable thead > tr > td:active {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before,
|
table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before {
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: block;
|
display: block;
|
||||||
bottom: 50%;
|
bottom: 50%;
|
||||||
content: "\25B2";
|
content: "\25B2";
|
||||||
content: "\25B2"/"";
|
content: "\25B2"/"";
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
|
table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after,
|
table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: block;
|
display: block;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
content: "\25BC";
|
content: "\25BC";
|
||||||
content: "\25BC"/"";
|
content: "\25BC"/"";
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order,
|
table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order,
|
||||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order,
|
||||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order,
|
table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
|
table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:after,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:before,
|
table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after,
|
table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:after,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:before,
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
||||||
left: 0;
|
left: 0;
|
||||||
opacity: 0.125;
|
opacity: 0.125;
|
||||||
line-height: 9px;
|
line-height: 9px;
|
||||||
@@ -139,14 +140,15 @@ table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
|||||||
outline: 2px solid rgba(0, 0, 0, 0.05);
|
outline: 2px solid rgba(0, 0, 0, 0.05);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
|
table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled span.dt-column-order:before,
|
table.dataTable thead > tr > th.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty, table.dataTable thead > tr > th.sorting_desc_disabled .dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.sorting_desc_disabled span.dt-column-order:after,
|
table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty,
|
||||||
table.dataTable thead > tr > td.sorting_asc_disabled span.dt-column-order:before {
|
table.dataTable thead > tr > td.sorting_desc_disabled .dt-column-order:after,
|
||||||
|
table.dataTable thead > tr > td.sorting_asc_disabled .dt-column-order:before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th:active,
|
table.dataTable thead > tr > th:active,
|
||||||
@@ -167,24 +169,24 @@ table.dataTable tfoot > tr > td div.dt-column-footer {
|
|||||||
align-items: var(--dt-header-align-items);
|
align-items: var(--dt-header-align-items);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th div.dt-column-header span.dt-column-title,
|
table.dataTable thead > tr > th div.dt-column-header .dt-column-title,
|
||||||
table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title,
|
table.dataTable thead > tr > th div.dt-column-footer .dt-column-title,
|
||||||
table.dataTable thead > tr > td div.dt-column-header span.dt-column-title,
|
table.dataTable thead > tr > td div.dt-column-header .dt-column-title,
|
||||||
table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title,
|
table.dataTable thead > tr > td div.dt-column-footer .dt-column-title,
|
||||||
table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title,
|
table.dataTable tfoot > tr > th div.dt-column-header .dt-column-title,
|
||||||
table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title,
|
table.dataTable tfoot > tr > th div.dt-column-footer .dt-column-title,
|
||||||
table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title,
|
table.dataTable tfoot > tr > td div.dt-column-header .dt-column-title,
|
||||||
table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title {
|
table.dataTable tfoot > tr > td div.dt-column-footer .dt-column-title {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th div.dt-column-header span.dt-column-title:empty,
|
table.dataTable thead > tr > th div.dt-column-header .dt-column-title:empty,
|
||||||
table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title:empty,
|
table.dataTable thead > tr > th div.dt-column-footer .dt-column-title:empty,
|
||||||
table.dataTable thead > tr > td div.dt-column-header span.dt-column-title:empty,
|
table.dataTable thead > tr > td div.dt-column-header .dt-column-title:empty,
|
||||||
table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title:empty,
|
table.dataTable thead > tr > td div.dt-column-footer .dt-column-title:empty,
|
||||||
table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title:empty,
|
table.dataTable tfoot > tr > th div.dt-column-header .dt-column-title:empty,
|
||||||
table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title:empty,
|
table.dataTable tfoot > tr > th div.dt-column-footer .dt-column-title:empty,
|
||||||
table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title:empty,
|
table.dataTable tfoot > tr > td div.dt-column-header .dt-column-title:empty,
|
||||||
table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title:empty {
|
table.dataTable tfoot > tr > td div.dt-column-footer .dt-column-title:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +342,7 @@ table.dataTable thead td,
|
|||||||
table.dataTable tfoot th,
|
table.dataTable tfoot th,
|
||||||
table.dataTable tfoot td {
|
table.dataTable tfoot td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
vertical-align: var(--dt-header-vertical-align);
|
||||||
}
|
}
|
||||||
table.dataTable thead th.dt-head-left,
|
table.dataTable thead th.dt-head-left,
|
||||||
table.dataTable thead td.dt-head-left,
|
table.dataTable thead td.dt-head-left,
|
||||||
@@ -422,10 +425,6 @@ table.dataTable tbody td.dt-body-nowrap {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
|
||||||
--dt-header-align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*! Bootstrap 5 integration for DataTables
|
/*! Bootstrap 5 integration for DataTables
|
||||||
*
|
*
|
||||||
* ©2020 SpryMedia Ltd, all rights reserved.
|
* ©2020 SpryMedia Ltd, all rights reserved.
|
||||||
@@ -453,7 +452,7 @@ table.table.dataTable > tbody > tr.selected > * {
|
|||||||
color: rgb(var(--dt-row-selected-text));
|
color: rgb(var(--dt-row-selected-text));
|
||||||
}
|
}
|
||||||
table.table.dataTable > tbody > tr.selected a {
|
table.table.dataTable > tbody > tr.selected a {
|
||||||
color: rgb(9, 10, 11);
|
color: rgb(228, 228, 228);
|
||||||
color: rgb(var(--dt-row-selected-link));
|
color: rgb(var(--dt-row-selected-link));
|
||||||
}
|
}
|
||||||
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
|
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
|
||||||
@@ -589,16 +588,16 @@ table.dataTable.table-sm > thead > tr td.dt-ordering-asc,
|
|||||||
table.dataTable.table-sm > thead > tr td.dt-ordering-desc {
|
table.dataTable.table-sm > thead > tr td.dt-ordering-desc {
|
||||||
padding-right: 0.25rem;
|
padding-right: 0.25rem;
|
||||||
}
|
}
|
||||||
table.dataTable.table-sm > thead > tr th.dt-orderable-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-orderable-desc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-desc span.dt-column-order,
|
table.dataTable.table-sm > thead > tr th.dt-orderable-asc .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-orderable-desc .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-asc .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-desc .dt-column-order,
|
||||||
table.dataTable.table-sm > thead > tr td.dt-orderable-asc span.dt-column-order,
|
table.dataTable.table-sm > thead > tr td.dt-orderable-asc .dt-column-order,
|
||||||
table.dataTable.table-sm > thead > tr td.dt-orderable-desc span.dt-column-order,
|
table.dataTable.table-sm > thead > tr td.dt-orderable-desc .dt-column-order,
|
||||||
table.dataTable.table-sm > thead > tr td.dt-ordering-asc span.dt-column-order,
|
table.dataTable.table-sm > thead > tr td.dt-ordering-asc .dt-column-order,
|
||||||
table.dataTable.table-sm > thead > tr td.dt-ordering-desc span.dt-column-order {
|
table.dataTable.table-sm > thead > tr td.dt-ordering-desc .dt-column-order {
|
||||||
right: 0.25rem;
|
right: 0.25rem;
|
||||||
}
|
}
|
||||||
table.dataTable.table-sm > thead > tr th.dt-type-date span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-type-numeric span.dt-column-order,
|
table.dataTable.table-sm > thead > tr th.dt-type-date .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-type-numeric .dt-column-order,
|
||||||
table.dataTable.table-sm > thead > tr td.dt-type-date span.dt-column-order,
|
table.dataTable.table-sm > thead > tr td.dt-type-date .dt-column-order,
|
||||||
table.dataTable.table-sm > thead > tr td.dt-type-numeric span.dt-column-order {
|
table.dataTable.table-sm > thead > tr td.dt-type-numeric .dt-column-order {
|
||||||
left: 0.25rem;
|
left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +606,8 @@ div.dt-scroll-head table.table-bordered {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.table-responsive > div.dt-container > div.row {
|
div.table-responsive > div.dt-container > div.row {
|
||||||
margin: 0;
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
div.table-responsive > div.dt-container > div.row > div[class^=col-]:first-child {
|
div.table-responsive > div.dt-container > div.row > div[class^=col-]:first-child {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|||||||
Vendored
+379
-297
File diff suppressed because it is too large
Load Diff
+1460
-1460
File diff suppressed because it is too large
Load Diff
+2182
-3943
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user