mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-23 12:28:09 +03:00
Compare commits
41
Commits
1.37.1
...
3714b504f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3714b504f6 | ||
|
|
f9f011d50c | ||
|
|
cb89088bc3 | ||
|
|
4444442075 | ||
|
|
061694d0cb | ||
|
|
32098ca7d1 | ||
|
|
d2660324e6 | ||
|
|
cc67d644f6 | ||
|
|
3347698712 | ||
|
|
a24683636a | ||
|
|
64c56411f6 | ||
|
|
c3f5477525 | ||
|
|
9c8aa2359f | ||
|
|
8719f86fcf | ||
|
|
fddc8e71be | ||
|
|
eb212e23fa | ||
|
|
25dfedafd7 | ||
|
|
e992cbb4f5 | ||
|
|
5b51b60f94 | ||
|
|
de7abaaafa | ||
|
|
b7667e27bf | ||
|
|
f1c36b8c1d | ||
|
|
f1ff613008 | ||
|
|
57fbed1bed | ||
|
|
277e1536eb | ||
|
|
2ffad8775d | ||
|
|
32d85d03bb | ||
|
|
a6c3bd6d18 | ||
|
|
6729e83521 | ||
|
|
fdc156b247 | ||
|
|
2073c03092 | ||
|
|
923f5d0b5e | ||
|
|
83724b301e | ||
|
|
10e044f563 | ||
|
|
fa2566d14f | ||
|
|
46d71107f5 | ||
|
|
9e78911a2f | ||
|
|
0cefa4cca7 | ||
|
|
b30cc08562 | ||
|
|
55f883a566 | ||
|
|
74ceaf2354 |
No files matched your search
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
// Ignore everything
|
# Ignore everything
|
||||||
*
|
*
|
||||||
|
|
||||||
// Allow what is needed
|
# Allow what is needed
|
||||||
!.git
|
!.git
|
||||||
!docker/healthcheck.sh
|
!docker/healthcheck.sh
|
||||||
!docker/start.sh
|
!docker/start.sh
|
||||||
|
|||||||
@@ -316,6 +316,14 @@
|
|||||||
## unauthenticated access to potentially sensitive data.
|
## unauthenticated access to potentially sensitive data.
|
||||||
# SHOW_PASSWORD_HINT=false
|
# SHOW_PASSWORD_HINT=false
|
||||||
|
|
||||||
|
#########################
|
||||||
|
### Client settings ###
|
||||||
|
#########################
|
||||||
|
|
||||||
|
## Control whether clients onboarding interstitials are suppressed
|
||||||
|
## (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals)
|
||||||
|
# CLIENT_SUPPRESS_ONBOARDING=false
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
### Advanced settings ###
|
### Advanced settings ###
|
||||||
#########################
|
#########################
|
||||||
@@ -382,6 +390,7 @@
|
|||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||||
|
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
|
||||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
||||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||||
@@ -393,10 +402,14 @@
|
|||||||
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
||||||
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
||||||
## - "pm-30529-webauthn-related-origins":
|
## - "pm-30529-webauthn-related-origins":
|
||||||
|
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
|
||||||
|
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
|
||||||
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||||
|
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
|
||||||
|
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||||
|
|
||||||
## Require new device emails. When a user logs in an email is required to be sent.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
@@ -510,6 +523,9 @@
|
|||||||
## Prevent users from logging in directly without going through SSO
|
## Prevent users from logging in directly without going through SSO
|
||||||
# SSO_ONLY=false
|
# SSO_ONLY=false
|
||||||
|
|
||||||
|
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
|
||||||
|
# SSO_SIGNUPS_ALLOWED=true
|
||||||
|
|
||||||
## On SSO Signup if a user with a matching email already exists make the association
|
## On SSO Signup if a user with a matching email already exists make the association
|
||||||
# SSO_SIGNUPS_MATCH_EMAIL=true
|
# SSO_SIGNUPS_MATCH_EMAIL=true
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
# Enable Rust Caching
|
# Enable Rust Caching
|
||||||
- name: Rust Caching
|
- name: Rust Caching
|
||||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -41,12 +41,12 @@ jobs:
|
|||||||
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
|
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
|
||||||
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
|
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
|
||||||
- name: Run hadolint on Dockerfile.debian
|
- name: Run hadolint on Dockerfile.debian
|
||||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||||
with:
|
with:
|
||||||
dockerfile: docker/Dockerfile.debian
|
dockerfile: docker/Dockerfile.debian
|
||||||
|
|
||||||
- name: Run hadolint on Dockerfile.alpine
|
- name: Run hadolint on Dockerfile.alpine
|
||||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||||
with:
|
with:
|
||||||
dockerfile: docker/Dockerfile.alpine
|
dockerfile: docker/Dockerfile.alpine
|
||||||
# End Test Dockerfiles with hadolint
|
# End Test Dockerfiles with hadolint
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Initialize QEMU binfmt support
|
- name: Initialize QEMU binfmt support
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
with:
|
with:
|
||||||
platforms: "arm64,arm"
|
platforms: "arm64,arm"
|
||||||
|
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
@@ -237,7 +237,7 @@ jobs:
|
|||||||
|
|
||||||
# Upload artifacts to Github Actions and Attest the binaries
|
# Upload artifacts to Github Actions and Attest the binaries
|
||||||
- name: Attest binaries
|
- name: Attest binaries
|
||||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -303,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -365,7 +365,7 @@ jobs:
|
|||||||
# Attest container images
|
# Attest container images
|
||||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
@@ -373,7 +373,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.GHCR_REPO }}
|
subject-name: ${{ vars.GHCR_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
@@ -381,7 +381,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.QUAY_REPO }}
|
subject-name: ${{ vars.QUAY_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
|
|||||||
@@ -50,6 +50,6 @@ jobs:
|
|||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
|
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ repos:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||||
- repo: https://github.com/crate-ci/typos
|
- repo: https://github.com/crate-ci/typos
|
||||||
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
|
rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: typos
|
- id: typos
|
||||||
|
always_run: true
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
@@ -38,8 +39,7 @@ repos:
|
|||||||
entry: cargo test
|
entry: cargo test
|
||||||
language: system
|
language: system
|
||||||
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||||
types_or: [ rust, file ]
|
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: cargo-clippy
|
- id: cargo-clippy
|
||||||
name: cargo clippy
|
name: cargo clippy
|
||||||
@@ -47,8 +47,7 @@ repos:
|
|||||||
entry: cargo clippy
|
entry: cargo clippy
|
||||||
language: system
|
language: system
|
||||||
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||||
types_or: [ rust, file ]
|
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: check-docker-templates
|
- id: check-docker-templates
|
||||||
name: check docker templates
|
name: check docker templates
|
||||||
|
|||||||
Generated
+638
-513
File diff suppressed because it is too large.
Load diff
+32
-30
@@ -1,6 +1,6 @@
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95.0"
|
rust-version = "1.96.1"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||||
publish = false
|
publish = false
|
||||||
@@ -12,7 +12,6 @@ members = ["macros"]
|
|||||||
name = "vaultwarden"
|
name = "vaultwarden"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||||
readme = "README.md"
|
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
@@ -41,6 +40,7 @@ vendored_openssl = ["openssl/vendored"]
|
|||||||
enable_mimalloc = ["dep:mimalloc"]
|
enable_mimalloc = ["dep:mimalloc"]
|
||||||
s3 = [
|
s3 = [
|
||||||
"opendal/services-s3",
|
"opendal/services-s3",
|
||||||
|
"dep:opendal-http-transport-reqwest",
|
||||||
"dep:aws-config",
|
"dep:aws-config",
|
||||||
"dep:aws-credential-types",
|
"dep:aws-credential-types",
|
||||||
"dep:aws-smithy-runtime-api",
|
"dep:aws-smithy-runtime-api",
|
||||||
@@ -58,6 +58,7 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
|
|||||||
unstable = []
|
unstable = []
|
||||||
|
|
||||||
[target."cfg(unix)".dependencies]
|
[target."cfg(unix)".dependencies]
|
||||||
|
nix = { version = "0.31.3", features = ["fs"] }
|
||||||
# Logging
|
# Logging
|
||||||
syslog = "7.0.0"
|
syslog = "7.0.0"
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.33"
|
log = "0.4.34"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||||
tracing = { version = "0.1.44", features = ["log"] }
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
@@ -89,7 +90,7 @@ rmpv = "1.3.1" # MessagePack library
|
|||||||
dashmap = "6.2.1"
|
dashmap = "6.2.1"
|
||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.33"
|
futures = "0.3.34"
|
||||||
tokio = { version = "1.53.1", features = [
|
tokio = { version = "1.53.1", features = [
|
||||||
"fs",
|
"fs",
|
||||||
"io-util",
|
"io-util",
|
||||||
@@ -106,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
|
|||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
|
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
|
||||||
diesel_migrations = "2.3.2"
|
diesel_migrations = "2.3.2"
|
||||||
|
|
||||||
derive_more = { version = "2.1.1", features = [
|
derive_more = { version = "2.1.1", features = [
|
||||||
@@ -119,36 +120,36 @@ derive_more = { version = "2.1.1", features = [
|
|||||||
diesel-derive-newtype = "2.1.3"
|
diesel-derive-newtype = "2.1.3"
|
||||||
|
|
||||||
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||||
libsqlite3-sys = { version = "0.37.0", optional = true }
|
libsqlite3-sys = { version = "0.38.2", optional = true }
|
||||||
|
|
||||||
# Crypto-related libraries
|
# Crypto-related libraries
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
rustls = { version = "0.23.42", features = ["ring", "std"], default-features = false }
|
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.24.0", features = ["v4"] }
|
uuid = { version = "1.26.1", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
time = "0.3.54"
|
time = "0.3.55"
|
||||||
|
|
||||||
# Job scheduler
|
# Job scheduler
|
||||||
job_scheduler_ng = "2.4.0"
|
job_scheduler_ng = "2.5.0"
|
||||||
|
|
||||||
# Data encoding library Hex/Base32/Base64
|
# Data encoding library Hex/Base32/Base64
|
||||||
data-encoding = "2.11.0"
|
data-encoding = "2.11.1"
|
||||||
|
|
||||||
# JWT library
|
# JWT library
|
||||||
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
||||||
|
|
||||||
# TOTP library
|
# TOTP library
|
||||||
totp-lite = "2.0.1"
|
totp-lite = "2.0.1"
|
||||||
|
|
||||||
# Yubico Library
|
# Yubico Library
|
||||||
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] }
|
yubico_ng = { version = "1.0.0", default-features = false }
|
||||||
|
|
||||||
# WebAuthn libraries
|
# WebAuthn libraries
|
||||||
# danger-allow-state-serialisation is needed to save the state in the db
|
# danger-allow-state-serialisation is needed to save the state in the db
|
||||||
@@ -161,7 +162,7 @@ webauthn-rs-core = "0.5.5"
|
|||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
||||||
# Email libraries
|
# Email libraries
|
||||||
lettre = { version = "0.11.22", default-features = false, features = [
|
lettre = { version = "0.11.23", default-features = false, features = [
|
||||||
# Misc
|
# Misc
|
||||||
"tracing",
|
"tracing",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -179,10 +180,10 @@ percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
|||||||
email_address = "0.2.9"
|
email_address = "0.2.9"
|
||||||
|
|
||||||
# HTML Template library
|
# HTML Template library
|
||||||
handlebars = { version = "6.4.3", features = ["dir_source"] }
|
handlebars = { version = "6.4.4", features = ["dir_source"] }
|
||||||
|
|
||||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = [
|
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||||
# Misc
|
# Misc
|
||||||
"charset",
|
"charset",
|
||||||
"cookies",
|
"cookies",
|
||||||
@@ -200,7 +201,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
|
|||||||
"socks",
|
"socks",
|
||||||
"system-proxy",
|
"system-proxy",
|
||||||
] }
|
] }
|
||||||
hickory-resolver = "0.26.1"
|
hickory-resolver = "0.26.3"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.8.4"
|
html5gum = "0.8.4"
|
||||||
@@ -211,13 +212,13 @@ regex = { version = "1.13.1", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
svg-hush = "0.9.6"
|
svg-hush = "0.9.7"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "2.0.2", features = ["async"] }
|
cached = { version = "4.0.0", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.2"
|
||||||
cookie_store = "0.22.1"
|
cookie_store = "0.22.1"
|
||||||
|
|
||||||
# Used by U2F, JWT and PostgreSQL
|
# Used by U2F, JWT and PostgreSQL
|
||||||
@@ -231,11 +232,11 @@ pastey = "0.2.3"
|
|||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
|
|
||||||
# CIDR parsing for the trusted proxies of the client IP header
|
# CIDR parsing for the trusted proxies of the client IP header
|
||||||
ipnet = "2.12.0"
|
ipnet = "2.12.2"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.0.1", default-features = false }
|
openidconnect = { version = "4.0.1", default-features = false }
|
||||||
moka = { version = "0.12.15", features = ["future"] }
|
moka = { version = "0.12.16", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
semver = "1.0.28"
|
semver = "1.0.28"
|
||||||
@@ -244,10 +245,10 @@ semver = "1.0.28"
|
|||||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||||
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
|
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
|
||||||
|
|
||||||
which = "8.0.5"
|
which = "8.0.6"
|
||||||
|
|
||||||
# Argon2 library with support for the PHC format
|
# Argon2 library with support for the PHC format
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.6.0"
|
||||||
|
|
||||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||||
rpassword = "7.5.4"
|
rpassword = "7.5.4"
|
||||||
@@ -256,20 +257,21 @@ rpassword = "7.5.4"
|
|||||||
grass_compiler = { version = "0.13.4", default-features = false }
|
grass_compiler = { version = "0.13.4", default-features = false }
|
||||||
|
|
||||||
# File are accessed through Apache OpenDAL
|
# File are accessed through Apache OpenDAL
|
||||||
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
|
opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] }
|
||||||
|
opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
aws-config = { version = "1.10.0", optional = true, default-features = false, features = [
|
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
||||||
"behavior-version-latest",
|
"behavior-version-latest",
|
||||||
"credentials-process",
|
"credentials-process",
|
||||||
"rt-tokio",
|
"rt-tokio",
|
||||||
"sso",
|
"sso",
|
||||||
] }
|
] }
|
||||||
aws-credential-types = { version = "1.3.0", optional = true }
|
aws-credential-types = { version = "1.3.0", optional = true }
|
||||||
aws-smithy-runtime-api = { version = "1.14.0", optional = true }
|
aws-smithy-runtime-api = { version = "1.16.2", optional = true }
|
||||||
http = { version = "1.4.2", optional = true }
|
http = { version = "1.5.0", optional = true }
|
||||||
reqsign-aws-v4 = { version = "3.0.2", optional = true }
|
reqsign-aws-v4 = { version = "3.3.0", optional = true }
|
||||||
reqsign-core = { version = "3.1.0", optional = true }
|
reqsign-core = { version = "3.3.1", optional = true }
|
||||||
|
|
||||||
# Strip debuginfo from the release builds
|
# Strip debuginfo from the release builds
|
||||||
# The debug symbols are to provide better panic traces
|
# The debug symbols are to provide better panic traces
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2026.6.4"
|
vault_version: "v2026.7.0"
|
||||||
vault_image_digest: "sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427"
|
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
|
||||||
# Cross Compile Docker Helper Scripts v1.9.0
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
rust_version: 1.97.1 # Rust version to be used
|
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
||||||
|
# rust_version: x.yy.z # Rust version to be used
|
||||||
debian_version: trixie # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
alpine_version: "3.24" # Alpine version to be used
|
alpine_version: "3.24" # Alpine version to be used
|
||||||
# For which platforms/architectures will we try to build images
|
# For which platforms/architectures will we try to build images
|
||||||
|
|||||||
+15
-14
@@ -19,23 +19,23 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.6.4]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.98.1 AS build_amd64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.1 AS build_arm64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.1 AS build_armv7
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.1 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
@@ -70,7 +70,7 @@ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
|||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -86,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
|||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
@@ -97,13 +97,13 @@ COPY . .
|
|||||||
ARG VW_VERSION
|
ARG VW_VERSION
|
||||||
|
|
||||||
# Builds again, this time it will be the actual source files being build
|
# Builds again, this time it will be the actual source files being build
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||||
@@ -126,6 +126,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
|
# hadolint ignore=DL3065
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
|
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
|
|||||||
+41
-13
@@ -19,15 +19,15 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.6.4]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
@@ -36,7 +36,8 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
|
|||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build
|
||||||
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
@@ -80,7 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
|||||||
RUN USER=root cargo new --bin /app
|
RUN USER=root cargo new --bin /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -95,9 +96,22 @@ ARG DB=sqlite,mysql,postgresql
|
|||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Workaround for xx related build issues
|
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||||
|
if xx-info is-cross; then \
|
||||||
|
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
fi && \
|
||||||
|
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||||
|
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||||
|
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||||
|
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||||
|
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||||
|
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||||
|
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||||
|
fi && \
|
||||||
|
export XX_RUSTFLAGS && \
|
||||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
@@ -108,15 +122,28 @@ COPY . .
|
|||||||
ARG VW_VERSION
|
ARG VW_VERSION
|
||||||
|
|
||||||
# Builds again, this time it will be the actual source files being build
|
# Builds again, this time it will be the actual source files being build
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
# Workaround for xx related build issues
|
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||||
|
if xx-info is-cross; then \
|
||||||
|
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
fi && \
|
||||||
|
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||||
|
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||||
|
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||||
|
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||||
|
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||||
|
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||||
|
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||||
|
fi && \
|
||||||
|
export XX_RUSTFLAGS && \
|
||||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||||
@@ -139,6 +166,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
|
# hadolint ignore=DL3065
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
|
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
|
|||||||
+20
-5
@@ -28,8 +28,21 @@
|
|||||||
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||||
#
|
#
|
||||||
{% macro xx_cargo_config() -%}
|
{% macro xx_cargo_config() -%}
|
||||||
# Workaround for xx related build issues
|
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||||
|
if xx-info is-cross; then \
|
||||||
|
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
fi && \
|
||||||
|
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||||
|
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||||
|
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||||
|
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||||
|
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||||
|
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||||
|
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||||
|
fi && \
|
||||||
|
export XX_RUSTFLAGS && \
|
||||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||||
@@ -52,6 +65,7 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
|
|||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
{% endif %}
|
{% endif %}
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -111,7 +125,7 @@ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
|||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -131,7 +145,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
|||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
{{ xx_cargo_config() }} && \
|
{{ xx_cargo_config() }} && \
|
||||||
{% elif base == "alpine" %}
|
{% elif base == "alpine" %}
|
||||||
@@ -146,7 +160,7 @@ COPY . .
|
|||||||
ARG VW_VERSION
|
ARG VW_VERSION
|
||||||
|
|
||||||
# Builds again, this time it will be the actual source files being build
|
# Builds again, this time it will be the actual source files being build
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
# Make sure that we actually build the project by updating the src/main.rs timestamp
|
||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
@@ -156,7 +170,7 @@ RUN source /env-cargo && \
|
|||||||
{% elif base == "alpine" %}
|
{% elif base == "alpine" %}
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
{% endif %}
|
{% endif %}
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||||
@@ -179,6 +193,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
|
# hadolint ignore=DL3065
|
||||||
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
|
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
|
|||||||
@@ -3,17 +3,23 @@
|
|||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import tomllib
|
||||||
import yaml
|
import yaml
|
||||||
import jinja2
|
import jinja2
|
||||||
|
|
||||||
# Load settings file
|
# Load settings file
|
||||||
with open("DockerSettings.yaml", 'r') as yaml_file:
|
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
|
||||||
yaml_data = yaml.safe_load(yaml_file)
|
yaml_data = yaml.safe_load(yaml_file)
|
||||||
|
|
||||||
|
# Extract the rust_version from the rust-toolchain.toml file
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
|
||||||
|
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
|
||||||
|
|
||||||
settings_env = jinja2.Environment(
|
settings_env = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(os.getcwd()),
|
loader=jinja2.FileSystemLoader(os.getcwd()),
|
||||||
)
|
)
|
||||||
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
|
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
|
||||||
|
|
||||||
args_parser = argparse.ArgumentParser()
|
args_parser = argparse.ArgumentParser()
|
||||||
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
|
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ proc-macro = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.47"
|
quote = "1.0.47"
|
||||||
syn = "3.0.3"
|
syn = "3.0.6"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
-- Dynamically create DROP FOREIGN KEY
|
SELECT if (
|
||||||
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
|
EXISTS(
|
||||||
-- This checks if the key exists, and if so, will drop it.
|
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||||
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
CONSTRAINT_SCHEMA = DATABASE() AND
|
AND TABLE_NAME = 'sso_users'
|
||||||
TABLE_NAME = 'sso_users' AND
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
|
AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
|
||||||
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
|
)
|
||||||
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
|
,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
|
||||||
'SELECT 1');
|
,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
|
||||||
PREPARE stmt FROM @drop_sso_fk;
|
) INTO @drop_stmt;
|
||||||
EXECUTE stmt;
|
PREPARE drop_stmt FROM @drop_stmt;
|
||||||
DEALLOCATE PREPARE stmt;
|
EXECUTE drop_stmt;
|
||||||
|
|
||||||
|
SELECT if (
|
||||||
|
EXISTS(
|
||||||
|
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'sso_users'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
AND CONSTRAINT_NAME = '1'
|
||||||
|
)
|
||||||
|
,'ALTER TABLE sso_users DROP FOREIGN KEY `1`'
|
||||||
|
,'SELECT "info: FK sso_users 1 does not exist."'
|
||||||
|
) INTO @drop_stmt;
|
||||||
|
PREPARE drop_stmt FROM @drop_stmt;
|
||||||
|
EXECUTE drop_stmt;
|
||||||
|
|
||||||
|
DEALLOCATE PREPARE drop_stmt;
|
||||||
|
|
||||||
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||||
@@ -21,11 +21,19 @@ TEST_USER3=test3
|
|||||||
TEST_USER3_PASSWORD=${TEST_USER3}
|
TEST_USER3_PASSWORD=${TEST_USER3}
|
||||||
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
|
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
|
||||||
|
|
||||||
|
TEST_USER4=test4
|
||||||
|
TEST_USER4_PASSWORD=${TEST_USER4}
|
||||||
|
TEST_USER4_MAIL=${TEST_USER4}@yopmail.com
|
||||||
|
|
||||||
|
TEST_USER5=test5
|
||||||
|
TEST_USER5_PASSWORD=${TEST_USER5}
|
||||||
|
TEST_USER5_MAIL=${TEST_USER5}@yopmail.com
|
||||||
|
|
||||||
###################
|
###################
|
||||||
# Keycloak Config #
|
# Keycloak Config #
|
||||||
###################
|
###################
|
||||||
KEYCLOAK_ADMIN=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||||
KC_HTTP_HOST=127.0.0.1
|
KC_HTTP_HOST=127.0.0.1
|
||||||
KC_HTTP_PORT=8080
|
KC_HTTP_PORT=8080
|
||||||
|
|
||||||
@@ -39,8 +47,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
######################
|
######################
|
||||||
ROCKET_ADDRESS=0.0.0.0
|
ROCKET_ADDRESS=0.0.0.0
|
||||||
ROCKET_PORT=8000
|
ROCKET_PORT=8000
|
||||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
|
||||||
|
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
|
SSO_DEBUG_TOKENS=true
|
||||||
I_REALLY_WANT_VOLATILE_STORAGE=true
|
I_REALLY_WANT_VOLATILE_STORAGE=true
|
||||||
|
|
||||||
SSO_ENABLED=true
|
SSO_ENABLED=true
|
||||||
|
|||||||
+14
-16
@@ -1,8 +1,8 @@
|
|||||||
# Integration tests
|
# Integration tests
|
||||||
|
|
||||||
This allows running integration tests using [Playwright](https://playwright.dev/).
|
This allows running integration tests using [Playwright](https://playwright.dev/).
|
||||||
|
\
|
||||||
It uses its own `test.env` with different ports to not collide with a running dev instance.
|
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
|
|||||||
|
|
||||||
### Running Playwright outside docker
|
### Running Playwright outside docker
|
||||||
|
|
||||||
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
|
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
|
||||||
You will additionally need `nodejs` then run:
|
You'll additionally need `nodejs` then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci --ignore-scripts
|
npm ci --ignore-scripts --allow-git=none --allow-remote=none
|
||||||
npx playwright install-deps
|
npx playwright install-deps
|
||||||
npx playwright install firefox
|
npx playwright install firefox
|
||||||
```
|
```
|
||||||
@@ -65,7 +65,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
|
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running specific tests
|
### Running specific tests
|
||||||
@@ -77,7 +77,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
|
||||||
```
|
```
|
||||||
|
|
||||||
To run only a specifc test (It might fail if it has dependency):
|
To run only a specific test (It might fail if it has dependency):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
|
||||||
@@ -92,7 +92,7 @@ This does not start the server, you will need to start it manually.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
npx playwright codegen "http://127.0.0.1:8003"
|
npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
|
||||||
```
|
```
|
||||||
|
|
||||||
## Override web-vault
|
## Override web-vault
|
||||||
@@ -112,12 +112,11 @@ You can check the result running:
|
|||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
```
|
```
|
||||||
|
|
||||||
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
Then check `https://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
||||||
|
|
||||||
# OpenID Connect test setup
|
# OpenID Connect test setup
|
||||||
|
|
||||||
Additionally this `docker-compose` template allows to run locally Vaultwarden,
|
Additionally this `docker-compose` template allow to run locally `Vaultwarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
||||||
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
|
|||||||
```bash
|
```bash
|
||||||
> docker compose --profile vaultwarden --env-file .env up
|
> docker compose --profile vaultwarden --env-file .env up
|
||||||
....
|
....
|
||||||
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
|
keycloakSetup_1 | Logging into https://127.0.0.1:8080 as user admin of realm master
|
||||||
keycloakSetup_1 | Created new realm with id 'test'
|
keycloakSetup_1 | Created new realm with id 'test'
|
||||||
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
||||||
oidc_keycloakSetup_1 exited with code 0
|
oidc_keycloakSetup_1 exited with code 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user
|
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
|
||||||
(It is normal for this container to stop once the configuration is done).
|
|
||||||
|
|
||||||
Then you can access :
|
Then you can access :
|
||||||
|
|
||||||
- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
- `Vaultwarden` on https://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
||||||
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
||||||
- `Maildev` on http://0.0.0.0:1080
|
- `Maildev` on http://0.0.0.0:1080
|
||||||
|
|
||||||
@@ -171,7 +169,7 @@ docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild V
|
|||||||
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
||||||
The content of the file will be loaded as environment variables in all containers.
|
The content of the file will be loaded as environment variables in all containers.
|
||||||
|
|
||||||
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
|
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
|
||||||
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
|
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
|
||||||
|
|
||||||
## Cleanup
|
## Cleanup
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ done
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
|
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
|
||||||
|
|
||||||
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
||||||
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
||||||
@@ -39,6 +39,6 @@ kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLi
|
|||||||
# THEN in another terminal:
|
# THEN in another terminal:
|
||||||
# docker exec -it keycloakSetup-dev /bin/bash
|
# docker exec -it keycloakSetup-dev /bin/bash
|
||||||
# export PATH=$PATH:/opt/keycloak/bin
|
# export PATH=$PATH:/opt/keycloak/bin
|
||||||
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
|
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
|
||||||
# ENJOY
|
# ENJOY
|
||||||
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
|
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
|
||||||
@@ -28,7 +28,7 @@ RUN mkdir /playwright
|
|||||||
WORKDIR /playwright
|
WORKDIR /playwright
|
||||||
|
|
||||||
COPY package.json package-lock.json .
|
COPY package.json package-lock.json .
|
||||||
RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox
|
RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox
|
||||||
|
|
||||||
COPY docker-compose.yml test.env ./
|
COPY docker-compose.yml test.env ./
|
||||||
COPY compose ./compose
|
COPY compose ./compose
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ WORKDIR /
|
|||||||
|
|
||||||
COPY --from=prebuilt /start.sh .
|
COPY --from=prebuilt /start.sh .
|
||||||
COPY --from=prebuilt /vaultwarden .
|
COPY --from=prebuilt /vaultwarden .
|
||||||
|
COPY --from=build /data ./data
|
||||||
COPY --from=build /web-vault ./web-vault
|
COPY --from=build /web-vault ./web-vault
|
||||||
|
|
||||||
ENTRYPOINT ["/start.sh"]
|
ENTRYPOINT ["/start.sh"]
|
||||||
@@ -22,3 +22,14 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
|||||||
|
|
||||||
mv build /web-vault
|
mv build /web-vault
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Lower the KDF iterations default for faster tests.
|
||||||
|
sed -i 's/(6e5,2e6,6e5)/(1e5,2e6,1e5)/' /web-vault/app/main.*.js
|
||||||
|
|
||||||
|
# Generate a self signed cert
|
||||||
|
mkdir -p /data/ssl; cd /data/ssl
|
||||||
|
|
||||||
|
openssl req -x509 -out localhost.crt -keyout localhost.key \
|
||||||
|
-newkey rsa:2048 -nodes -sha256 \
|
||||||
|
-subj '/CN=localhost' -extensions EXT -config <( \
|
||||||
|
printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
|
||||||
@@ -24,12 +24,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- ADMIN_TOKEN
|
- ADMIN_TOKEN
|
||||||
- DATABASE_URL
|
- DATABASE_URL
|
||||||
|
- CLIENT_SUPPRESS_ONBOARDING
|
||||||
|
- EMAIL_2FA_AUTO_FALLBACK
|
||||||
- I_REALLY_WANT_VOLATILE_STORAGE
|
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||||
- LOG_LEVEL
|
- LOG_LEVEL
|
||||||
- LOGIN_RATELIMIT_MAX_BURST
|
- LOGIN_RATELIMIT_MAX_BURST
|
||||||
- SMTP_HOST
|
- SMTP_HOST
|
||||||
- SMTP_FROM
|
- SMTP_FROM
|
||||||
- SMTP_DEBUG
|
- SMTP_DEBUG
|
||||||
|
- SSO_AUTH_ONLY_NOT_SESSION
|
||||||
- SSO_DEBUG_TOKENS
|
- SSO_DEBUG_TOKENS
|
||||||
- SSO_ENABLED
|
- SSO_ENABLED
|
||||||
- SSO_FRONTEND
|
- SSO_FRONTEND
|
||||||
@@ -58,7 +61,7 @@ services:
|
|||||||
Mariadb:
|
Mariadb:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_mariadb
|
container_name: playwright_mariadb
|
||||||
image: mariadb:11.2.4
|
image: mariadb:12.2.2
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
@@ -70,7 +73,7 @@ services:
|
|||||||
Mysql:
|
Mysql:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_mysql
|
container_name: playwright_mysql
|
||||||
image: mysql:8.4.1
|
image: mysql:9.7.0
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
@@ -82,7 +85,7 @@ services:
|
|||||||
Postgres:
|
Postgres:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_postgres
|
container_name: playwright_postgres
|
||||||
image: postgres:16.3
|
image: postgres:18.4
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
@@ -94,7 +97,7 @@ services:
|
|||||||
Maildev:
|
Maildev:
|
||||||
profiles: ["vaultwarden", "maildev"]
|
profiles: ["vaultwarden", "maildev"]
|
||||||
container_name: maildev
|
container_name: maildev
|
||||||
image: timshel/maildev:3.0.4
|
image: timshel/maildev:3.2.19
|
||||||
ports:
|
ports:
|
||||||
- ${SMTP_PORT}:1025
|
- ${SMTP_PORT}:1025
|
||||||
- 1080:1080
|
- 1080:1080
|
||||||
@@ -102,7 +105,7 @@ services:
|
|||||||
Keycloak:
|
Keycloak:
|
||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloak-${ENV:-dev}
|
container_name: keycloak-${ENV:-dev}
|
||||||
image: quay.io/keycloak/keycloak:26.3.4
|
image: quay.io/keycloak/keycloak:26.6.2
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
@@ -112,12 +115,12 @@ services:
|
|||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloakSetup-${ENV:-dev}
|
container_name: keycloakSetup-${ENV:-dev}
|
||||||
image: keycloak_setup-${ENV:-dev}
|
image: keycloak_setup-${ENV:-dev}
|
||||||
|
network_mode: "host"
|
||||||
build:
|
build:
|
||||||
context: compose/keycloak
|
context: compose/keycloak
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
KEYCLOAK_VERSION: 26.3.4
|
KEYCLOAK_VERSION: 26.6.2
|
||||||
network_mode: "host"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- Keycloak
|
- Keycloak
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { firefox, type FullConfig } from '@playwright/test';
|
import { type FullConfig } from '@playwright/test';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stopVault(force: boolean = false) {
|
export async function stopVault(force: boolean = false) {
|
||||||
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||||
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Vaultwarden stopping`);
|
console.log(`Vaultwarden stopping`);
|
||||||
@@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanLanding(page: Page) {
|
export async function cleanLanding(page: Page) {
|
||||||
|
await page.context().clearCookies();
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
||||||
|
|
||||||
@@ -248,15 +249,3 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ignoreExtension(page: Page) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
|
||||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Extension setup not visible. Continuing');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Generated
+582
-580
File diff suppressed because it is too large.
Load diff
@@ -8,14 +8,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.60.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.4.2",
|
||||||
"dotenv-expand": "12.0.3",
|
"dotenv-expand": "13.0.0",
|
||||||
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
"maildev": "npm:@timshel_npm/maildev@3.2.19"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "3.15.3",
|
"mysql2": "3.22.3",
|
||||||
"otpauth": "9.4.1",
|
"otpauth": "9.5.1",
|
||||||
"pg": "8.16.3"
|
"pg": "8.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,10 +25,12 @@ export default defineConfig({
|
|||||||
/* Long global timeout for complex tests
|
/* Long global timeout for complex tests
|
||||||
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
||||||
*/
|
*/
|
||||||
timeout: 120 * 1000,
|
timeout: 240 * 1000,
|
||||||
actionTimeout: 20 * 1000,
|
actionTimeout: 40 * 1000,
|
||||||
navigationTimeout: 20 * 1000,
|
navigationTimeout: 40 * 1000,
|
||||||
expect: { timeout: 20 * 1000 },
|
expect: { timeout: 40 * 1000 },
|
||||||
|
|
||||||
|
"permissions": ["clipboard-read"],
|
||||||
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
@@ -37,6 +39,10 @@ export default defineConfig({
|
|||||||
browserName: 'firefox',
|
browserName: 'firefox',
|
||||||
locale: 'en-GB',
|
locale: 'en-GB',
|
||||||
timezoneId: 'Europe/London',
|
timezoneId: 'Europe/London',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--ignore-certificate-errors']
|
||||||
|
},
|
||||||
|
|
||||||
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on',
|
trace: 'on',
|
||||||
|
|||||||
+6
-4
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
|
|||||||
#####################
|
#####################
|
||||||
# Playwright Config #
|
# Playwright Config #
|
||||||
#####################
|
#####################
|
||||||
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
|
PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false}
|
||||||
PW_SMTP_FROM=vaultwarden@playwright.test
|
PW_SMTP_FROM=vaultwarden@playwright.test
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
|
|||||||
###################
|
###################
|
||||||
# Keycloak Config #
|
# Keycloak Config #
|
||||||
###################
|
###################
|
||||||
KEYCLOAK_ADMIN=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||||
KC_HTTP_HOST=127.0.0.1
|
KC_HTTP_HOST=127.0.0.1
|
||||||
KC_HTTP_PORT=8081
|
KC_HTTP_PORT=8081
|
||||||
|
|
||||||
@@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
# Vaultwarden Config #
|
# Vaultwarden Config #
|
||||||
######################
|
######################
|
||||||
ROCKET_PORT=8003
|
ROCKET_PORT=8003
|
||||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
|
||||||
|
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
LOGIN_RATELIMIT_MAX_BURST=100
|
LOGIN_RATELIMIT_MAX_BURST=100
|
||||||
ADMIN_TOKEN=admin
|
ADMIN_TOKEN=admin
|
||||||
|
CLIENT_SUPPRESS_ONBOARDING=true
|
||||||
|
|
||||||
SMTP_SECURITY=off
|
SMTP_SECURITY=off
|
||||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect, type TestInfo } from '@playwright/test';
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
|
|
||||||
|
import * as orgs from './setups/orgs';
|
||||||
import { createAccount } from './setups/user';
|
import { createAccount } from './setups/user';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
@@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
|
|||||||
test('Create', async ({ page }) => {
|
test('Create', async ({ page }) => {
|
||||||
await createAccount(test, page, users.user1);
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
await test.step('Create Org', async () => {
|
await orgs.create(test, page, 'New organisation');
|
||||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
|
||||||
await page.getByLabel('Organisation name (required)').fill('Test');
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Organisation created');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Create Collection', async () => {
|
await test.step('Create Collection', async () => {
|
||||||
await page.getByRole('link', { name: 'Collections' }).click();
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'New' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Collection' }).click();
|
await page.getByRole('menuitem', { name: 'Collection' }).click();
|
||||||
await page.getByLabel('Name (required)').fill('RandomCollec');
|
await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Created collection RandomCollec');
|
await utils.checkNotification(page, 'Created collection RandomCollec');
|
||||||
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP, disableTOTP } from './setups/2fa';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Change Key settings', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Change SHA-256 Iterations', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Keys' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('700000');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Update settings' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Switch to Argon2', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Keys' }).click();
|
||||||
|
|
||||||
|
await page.locator('.ng-arrow-wrapper').click();
|
||||||
|
await page.getByText('Argon2id').click();
|
||||||
|
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF memory (MB) * (required)'}).fill('16');
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('2');
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF parallelism * (required)'}).fill('1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Update settings' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
@@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
|
|||||||
test('Login', async ({ context, page }) => {
|
test('Login', async ({ context, page }) => {
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await logUser(test, page, users.user1, mailBuffer);
|
await logUser(test, page, users.user1, { mailBuffer });
|
||||||
|
|
||||||
await test.step('verify email', async () => {
|
await test.step('verify email', async () => {
|
||||||
await page.getByText('Verify your account\'s email').click();
|
await page.getByRole('button', { name: "Send email" }).click();
|
||||||
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
||||||
|
|
||||||
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
||||||
@@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
|
|||||||
test('2fa', async ({ page }) => {
|
test('2fa', async ({ page }) => {
|
||||||
const emails = mailserver.buffer(users.user1.email);
|
const emails = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await test.step('login', async () => {
|
await logUser(test, page, users.user1, {
|
||||||
await page.goto('/');
|
mailBuffer: emails,
|
||||||
|
mail2fa: true,
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
});
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
|
||||||
const code = await retrieveEmailCode(test, page, emails);
|
|
||||||
await page.getByLabel(/Verification code/).fill(code);
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add it later' }).click();
|
|
||||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Vaults/);
|
|
||||||
})
|
|
||||||
|
|
||||||
await disableEmail(test, page, users.user1);
|
|
||||||
|
|
||||||
emails.close();
|
emails.close();
|
||||||
});
|
});
|
||||||
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
|
|||||||
import * as utils from '../global-utils';
|
import * as utils from '../global-utils';
|
||||||
import * as orgs from './setups/orgs';
|
import * as orgs from './setups/orgs';
|
||||||
import { createAccount, logUser } from './setups/user';
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP } from './setups/2fa';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
|||||||
await utils.startVault(browser, testInfo, {
|
await utils.startVault(browser, testInfo, {
|
||||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
|
EMAIL_2FA_AUTO_FALLBACK: "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
mail1Buffer = mailServer.buffer(users.user1.email);
|
mail1Buffer = mailServer.buffer(users.user1.email);
|
||||||
@@ -45,7 +47,7 @@ test('Invite users', async ({ page }) => {
|
|||||||
await orgs.policies(test, page, 'Test');
|
await orgs.policies(test, page, 'Test');
|
||||||
await page.getByRole('button', { name: 'Account recovery' }).click();
|
await page.getByRole('button', { name: 'Account recovery' }).click();
|
||||||
await page.getByRole('checkbox', { name: 'Turn on' }).check();
|
await page.getByRole('checkbox', { name: 'Turn on' }).check();
|
||||||
await page.getByRole('checkbox', { name: 'Require new members' }).check();
|
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Edited policy Account recovery');
|
await utils.checkNotification(page, 'Edited policy Account recovery');
|
||||||
});
|
});
|
||||||
@@ -66,18 +68,16 @@ test('invited with new account', async ({ page }) => {
|
|||||||
await page.goto(link);
|
await page.goto(link);
|
||||||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
|
||||||
//await page.getByLabel('Name').fill(users.user2.name);
|
// await page.getByLabel('Name').fill(users.user2.name);
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await utils.checkNotification(page, 'Your new account has been created');
|
await utils.checkNotification(page, 'Your new account has been created');
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// Redirected to the vault
|
// Redirected to the vault
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
// await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -100,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
|
|
||||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Confirm invited user', async ({ page }) => {
|
test('Confirm invited user', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1, mail1Buffer);
|
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await orgs.confirm(test, page, 'Test', users.user2.email);
|
await orgs.confirm(test, page, 'Test', users.user2.email);
|
||||||
@@ -123,25 +121,30 @@ test('Confirm invited user', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Organization is visible', async ({ page }) => {
|
test('Organization is visible', async ({ page }) => {
|
||||||
await logUser(test, page, users.user2, mail2Buffer);
|
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
|
||||||
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Recover user password', async ({ page }) => {
|
test('Recover user password', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1, mail1Buffer);
|
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
|
||||||
|
await activateTOTP(test, page, users.user2);
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||||
|
|
||||||
let newPassword = "TotoNewPassword";
|
let newPassword = "TotoNewPassword";
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await test.step(`Rrcover ${users.user2.email}`, async () => {
|
await test.step(`Recover ${users.user2.email}`, async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
|
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
|
||||||
await page.getByRole('menuitem', { name: 'Recover account' }).click();
|
await page.getByRole('menuitem', { name: 'Recover account' }).click();
|
||||||
await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword);
|
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
|
||||||
await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword);
|
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
|
||||||
await utils.checkNotification(page, 'Password reset success');
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Account recovery success');
|
||||||
|
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
|
||||||
});
|
});
|
||||||
|
|
||||||
let user2 = {
|
let user2 = {
|
||||||
@@ -149,5 +152,9 @@ test('Recover user password', async ({ page }) => {
|
|||||||
name: users.user2.name,
|
name: users.user2.name,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
};
|
};
|
||||||
await logUser(test, page, user2, mail2Buffer);
|
await logUser(test, page, user2, {
|
||||||
|
mailBuffer: mail2Buffer,
|
||||||
|
mail2fa: true,
|
||||||
|
notNewDevice: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Password', async ({ context, page }, testInfo: TestInfo) => {
|
||||||
|
const label = 'Test Password';
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Create password entry', async () => {
|
||||||
|
await page.getByRole('button', { name: 'New item' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
|
||||||
|
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item added');
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Check', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||||
|
await page.getByTestId('copy-username').click();
|
||||||
|
await utils.checkNotification(page, 'Username copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
|
||||||
|
await page.getByTestId('copy-password').click();
|
||||||
|
await utils.checkNotification(page, 'Password copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item sent to bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Deleted', async () => {
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
|
||||||
|
const label = 'Test SSH key';
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
const privateKey = await test.step('Create key entry', async () => {
|
||||||
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'SSH key' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item added');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||||
|
await utils.checkNotification(page, 'Private key copied');
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Check', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||||
|
await utils.checkNotification(page, 'Private key copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item sent to bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Deleted', async () => {
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -21,11 +21,11 @@ test('Send', async ({ browser, page }) => {
|
|||||||
await page.getByRole('link', { name: 'Send' }).click();
|
await page.getByRole('link', { name: 'Send' }).click();
|
||||||
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
await page.getByRole('button', { name: 'New Send', exact: true }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Text' }).click();
|
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||||
|
|
||||||
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
|
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
|
||||||
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
|
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||||
@@ -46,14 +46,14 @@ test('Send', async ({ browser, page }) => {
|
|||||||
await page.getByRole('link', { name: 'Send' }).click();
|
await page.getByRole('link', { name: 'Send' }).click();
|
||||||
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
await page.getByRole('button', { name: 'New' }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Text' }).click();
|
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||||
|
|
||||||
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
|
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
|
||||||
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
|
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
|
||||||
await page.getByRole('combobox', { name: 'Who can view' }).click();
|
await page.getByRole('combobox', { name: 'Who can view' }).click();
|
||||||
await page.getByText('Anyone with a password set by you').click();
|
await page.getByText('Anyone with a password set by you').click();
|
||||||
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');
|
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||||
@@ -64,7 +64,7 @@ test('Send', async ({ browser, page }) => {
|
|||||||
await test.step('View with password', async () => {
|
await test.step('View with password', async () => {
|
||||||
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
|
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
|
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
|
||||||
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
|
await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
|
||||||
await page2.getByRole('button', { name: 'Continue' }).click();
|
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||||
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||||
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
|
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
const secret = await page.getByLabel('Key').innerText();
|
const secret = await page.getByLabel('Key', { exact: true }).innerText();
|
||||||
|
|
||||||
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
||||||
|
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate());
|
await page.getByLabel(/Verification code/).fill(totp.generate());
|
||||||
@@ -33,8 +34,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').click();
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
await page.getByRole('button', { name: 'Yes' }).click();
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
@@ -49,7 +50,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
await page.getByRole('button', { name: 'Send email' }).click();
|
||||||
});
|
});
|
||||||
@@ -81,8 +82,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').click();
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
await page.getByRole('button', { name: 'Yes' }).click();
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, type Browser, Page } from '@playwright/test';
|
||||||
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
export async function login(test, page: Page) {
|
||||||
|
await test.step(`Admin login`, async () => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN);
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invite(test, page: Page, email: string) {
|
||||||
|
await test.step(`Invite user with ${email}`, async () => {
|
||||||
|
await page.getByRole('link', { name: 'Users' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Enter email' }).fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Invite' }).click();
|
||||||
|
await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
|
|||||||
utils.loadEnv();
|
utils.loadEnv();
|
||||||
|
|
||||||
test('DB teardown ?', async ({ serviceName }) => {
|
test('DB teardown ?', async ({ serviceName }) => {
|
||||||
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
|
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
|
||||||
utils.stopComposeService(serviceName);
|
utils.stopComposeService(serviceName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
|
|||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
export async function create(test, page: Page, name: string) {
|
export async function create(test, page: Page, name: string) {
|
||||||
await test.step('Create Org', async () => {
|
await test.step(`Create Org ${name}`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
|
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
|
||||||
|
if( await pm_locator.count() > 0 ){
|
||||||
|
pm_locator.first().click();
|
||||||
|
}
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
await page.getByRole('link', { name: 'New organisation' }).click();
|
||||||
await page.getByLabel('Organisation name (required)').fill(name);
|
await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name);
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Organisation created');
|
await utils.checkNotification(page, 'Organisation created');
|
||||||
@@ -18,7 +21,7 @@ export async function policies(test, page: Page, name: string) {
|
|||||||
await test.step(`Navigate to ${name} policies`, async () => {
|
await test.step(`Navigate to ${name} policies`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
||||||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
||||||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
|
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
await page.getByRole('link', { name: 'Policies' }).click();
|
await page.getByRole('link', { name: 'Policies' }).click();
|
||||||
@@ -30,11 +33,11 @@ export async function members(test, page: Page, name: string) {
|
|||||||
await test.step(`Navigate to ${name} members`, async () => {
|
await test.step(`Navigate to ${name} members`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
||||||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
||||||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
|
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
||||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
await page.getByRole('link', { name: 'Members' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
|
await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +45,13 @@ export async function invite(test, page: Page, name: string, email: string) {
|
|||||||
await test.step(`Invite ${email}`, async () => {
|
await test.step(`Invite ${email}`, async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Invite member' }).click();
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
await page.getByLabel('Email (required)').fill(email);
|
await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email);
|
||||||
await page.getByRole('tab', { name: 'Collections' }).click();
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
await page.getByRole('combobox', { name: 'Permission' }).click();
|
await page.getByRole('combobox', { name: 'Permission' }).click();
|
||||||
await page.getByText('Edit items', { exact: true }).click();
|
await page.getByText('Edit items', { exact: true }).click();
|
||||||
await page.getByLabel('Select collections').click();
|
await page.getByRole('combobox', { name: 'Select collections' }).click();
|
||||||
await page.getByText('Default collection').click();
|
await page.getByLabel('Options List').getByText('Default collection').click();
|
||||||
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
|
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'User(s) invited');
|
await utils.checkNotification(page, 'User(s) invited');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
|
|||||||
utils.loadEnv();
|
utils.loadEnv();
|
||||||
|
|
||||||
test('Keycloak teardown', async () => {
|
test('Keycloak teardown', async () => {
|
||||||
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||||
console.log("Keep Keycloak running");
|
console.log("Keep Keycloak running");
|
||||||
} else {
|
} else {
|
||||||
console.log("Keycloak stopping");
|
console.log("Keycloak stopping");
|
||||||
|
|||||||
@@ -15,11 +15,8 @@ export async function logNewUser(
|
|||||||
options: { mailBuffer?: MailBuffer } = {}
|
options: { mailBuffer?: MailBuffer } = {}
|
||||||
) {
|
) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
await page.context().clearCookies();
|
|
||||||
|
|
||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
@@ -33,26 +30,24 @@ export async function logNewUser(
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
|
||||||
if( options.mailBuffer ){
|
if( options.mailBuffer ){
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
await test.step('Check emails', async () => {
|
await test.step('Check emails', async () => {
|
||||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
|
||||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,16 +64,14 @@ export async function logUser(
|
|||||||
mailBuffer ?: MailBuffer,
|
mailBuffer ?: MailBuffer,
|
||||||
totp?: OTPAuth.TOTP,
|
totp?: OTPAuth.TOTP,
|
||||||
mail2fa?: boolean,
|
mail2fa?: boolean,
|
||||||
|
notNewDevice?: boolean,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
|
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
await page.context().clearCookies();
|
|
||||||
|
|
||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
@@ -117,14 +110,12 @@ export async function logUser(
|
|||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( mailBuffer && !options.notNewDevice ){
|
||||||
await test.step('Check email', async () => {
|
await test.step('Check email', async () => {
|
||||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
|
|||||||
import { type MailBuffer } from 'maildev';
|
import { type MailBuffer } from 'maildev';
|
||||||
|
|
||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
import { retrieveEmailCode } from './2fa';
|
||||||
|
|
||||||
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
@@ -17,12 +18,11 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Vault finish Creation
|
// Vault finish Creation
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Your new account has been created')
|
await utils.checkNotification(page, 'Your new account has been created')
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
@@ -35,7 +35,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
|
export async function logUser(
|
||||||
|
test,
|
||||||
|
page: Page,
|
||||||
|
user: { email: string, password: string },
|
||||||
|
options: {
|
||||||
|
mailBuffer ?: MailBuffer,
|
||||||
|
mail2fa?: boolean,
|
||||||
|
notNewDevice?: boolean,
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
@@ -43,16 +52,23 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
if( options.mail2fa ){
|
||||||
|
await test.step('2FA check', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
|
let code = await retrieveEmailCode(test, page, options.mailBuffer);
|
||||||
|
await page.getByLabel(/Verification code/).fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( options.mailBuffer && !options.notNewDevice ){
|
||||||
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, type TestInfo } from '@playwright/test';
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
import { MailDev } from 'maildev';
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
import * as admin from "./setups/admin";
|
||||||
import { logNewUser, logUser } from './setups/sso';
|
import { logNewUser, logUser } from './setups/sso';
|
||||||
import { activateEmail, disableEmail } from './setups/2fa';
|
import { activateEmail, disableEmail } from './setups/2fa';
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
@@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
await utils.startVault(browser, testInfo, {
|
await utils.startVault(browser, testInfo, {
|
||||||
SSO_ENABLED: true,
|
SSO_ENABLED: true,
|
||||||
SSO_ONLY: false,
|
SSO_ONLY: true,
|
||||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
});
|
});
|
||||||
@@ -32,22 +33,64 @@ test.afterAll('Teardown', async ({}) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Create and activate 2FA', async ({ page }) => {
|
test('2FA email', async ({ page }) => {
|
||||||
|
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
||||||
|
|
||||||
await activateEmail(test, page, users.user1, mailBuffer);
|
await activateEmail(test, page, users.user1, mailBuffer);
|
||||||
|
|
||||||
mailBuffer.close();
|
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
|
||||||
});
|
|
||||||
|
|
||||||
test('Log and disable', async ({ page }) => {
|
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
|
||||||
|
|
||||||
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
|
|
||||||
|
|
||||||
await disableEmail(test, page, users.user1);
|
await disableEmail(test, page, users.user1);
|
||||||
|
|
||||||
mailBuffer.close();
|
mailBuffer.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Admin invite', async ({ page }) => {
|
||||||
|
const mailBuffer = mailserver.buffer(users.user2.email);
|
||||||
|
|
||||||
|
await admin.login(test, page);
|
||||||
|
await admin.invite(test, page, users.user2.email);
|
||||||
|
|
||||||
|
|
||||||
|
const link = await test.step('Extract email link', async () => {
|
||||||
|
const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden");
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
return await page.getByTestId("invite").getAttribute("href");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
|
await page.goto(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create Vault account', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Check mails', async () => {
|
||||||
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
mailBuffer.close();
|
||||||
|
});
|
||||||
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Other' }).click();
|
await page.getByRole('button', { name: 'Other' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
@@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
||||||
|
|
||||||
// Check that SSO login is available
|
// Check that SSO login is available
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
||||||
@@ -66,7 +67,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
||||||
await utils.restartVault(page, testInfo, {
|
await utils.restartVault(page, testInfo, {
|
||||||
SSO_ENABLED: false
|
SSO_ENABLED: false
|
||||||
@@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
|
||||||
// No SSO button (rely on a correct selector checked in previous test)
|
// No SSO button (rely on a correct selector checked in previous test)
|
||||||
|
await page.getByLabel('Master password');
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
||||||
|
|
||||||
// Can continue to Master password
|
// Can continue to Master password
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
@@ -67,17 +67,16 @@ test('invited with new account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Redirect to Keycloak', async () => {
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
await page.goto(link);
|
await page.goto(link);
|
||||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Keycloak login', async () => {
|
await test.step('Keycloak login', async () => {
|
||||||
@@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Enforce password policy', async ({ page }) => {
|
test('Activate password policy', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1);
|
await logUser(test, page, users.user1);
|
||||||
await orgs.policies(test, page, '/Test');
|
await orgs.policies(test, page, '/Test');
|
||||||
|
|
||||||
@@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
await utils.logout(test, page, users.user1);
|
|
||||||
|
test('Unlock trigger policyy', async ({ page }) => {
|
||||||
await test.step(`Unlock trigger policy`, async () => {
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
|
||||||
await page.getByRole('button', { name: 'Use single sign-on' }).click();
|
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
|
||||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
});
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Unlock vault', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
|
||||||
|
await page.getByLabel('Master password').fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
||||||
});
|
});
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.97.1"
|
channel = "1.98.1"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
+49
-18
@@ -231,7 +231,7 @@ fn validate_token(token: &str) -> bool {
|
|||||||
None => false,
|
None => false,
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
use argon2::password_hash::PasswordVerifier;
|
use argon2::password_hash::PasswordVerifier;
|
||||||
match argon2::password_hash::PasswordHash::new(t) {
|
match argon2::password_hash::phc::PasswordHash::new(t) {
|
||||||
Ok(h) => {
|
Ok(h) => {
|
||||||
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
|
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
|
||||||
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
|
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
|
||||||
@@ -425,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
|
|||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserDeleted as i32,
|
EventType::OrganizationUserDeleted,
|
||||||
&membership.uuid,
|
&membership.uuid,
|
||||||
&membership.org_uuid,
|
&membership.org_uuid,
|
||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
@@ -446,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
|
|||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUnlinkedSso as i32,
|
EventType::OrganizationUserUnlinkedSso,
|
||||||
&membership.uuid,
|
&membership.uuid,
|
||||||
&membership.org_uuid,
|
&membership.org_uuid,
|
||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
@@ -571,7 +571,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdated as i32,
|
EventType::OrganizationUserUpdated,
|
||||||
&member_to_edit.uuid,
|
&member_to_edit.uuid,
|
||||||
&data.org_uuid,
|
&data.org_uuid,
|
||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
@@ -647,7 +647,7 @@ use cached::macros::cached;
|
|||||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||||
/// Any cache will be lost if Vaultwarden is restarted
|
/// Any cache will be lost if Vaultwarden is restarted
|
||||||
#[cached(ttl = 600, sync_writes = "default")]
|
#[cached(ttl_secs = 600, sync_writes = "default")]
|
||||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||||
if has_http_access {
|
if has_http_access {
|
||||||
@@ -716,6 +716,36 @@ fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_template_overrides() -> Vec<&'static str> {
|
||||||
|
let template_folder = std::path::PathBuf::from(CONFIG.templates_folder());
|
||||||
|
let mut overrides = Vec::new();
|
||||||
|
for folder in ["admin", "email", "scss"] {
|
||||||
|
if folder_has_hbs_files(&template_folder.join(folder)) {
|
||||||
|
overrides.push(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder_has_hbs_files(&template_folder) {
|
||||||
|
overrides.push("other");
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_has_hbs_files(dir: &std::path::Path) -> bool {
|
||||||
|
let Ok(files) = std::fs::read_dir(dir) else {
|
||||||
|
// No files in this directory at all, so we can return false
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
files.flatten().any(|f| {
|
||||||
|
// Validate if it is a file and if it has the `.hbs` extension and starts with a-z or 0-9
|
||||||
|
f.file_type().is_ok_and(|t| t.is_file())
|
||||||
|
&& f.path().extension().is_some_and(|e| e.eq_ignore_ascii_case("hbs"))
|
||||||
|
&& f.file_name().to_str().is_some_and(|n| n.starts_with(|c: char| c.is_ascii_alphanumeric()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/diagnostics")]
|
#[get("/diagnostics")]
|
||||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
@@ -770,6 +800,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"template_overrides": check_template_overrides().join(", "),
|
||||||
"invalid_feature_flags": invalid_feature_flags,
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
@@ -874,23 +905,23 @@ mod tests {
|
|||||||
fn validate_web_vault_compare() {
|
fn validate_web_vault_compare() {
|
||||||
// web_vault_compare(active, latest)
|
// web_vault_compare(active, latest)
|
||||||
// Test normal versions
|
// Test normal versions
|
||||||
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
|
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
|
||||||
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
|
||||||
|
|
||||||
// Test patched/+build.n versions
|
// Test patched/+build.n versions
|
||||||
// Newer latest version
|
// Newer latest version
|
||||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
|
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
|
||||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
|
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
|
||||||
// Equal versions
|
// Equal versions
|
||||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
|
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
|
||||||
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
|
assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
|
||||||
// Newer active version
|
// Newer active version
|
||||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
|
||||||
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
|
||||||
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
|
assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
|
||||||
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
|
assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+64
-16
@@ -21,8 +21,9 @@ use crate::{
|
|||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
|
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
|
||||||
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
|
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
|
||||||
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
|
||||||
|
UserKdfType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -46,6 +47,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
post_set_password,
|
post_set_password,
|
||||||
post_kdf,
|
post_kdf,
|
||||||
post_rotatekey,
|
post_rotatekey,
|
||||||
|
post_user_key,
|
||||||
post_sstamp,
|
post_sstamp,
|
||||||
post_email_token,
|
post_email_token,
|
||||||
post_email,
|
post_email,
|
||||||
@@ -595,29 +597,52 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ChangePassData {
|
struct ChangePassData {
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
new_master_password_hash: String,
|
|
||||||
master_password_hint: Option<String>,
|
master_password_hint: Option<String>,
|
||||||
key: String,
|
authentication_data: Option<AuthenticationData>,
|
||||||
|
unlock_data: Option<UnlockData>,
|
||||||
|
|
||||||
|
// Outdated values, might still be used by older clients
|
||||||
|
new_master_password_hash: Option<String>,
|
||||||
|
key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/password", data = "<data>")]
|
#[post("/accounts/password", data = "<data>")]
|
||||||
async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: ChangePassData = data.into_inner();
|
let data: ChangePassData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
if !user.check_valid_password(&data.master_password_hash) {
|
if !user.check_valid_password(&data.master_password_hash) {
|
||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
|
||||||
enforce_password_hint_setting(user.password_hint.as_ref())?;
|
|
||||||
|
|
||||||
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let (new_master_password_hash, new_key) =
|
||||||
|
if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) {
|
||||||
|
if authentication_data.kdf != unlock_data.kdf {
|
||||||
|
err!("KDF settings must be equal for authentication and unlock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.email != authentication_data.salt || user.email != unlock_data.salt {
|
||||||
|
err!("Invalid master password salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
|
||||||
|
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
|
||||||
|
(new_master_password_hash, new_key)
|
||||||
|
} else {
|
||||||
|
err!("Invalid request!")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut user = user;
|
||||||
|
|
||||||
|
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||||
|
enforce_password_hint_setting(user.password_hint.as_ref())?;
|
||||||
|
|
||||||
user.set_password(
|
user.set_password(
|
||||||
&data.new_master_password_hash,
|
&new_master_password_hash,
|
||||||
Some(data.key),
|
Some(new_key),
|
||||||
true,
|
true,
|
||||||
Some(vec![
|
Some(vec![
|
||||||
String::from("post_rotatekey"),
|
String::from("post_rotatekey"),
|
||||||
@@ -1002,6 +1027,23 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct KeyIdData {
|
||||||
|
user_key_id: KeyId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/accounts/key-management/user-key-id", data = "<data>")]
|
||||||
|
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
|
let mut user = headers.user;
|
||||||
|
if user.key_id.is_some() {
|
||||||
|
err_code!("Unexpected data", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.key_id = Some(data.into_inner().user_key_id);
|
||||||
|
user.save(&conn).await
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/accounts/security-stamp", data = "<data>")]
|
#[post("/accounts/security-stamp", data = "<data>")]
|
||||||
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
@@ -1317,11 +1359,13 @@ pub struct PreloginData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin", data = "<data>")]
|
#[post("/accounts/prelogin", data = "<data>")]
|
||||||
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
prelogin(data, conn).await
|
prelogin(data, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data: PreloginData = data.into_inner();
|
let data: PreloginData = data.into_inner();
|
||||||
|
|
||||||
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
||||||
@@ -1329,7 +1373,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
|||||||
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
|
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
Json(json!({
|
Ok(Json(json!({
|
||||||
"kdf": kdf_type,
|
"kdf": kdf_type,
|
||||||
"kdfIterations": kdf_iter,
|
"kdfIterations": kdf_iter,
|
||||||
"kdfMemory": kdf_mem,
|
"kdfMemory": kdf_mem,
|
||||||
@@ -1341,7 +1385,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
|||||||
"parallelism": kdf_para
|
"parallelism": kdf_para
|
||||||
},
|
},
|
||||||
"salt": null,
|
"salt": null,
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
|
||||||
@@ -1572,6 +1616,8 @@ async fn post_auth_request(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
|
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
|
||||||
@@ -1584,7 +1630,7 @@ async fn post_auth_request(
|
|||||||
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
|
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut auth_request = AuthRequest::new(
|
let auth_request = AuthRequest::new(
|
||||||
user.uuid.clone(),
|
user.uuid.clone(),
|
||||||
data.device_identifier.clone(),
|
data.device_identifier.clone(),
|
||||||
client_headers.device_type,
|
client_headers.device_type,
|
||||||
@@ -1733,6 +1779,8 @@ async fn get_auth_request_response(
|
|||||||
client_headers: ClientHeaders,
|
client_headers: ClientHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
|
||||||
|
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
|
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
|
||||||
err!("AuthRequest doesn't exist", "User not found")
|
err!("AuthRequest doesn't exist", "User not found")
|
||||||
};
|
};
|
||||||
|
|||||||
+65
-35
@@ -6,6 +6,7 @@ use rocket::{
|
|||||||
Route,
|
Route,
|
||||||
form::{Form, FromForm},
|
form::{Form, FromForm},
|
||||||
fs::TempFile,
|
fs::TempFile,
|
||||||
|
http::Status,
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -21,8 +22,8 @@ use crate::{
|
|||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
|
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
||||||
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
||||||
@@ -161,13 +162,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
let policies_json: Vec<Value> =
|
let policies_json: Vec<Value> =
|
||||||
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
|
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
|
||||||
|
|
||||||
|
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(OrgPolicy::to_json)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let domains_json = if data.exclude_domains {
|
let domains_json = if data.exclude_domains {
|
||||||
Value::Null
|
Value::Null
|
||||||
} else {
|
} else {
|
||||||
api::core::get_eq_domains(&headers, true).into_inner()
|
api::core::get_eq_domains(&headers, true).into_inner()
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is very similar to the the userDecryptionOptions sent in connect/token,
|
// This is very similar to the userDecryptionOptions sent in connect/token,
|
||||||
// but as of 2025-12-19 they're both using different casing conventions.
|
// but as of 2025-12-19 they're both using different casing conventions.
|
||||||
let has_master_password = !headers.user.password_hash.is_empty();
|
let has_master_password = !headers.user.password_hash.is_empty();
|
||||||
let master_password_unlock = if has_master_password {
|
let master_password_unlock = if has_master_password {
|
||||||
@@ -193,11 +200,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
"folders": folders_json,
|
"folders": folders_json,
|
||||||
"collections": collections_json,
|
"collections": collections_json,
|
||||||
"policies": policies_json,
|
"policies": policies_json,
|
||||||
|
"policiesNew": policies_new_json,
|
||||||
"ciphers": ciphers_json,
|
"ciphers": ciphers_json,
|
||||||
"domains": domains_json,
|
"domains": domains_json,
|
||||||
"sends": sends_json,
|
"sends": sends_json,
|
||||||
"userDecryption": {
|
"userDecryption": {
|
||||||
"masterPasswordUnlock": master_password_unlock,
|
"masterPasswordUnlock": master_password_unlock,
|
||||||
|
"userKeyId": headers.user.key_id,
|
||||||
},
|
},
|
||||||
"object": "sync"
|
"object": "sync"
|
||||||
})))
|
})))
|
||||||
@@ -260,12 +269,19 @@ pub struct CipherData {
|
|||||||
|
|
||||||
key: Option<String>,
|
key: Option<String>,
|
||||||
|
|
||||||
|
pub encrypted_for: UserId, // Added in web-v2025.6.0
|
||||||
|
// Added in web-v2025.8.1, Optional for compat
|
||||||
|
pub encrypted_by_key_id: Option<KeyId>,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Login = 1,
|
Login = 1,
|
||||||
SecureNote = 2,
|
SecureNote = 2,
|
||||||
Card = 3,
|
Card = 3,
|
||||||
Identity = 4,
|
Identity = 4,
|
||||||
SshKey = 5
|
SshKey = 5
|
||||||
|
BankAccount = 6
|
||||||
|
DriversLicense = 7
|
||||||
|
Passport = 8
|
||||||
*/
|
*/
|
||||||
pub r#type: i32,
|
pub r#type: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -278,6 +294,9 @@ pub struct CipherData {
|
|||||||
card: Option<Value>,
|
card: Option<Value>,
|
||||||
identity: Option<Value>,
|
identity: Option<Value>,
|
||||||
ssh_key: Option<Value>,
|
ssh_key: Option<Value>,
|
||||||
|
bank_account: Option<Value>,
|
||||||
|
drivers_license: Option<Value>,
|
||||||
|
passport: Option<Value>,
|
||||||
|
|
||||||
favorite: Option<bool>,
|
favorite: Option<bool>,
|
||||||
reprompt: Option<i32>,
|
reprompt: Option<i32>,
|
||||||
@@ -333,6 +352,10 @@ async fn post_ciphers_create(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut data: ShareCipherData = data.into_inner();
|
let mut data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
|
if data.cipher.encrypted_for != headers.user.uuid {
|
||||||
|
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// This check is usually only needed in update_cipher_from_data(), but we
|
// This check is usually only needed in update_cipher_from_data(), but we
|
||||||
// need it here as well to avoid creating an empty cipher in the call to
|
// need it here as well to avoid creating an empty cipher in the call to
|
||||||
// cipher.save() below.
|
// cipher.save() below.
|
||||||
@@ -362,6 +385,17 @@ async fn post_ciphers_create(
|
|||||||
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
let mut data: CipherData = data.into_inner();
|
let mut data: CipherData = data.into_inner();
|
||||||
|
|
||||||
|
if data.encrypted_for != headers.user.uuid {
|
||||||
|
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cipher_key_id) = &data.encrypted_by_key_id
|
||||||
|
&& let Some(user_key_id) = &headers.user.key_id
|
||||||
|
&& cipher_key_id != user_key_id
|
||||||
|
{
|
||||||
|
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// The web/browser clients set this field to null as expected, but the
|
// The web/browser clients set this field to null as expected, but the
|
||||||
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
|
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
|
||||||
// which results in a warning message being logged. This field isn't
|
// which results in a warning message being logged. This field isn't
|
||||||
@@ -510,6 +544,9 @@ pub async fn update_cipher_from_data(
|
|||||||
3 => data.card,
|
3 => data.card,
|
||||||
4 => data.identity,
|
4 => data.identity,
|
||||||
5 => data.ssh_key,
|
5 => data.ssh_key,
|
||||||
|
6 => data.bank_account,
|
||||||
|
7 => data.drivers_license,
|
||||||
|
8 => data.passport,
|
||||||
_ => err!("Invalid type"),
|
_ => err!("Invalid type"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -537,11 +574,12 @@ pub async fn update_cipher_from_data(
|
|||||||
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
|
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
|
||||||
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
|
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
|
||||||
|
|
||||||
if let Some(dt_str) = data.archived_date {
|
match data.archived_date {
|
||||||
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||||
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
||||||
}
|
},
|
||||||
|
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ut != UpdateType::None {
|
if ut != UpdateType::None {
|
||||||
@@ -553,16 +591,8 @@ pub async fn update_cipher_from_data(
|
|||||||
(_, _) => EventType::CipherUpdated,
|
(_, _) => EventType::CipherUpdated,
|
||||||
};
|
};
|
||||||
|
|
||||||
log_event(
|
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
||||||
event_type as i32,
|
.await;
|
||||||
&cipher.uuid,
|
|
||||||
org_id,
|
|
||||||
&headers.user.uuid,
|
|
||||||
headers.device.atype,
|
|
||||||
&headers.ip.ip,
|
|
||||||
conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
ut,
|
ut,
|
||||||
@@ -850,7 +880,7 @@ async fn post_collections_update(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherUpdatedCollections as i32,
|
EventType::CipherUpdatedCollections,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_uuid,
|
org_uuid,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -870,7 +900,7 @@ async fn put_collections_admin(
|
|||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> JsonResult {
|
||||||
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,7 +911,7 @@ async fn post_collections_admin(
|
|||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> JsonResult {
|
||||||
let data: CollectionsAdminData = data.into_inner();
|
let data: CollectionsAdminData = data.into_inner();
|
||||||
|
|
||||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
@@ -930,7 +960,7 @@ async fn post_collections_admin(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherUpdatedCollections as i32,
|
EventType::CipherUpdatedCollections,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_uuid,
|
org_uuid,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -940,7 +970,7 @@ async fn post_collections_admin(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1335,7 +1365,7 @@ async fn save_attachment(
|
|||||||
|
|
||||||
if let Some(org_id) = &cipher.organization_uuid {
|
if let Some(org_id) = &cipher.organization_uuid {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherAttachmentCreated as i32,
|
EventType::CipherAttachmentCreated,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1696,7 +1726,7 @@ async fn purge_org_vault(
|
|||||||
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationPurgedVault as i32,
|
EventType::OrganizationPurgedVault,
|
||||||
&organization.org_id,
|
&organization.org_id,
|
||||||
&organization.org_id,
|
&organization.org_id,
|
||||||
&user.uuid,
|
&user.uuid,
|
||||||
@@ -1824,9 +1854,9 @@ async fn delete_cipher_by_uuid(
|
|||||||
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|
||||||
|| *delete_options == CipherDeleteOptions::SoftMulti
|
|| *delete_options == CipherDeleteOptions::SoftMulti
|
||||||
{
|
{
|
||||||
EventType::CipherSoftDeleted as i32
|
EventType::CipherSoftDeleted
|
||||||
} else {
|
} else {
|
||||||
EventType::CipherDeleted as i32
|
EventType::CipherDeleted
|
||||||
};
|
};
|
||||||
|
|
||||||
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
||||||
@@ -1895,7 +1925,7 @@ async fn restore_cipher_by_uuid(
|
|||||||
|
|
||||||
if let Some(org_id) = &cipher.organization_uuid {
|
if let Some(org_id) = &cipher.organization_uuid {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherRestored as i32,
|
EventType::CipherRestored,
|
||||||
&cipher.uuid.clone(),
|
&cipher.uuid.clone(),
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1972,7 +2002,7 @@ async fn delete_cipher_attachment_by_id(
|
|||||||
|
|
||||||
if let Some(ref org_id) = cipher.organization_uuid {
|
if let Some(ref org_id) = cipher.organization_uuid {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CipherAttachmentDeleted as i32,
|
EventType::CipherAttachmentDeleted,
|
||||||
&cipher.uuid,
|
&cipher.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2150,8 +2180,15 @@ impl CipherSyncData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a HashMap with the Organization UUID as key and the Membership record
|
||||||
|
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.org_uuid.clone(), m))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
|
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
|
||||||
let orgs = Membership::get_orgs_by_user(user_id, conn).await;
|
let orgs: Vec<OrganizationId> = members.keys().cloned().collect();
|
||||||
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
|
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
|
||||||
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
|
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
@@ -2166,13 +2203,6 @@ impl CipherSyncData {
|
|||||||
cipher_collections.entry(cipher).or_default().push(collection);
|
cipher_collections.entry(cipher).or_default().push(collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a HashMap with the Organization UUID as key and the Membership record
|
|
||||||
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| (m.org_uuid.clone(), m))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
||||||
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+23
-3
@@ -10,7 +10,7 @@ use crate::{
|
|||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
||||||
},
|
},
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
};
|
};
|
||||||
@@ -199,6 +199,26 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only the vault notification banner click is accepted from clients. The rest of
|
||||||
|
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
|
||||||
|
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
|
||||||
|
if let Some(org_id) = &event.organization_id
|
||||||
|
&& let Some(membership) =
|
||||||
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
|
||||||
|
{
|
||||||
|
log_event_impl(
|
||||||
|
event.r#type,
|
||||||
|
&membership.uuid,
|
||||||
|
org_id,
|
||||||
|
&headers.user.uuid,
|
||||||
|
headers.device.atype,
|
||||||
|
Some(event_date),
|
||||||
|
&headers.ip.ip,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// The cipher determines the organization the event is logged to, so make sure the
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
// user can actually access it instead of trusting the provided cipher uuid.
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
@@ -267,7 +287,7 @@ async fn log_user_event_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn log_event(
|
pub async fn log_event(
|
||||||
event_type: i32,
|
event_type: EventType,
|
||||||
source_uuid: &str,
|
source_uuid: &str,
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
act_user_id: &UserId,
|
act_user_id: &UserId,
|
||||||
@@ -278,7 +298,7 @@ pub async fn log_event(
|
|||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(clippy::too_many_arguments)]
|
#[expect(clippy::too_many_arguments)]
|
||||||
|
|||||||
+1
-1
@@ -238,7 +238,7 @@ fn config() -> Json<Value> {
|
|||||||
"disableUserRegistration": CONFIG.is_signup_disabled(),
|
"disableUserRegistration": CONFIG.is_signup_disabled(),
|
||||||
// When enabled, this setting signals to clients that onboarding interstitials
|
// When enabled, this setting signals to clients that onboarding interstitials
|
||||||
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
|
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
|
||||||
"suppressOnboardingInterstitials": false
|
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"vault": domain,
|
"vault": domain,
|
||||||
|
|||||||
+106
-66
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, http::Status, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -17,7 +17,8 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
|
||||||
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
|
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
|
||||||
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
|
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
|
||||||
|
UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -132,7 +133,6 @@ struct FullCollectionData {
|
|||||||
name: String,
|
name: String,
|
||||||
groups: Vec<CollectionGroupData>,
|
groups: Vec<CollectionGroupData>,
|
||||||
users: Vec<CollectionMembershipData>,
|
users: Vec<CollectionMembershipData>,
|
||||||
id: Option<CollectionId>,
|
|
||||||
external_id: Option<String>,
|
external_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserLeft as i32,
|
EventType::OrganizationUserLeft,
|
||||||
&membership.uuid,
|
&membership.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -327,7 +327,7 @@ async fn post_organization(
|
|||||||
org.save(&conn).await?;
|
org.save(&conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUpdated as i32,
|
EventType::OrganizationUpdated,
|
||||||
org_id.as_ref(),
|
org_id.as_ref(),
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -391,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !headers.membership.has_full_access() {
|
if !headers.membership.has_full_access() {
|
||||||
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -415,8 +415,9 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
|
|||||||
let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await;
|
let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await;
|
||||||
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
|
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
|
||||||
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
|
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
|
||||||
|
// This lists other members for admins, so it must not depend on the membership status
|
||||||
let membership_type: HashMap<MembershipId, i32> =
|
let membership_type: HashMap<MembershipId, i32> =
|
||||||
Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
|
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
|
||||||
|
|
||||||
// check if current user has full access to the organization (either directly or via any group)
|
// check if current user has full access to the organization (either directly or via any group)
|
||||||
let has_full_access_to_org = member.has_full_access()
|
let has_full_access_to_org = member.has_full_access()
|
||||||
@@ -514,7 +515,7 @@ async fn post_organization_collections(
|
|||||||
collection.save(&conn).await?;
|
collection.save(&conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CollectionCreated as i32,
|
EventType::CollectionCreated,
|
||||||
&collection.uuid,
|
&collection.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -597,7 +598,7 @@ async fn post_bulk_access_collections(
|
|||||||
collection.save(&conn).await?;
|
collection.save(&conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CollectionUpdated as i32,
|
EventType::CollectionUpdated,
|
||||||
&collection.uuid,
|
&collection.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -674,7 +675,7 @@ async fn post_organization_collection_update(
|
|||||||
collection.save(&conn).await?;
|
collection.save(&conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CollectionUpdated as i32,
|
EventType::CollectionUpdated,
|
||||||
&collection.uuid,
|
&collection.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -723,7 +724,7 @@ async fn delete_organization_collection_impl(
|
|||||||
err!("Collection not found", "Collection does not exist or does not belong to this organization")
|
err!("Collection not found", "Collection does not exist or does not belong to this organization")
|
||||||
};
|
};
|
||||||
log_event(
|
log_event(
|
||||||
EventType::CollectionDeleted as i32,
|
EventType::CollectionDeleted,
|
||||||
&collection.uuid,
|
&collection.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -818,11 +819,9 @@ async fn get_org_collection_detail(
|
|||||||
|
|
||||||
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
|
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
|
||||||
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
|
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
|
||||||
let membership_type: HashMap<MembershipId, i32> = Membership::find_confirmed_by_org(&org_id, &conn)
|
// This lists other members for admins, so it must not depend on the membership status
|
||||||
.await
|
let membership_type: HashMap<MembershipId, i32> =
|
||||||
.into_iter()
|
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
|
||||||
.map(|m| (m.uuid, m.atype))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let users: Vec<Value> =
|
let users: Vec<Value> =
|
||||||
CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn)
|
CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn)
|
||||||
@@ -887,11 +886,11 @@ struct OrgIdData {
|
|||||||
#[get("/ciphers/organization-details?<data..>")]
|
#[get("/ciphers/organization-details?<data..>")]
|
||||||
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
|
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
|
||||||
if data.organization_id != headers.membership.org_uuid {
|
if data.organization_id != headers.membership.org_uuid {
|
||||||
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
|
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !headers.membership.has_full_access() {
|
if !headers.membership.has_full_access() {
|
||||||
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -955,13 +954,14 @@ async fn get_members(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !headers.membership.has_full_access() {
|
if !headers.membership.has_full_access() {
|
||||||
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut users_json = Vec::new();
|
let mut users_json = Vec::new();
|
||||||
for u in Membership::find_by_org(&org_id, &conn).await {
|
for u in Membership::find_by_org(&org_id, &conn).await {
|
||||||
|
// The user can be a manager instead of an admin, but we've checked above that they have full access
|
||||||
users_json.push(
|
users_json.push(
|
||||||
u.to_json_user_details(
|
u.to_json_details_for_admin(
|
||||||
data.include_collections.unwrap_or(false),
|
data.include_collections.unwrap_or(false),
|
||||||
data.include_groups.unwrap_or(false),
|
data.include_groups.unwrap_or(false),
|
||||||
&conn,
|
&conn,
|
||||||
@@ -1075,7 +1075,7 @@ async fn send_invite(
|
|||||||
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
||||||
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
||||||
|
|
||||||
let mut user_created: bool = false;
|
let mut user_created: bool;
|
||||||
for email in &data.emails {
|
for email in &data.emails {
|
||||||
let mut member_status = MembershipStatus::Invited as i32;
|
let mut member_status = MembershipStatus::Invited as i32;
|
||||||
let user = match User::find_by_mail(email, &conn).await {
|
let user = match User::find_by_mail(email, &conn).await {
|
||||||
@@ -1110,6 +1110,7 @@ async fn send_invite(
|
|||||||
member_status = MembershipStatus::Accepted as i32;
|
member_status = MembershipStatus::Accepted as i32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
user_created = false;
|
||||||
user
|
user
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1148,7 +1149,7 @@ async fn send_invite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserInvited as i32,
|
EventType::OrganizationUserInvited,
|
||||||
&new_member.uuid,
|
&new_member.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1447,7 +1448,7 @@ async fn confirm_invite_impl(
|
|||||||
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
|
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserConfirmed as i32,
|
EventType::OrganizationUserConfirmed,
|
||||||
&member_to_confirm.uuid,
|
&member_to_confirm.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1515,7 +1516,9 @@ async fn get_user(
|
|||||||
// In this case, when groups are requested we also need to include collections.
|
// In this case, when groups are requested we also need to include collections.
|
||||||
// Else these will not be shown in the interface, and could lead to missing collections when saved.
|
// Else these will not be shown in the interface, and could lead to missing collections when saved.
|
||||||
let include_groups = data.include_groups.unwrap_or(false);
|
let include_groups = data.include_groups.unwrap_or(false);
|
||||||
Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await))
|
Ok(Json(
|
||||||
|
user.to_json_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1637,7 +1640,7 @@ async fn edit_member(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdated as i32,
|
EventType::OrganizationUserUpdated,
|
||||||
&member_to_edit.uuid,
|
&member_to_edit.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1724,7 +1727,7 @@ async fn delete_member_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRemoved as i32,
|
EventType::OrganizationUserRemoved,
|
||||||
&member_to_delete.uuid,
|
&member_to_delete.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -1793,11 +1796,22 @@ async fn bulk_public_keys(
|
|||||||
use super::ciphers::CipherData;
|
use super::ciphers::CipherData;
|
||||||
use super::ciphers::update_cipher_from_data;
|
use super::ciphers::update_cipher_from_data;
|
||||||
|
|
||||||
|
// The import endpoint only ever uses the name/id/external_id of a collection.
|
||||||
|
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
|
||||||
|
// mandatory: clients are free to leave them out.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ImportCollectionData {
|
||||||
|
name: String,
|
||||||
|
id: Option<CollectionId>,
|
||||||
|
external_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ImportData {
|
struct ImportData {
|
||||||
ciphers: Vec<CipherData>,
|
ciphers: Vec<CipherData>,
|
||||||
collections: Vec<FullCollectionData>,
|
collections: Vec<ImportCollectionData>,
|
||||||
collection_relationships: Vec<RelationsData>,
|
collection_relationships: Vec<RelationsData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1823,6 +1837,10 @@ async fn post_org_import(
|
|||||||
if org_id != headers.membership.org_uuid {
|
if org_id != headers.membership.org_uuid {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
|
// OrgMemberHeaders also allows invited and accepted members, which are not allowed to import
|
||||||
|
if headers.membership.status != MembershipStatus::Confirmed as i32 {
|
||||||
|
err!("You need to be a Member of the Organization to call this endpoint")
|
||||||
|
}
|
||||||
let data: ImportData = data.into_inner();
|
let data: ImportData = data.into_inner();
|
||||||
|
|
||||||
// Validate the import before continuing
|
// Validate the import before continuing
|
||||||
@@ -2144,7 +2162,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRemoved as i32,
|
EventType::OrganizationUserRemoved,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2170,7 +2188,7 @@ async fn put_policy(
|
|||||||
policy.save(&conn).await?;
|
policy.save(&conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::PolicyUpdated as i32,
|
EventType::PolicyUpdated,
|
||||||
policy.uuid.as_ref(),
|
policy.uuid.as_ref(),
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2339,7 +2357,7 @@ async fn revoke_member_impl(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevoked as i32,
|
EventType::OrganizationUserRevoked,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2437,7 +2455,7 @@ async fn restore_member_impl(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRestored as i32,
|
EventType::OrganizationUserRestored,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2476,7 +2494,7 @@ async fn get_groups_data(
|
|||||||
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
|
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
|
||||||
};
|
};
|
||||||
if !allowed {
|
if !allowed {
|
||||||
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
||||||
@@ -2605,7 +2623,7 @@ async fn post_groups(
|
|||||||
let group = group_request.to_group(&org_id);
|
let group = group_request.to_group(&org_id);
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::GroupCreated as i32,
|
EventType::GroupCreated,
|
||||||
&group.uuid,
|
&group.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2646,7 +2664,7 @@ async fn put_group(
|
|||||||
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::GroupUpdated as i32,
|
EventType::GroupUpdated,
|
||||||
&updated_group.uuid,
|
&updated_group.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2679,7 +2697,7 @@ async fn add_update_group(
|
|||||||
user_entry.save(conn).await?;
|
user_entry.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdatedGroups as i32,
|
EventType::OrganizationUserUpdatedGroups,
|
||||||
&assigned_member,
|
&assigned_member,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2754,7 +2772,7 @@ async fn delete_group_impl(
|
|||||||
};
|
};
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::GroupDeleted as i32,
|
EventType::GroupDeleted,
|
||||||
&group.uuid,
|
&group.uuid,
|
||||||
org_id,
|
org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2865,7 +2883,7 @@ async fn put_group_members(
|
|||||||
user_entry.save(&conn).await?;
|
user_entry.save(&conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdatedGroups as i32,
|
EventType::OrganizationUserUpdatedGroups,
|
||||||
&assigned_member,
|
&assigned_member,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2903,7 +2921,7 @@ async fn post_delete_group_member(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdatedGroups as i32,
|
EventType::OrganizationUserUpdatedGroups,
|
||||||
&member_id,
|
&member_id,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2927,8 +2945,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct OrganizationUserRecoverAccountRequest {
|
struct OrganizationUserRecoverAccountRequest {
|
||||||
new_master_password_hash: String,
|
new_master_password_hash: Option<String>,
|
||||||
key: String,
|
key: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
reset_master_password: bool,
|
reset_master_password: bool,
|
||||||
@@ -2972,12 +2990,7 @@ async fn put_recover_account(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let req = data.into_inner();
|
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
|
||||||
if req.reset_master_password && !req.reset_two_factor {
|
|
||||||
recover_account(org_id, member_id, headers, req, conn, nt).await
|
|
||||||
} else {
|
|
||||||
err!("Unsupported operation")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated since `v2026.4.2`
|
// Deprecated since `v2026.4.2`
|
||||||
@@ -2997,7 +3010,7 @@ async fn recover_account(
|
|||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
reset_request: OrganizationUserRecoverAccountRequest,
|
req: OrganizationUserRecoverAccountRequest,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
@@ -3012,7 +3025,7 @@ async fn recover_account(
|
|||||||
err!("User to reset isn't member of required organization")
|
err!("User to reset isn't member of required organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
|
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
|
||||||
err!("User not found")
|
err!("User not found")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3025,29 +3038,56 @@ async fn recover_account(
|
|||||||
err!("Organization user must be confirmed for password reset functionality");
|
err!("Organization user must be confirmed for password reset functionality");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sending email before resetting password to ensure working email configuration and the resulting
|
let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() {
|
||||||
// user notification. Also this might add some protection against security flaws and misuse
|
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
|
||||||
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await {
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sending email first ensure working email configuration and the resulting user notification.
|
||||||
|
// Also this might add some protection against security flaws and misuse
|
||||||
|
if let Err(e) = mail::send_admin_account_recovery(
|
||||||
|
&user.email,
|
||||||
|
user.display_name(),
|
||||||
|
&org.name,
|
||||||
|
req.reset_master_password,
|
||||||
|
req.reset_two_factor,
|
||||||
|
fallback_2fa_email,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
err!(format!("Error sending user reset password email: {e:#?}"));
|
err!(format!("Error sending user reset password email: {e:#?}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = user;
|
if req.reset_master_password {
|
||||||
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
|
if let Some(key) = req.key
|
||||||
.await?;
|
&& let Some(hash) = req.new_master_password_hash
|
||||||
|
{
|
||||||
|
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
|
||||||
|
} else {
|
||||||
|
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.reset_two_factor {
|
||||||
|
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
|
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
|
||||||
|
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
|
||||||
log_event(
|
if req.reset_master_password {
|
||||||
EventType::OrganizationUserAdminResetPassword as i32,
|
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
|
||||||
&member_id,
|
}
|
||||||
&org_id,
|
|
||||||
&headers.user.uuid,
|
if req.reset_two_factor {
|
||||||
headers.device.atype,
|
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
|
||||||
&headers.ip.ip,
|
}
|
||||||
&conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -3166,9 +3206,9 @@ async fn put_reset_password_enrollment(
|
|||||||
membership.save(&conn).await?;
|
membership.save(&conn).await?;
|
||||||
|
|
||||||
let event_type = if membership.reset_password_key.is_some() {
|
let event_type = if membership.reset_password_key.is_some() {
|
||||||
EventType::OrganizationUserResetPasswordEnroll as i32
|
EventType::OrganizationUserResetPasswordEnroll
|
||||||
} else {
|
} else {
|
||||||
EventType::OrganizationUserResetPasswordWithdraw as i32
|
EventType::OrganizationUserResetPasswordWithdraw
|
||||||
};
|
};
|
||||||
|
|
||||||
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
|
|||||||
@@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
|
|
||||||
let user = if let Some(email) = email {
|
let user = if let Some(email) = email {
|
||||||
let Some(user) = User::find_by_mail(email, &conn).await else {
|
let Some(user) = User::find_by_mail(email, &conn).await else {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!(
|
||||||
|
"Username or password is incorrect. Try again",
|
||||||
|
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(master_password_hash) = master_password_hash {
|
if let Some(master_password_hash) = master_password_hash {
|
||||||
// Check password
|
// Check password
|
||||||
if !user.check_valid_password(master_password_hash) {
|
if !user.check_valid_password(master_password_hash) {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!(
|
||||||
|
"Username or password is incorrect. Try again",
|
||||||
|
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if let Some(auth_request_id) = auth_request_id {
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||||
@@ -96,7 +102,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
};
|
};
|
||||||
// SSO login only sends device id, so we get the user by the most recently used device
|
// SSO login only sends device id, so we get the user by the most recently used device
|
||||||
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!(
|
||||||
|
"Username or password is incorrect. Try again",
|
||||||
|
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
user
|
user
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
|
||||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -151,6 +151,7 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
|
|||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
twofactor.delete(&conn).await?;
|
twofactor.delete(&conn).await?;
|
||||||
|
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
|
||||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevoked as i32,
|
EventType::OrganizationUserRevoked,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&member.org_uuid,
|
&member.org_uuid,
|
||||||
act_user_id,
|
act_user_id,
|
||||||
@@ -224,16 +225,8 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
member.revoke();
|
member.revoke();
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
||||||
EventType::OrganizationUserRevoked as i32,
|
.await;
|
||||||
&member.uuid,
|
|
||||||
org_id,
|
|
||||||
act_user_id,
|
|
||||||
device_type,
|
|
||||||
ip,
|
|
||||||
conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yubico::{config::Config, verify_async};
|
use yubico_ng::{
|
||||||
|
Verifier, YubicoError,
|
||||||
|
config::Config,
|
||||||
|
transport::{AsyncTransport, Response},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
@@ -14,12 +18,39 @@ use crate::{
|
|||||||
models::{EventType, TwoFactor, TwoFactorType},
|
models::{EventType, TwoFactor, TwoFactorType},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
|
http_client,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct HttpClientTransport {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientTransport {
|
||||||
|
fn new() -> Result<Self, reqwest::Error> {
|
||||||
|
http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(
|
||||||
|
|client| Self {
|
||||||
|
client,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncTransport for HttpClientTransport {
|
||||||
|
type Error = YubicoError;
|
||||||
|
|
||||||
|
async fn yubico_get(&self, url: &str) -> Result<Response, Self::Error> {
|
||||||
|
let response = self.client.get(url).send().await.map_err(YubicoError::transport)?;
|
||||||
|
Ok(Response {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
body: response.text().await.map_err(YubicoError::transport)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EnableYubikeyData {
|
struct EnableYubikeyData {
|
||||||
@@ -44,8 +75,7 @@ pub struct YubikeyMetadata {
|
|||||||
|
|
||||||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||||
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
||||||
|
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
|
||||||
data_keys.into_iter().flatten().cloned().collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||||
@@ -73,13 +103,15 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
|
|||||||
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
||||||
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
||||||
|
|
||||||
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
|
let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
|
||||||
|
if let Some(yubico_server) = CONFIG.yubico_server() {
|
||||||
match CONFIG.yubico_server() {
|
config = config.set_api_host(yubico_server);
|
||||||
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
|
|
||||||
None => verify_async(otp, config).await,
|
|
||||||
}
|
}
|
||||||
.map_res("Failed to verify OTP")
|
|
||||||
|
let client = HttpClientTransport::new()?;
|
||||||
|
let verifier = Verifier::with_client(config, client)?;
|
||||||
|
|
||||||
|
verifier.verify(otp).await.map_res("Failed to verify OTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-yubikey", data = "<data>")]
|
#[post("/two-factor/get-yubikey", data = "<data>")]
|
||||||
@@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
let yubikeys = parse_yubikeys(&data);
|
let yubikeys = parse_yubikeys(&data);
|
||||||
|
|
||||||
if yubikeys.is_empty() {
|
if yubikeys.is_empty() {
|
||||||
return Ok(Json(json!({
|
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
|
||||||
"enabled": false,
|
// To remove all keys users should click the `Deactivate all keys` button
|
||||||
"object": "twoFactorU2f",
|
err!("A key is required.");
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure they are valid OTPs
|
// Ensure they are valid OTPs
|
||||||
|
|||||||
+52
-9
@@ -3,7 +3,7 @@ use num_traits::FromPrimitive;
|
|||||||
use rocket::{
|
use rocket::{
|
||||||
Route,
|
Route,
|
||||||
form::{Form, FromForm},
|
form::{Form, FromForm},
|
||||||
http::{Cookie, CookieJar, SameSite},
|
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
@@ -234,6 +234,24 @@ async fn sso_login(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Some((user, None))
|
||||||
|
if user.private_key.is_none()
|
||||||
|
&& !CONFIG.sso_signups_allowed()
|
||||||
|
&& !CONFIG.is_email_domain_allowed(&user.email)
|
||||||
|
&& !CONFIG.mail_enabled()
|
||||||
|
&& Invitation::find_by_mail(&user.email, conn).await.is_none() =>
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"Login failure ({}), no invitation with email ({}) was found",
|
||||||
|
user_infos.identifier, user.email
|
||||||
|
);
|
||||||
|
err_silent!(
|
||||||
|
"Missing invitation",
|
||||||
|
ErrorEvent {
|
||||||
|
event: EventType::UserFailedLogIn
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
||||||
error!(
|
error!(
|
||||||
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
||||||
@@ -281,7 +299,15 @@ async fn sso_login(
|
|||||||
// Will trigger 2FA flow if needed
|
// Will trigger 2FA flow if needed
|
||||||
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
|
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
|
||||||
None => {
|
None => {
|
||||||
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
|
if !CONFIG.is_sso_signup_allowed(&user_infos.email) {
|
||||||
|
if CONFIG.signups_domains_whitelist().is_empty() {
|
||||||
|
err!(
|
||||||
|
"Signups are disabled. You will need an invitation",
|
||||||
|
ErrorEvent {
|
||||||
|
event: EventType::UserFailedLogIn
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
err!(
|
err!(
|
||||||
"Email domain not allowed",
|
"Email domain not allowed",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
@@ -318,7 +344,7 @@ async fn sso_login(
|
|||||||
Some((user, _)) if !user.enabled => {
|
Some((user, _)) if !user.enabled => {
|
||||||
err!(
|
err!(
|
||||||
"This user has been disabled",
|
"This user has been disabled",
|
||||||
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
|
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn
|
event: EventType::UserFailedLogIn
|
||||||
}
|
}
|
||||||
@@ -577,7 +603,7 @@ async fn authenticated_response(
|
|||||||
result["TwoFactorToken"] = Value::String(token);
|
result["TwoFactorToken"] = Value::String(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip);
|
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +905,12 @@ async fn twofactor_auth(
|
|||||||
|
|
||||||
// Remove all twofactors from the user
|
// Remove all twofactors from the user
|
||||||
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
|
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
|
||||||
|
|
||||||
|
// No device may keep skipping 2FA once every second factor is gone.
|
||||||
|
// `device` is cleared in memory too, since saving it later would restore its token.
|
||||||
|
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
|
||||||
|
device.delete_twofactor_remember();
|
||||||
|
|
||||||
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
||||||
|
|
||||||
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||||
@@ -1024,13 +1056,13 @@ async fn json_err_twofactor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin", data = "<data>")]
|
#[post("/accounts/prelogin", data = "<data>")]
|
||||||
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
prelogin(data, conn).await
|
prelogin(data, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin/password", data = "<data>")]
|
#[post("/accounts/prelogin/password", data = "<data>")]
|
||||||
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
prelogin(data, conn).await
|
prelogin(data, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register", data = "<data>")]
|
#[post("/accounts/register", data = "<data>")]
|
||||||
@@ -1051,11 +1083,18 @@ enum RegisterVerificationResponse {
|
|||||||
#[response(status = 204)]
|
#[response(status = 204)]
|
||||||
NoContent(()),
|
NoContent(()),
|
||||||
Token(Json<String>),
|
Token(Json<String>),
|
||||||
|
PlainToken(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return JSON only when the client explicitly requests it, otherwise return plain text.
|
||||||
|
fn accepts_json(accept: Option<&Accept>) -> bool {
|
||||||
|
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
||||||
async fn register_verification_email(
|
async fn register_verification_email(
|
||||||
data: Json<RegisterVerificationData>,
|
data: Json<RegisterVerificationData>,
|
||||||
|
accept: Option<&Accept>,
|
||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> ApiResult<RegisterVerificationResponse> {
|
) -> ApiResult<RegisterVerificationResponse> {
|
||||||
@@ -1093,7 +1132,11 @@ async fn register_verification_email(
|
|||||||
} else {
|
} else {
|
||||||
// If email verification is not required, return the token directly
|
// If email verification is not required, return the token directly
|
||||||
// the clients will use this token to finish the registration
|
// the clients will use this token to finish the registration
|
||||||
Ok(RegisterVerificationResponse::Token(Json(token)))
|
Ok(if accepts_json(accept) {
|
||||||
|
RegisterVerificationResponse::Token(Json(token))
|
||||||
|
} else {
|
||||||
|
RegisterVerificationResponse::PlainToken(token)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ pub use crate::api::{
|
|||||||
},
|
},
|
||||||
web::catchers as web_catchers,
|
web::catchers as web_catchers,
|
||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::static_files,
|
web::{invalidate_css_cache, static_files},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
|||||||
+39
-8
@@ -1,4 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Catcher, Route,
|
Catcher, Route,
|
||||||
@@ -13,12 +16,13 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, core::now},
|
api::{ApiResult, EmptyResult, core::now},
|
||||||
auth::decode_file_download,
|
auth::decode_file_download,
|
||||||
|
crypto::sha256_hex,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AttachmentId, CipherId},
|
models::{AttachmentId, CipherId},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
util::Cached,
|
util::{Cached, EtagCached},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -63,9 +67,29 @@ fn not_found() -> ApiResult<Html<String>> {
|
|||||||
Ok(Html(text))
|
Ok(Html(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CssCache {
|
||||||
|
css: String,
|
||||||
|
etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static CSS_CACHE: RwLock<Option<Arc<CssCache>>> = RwLock::new(None);
|
||||||
|
|
||||||
|
pub fn invalidate_css_cache() {
|
||||||
|
*CSS_CACHE.write().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/css/vaultwarden.css")]
|
#[get("/css/vaultwarden.css")]
|
||||||
fn vaultwarden_css() -> Cached<Css<String>> {
|
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
||||||
|
// If reload_templates is false, and we already have the CSS Cached, return this
|
||||||
|
if !CONFIG.reload_templates()
|
||||||
|
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
||||||
|
{
|
||||||
|
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
||||||
let css_options = json!({
|
let css_options = json!({
|
||||||
|
"email_change_allowed": CONFIG.email_change_allowed(),
|
||||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||||
"load_user_scss": true,
|
"load_user_scss": true,
|
||||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||||
@@ -112,8 +136,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache for one day should be enough and not too much
|
let etag = sha256_hex(css.as_bytes());
|
||||||
Cached::ttl(Css(css), 86_400, false)
|
let cached = Arc::new(CssCache {
|
||||||
|
css,
|
||||||
|
etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
if !CONFIG.reload_templates() {
|
||||||
|
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||||
|
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -268,9 +302,6 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
|
|||||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||||
"jquery-4.0.0.slim.js" => {
|
|
||||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
|
|
||||||
}
|
|
||||||
_ => err!(format!("Static file not found: {filename}")),
|
_ => err!(format!("Static file not found: {filename}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-3
@@ -23,14 +23,14 @@ use rocket::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::ApiResult,
|
api::{ApiResult, core::log_event},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
|
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||||
SendId, User, UserId, UserStampException,
|
SendFileId, SendId, User, UserId, UserStampException,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -822,6 +822,12 @@ pub struct AdminHeaders {
|
|||||||
pub org_id: OrganizationId,
|
pub org_id: OrganizationId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AdminHeaders {
|
||||||
|
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
|
||||||
|
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for AdminHeaders {
|
impl<'r> FromRequest<'r> for AdminHeaders {
|
||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|||||||
+35
-7
@@ -659,6 +659,11 @@ make_config! {
|
|||||||
events_days_retain: i64, false, option;
|
events_days_retain: i64, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
client {
|
||||||
|
/// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals
|
||||||
|
client_suppress_onboarding: bool, true, def, false;
|
||||||
|
},
|
||||||
|
|
||||||
/// Advanced settings
|
/// Advanced settings
|
||||||
advanced {
|
advanced {
|
||||||
/// Client IP header |> If not present, the remote IP is used.
|
/// Client IP header |> If not present, the remote IP is used.
|
||||||
@@ -812,6 +817,8 @@ make_config! {
|
|||||||
sso_enabled: bool, true, def, false;
|
sso_enabled: bool, true, def, false;
|
||||||
/// Only SSO login |> Disable Email+Master Password login
|
/// Only SSO login |> Disable Email+Master Password login
|
||||||
sso_only: bool, true, def, false;
|
sso_only: bool, true, def, false;
|
||||||
|
/// Allow SSO flow to create account |> You probably want to disable it when using a public provider
|
||||||
|
sso_signups_allowed: bool, true, def, true;
|
||||||
/// Allow email association |> Associate existing non-SSO user based on email
|
/// Allow email association |> Associate existing non-SSO user based on email
|
||||||
sso_signups_match_email: bool, true, def, true;
|
sso_signups_match_email: bool, true, def, true;
|
||||||
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
|
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
|
||||||
@@ -1157,11 +1164,8 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
err!(format!("sendmail command at `{path:?}` isn't executable"));
|
||||||
if !metadata.permissions().mode() & 0o111 != 0 {
|
|
||||||
err!(format!("sendmail command at `{path:?}` isn't executable"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1268,7 +1272,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
if !cfg.disable_admin_token {
|
if !cfg.disable_admin_token {
|
||||||
match cfg.admin_token.as_ref() {
|
match cfg.admin_token.as_ref() {
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
if let Err(e) = argon2::password_hash::PasswordHash::new(t) {
|
if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) {
|
||||||
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
|
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1421,21 +1425,28 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
"desktop-ui-migration-milestone-4",
|
"desktop-ui-migration-milestone-4",
|
||||||
// Auth Team
|
// Auth Team
|
||||||
"pm-5594-safari-account-switching",
|
"pm-5594-safari-account-switching",
|
||||||
|
"pm-32413-multi-client-password-management",
|
||||||
// Autofill Team
|
// Autofill Team
|
||||||
|
"undetermined-cipher-scenario-logic",
|
||||||
|
"enable-basic-auth-response",
|
||||||
"ssh-agent",
|
"ssh-agent",
|
||||||
"ssh-agent-v2",
|
"ssh-agent-v2",
|
||||||
// Key Management Team
|
// Key Management Team
|
||||||
"ssh-key-vault-item",
|
"ssh-key-vault-item",
|
||||||
"pm-25373-windows-biometrics-v2",
|
"pm-25373-windows-biometrics-v2",
|
||||||
"pm-26340-linux-biometrics-v2",
|
"pm-26340-linux-biometrics-v2",
|
||||||
|
"windows-native-credential-sync",
|
||||||
// Mobile Team
|
// Mobile Team
|
||||||
"anon-addy-self-host-alias",
|
"anon-addy-self-host-alias",
|
||||||
"simple-login-self-host-alias",
|
"simple-login-self-host-alias",
|
||||||
"mutual-tls",
|
"mutual-tls",
|
||||||
"cxp-import-mobile",
|
"cxp-import-mobile",
|
||||||
"cxp-export-mobile",
|
"cxp-export-mobile",
|
||||||
|
"pm-34171-card-scanner",
|
||||||
// Platform Team
|
// Platform Team
|
||||||
"pm-30529-webauthn-related-origins",
|
"pm-30529-webauthn-related-origins",
|
||||||
|
// Vault Team
|
||||||
|
"pm-32009-new-item-types",
|
||||||
];
|
];
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -1501,6 +1512,9 @@ impl Config {
|
|||||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||||
operator.write(&CONFIG_FILENAME, config_str).await?;
|
operator.write(&CONFIG_FILENAME, config_str).await?;
|
||||||
|
|
||||||
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
crate::api::invalidate_css_cache();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,6 +1553,17 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tests whether SSO signup is allowed for an email address, taking into
|
||||||
|
/// account the sso_signups_allowed and signups_domains_whitelist settings.
|
||||||
|
pub fn is_sso_signup_allowed(&self, email: &str) -> bool {
|
||||||
|
if self.signups_domains_whitelist().is_empty() {
|
||||||
|
self.sso_signups_allowed()
|
||||||
|
} else {
|
||||||
|
// The whitelist setting overrides the signups_allowed setting.
|
||||||
|
self.is_email_domain_allowed(email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The registration link should be hidden if
|
// The registration link should be hidden if
|
||||||
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
|
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
|
||||||
// - The SSO is activated and password login is disabled.
|
// - The SSO is activated and password login is disabled.
|
||||||
@@ -1583,6 +1608,9 @@ impl Config {
|
|||||||
writer._overrides = Vec::new();
|
writer._overrides = Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
crate::api::invalidate_css_cache();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1723,7 +1751,7 @@ where
|
|||||||
reg!("email/email_footer");
|
reg!("email/email_footer");
|
||||||
reg!("email/email_footer_text");
|
reg!("email/email_footer_text");
|
||||||
|
|
||||||
reg!("email/admin_reset_password", ".html");
|
reg!("email/admin_account_recovery", ".html");
|
||||||
reg!("email/change_email_existing", ".html");
|
reg!("email/change_email_existing", ".html");
|
||||||
reg!("email/change_email_invited", ".html");
|
reg!("email/change_email_invited", ".html");
|
||||||
reg!("email/change_email", ".html");
|
reg!("email/change_email", ".html");
|
||||||
|
|||||||
@@ -41,17 +41,20 @@ impl Archive {
|
|||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
diesel::replace_into(archives::table)
|
diesel::insert_into(archives::table)
|
||||||
.values((
|
.values((
|
||||||
archives::user_uuid.eq(user_uuid),
|
archives::user_uuid.eq(user_uuid),
|
||||||
archives::cipher_uuid.eq(cipher_uuid),
|
archives::cipher_uuid.eq(cipher_uuid),
|
||||||
archives::archived_at.eq(archived_at),
|
archives::archived_at.eq(archived_at),
|
||||||
))
|
))
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(archives::archived_at.eq(archived_at))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving archive")
|
.map_res("Error saving archive")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(archives::table)
|
diesel::insert_into(archives::table)
|
||||||
.values((
|
.values((
|
||||||
archives::user_uuid.eq(user_uuid),
|
archives::user_uuid.eq(user_uuid),
|
||||||
|
|||||||
@@ -82,24 +82,16 @@ impl Attachment {
|
|||||||
impl Attachment {
|
impl Attachment {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(attachments::table)
|
diesel::insert_into(attachments::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving attachment")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(attachments::table)
|
|
||||||
.filter(attachments::id.eq(&self.id))
|
|
||||||
.set(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving attachment")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving attachment")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(attachments::table)
|
diesel::insert_into(attachments::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(attachments::id)
|
.on_conflict(attachments::id)
|
||||||
|
|||||||
@@ -82,31 +82,23 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AuthRequest {
|
impl AuthRequest {
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(auth_requests::table)
|
|
||||||
.values(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(auth_requests::table)
|
|
||||||
.filter(auth_requests::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error auth_request")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error auth_request")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(auth_requests::table)
|
diesel::insert_into(auth_requests::table)
|
||||||
.values(&*self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving auth_request")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(auth_requests::table)
|
||||||
|
.values(self)
|
||||||
.on_conflict(auth_requests::uuid)
|
.on_conflict(auth_requests::uuid)
|
||||||
.do_update()
|
.do_update()
|
||||||
.set(&*self)
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving auth_request")
|
.map_res("Error saving auth_request")
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-16
@@ -440,24 +440,16 @@ impl Cipher {
|
|||||||
self.updated_at = Utc::now().naive_utc();
|
self.updated_at = Utc::now().naive_utc();
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(ciphers::table)
|
diesel::insert_into(ciphers::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving cipher")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(ciphers::table)
|
|
||||||
.filter(ciphers::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving cipher")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving cipher")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(ciphers::table)
|
diesel::insert_into(ciphers::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
.on_conflict(ciphers::uuid)
|
.on_conflict(ciphers::uuid)
|
||||||
@@ -665,16 +657,27 @@ impl Cipher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
|
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
|
||||||
|
let user_uuid = user_uuid.to_string();
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
// Check whether this cipher is in any collections accessible to the
|
// Check whether this cipher is in any collections accessible to the
|
||||||
// user. If so, retrieve the access flags for each collection.
|
// user. If so, retrieve the access flags for each collection.
|
||||||
|
// The user must have a confirmed membership in the cipher's
|
||||||
|
// organization, since users_collections rows are kept when a
|
||||||
|
// membership is revoked or not confirmed yet.
|
||||||
ciphers::table
|
ciphers::table
|
||||||
.filter(ciphers::uuid.eq(&self.uuid))
|
.filter(ciphers::uuid.eq(&self.uuid))
|
||||||
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
|
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
|
||||||
|
.inner_join(
|
||||||
|
users_organizations::table.on(ciphers::organization_uuid
|
||||||
|
.eq(users_organizations::org_uuid.nullable())
|
||||||
|
.and(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
|
||||||
|
)
|
||||||
.inner_join(
|
.inner_join(
|
||||||
users_collections::table.on(ciphers_collections::collection_uuid
|
users_collections::table.on(ciphers_collections::collection_uuid
|
||||||
.eq(users_collections::collection_uuid)
|
.eq(users_collections::collection_uuid)
|
||||||
.and(users_collections::user_uuid.eq(user_uuid))),
|
// Only allow collection access via the confirmed membership.
|
||||||
|
.and(users_organizations::user_uuid.eq(users_collections::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)
|
||||||
@@ -705,6 +708,9 @@ impl Cipher {
|
|||||||
.and(groups::organizations_uuid.eq(users_organizations::org_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))
|
||||||
|
// Only allow group access via a confirmed membership, since
|
||||||
|
// groups_users rows are kept when a membership is revoked.
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.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)
|
||||||
.expect("Error getting group access restrictions")
|
.expect("Error getting group access restrictions")
|
||||||
@@ -997,6 +1003,7 @@ impl Cipher {
|
|||||||
.eq(ciphers_collections::collection_uuid)
|
.eq(ciphers_collections::collection_uuid)
|
||||||
.and(collections_groups::groups_uuid.eq(groups::uuid))),
|
.and(collections_groups::groups_uuid.eq(groups::uuid))),
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::access_all
|
users_organizations::access_all
|
||||||
.eq(true) // User has access all
|
.eq(true) // User has access all
|
||||||
@@ -1028,6 +1035,7 @@ impl Cipher {
|
|||||||
.eq(ciphers_collections::collection_uuid)
|
.eq(ciphers_collections::collection_uuid)
|
||||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::access_all
|
users_organizations::access_all
|
||||||
.eq(true) // User has access all
|
.eq(true) // User has access all
|
||||||
@@ -1072,6 +1080,7 @@ impl Cipher {
|
|||||||
.eq(ciphers_collections::collection_uuid)
|
.eq(ciphers_collections::collection_uuid)
|
||||||
.and(collections_groups::groups_uuid.eq(groups::uuid))),
|
.and(collections_groups::groups_uuid.eq(groups::uuid))),
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::access_all
|
users_organizations::access_all
|
||||||
.eq(true) // User has access all
|
.eq(true) // User has access all
|
||||||
@@ -1104,6 +1113,7 @@ impl Cipher {
|
|||||||
.eq(ciphers_collections::collection_uuid)
|
.eq(ciphers_collections::collection_uuid)
|
||||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::access_all
|
users_organizations::access_all
|
||||||
.eq(true) // User has access all
|
.eq(true) // User has access all
|
||||||
@@ -1155,6 +1165,7 @@ impl Cipher {
|
|||||||
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
||||||
.or_filter(groups::access_all.eq(true)) //Access via group
|
.or_filter(groups::access_all.eq(true)) //Access via group
|
||||||
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.select(ciphers_collections::all_columns)
|
.select(ciphers_collections::all_columns)
|
||||||
.distinct()
|
.distinct()
|
||||||
.load::<(CipherId, CollectionId)>(conn)
|
.load::<(CipherId, CollectionId)>(conn)
|
||||||
|
|||||||
+49
-66
@@ -168,24 +168,16 @@ impl Collection {
|
|||||||
self.update_users_revision(conn).await;
|
self.update_users_revision(conn).await;
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(collections::table)
|
diesel::insert_into(collections::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving collection")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(collections::table)
|
|
||||||
.filter(collections::uuid.eq(&self.uuid))
|
|
||||||
.set(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving collection")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving collection")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(collections::table)
|
diesel::insert_into(collections::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(collections::uuid)
|
.on_conflict(collections::uuid)
|
||||||
@@ -375,6 +367,7 @@ impl Collection {
|
|||||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||||
)
|
)
|
||||||
.filter(collections::uuid.eq(uuid))
|
.filter(collections::uuid.eq(uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_collections::collection_uuid
|
users_collections::collection_uuid
|
||||||
.eq(uuid)
|
.eq(uuid)
|
||||||
@@ -414,6 +407,7 @@ impl Collection {
|
|||||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||||
)
|
)
|
||||||
.filter(collections::uuid.eq(uuid))
|
.filter(collections::uuid.eq(uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(users_collections::collection_uuid.eq(uuid).or(
|
.filter(users_collections::collection_uuid.eq(uuid).or(
|
||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
@@ -438,7 +432,8 @@ impl Collection {
|
|||||||
.inner_join(
|
.inner_join(
|
||||||
users_organizations::table.on(collections::org_uuid
|
users_organizations::table.on(collections::org_uuid
|
||||||
.eq(users_organizations::org_uuid)
|
.eq(users_organizations::org_uuid)
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
|
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||||
|
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
|
||||||
)
|
)
|
||||||
.left_join(
|
.left_join(
|
||||||
users_collections::table.on(users_collections::collection_uuid
|
users_collections::table.on(users_collections::collection_uuid
|
||||||
@@ -484,7 +479,8 @@ impl Collection {
|
|||||||
.inner_join(
|
.inner_join(
|
||||||
users_organizations::table.on(collections::org_uuid
|
users_organizations::table.on(collections::org_uuid
|
||||||
.eq(users_organizations::org_uuid)
|
.eq(users_organizations::org_uuid)
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
|
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||||
|
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
|
||||||
)
|
)
|
||||||
.left_join(
|
.left_join(
|
||||||
users_collections::table.on(users_collections::collection_uuid
|
users_collections::table.on(users_collections::collection_uuid
|
||||||
@@ -535,6 +531,7 @@ impl Collection {
|
|||||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||||
)
|
)
|
||||||
.filter(collections::uuid.eq(&self.uuid))
|
.filter(collections::uuid.eq(&self.uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_collections::collection_uuid
|
users_collections::collection_uuid
|
||||||
.eq(&self.uuid)
|
.eq(&self.uuid)
|
||||||
@@ -594,6 +591,7 @@ impl Collection {
|
|||||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||||
)
|
)
|
||||||
.filter(collections::uuid.eq(&uuid))
|
.filter(collections::uuid.eq(&uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_collections::collection_uuid
|
users_collections::collection_uuid
|
||||||
.eq(&uuid)
|
.eq(&uuid)
|
||||||
@@ -658,6 +656,7 @@ impl Collection {
|
|||||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||||
)
|
)
|
||||||
.filter(collections::org_uuid.eq(&org_uuid))
|
.filter(collections::org_uuid.eq(&org_uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
// Manage permission on a collection assigned directly or via a group.
|
// Manage permission on a collection assigned directly or via a group.
|
||||||
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
|
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
|
||||||
@@ -728,53 +727,30 @@ impl CollectionUser {
|
|||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
|
|
||||||
|
let values = (
|
||||||
|
users_collections::user_uuid.eq(user_uuid),
|
||||||
|
users_collections::collection_uuid.eq(collection_uuid),
|
||||||
|
users_collections::read_only.eq(read_only),
|
||||||
|
users_collections::hide_passwords.eq(hide_passwords),
|
||||||
|
users_collections::manage.eq(manage),
|
||||||
|
);
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(users_collections::table)
|
|
||||||
.values((
|
|
||||||
users_collections::user_uuid.eq(user_uuid),
|
|
||||||
users_collections::collection_uuid.eq(collection_uuid),
|
|
||||||
users_collections::read_only.eq(read_only),
|
|
||||||
users_collections::hide_passwords.eq(hide_passwords),
|
|
||||||
users_collections::manage.eq(manage),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(users_collections::table)
|
|
||||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
|
||||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
|
||||||
.set((
|
|
||||||
users_collections::user_uuid.eq(user_uuid),
|
|
||||||
users_collections::collection_uuid.eq(collection_uuid),
|
|
||||||
users_collections::read_only.eq(read_only),
|
|
||||||
users_collections::hide_passwords.eq(hide_passwords),
|
|
||||||
users_collections::manage.eq(manage),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error adding user to collection")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error adding user to collection")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(users_collections::table)
|
diesel::insert_into(users_collections::table)
|
||||||
.values((
|
.values(values)
|
||||||
users_collections::user_uuid.eq(user_uuid),
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
users_collections::collection_uuid.eq(collection_uuid),
|
.do_update()
|
||||||
users_collections::read_only.eq(read_only),
|
.set(values)
|
||||||
users_collections::hide_passwords.eq(hide_passwords),
|
.execute(conn)
|
||||||
users_collections::manage.eq(manage),
|
.map_res("Error adding user to collection")
|
||||||
))
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(users_collections::table)
|
||||||
|
.values(values)
|
||||||
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
|
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
|
||||||
.do_update()
|
.do_update()
|
||||||
.set((
|
.set(values)
|
||||||
users_collections::read_only.eq(read_only),
|
|
||||||
users_collections::hide_passwords.eq(hide_passwords),
|
|
||||||
users_collections::manage.eq(manage),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error adding user to collection")
|
.map_res("Error adding user to collection")
|
||||||
}
|
}
|
||||||
@@ -850,10 +826,18 @@ impl CollectionUser {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only returns the collections of organizations the user is a confirmed member of
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
users_collections::table
|
users_collections::table
|
||||||
|
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
|
||||||
|
.inner_join(
|
||||||
|
users_organizations::table.on(users_organizations::org_uuid
|
||||||
|
.eq(collections::org_uuid)
|
||||||
|
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
|
||||||
|
)
|
||||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.select(users_collections::all_columns)
|
.select(users_collections::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading users_collections")
|
.expect("Error loading users_collections")
|
||||||
@@ -909,19 +893,18 @@ impl CollectionCipher {
|
|||||||
Self::update_users_revision(collection_uuid, conn).await;
|
Self::update_users_revision(collection_uuid, conn).await;
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
// Not checking for ForeignKey Constraints here.
|
diesel::insert_into(ciphers_collections::table)
|
||||||
// Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts.
|
|
||||||
// This table has no constraints pointing to itself, but only to others.
|
|
||||||
diesel::replace_into(ciphers_collections::table)
|
|
||||||
.values((
|
.values((
|
||||||
ciphers_collections::cipher_uuid.eq(cipher_uuid),
|
ciphers_collections::cipher_uuid.eq(cipher_uuid),
|
||||||
ciphers_collections::collection_uuid.eq(collection_uuid),
|
ciphers_collections::collection_uuid.eq(collection_uuid),
|
||||||
))
|
))
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_nothing()
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error adding cipher to collection")
|
.map_res("Error adding cipher to collection")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(ciphers_collections::table)
|
diesel::insert_into(ciphers_collections::table)
|
||||||
.values((
|
.values((
|
||||||
ciphers_collections::cipher_uuid.eq(cipher_uuid),
|
ciphers_collections::cipher_uuid.eq(cipher_uuid),
|
||||||
|
|||||||
+18
-3
@@ -146,15 +146,18 @@ impl Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
crate::util::retry(||
|
crate::util::retry(||
|
||||||
diesel::replace_into(devices::table)
|
diesel::insert_into(devices::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
.execute(conn),
|
.execute(conn),
|
||||||
10,
|
10,
|
||||||
).map_res("Error saving device")
|
).map_res("Error saving device")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
crate::util::retry(||
|
crate::util::retry(||
|
||||||
diesel::insert_into(devices::table)
|
diesel::insert_into(devices::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
@@ -266,10 +269,22 @@ impl Device {
|
|||||||
let devices = Self::find_by_user(user_uuid, conn).await;
|
let devices = Self::find_by_user(user_uuid, conn).await;
|
||||||
for mut device in devices {
|
for mut device in devices {
|
||||||
device.refresh_token = Device::generate_refresh_token();
|
device.refresh_token = Device::generate_refresh_token();
|
||||||
|
device.twofactor_remember = None;
|
||||||
device.save(false, conn).await?;
|
device.save(false, conn).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
diesel::update(devices::table)
|
||||||
|
.filter(devices::user_uuid.eq(user_uuid))
|
||||||
|
.set(devices::twofactor_remember.eq::<Option<String>>(None))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error removing two factor remember tokens")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Display)]
|
#[derive(Display)]
|
||||||
|
|||||||
@@ -146,24 +146,16 @@ impl EmergencyAccess {
|
|||||||
self.updated_at = Utc::now().naive_utc();
|
self.updated_at = Utc::now().naive_utc();
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(emergency_access::table)
|
diesel::insert_into(emergency_access::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving emergency access")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(emergency_access::table)
|
|
||||||
.filter(emergency_access::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error updating emergency access")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving emergency access")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(emergency_access::table)
|
diesel::insert_into(emergency_access::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
.on_conflict(emergency_access::uuid)
|
.on_conflict(emergency_access::uuid)
|
||||||
|
|||||||
+30
-17
@@ -43,7 +43,7 @@ pub struct Event {
|
|||||||
pub provider_org_uuid: Option<String>,
|
pub provider_org_uuid: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
|
// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
pub enum EventType {
|
pub enum EventType {
|
||||||
// User
|
// User
|
||||||
@@ -108,6 +108,12 @@ pub enum EventType {
|
|||||||
OrganizationUserRejectedAuthRequest = 1514,
|
OrganizationUserRejectedAuthRequest = 1514,
|
||||||
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
|
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
|
||||||
OrganizationUserLeft = 1516, // User voluntarily left the organization
|
OrganizationUserLeft = 1516, // User voluntarily left the organization
|
||||||
|
// OrganizationUserAutomaticallyConfirmed = 1517,
|
||||||
|
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
||||||
|
OrganizationUserAdminResetTwoFactor = 1519,
|
||||||
|
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
|
||||||
|
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
|
||||||
|
OrganizationUserNotificationBannerActionClicked = 1522,
|
||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
OrganizationUpdated = 1600,
|
OrganizationUpdated = 1600,
|
||||||
@@ -202,20 +208,23 @@ impl Event {
|
|||||||
/// Basic Queries
|
/// Basic Queries
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
diesel::replace_into(event::table)
|
|
||||||
.values(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving event")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(event::table)
|
diesel::insert_into(event::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(event::uuid)
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
.do_update()
|
.do_update()
|
||||||
.set(self)
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving event")
|
.map_res("Error saving event")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(event::table)
|
||||||
|
.values(self)
|
||||||
|
.on_conflict(event::uuid)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,12 +307,16 @@ impl Event {
|
|||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
event::table
|
event::table
|
||||||
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid)))
|
.inner_join(
|
||||||
|
users_organizations::table
|
||||||
|
.on(users_organizations::uuid.eq(member_uuid).and(users_organizations::org_uuid.eq(org_uuid))),
|
||||||
|
)
|
||||||
.filter(event::org_uuid.eq(org_uuid))
|
.filter(event::org_uuid.eq(org_uuid))
|
||||||
.filter(event::event_date.between(start, end))
|
.filter(event::event_date.between(start, end))
|
||||||
.filter(
|
.filter(
|
||||||
event::user_uuid
|
event::org_user_uuid
|
||||||
.eq(users_organizations::user_uuid.nullable())
|
.eq(member_uuid)
|
||||||
|
.or(event::user_uuid.eq(users_organizations::user_uuid.nullable()))
|
||||||
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
|
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
|
||||||
)
|
)
|
||||||
.select(event::all_columns)
|
.select(event::all_columns)
|
||||||
|
|||||||
+12
-21
@@ -77,24 +77,16 @@ impl Folder {
|
|||||||
self.updated_at = Utc::now().naive_utc();
|
self.updated_at = Utc::now().naive_utc();
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(folders::table)
|
diesel::insert_into(folders::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving folder")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(folders::table)
|
|
||||||
.filter(folders::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving folder")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving folder")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(folders::table)
|
diesel::insert_into(folders::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
.on_conflict(folders::uuid)
|
.on_conflict(folders::uuid)
|
||||||
@@ -147,16 +139,15 @@ impl Folder {
|
|||||||
impl FolderCipher {
|
impl FolderCipher {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
// Not checking for ForeignKey Constraints here.
|
diesel::insert_into(folders_ciphers::table)
|
||||||
// Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts.
|
|
||||||
// This table has no constraints pointing to itself, but only to others.
|
|
||||||
diesel::replace_into(folders_ciphers::table)
|
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_nothing()
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error adding cipher to folder")
|
.map_res("Error adding cipher to folder")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(folders_ciphers::table)
|
diesel::insert_into(folders_ciphers::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid))
|
.on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid))
|
||||||
|
|||||||
+49
-93
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
use super::{CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = groups)]
|
#[diesel(table_name = groups)]
|
||||||
@@ -166,24 +166,16 @@ impl Group {
|
|||||||
self.revision_date = Utc::now().naive_utc();
|
self.revision_date = Utc::now().naive_utc();
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(groups::table)
|
diesel::insert_into(groups::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving group")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(groups::table)
|
|
||||||
.filter(groups::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving group")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving group")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(groups::table)
|
diesel::insert_into(groups::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
.on_conflict(groups::uuid)
|
.on_conflict(groups::uuid)
|
||||||
@@ -257,6 +249,7 @@ impl Group {
|
|||||||
.and(groups::organizations_uuid.eq(users_organizations::org_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(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(groups::access_all.eq(true))
|
.filter(groups::access_all.eq(true))
|
||||||
.select(groups::organizations_uuid)
|
.select(groups::organizations_uuid)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -276,6 +269,9 @@ impl Group {
|
|||||||
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
|
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
|
||||||
)
|
)
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
// Only allow full access via a confirmed membership, since
|
||||||
|
// groups_users rows are kept when a membership is revoked.
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
.filter(groups::access_all.eq(true))
|
.filter(groups::access_all.eq(true))
|
||||||
.select(groups::access_all)
|
.select(groups::access_all)
|
||||||
@@ -326,53 +322,30 @@ impl CollectionGroup {
|
|||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let values = (
|
||||||
|
collections_groups::collections_uuid.eq(&self.collections_uuid),
|
||||||
|
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
||||||
|
collections_groups::read_only.eq(&self.read_only),
|
||||||
|
collections_groups::hide_passwords.eq(&self.hide_passwords),
|
||||||
|
collections_groups::manage.eq(&self.manage),
|
||||||
|
);
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(collections_groups::table)
|
|
||||||
.values((
|
|
||||||
collections_groups::collections_uuid.eq(&self.collections_uuid),
|
|
||||||
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
|
||||||
collections_groups::read_only.eq(&self.read_only),
|
|
||||||
collections_groups::hide_passwords.eq(&self.hide_passwords),
|
|
||||||
collections_groups::manage.eq(&self.manage),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(collections_groups::table)
|
|
||||||
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
|
|
||||||
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
|
|
||||||
.set((
|
|
||||||
collections_groups::collections_uuid.eq(&self.collections_uuid),
|
|
||||||
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
|
||||||
collections_groups::read_only.eq(&self.read_only),
|
|
||||||
collections_groups::hide_passwords.eq(&self.hide_passwords),
|
|
||||||
collections_groups::manage.eq(&self.manage),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error adding group to collection")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error adding group to collection")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(collections_groups::table)
|
diesel::insert_into(collections_groups::table)
|
||||||
.values((
|
.values(values)
|
||||||
collections_groups::collections_uuid.eq(&self.collections_uuid),
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
.do_update()
|
||||||
collections_groups::read_only.eq(self.read_only),
|
.set(values)
|
||||||
collections_groups::hide_passwords.eq(self.hide_passwords),
|
.execute(conn)
|
||||||
collections_groups::manage.eq(self.manage),
|
.map_res("Error adding group to collection")
|
||||||
))
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(collections_groups::table)
|
||||||
|
.values(values)
|
||||||
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
|
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
|
||||||
.do_update()
|
.do_update()
|
||||||
.set((
|
.set(values)
|
||||||
collections_groups::read_only.eq(self.read_only),
|
|
||||||
collections_groups::hide_passwords.eq(self.hide_passwords),
|
|
||||||
collections_groups::manage.eq(self.manage),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error adding group to collection")
|
.map_res("Error adding group to collection")
|
||||||
}
|
}
|
||||||
@@ -415,6 +388,7 @@ impl CollectionGroup {
|
|||||||
.and(collections::org_uuid.eq(groups::organizations_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))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.select(collections_groups::all_columns)
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user collection groups")
|
.expect("Error loading user collection groups")
|
||||||
@@ -497,43 +471,25 @@ impl GroupUser {
|
|||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
self.update_user_revision(conn).await;
|
self.update_user_revision(conn).await;
|
||||||
|
|
||||||
|
let values = (
|
||||||
|
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
|
||||||
|
groups_users::groups_uuid.eq(&self.groups_uuid),
|
||||||
|
);
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(groups_users::table)
|
|
||||||
.values((
|
|
||||||
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
|
|
||||||
groups_users::groups_uuid.eq(&self.groups_uuid),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(groups_users::table)
|
|
||||||
.filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid))
|
|
||||||
.filter(groups_users::groups_uuid.eq(&self.groups_uuid))
|
|
||||||
.set((
|
|
||||||
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
|
|
||||||
groups_users::groups_uuid.eq(&self.groups_uuid),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error adding user to group")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error adding user to group")
|
|
||||||
}
|
|
||||||
postgresql {
|
|
||||||
diesel::insert_into(groups_users::table)
|
diesel::insert_into(groups_users::table)
|
||||||
.values((
|
.values(values)
|
||||||
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
groups_users::groups_uuid.eq(&self.groups_uuid),
|
.do_nothing()
|
||||||
))
|
.execute(conn)
|
||||||
|
.map_res("Error adding user to group")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(groups_users::table)
|
||||||
|
.values(values)
|
||||||
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
|
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
|
||||||
.do_update()
|
.do_nothing()
|
||||||
.set((
|
|
||||||
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
|
|
||||||
groups_users::groups_uuid.eq(&self.groups_uuid),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error adding user to group")
|
.map_res("Error adding user to group")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,4 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
|||||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||||
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
|
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
|
||||||
@@ -49,6 +49,7 @@ pub enum OrgPolicyType {
|
|||||||
// AutotypeDefaultSetting = 17, // Not supported yet
|
// AutotypeDefaultSetting = 17, // Not supported yet
|
||||||
// AutoConfirm = 18, // Not supported (not implemented yet)
|
// AutoConfirm = 18, // Not supported (not implemented yet)
|
||||||
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
||||||
|
OrganizationUserNotification = 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -91,6 +92,7 @@ impl OrgPolicy {
|
|||||||
"type": self.atype,
|
"type": self.atype,
|
||||||
"data": data_json,
|
"data": data_json,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
|
"revisionDate": null,
|
||||||
"object": "policy",
|
"object": "policy",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +187,26 @@ impl OrgPolicy {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
org_policies::table
|
||||||
|
.inner_join(
|
||||||
|
users_organizations::table.on(users_organizations::org_uuid
|
||||||
|
.eq(org_policies::org_uuid)
|
||||||
|
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
users_organizations::status
|
||||||
|
.eq(MembershipStatus::Accepted as i32)
|
||||||
|
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
|
||||||
|
)
|
||||||
|
.select(org_policies::all_columns)
|
||||||
|
.load::<Self>(conn)
|
||||||
|
.expect("Error loading org_policy")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_org_and_type(
|
pub async fn find_by_org_and_type(
|
||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
policy_type: OrgPolicyType,
|
policy_type: OrgPolicyType,
|
||||||
@@ -317,6 +339,13 @@ impl OrgPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool {
|
pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool {
|
||||||
|
// Account recovery depends on outbound mail. When SMTP is disabled, treat the
|
||||||
|
// auto-enroll policy as inactive so invites/registration are not forced to
|
||||||
|
// supply a reset-password key (see check_reset_password_applicable).
|
||||||
|
if !CONFIG.mail_enabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await {
|
match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await {
|
||||||
Some(policy) => match serde_json::from_str::<ResetPasswordDataModel>(&policy.data) {
|
Some(policy) => match serde_json::from_str::<ResetPasswordDataModel>(&policy.data) {
|
||||||
Ok(opts) => {
|
Ok(opts) => {
|
||||||
|
|||||||
+58
-105
@@ -1,7 +1,4 @@
|
|||||||
use std::{
|
use std::cmp::Ordering;
|
||||||
cmp::Ordering,
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
};
|
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
@@ -24,8 +21,8 @@ use crate::{
|
|||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
|
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
|
||||||
OrgPolicyType, TwoFactor, User, UserId,
|
TwoFactor, User, UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -353,25 +350,16 @@ impl Organization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(organizations::table)
|
diesel::insert_into(organizations::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving organization")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(organizations::table)
|
|
||||||
.filter(organizations::uuid.eq(&self.uuid))
|
|
||||||
.set(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving organization")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving organization")
|
|
||||||
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(organizations::table)
|
diesel::insert_into(organizations::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(organizations::uuid)
|
.on_conflict(organizations::uuid)
|
||||||
@@ -545,7 +533,13 @@ impl Membership {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn to_json_user_details(&self, include_collections: bool, include_groups: bool, conn: &DbConn) -> Value {
|
// Used by admins to view other members, so nothing here may depend on this member's status
|
||||||
|
pub async fn to_json_details_for_admin(
|
||||||
|
&self,
|
||||||
|
include_collections: bool,
|
||||||
|
include_groups: bool,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Value {
|
||||||
let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap();
|
let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap();
|
||||||
|
|
||||||
// Because BitWarden want the status to be -1 for revoked users we need to catch that here.
|
// Because BitWarden want the status to be -1 for revoked users we need to catch that here.
|
||||||
@@ -568,52 +562,23 @@ impl Membership {
|
|||||||
|
|
||||||
// Check if a user is in a group which has access to all collections
|
// Check if a user is in a group which has access to all collections
|
||||||
// If that is the case, we should not return individual collections!
|
// If that is the case, we should not return individual collections!
|
||||||
|
// This is used by admins to view other members, so it must not depend on the membership status
|
||||||
let full_access_group =
|
let full_access_group =
|
||||||
CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await;
|
CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await;
|
||||||
|
|
||||||
// If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
|
// If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
|
||||||
|
// Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint
|
||||||
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) {
|
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) {
|
||||||
// Get all collections for the user here already to prevent more queries
|
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
||||||
let cu: HashMap<CollectionId, CollectionUser> =
|
|
||||||
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.map(|cu| (cu.collection_uuid.clone(), cu))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Get all collection groups for this user to prevent there inclusion
|
|
||||||
let cg: HashSet<CollectionId> = CollectionGroup::find_by_user(&self.user_uuid, conn)
|
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|cg| cg.collections_uuid)
|
.map(|cu| {
|
||||||
.collect();
|
json!({
|
||||||
|
"id": cu.collection_uuid,
|
||||||
Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
"readOnly": cu.read_only,
|
||||||
.await
|
"hidePasswords": cu.hide_passwords,
|
||||||
.into_iter()
|
"manage": cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
|
||||||
.filter_map(|c| {
|
})
|
||||||
let (read_only, hide_passwords, manage) = if self.has_full_access() {
|
|
||||||
(false, false, self.atype >= MembershipType::Manager)
|
|
||||||
} else if let Some(cu) = cu.get(&c.uuid) {
|
|
||||||
(
|
|
||||||
cu.read_only,
|
|
||||||
cu.hide_passwords,
|
|
||||||
cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
|
|
||||||
)
|
|
||||||
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here
|
|
||||||
// Those are returned via a special group endpoint
|
|
||||||
} else if cg.contains(&c.uuid) {
|
|
||||||
return None;
|
|
||||||
} else {
|
|
||||||
(true, true, false)
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(json!({
|
|
||||||
"id": c.uuid,
|
|
||||||
"readOnly": read_only,
|
|
||||||
"hidePasswords": hide_passwords,
|
|
||||||
"manage": manage,
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
@@ -753,24 +718,16 @@ impl Membership {
|
|||||||
User::update_uuid_revision(&self.user_uuid, conn).await;
|
User::update_uuid_revision(&self.user_uuid, conn).await;
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(users_organizations::table)
|
diesel::insert_into(users_organizations::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error adding user to organization")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(users_organizations::table)
|
|
||||||
.filter(users_organizations::uuid.eq(&self.uuid))
|
|
||||||
.set(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error adding user to organization")
|
|
||||||
},
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error adding user to organization")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(users_organizations::table)
|
diesel::insert_into(users_organizations::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(users_organizations::uuid)
|
.on_conflict(users_organizations::uuid)
|
||||||
@@ -861,6 +818,21 @@ impl Membership {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
users_organizations::table
|
||||||
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
.filter(
|
||||||
|
users_organizations::status
|
||||||
|
.eq(MembershipStatus::Accepted as i32)
|
||||||
|
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
|
||||||
|
)
|
||||||
|
.load::<Self>(conn)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
@@ -1034,17 +1006,6 @@ impl Membership {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<OrganizationId> {
|
|
||||||
conn.run(move |conn| {
|
|
||||||
users_organizations::table
|
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
|
||||||
.select(users_organizations::org_uuid)
|
|
||||||
.load::<OrganizationId>(conn)
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
@@ -1128,6 +1089,7 @@ impl Membership {
|
|||||||
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
|
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
|
||||||
)
|
)
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
|
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
|
||||||
)
|
)
|
||||||
@@ -1186,25 +1148,16 @@ impl Membership {
|
|||||||
impl OrganizationApiKey {
|
impl OrganizationApiKey {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(organization_api_key::table)
|
diesel::insert_into(organization_api_key::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving organization")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(organization_api_key::table)
|
|
||||||
.filter(organization_api_key::uuid.eq(&self.uuid))
|
|
||||||
.set(self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving organization")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving organization")
|
|
||||||
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(organization_api_key::table)
|
diesel::insert_into(organization_api_key::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict((organization_api_key::uuid, organization_api_key::org_uuid))
|
.on_conflict((organization_api_key::uuid, organization_api_key::org_uuid))
|
||||||
|
|||||||
+7
-15
@@ -202,24 +202,16 @@ impl Send {
|
|||||||
self.revision_date = Utc::now().naive_utc();
|
self.revision_date = Utc::now().naive_utc();
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
match diesel::replace_into(sends::table)
|
diesel::insert_into(sends::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
{
|
.map_res("Error saving send")
|
||||||
Ok(_) => Ok(()),
|
|
||||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
|
||||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
|
||||||
diesel::update(sends::table)
|
|
||||||
.filter(sends::uuid.eq(&self.uuid))
|
|
||||||
.set(&*self)
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error saving send")
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}.map_res("Error saving send")
|
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(sends::table)
|
diesel::insert_into(sends::table)
|
||||||
.values(&*self)
|
.values(&*self)
|
||||||
.on_conflict(sends::uuid)
|
.on_conflict(sends::uuid)
|
||||||
|
|||||||
+40
-8
@@ -69,6 +69,8 @@ pub struct User {
|
|||||||
pub avatar_color: Option<String>,
|
pub avatar_color: Option<String>,
|
||||||
|
|
||||||
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
|
pub key_id: Option<KeyId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
@@ -154,6 +156,8 @@ impl User {
|
|||||||
avatar_color: None,
|
avatar_color: None,
|
||||||
|
|
||||||
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
|
key_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +263,11 @@ impl User {
|
|||||||
orgs_json.push(c.to_json(conn).await);
|
orgs_json.push(c.to_json(conn).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut orgs_new_json = Vec::new();
|
||||||
|
for c in Membership::find_accepted_and_confirmed_by_user(&self.uuid, conn).await {
|
||||||
|
orgs_new_json.push(c.to_json(conn).await);
|
||||||
|
}
|
||||||
|
|
||||||
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
|
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
|
||||||
|
|
||||||
// TODO: Might want to save the status field in the DB
|
// TODO: Might want to save the status field in the DB
|
||||||
@@ -299,6 +308,7 @@ impl User {
|
|||||||
"privateKey": self.private_key,
|
"privateKey": self.private_key,
|
||||||
"securityStamp": self.security_stamp,
|
"securityStamp": self.security_stamp,
|
||||||
"organizations": orgs_json,
|
"organizations": orgs_json,
|
||||||
|
"organizationsNew": orgs_new_json,
|
||||||
"providers": [],
|
"providers": [],
|
||||||
"providerOrganizations": [],
|
"providerOrganizations": [],
|
||||||
"forcePasswordReset": false,
|
"forcePasswordReset": false,
|
||||||
@@ -463,15 +473,17 @@ impl Invitation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
// Not checking for ForeignKey Constraints here
|
||||||
// Not checking for ForeignKey Constraints here
|
// Table invitations does not have any ForeignKey Constraints.
|
||||||
// Table invitations does not have any ForeignKey Constraints.
|
mysql {
|
||||||
diesel::replace_into(invitations::table)
|
diesel::insert_into(invitations::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_nothing()
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving invitation")
|
.map_res("Error saving invitation")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(invitations::table)
|
diesel::insert_into(invitations::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.on_conflict(invitations::email)
|
.on_conflict(invitations::email)
|
||||||
@@ -525,16 +537,36 @@ impl Invitation {
|
|||||||
#[from(forward)]
|
#[from(forward)]
|
||||||
pub struct UserId(String);
|
pub struct UserId(String);
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone,
|
||||||
|
Debug,
|
||||||
|
DieselNewType,
|
||||||
|
FromForm,
|
||||||
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
Hash,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
AsRef,
|
||||||
|
Deref,
|
||||||
|
Display,
|
||||||
|
From,
|
||||||
|
UuidFromParam,
|
||||||
|
)]
|
||||||
|
#[deref(forward)]
|
||||||
|
#[from(forward)]
|
||||||
|
pub struct KeyId(String);
|
||||||
|
|
||||||
impl SsoUser {
|
impl SsoUser {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
sqlite, mysql {
|
mysql {
|
||||||
diesel::replace_into(sso_users::table)
|
diesel::insert_into(sso_users::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving SSO user")
|
.map_res("Error saving SSO user")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql, sqlite {
|
||||||
diesel::insert_into(sso_users::table)
|
diesel::insert_into(sso_users::table)
|
||||||
.values(self)
|
.values(self)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ table! {
|
|||||||
api_key -> Nullable<Text>,
|
api_key -> Nullable<Text>,
|
||||||
avatar_color -> Nullable<Text>,
|
avatar_color -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
|
key_id -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ use serde_json::{Error as SerdeErr, Value};
|
|||||||
use std::io::Error as IoErr;
|
use std::io::Error as IoErr;
|
||||||
use std::time::SystemTimeError as TimeErr;
|
use std::time::SystemTimeError as TimeErr;
|
||||||
use webauthn_rs::prelude::WebauthnError as WebauthnErr;
|
use webauthn_rs::prelude::WebauthnError as WebauthnErr;
|
||||||
use yubico::yubicoerror::YubicoError as YubiErr;
|
use yubico_ng::error::YubicoError as YubiErr;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct Empty {}
|
pub struct Empty {}
|
||||||
|
|||||||
+82
-23
@@ -14,7 +14,10 @@ use reqwest::{
|
|||||||
};
|
};
|
||||||
use url::Host;
|
use url::Host;
|
||||||
|
|
||||||
use crate::{CONFIG, util::is_global};
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
util::{get_env_bool, is_global},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
|
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
|
||||||
static INSTANCE: LazyLock<Client> =
|
static INSTANCE: LazyLock<Client> =
|
||||||
@@ -36,7 +39,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
|
|||||||
let mut headers = header::HeaderMap::new();
|
let mut headers = header::HeaderMap::new();
|
||||||
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
|
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
|
||||||
|
|
||||||
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
|
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
|
||||||
if attempt.previous().len() >= 5 {
|
if attempt.previous().len() >= 5 {
|
||||||
return attempt.error("Too many redirects");
|
return attempt.error("Too many redirects");
|
||||||
}
|
}
|
||||||
@@ -45,7 +48,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
|
|||||||
return attempt.error("Invalid host");
|
return attempt.error("Invalid host");
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = should_block_host(&host) {
|
if enforce_block && let Err(e) = should_block_host(&host) {
|
||||||
return attempt.error(e);
|
return attempt.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,14 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
|
|||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dns_prefer_ipv6() -> bool {
|
||||||
|
// CONFIG may require DNS to initialize, so avoid forcing it during bootstrap.
|
||||||
|
match LazyLock::get(&CONFIG) {
|
||||||
|
Some(config) => config.dns_prefer_ipv6(),
|
||||||
|
None => get_env_bool("DNS_PREFER_IPV6").unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn should_block_ip(ip: IpAddr) -> bool {
|
fn should_block_ip(ip: IpAddr) -> bool {
|
||||||
if !CONFIG.http_request_block_non_global_ips() {
|
if !CONFIG.http_request_block_non_global_ips() {
|
||||||
return false;
|
return false;
|
||||||
@@ -258,12 +269,8 @@ impl CustomDnsResolver {
|
|||||||
fn new() -> Arc<Self> {
|
fn new() -> Arc<Self> {
|
||||||
TokioResolver::builder(TokioRuntimeProvider::default())
|
TokioResolver::builder(TokioRuntimeProvider::default())
|
||||||
.and_then(|mut builder| {
|
.and_then(|mut builder| {
|
||||||
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
|
// Query both families; the preferred order is applied per lookup below.
|
||||||
// This might cause issues on IPv4 only systems or containers
|
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
|
||||||
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
|
|
||||||
if !CONFIG.dns_prefer_ipv6() {
|
|
||||||
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
|
|
||||||
}
|
|
||||||
builder.build()
|
builder.build()
|
||||||
})
|
})
|
||||||
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
|
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
|
||||||
@@ -289,6 +296,17 @@ impl CustomDnsResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sort_addresses(addresses: &mut [SocketAddr], prefer_ipv6: bool) {
|
||||||
|
// `sort_by_key` orders `false` before `true`.
|
||||||
|
// When IPv6 is preferred, IPv6 addresses return `false` for `is_ipv4()` and sort first.
|
||||||
|
// When IPv4 is preferred, IPv4 addresses return `false` for `is_ipv6()` and sort first.
|
||||||
|
if prefer_ipv6 {
|
||||||
|
addresses.sort_by_key(SocketAddr::is_ipv4);
|
||||||
|
} else {
|
||||||
|
addresses.sort_by_key(SocketAddr::is_ipv6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
|
fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
|
||||||
let Ok(host) = get_valid_host(name) else {
|
let Ok(host) = get_valid_host(name) else {
|
||||||
return Err(CustomHttpClientError::Invalid {
|
return Err(CustomHttpClientError::Invalid {
|
||||||
@@ -320,7 +338,9 @@ impl Resolve for CustomDns {
|
|||||||
let this = Arc::clone(&self.resolver);
|
let this = Arc::clone(&self.resolver);
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let name = name.as_str();
|
let name = name.as_str();
|
||||||
let results = this.resolve_domain(name, enforce_block).await?;
|
let mut results = this.resolve_domain(name, enforce_block).await?;
|
||||||
|
// Recheck after bootstrap so long-lived clients adopt the loaded config.
|
||||||
|
sort_addresses(&mut results, dns_prefer_ipv6());
|
||||||
if results.is_empty() {
|
if results.is_empty() {
|
||||||
warn!("Unable to resolve {name} to any valid IP address");
|
warn!("Unable to resolve {name} to any valid IP address");
|
||||||
}
|
}
|
||||||
@@ -339,10 +359,29 @@ pub(crate) mod aws {
|
|||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use super::get_reqwest_client_builder;
|
||||||
|
|
||||||
// Adapter that wraps reqwest to be compatible with the AWS SDK
|
// Adapter that wraps reqwest to be compatible with the AWS SDK
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct AwsReqwestConnector {
|
pub(crate) struct AwsReqwestConnector {
|
||||||
pub(crate) client: Client,
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AwsReqwestConnector {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
let client = get_reqwest_client_builder(false).build().expect("Failed to build AWS HTTP client");
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connector_error(error: reqwest::Error) -> ConnectorError {
|
||||||
|
if error.is_timeout() {
|
||||||
|
ConnectorError::timeout(Box::new(error))
|
||||||
|
} else {
|
||||||
|
ConnectorError::io(Box::new(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpConnector for AwsReqwestConnector {
|
impl HttpConnector for AwsReqwestConnector {
|
||||||
@@ -362,10 +401,10 @@ pub(crate) mod aws {
|
|||||||
req_builder = req_builder.body(body_bytes.to_vec());
|
req_builder = req_builder.body(body_bytes.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?;
|
let response = req_builder.send().await.map_err(connector_error)?;
|
||||||
|
|
||||||
let status = response.status().into();
|
let status = response.status().into();
|
||||||
let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?;
|
let bytes = response.bytes().await.map_err(connector_error)?;
|
||||||
|
|
||||||
Ok(HttpResponse::new(status, bytes.into()))
|
Ok(HttpResponse::new(status, bytes.into()))
|
||||||
};
|
};
|
||||||
@@ -391,7 +430,7 @@ pub(crate) mod aws {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::util::is_global_hardcoded;
|
use crate::util::is_global_hardcoded;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
use url::Host;
|
use url::Host;
|
||||||
|
|
||||||
// ===
|
// ===
|
||||||
@@ -404,10 +443,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dns_setup_does_not_initialize_config() {
|
||||||
|
assert!(LazyLock::get(&CONFIG).is_none());
|
||||||
|
drop(CustomDns::instance(false));
|
||||||
|
assert!(LazyLock::get(&CONFIG).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dns_preference_orders_addresses() {
|
||||||
|
let ipv4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||||
|
let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0);
|
||||||
|
let mut addresses = [ipv6, ipv4];
|
||||||
|
|
||||||
|
sort_addresses(&mut addresses, false);
|
||||||
|
assert_eq!(addresses, [ipv4, ipv6]);
|
||||||
|
|
||||||
|
sort_addresses(&mut addresses, true);
|
||||||
|
assert_eq!(addresses, [ipv6, ipv4]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dotted_decimal_loopback_normalizes() {
|
fn dotted_decimal_loopback_normalizes() {
|
||||||
let ip = parse_to_ip("127.0.0.1").unwrap();
|
let ip = parse_to_ip("127.0.0.1").unwrap();
|
||||||
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
assert!(!is_global_hardcoded(ip));
|
assert!(!is_global_hardcoded(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,21 +474,21 @@ mod tests {
|
|||||||
fn single_decimal_loopback_normalizes() {
|
fn single_decimal_loopback_normalizes() {
|
||||||
// 127.0.0.1 == 2130706433
|
// 127.0.0.1 == 2130706433
|
||||||
let ip = parse_to_ip("2130706433").unwrap();
|
let ip = parse_to_ip("2130706433").unwrap();
|
||||||
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
assert!(!is_global_hardcoded(ip));
|
assert!(!is_global_hardcoded(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hex_loopback_normalizes() {
|
fn hex_loopback_normalizes() {
|
||||||
let ip = parse_to_ip("0x7f000001").unwrap();
|
let ip = parse_to_ip("0x7f000001").unwrap();
|
||||||
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
assert!(!is_global_hardcoded(ip));
|
assert!(!is_global_hardcoded(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dotted_hex_loopback_normalizes() {
|
fn dotted_hex_loopback_normalizes() {
|
||||||
let ip = parse_to_ip("0x7f.0.0.1").unwrap();
|
let ip = parse_to_ip("0x7f.0.0.1").unwrap();
|
||||||
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
assert!(!is_global_hardcoded(ip));
|
assert!(!is_global_hardcoded(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,14 +496,14 @@ mod tests {
|
|||||||
fn octal_loopback_normalizes() {
|
fn octal_loopback_normalizes() {
|
||||||
// 017700000001 == 127.0.0.1
|
// 017700000001 == 127.0.0.1
|
||||||
let ip = parse_to_ip("017700000001").unwrap();
|
let ip = parse_to_ip("017700000001").unwrap();
|
||||||
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
assert!(!is_global_hardcoded(ip));
|
assert!(!is_global_hardcoded(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dotted_octal_loopback_normalizes() {
|
fn dotted_octal_loopback_normalizes() {
|
||||||
let ip = parse_to_ip("0177.0.0.01").unwrap();
|
let ip = parse_to_ip("0177.0.0.01").unwrap();
|
||||||
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
assert!(!is_global_hardcoded(ip));
|
assert!(!is_global_hardcoded(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,19 +535,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_valid_host_normalizes_decimal_int() {
|
fn get_valid_host_normalizes_decimal_int() {
|
||||||
let h = get_valid_host("2130706433").expect("valid");
|
let h = get_valid_host("2130706433").expect("valid");
|
||||||
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
|
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_valid_host_normalizes_hex() {
|
fn get_valid_host_normalizes_hex() {
|
||||||
let h = get_valid_host("0x7f000001").expect("valid");
|
let h = get_valid_host("0x7f000001").expect("valid");
|
||||||
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
|
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_valid_host_normalizes_octal() {
|
fn get_valid_host_normalizes_octal() {
|
||||||
let h = get_valid_host("017700000001").expect("valid");
|
let h = get_valid_host("017700000001").expect("valid");
|
||||||
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
|
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===
|
// ===
|
||||||
|
|||||||
+12
-2
@@ -633,14 +633,24 @@ pub async fn send_test(address: &str) -> EmptyResult {
|
|||||||
send_email(address, &subject, body_html, body_text).await
|
send_email(address, &subject, body_html, body_text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult {
|
pub async fn send_admin_account_recovery(
|
||||||
|
address: &str,
|
||||||
|
user_name: &str,
|
||||||
|
org_name: &str,
|
||||||
|
reset_password: bool,
|
||||||
|
reset_2fa: bool,
|
||||||
|
fallback_2fa_email: bool,
|
||||||
|
) -> EmptyResult {
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
"email/admin_reset_password",
|
"email/admin_account_recovery",
|
||||||
json!({
|
json!({
|
||||||
"url": CONFIG.domain(),
|
"url": CONFIG.domain(),
|
||||||
"img_src": CONFIG._smtp_img_src(),
|
"img_src": CONFIG._smtp_img_src(),
|
||||||
"user_name": user_name,
|
"user_name": user_name,
|
||||||
"org_name": org_name,
|
"org_name": org_name,
|
||||||
|
"reset_password": reset_password,
|
||||||
|
"reset_2fa": reset_2fa,
|
||||||
|
"fallback_2fa_email": fallback_2fa_email,
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
send_email(address, &subject, body_html, body_text).await
|
send_email(address, &subject, body_html, body_text).await
|
||||||
|
|||||||
+3
-5
@@ -137,9 +137,7 @@ fn parse_args() {
|
|||||||
|
|
||||||
if let Some(command) = pargs.subcommand().unwrap_or_default() {
|
if let Some(command) = pargs.subcommand().unwrap_or_default() {
|
||||||
if command == "hash" {
|
if command == "hash" {
|
||||||
use argon2::{
|
use argon2::{Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13};
|
||||||
Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13, password_hash::SaltString,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut argon2_params = ParamsBuilder::new();
|
let mut argon2_params = ParamsBuilder::new();
|
||||||
let preset: Option<String> = pargs.opt_value_from_str(["-p", "--preset"]).unwrap_or_default();
|
let preset: Option<String> = pargs.opt_value_from_str(["-p", "--preset"]).unwrap_or_default();
|
||||||
@@ -172,10 +170,10 @@ fn parse_args() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap());
|
let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap());
|
||||||
let salt = SaltString::encode_b64(&crypto::get_random_bytes::<32>()).unwrap();
|
let salt = crypto::get_random_bytes::<32>();
|
||||||
|
|
||||||
let argon2_timer = tokio::time::Instant::now();
|
let argon2_timer = tokio::time::Instant::now();
|
||||||
if let Ok(password_hash) = argon2.hash_password(password.as_bytes(), &salt) {
|
if let Ok(password_hash) = argon2.hash_password_with_salt(password.as_bytes(), &salt) {
|
||||||
println!(
|
println!(
|
||||||
"\n\
|
"\n\
|
||||||
ADMIN_TOKEN='{password_hash}'\n\n\
|
ADMIN_TOKEN='{password_hash}'\n\n\
|
||||||
|
|||||||
Vendored
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser */
|
/* exported BASE_URL, _post, _delete */
|
||||||
/* 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/`,
|
||||||
|
|||||||
+21
-15
@@ -1,5 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser */
|
|
||||||
/* global BASE_URL:readable, bootstrap:readable */
|
/* global BASE_URL:readable, bootstrap:readable */
|
||||||
|
|
||||||
var dnsCheck = false;
|
var dnsCheck = false;
|
||||||
@@ -80,37 +79,44 @@ async function generateSupportString(event, dj) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Health check Markdown emoji, if something is a failure or not
|
||||||
|
const chk = v => v ? "true :white_check_mark:" : "false :x:";
|
||||||
|
// Yes/No Markdown emoji, if something is not a failure, but just yes or no
|
||||||
|
const yn = v => v ? "yes :heavy_plus_sign:" : "no :heavy_minus_sign:";
|
||||||
|
|
||||||
|
const template_overrides = dj.template_overrides !== "" ? ` (${dj.template_overrides})` : "";
|
||||||
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.active_web_release}\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: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
||||||
supportString += `* Database type: ${dj.db_type}\n`;
|
supportString += `* Database type: ${dj.db_type}\n`;
|
||||||
supportString += `* Database version: ${dj.db_version}\n`;
|
supportString += `* Database version: ${dj.db_version}\n`;
|
||||||
supportString += `* Uses config.json: ${dj.overrides !== ""}\n`;
|
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
|
||||||
supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`;
|
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
|
||||||
|
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
|
||||||
if (dj.ip_header_exists) {
|
if (dj.ip_header_exists) {
|
||||||
supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`;
|
supportString += `* IP Header check: ${chk(dj.ip_header_match)} (${dj.ip_header_name})\n`;
|
||||||
}
|
}
|
||||||
supportString += `* Internet access: ${dj.has_http_access}\n`;
|
supportString += `* Internet access: ${chk(dj.has_http_access)}\n`;
|
||||||
supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`;
|
supportString += `* Internet access via a proxy: ${yn(dj.uses_proxy)}\n`;
|
||||||
supportString += `* DNS Check: ${dnsCheck}\n`;
|
supportString += `* DNS Check: ${chk(dnsCheck)}\n`;
|
||||||
if (dj.tz_env !== "") {
|
if (dj.tz_env !== "") {
|
||||||
supportString += `* TZ environment: ${dj.tz_env}\n`;
|
supportString += `* TZ environment: ${dj.tz_env}\n`;
|
||||||
}
|
}
|
||||||
supportString += `* Browser/Server Time Check: ${timeCheck}\n`;
|
supportString += `* Browser/Server Time Check: ${chk(timeCheck)}\n`;
|
||||||
supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`;
|
supportString += `* Server/NTP Time Check: ${chk(ntpTimeCheck)}\n`;
|
||||||
supportString += `* Domain Configuration Check: ${domainCheck}\n`;
|
supportString += `* Domain Configuration Check: ${chk(domainCheck)}\n`;
|
||||||
supportString += `* HTTPS Check: ${httpsCheck}\n`;
|
supportString += `* HTTPS Check: ${chk(httpsCheck)}\n`;
|
||||||
if (dj.enable_websocket) {
|
if (dj.enable_websocket) {
|
||||||
supportString += `* Websocket Check: ${websocketCheck}\n`;
|
supportString += `* Websocket Check: ${chk(websocketCheck)}\n`;
|
||||||
} else {
|
} else {
|
||||||
supportString += "* Websocket Check: disabled\n";
|
supportString += "* Websocket Check: disabled\n";
|
||||||
}
|
}
|
||||||
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
|
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
|
||||||
if (dj.invalid_feature_flags != "") {
|
if (dj.invalid_feature_flags != "") {
|
||||||
supportString += `* Invalid feature flags: true\n`;
|
supportString += "* Invalid feature flags: true\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,5 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser, jquery */
|
/* global DataTable, _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
|
||||||
/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
|
|
||||||
|
|
||||||
function deleteOrganization(event) {
|
function deleteOrganization(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -42,8 +41,9 @@ function initActions() {
|
|||||||
|
|
||||||
// onLoad events
|
// onLoad events
|
||||||
document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||||
jQuery("#orgs-table").DataTable({
|
const columnCount = document.getElementById("orgs-table").querySelectorAll("thead th").length;
|
||||||
"drawCallback": function() {
|
new DataTable("#orgs-table", {
|
||||||
|
"drawCallback": function () {
|
||||||
initActions();
|
initActions();
|
||||||
},
|
},
|
||||||
"stateSave": true,
|
"stateSave": true,
|
||||||
@@ -54,7 +54,7 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
|||||||
],
|
],
|
||||||
"pageLength": -1, // Default show all
|
"pageLength": -1, // Default show all
|
||||||
"columnDefs": [{
|
"columnDefs": [{
|
||||||
"targets": [4,5],
|
"targets": [columnCount - 2, columnCount - 1], // Do not include the last two columns into the search/order features
|
||||||
"searchable": false,
|
"searchable": false,
|
||||||
"orderable": false
|
"orderable": false
|
||||||
}]
|
}]
|
||||||
@@ -67,4 +67,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
|||||||
if (btnReload) {
|
if (btnReload) {
|
||||||
btnReload.addEventListener("click", reload);
|
btnReload.addEventListener("click", reload);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Vendored
-1
@@ -1,5 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser */
|
|
||||||
/* global _post:readable, BASE_URL:readable */
|
/* global _post:readable, BASE_URL:readable */
|
||||||
|
|
||||||
function smtpTest(event) {
|
function smtpTest(event) {
|
||||||
|
|||||||
Vendored
+35
-42
@@ -1,6 +1,5 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/* eslint-env es2017, browser, jquery */
|
/* global DataTable, _post:readable, _delete: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();
|
||||||
@@ -142,7 +141,7 @@ function inviteUser(event) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resendUserInvite (event) {
|
function resendUserInvite(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const id = event.target.parentNode.dataset.vwUserUuid;
|
const id = event.target.parentNode.dataset.vwUserUuid;
|
||||||
@@ -180,37 +179,9 @@ const ORG_TYPES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Special sort function to sort dates in ISO format
|
|
||||||
jQuery.extend(jQuery.fn.dataTableExt.oSort, {
|
|
||||||
"date-iso-pre": function(a) {
|
|
||||||
let x;
|
|
||||||
const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim();
|
|
||||||
if (sortDate !== "") {
|
|
||||||
const dtParts = sortDate.split(" ");
|
|
||||||
const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"];
|
|
||||||
const dateParts = dtParts[0].split("-");
|
|
||||||
x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1;
|
|
||||||
if (isNaN(x)) {
|
|
||||||
x = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
x = Infinity;
|
|
||||||
}
|
|
||||||
return x;
|
|
||||||
},
|
|
||||||
|
|
||||||
"date-iso-asc": function(a, b) {
|
|
||||||
return a - b;
|
|
||||||
},
|
|
||||||
|
|
||||||
"date-iso-desc": function(a, b) {
|
|
||||||
return b - a;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
||||||
// Fill the form and title
|
// Fill the form and title
|
||||||
userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
|
userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
|
||||||
// Get shared values
|
// Get shared values
|
||||||
const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail;
|
const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail;
|
||||||
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
|
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
|
||||||
@@ -228,7 +199,7 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
|
|||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
|
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
|
||||||
userOrgTypeDialog.addEventListener("hide.bs.modal", function() {
|
userOrgTypeDialog.addEventListener("hide.bs.modal", function () {
|
||||||
document.getElementById("userOrgTypeDialogOrgName").textContent = "";
|
document.getElementById("userOrgTypeDialogOrgName").textContent = "";
|
||||||
document.getElementById("userOrgTypeDialogUserEmail").textContent = "";
|
document.getElementById("userOrgTypeDialogUserEmail").textContent = "";
|
||||||
document.getElementById("userOrgTypeUserUuid").value = "";
|
document.getElementById("userOrgTypeUserUuid").value = "";
|
||||||
@@ -250,7 +221,7 @@ function updateUserOrgType(event) {
|
|||||||
|
|
||||||
function initUserTable() {
|
function initUserTable() {
|
||||||
// Color all the org buttons per type
|
// Color all the org buttons per type
|
||||||
document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) {
|
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
|
||||||
const orgType = ORG_TYPES[e.dataset.vwOrgType];
|
const orgType = ORG_TYPES[e.dataset.vwOrgType];
|
||||||
e.style.backgroundColor = orgType.bg;
|
e.style.backgroundColor = orgType.bg;
|
||||||
if (orgType.font !== undefined) {
|
if (orgType.font !== undefined) {
|
||||||
@@ -286,12 +257,37 @@ function initUserTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special sort function to sort dates in ISO format and have anything else as 0
|
||||||
|
DataTable.ext.type.order["date-iso-pre"] = function (a) {
|
||||||
|
let x;
|
||||||
|
const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim();
|
||||||
|
if (sortDate !== "") {
|
||||||
|
const dtParts = sortDate.split(" ");
|
||||||
|
const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"];
|
||||||
|
const dateParts = dtParts[0].split("-");
|
||||||
|
x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1;
|
||||||
|
if (isNaN(x)) {
|
||||||
|
x = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
x = Infinity;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
|
||||||
// onLoad events
|
// onLoad events
|
||||||
document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||||
const size = jQuery("#users-table > thead th").length;
|
DataTable.ext.type.detect.unshift(function (data) {
|
||||||
const ssoOffset = size-7;
|
if (typeof data !== "string") { return null; }
|
||||||
jQuery("#users-table").DataTable({
|
return data.indexOf("data-sort-type=\"date-iso\"") !== -1
|
||||||
"drawCallback": function() {
|
? "date-iso"
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnCount = document.getElementById("users-table").querySelectorAll("thead th").length;
|
||||||
|
new DataTable("#users-table", {
|
||||||
|
"typeDetect": true,
|
||||||
|
"drawCallback": function () {
|
||||||
initUserTable();
|
initUserTable();
|
||||||
},
|
},
|
||||||
"stateSave": true,
|
"stateSave": true,
|
||||||
@@ -302,10 +298,7 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
|||||||
],
|
],
|
||||||
"pageLength": -1, // Default show all
|
"pageLength": -1, // Default show all
|
||||||
"columnDefs": [{
|
"columnDefs": [{
|
||||||
"targets": [1 + ssoOffset, 2 + ssoOffset],
|
"targets": columnCount - 1, // Do not include the last column into the search/order features
|
||||||
"type": "date-iso"
|
|
||||||
}, {
|
|
||||||
"targets": size-1,
|
|
||||||
"searchable": false,
|
"searchable": false,
|
||||||
"orderable": false
|
"orderable": false
|
||||||
}]
|
}]
|
||||||
|
|||||||
Vendored
+107
-65
@@ -4,25 +4,32 @@
|
|||||||
*
|
*
|
||||||
* 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.8
|
* https://datatables.net/download/#bs5/dt-3.0.4
|
||||||
*
|
*
|
||||||
* Included libraries:
|
* Included libraries:
|
||||||
* DataTables 2.3.8
|
* DataTables 3.0.4
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*! DataTables Bootstrap 5 integration
|
||||||
|
* © SpryMedia Ltd - datatables.net/license
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
--dt-row-selected: 13, 110, 253;
|
--dt_background-selected: 13, 110, 253;
|
||||||
--dt-row-selected-text: 255, 255, 255;
|
--dt_color-selected: 255, 255, 255;
|
||||||
--dt-row-selected-link: 228, 228, 228;
|
--dt_link_color-selected: 228, 228, 228;
|
||||||
--dt-row-stripe: 0, 0, 0;
|
--dt-row_background: transparent;
|
||||||
--dt-row-hover: 0, 0, 0;
|
--dt-row_background-selected: var(--dt_background-selected);
|
||||||
--dt-column-ordering: 0, 0, 0;
|
--dt-row-text_color-selected: var(--dt_color-selected);
|
||||||
--dt-header-align-items: center;
|
--dt-row-link_color-selected: var(--dt_link_color-selected);
|
||||||
--dt-header-vertical-align: middle;
|
--dt-row_background-stripe: 0, 0, 0;
|
||||||
--dt-html-background: white;
|
--dt-row_background-hover: 0, 0, 0;
|
||||||
|
--dt-column-ordering_background: 0, 0, 0;
|
||||||
|
--dt-header-cell_align-items: center;
|
||||||
|
--dt-header-cell_vertical-align: middle;
|
||||||
|
--dt-html_background: white;
|
||||||
}
|
}
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--dt-html-background: rgb(33, 37, 41);
|
--dt-html_background: rgb(33, 37, 41);
|
||||||
}
|
}
|
||||||
|
|
||||||
table.dataTable tbody td.dt-control {
|
table.dataTable tbody td.dt-control {
|
||||||
@@ -44,16 +51,13 @@ table.dataTable tbody tr.dt-hasChild td.dt-control:before {
|
|||||||
border-bottom: 0px solid transparent;
|
border-bottom: 0px solid transparent;
|
||||||
border-right: 5px solid transparent;
|
border-right: 5px solid transparent;
|
||||||
}
|
}
|
||||||
table.dataTable tfoot:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark table.dataTable td.dt-control:before,
|
:root.dark table.dataTable td.dt-control:before,
|
||||||
:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
|
:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
|
||||||
:root[data-theme=dark] table.dataTable td.dt-control:before {
|
:root[data-theme=dark] table.dataTable td.dt-control:before {
|
||||||
border-left-color: rgba(255, 255, 255, 0.5);
|
border-left-color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
html.dark table.dataTable tr.dt-hasChild td.dt-control:before,
|
:root.dark table.dataTable tr.dt-hasChild td.dt-control:before,
|
||||||
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
|
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
|
||||||
:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
|
:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
|
||||||
border-top-color: rgba(255, 255, 255, 0.5);
|
border-top-color: rgba(255, 255, 255, 0.5);
|
||||||
@@ -84,6 +88,25 @@ div.dt-scroll-body tfoot tr td div.dt-scroll-sizing {
|
|||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*! DataTables Bootstrap 5 integration
|
||||||
|
* © SpryMedia Ltd - datatables.net/license
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--dt-order-arrow_color: rgb(51, 51, 51);
|
||||||
|
--dt-order-arrow_color-current: rgb(51, 51, 51);
|
||||||
|
--dt-order-arrow-height: 7px;
|
||||||
|
--dt-order-arrow_opacity: 0.125;
|
||||||
|
--dt-order-arrow_opacity-current: 0.65;
|
||||||
|
--dt-order-arrow-width: 8px;
|
||||||
|
--dt-order-arrow-gap: 1px;
|
||||||
|
--dt-order-header_outline-hover: 2px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
:root.dark, :root[data-bs-theme=dark], :root[data-theme=dark] {
|
||||||
|
--dt-order-arrow_color: rgb(229, 233, 238);
|
||||||
|
--dt-order-arrow_color-current: rgb(229, 233, 238);
|
||||||
|
--dt-order-header_outline-hover: 2px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
table.dataTable thead > tr > th:active,
|
table.dataTable thead > tr > th:active,
|
||||||
table.dataTable thead > tr > td:active {
|
table.dataTable thead > tr > td:active {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -91,20 +114,18 @@ table.dataTable thead > tr > td:active {
|
|||||||
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 > 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 .dt-column-order:before,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before {
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before {
|
||||||
position: absolute;
|
bottom: calc(50% + var(--dt-order-arrow-gap));
|
||||||
display: block;
|
border-bottom: var(--dt-order-arrow-height) solid var(--dt-order-arrow_color);
|
||||||
bottom: 50%;
|
border-left: calc(var(--dt-order-arrow-width) / 2) solid transparent;
|
||||||
content: "\25B2";
|
border-right: calc(var(--dt-order-arrow-width) / 2) solid transparent;
|
||||||
content: "\25B2"/"";
|
|
||||||
}
|
}
|
||||||
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 > 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 .dt-column-order:after,
|
table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
||||||
position: absolute;
|
top: calc(50% + 1px);
|
||||||
display: block;
|
border-top: var(--dt-order-arrow-height) solid var(--dt-order-arrow_color);
|
||||||
top: 50%;
|
border-left: calc(var(--dt-order-arrow-width) / 2) solid transparent;
|
||||||
content: "\25BC";
|
border-right: calc(var(--dt-order-arrow-width) / 2) solid transparent;
|
||||||
content: "\25BC"/"";
|
|
||||||
}
|
}
|
||||||
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 > 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 .dt-column-order,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order,
|
||||||
@@ -112,8 +133,8 @@ table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order,
|
|||||||
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 12px;
|
width: var(--dt-order-arrow-width);
|
||||||
height: 20px;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
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 > 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 .dt-column-order:before,
|
table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before,
|
||||||
@@ -124,10 +145,14 @@ table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before,
|
|||||||
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:after,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:before,
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
content: " ";
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
opacity: 0.125;
|
color: var(--dt-order-arrow_color);
|
||||||
line-height: 9px;
|
opacity: var(--dt-order-arrow_opacity);
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc,
|
table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc,
|
||||||
table.dataTable thead > tr > td.dt-orderable-asc,
|
table.dataTable thead > tr > td.dt-orderable-asc,
|
||||||
@@ -137,13 +162,18 @@ table.dataTable thead > tr > td.dt-orderable-desc {
|
|||||||
table.dataTable thead > tr > th.dt-orderable-asc:hover, table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
table.dataTable thead > tr > th.dt-orderable-asc:hover, table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
||||||
table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
||||||
table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
||||||
outline: 2px solid rgba(0, 0, 0, 0.05);
|
outline: var(--dt-order-header_outline-hover);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
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 > th.dt-ordering-asc .dt-column-order:before,
|
||||||
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before,
|
table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before {
|
||||||
|
border-bottom-color: var(--dt-order-arrow_color-current);
|
||||||
|
opacity: var(--dt-order-arrow_opacity-current);
|
||||||
|
}
|
||||||
|
table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after,
|
||||||
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after {
|
||||||
opacity: 0.6;
|
border-top-color: var(--dt-order-arrow_color-current);
|
||||||
|
opacity: var(--dt-order-arrow_opacity-current);
|
||||||
}
|
}
|
||||||
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 > 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.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty,
|
table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty,
|
||||||
@@ -166,7 +196,7 @@ table.dataTable tfoot > tr > td div.dt-column-header,
|
|||||||
table.dataTable tfoot > tr > td div.dt-column-footer {
|
table.dataTable tfoot > tr > td div.dt-column-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: var(--dt-header-align-items);
|
align-items: var(--dt-header-cell_align-items);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
table.dataTable thead > tr > th div.dt-column-header .dt-column-title,
|
table.dataTable thead > tr > th div.dt-column-header .dt-column-title,
|
||||||
@@ -202,7 +232,14 @@ div.dt-scroll-body > table.dataTable > thead > tr > td {
|
|||||||
:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
||||||
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
||||||
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
||||||
outline: 2px solid rgba(255, 255, 255, 0.05);
|
outline: var(--dt-order-header_outline-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*! DataTables Bootstrap 5 integration
|
||||||
|
* © SpryMedia Ltd - datatables.net/license
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--dt-processing-circle_background: var(--dt_background-selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dt-processing {
|
div.dt-processing {
|
||||||
@@ -228,8 +265,7 @@ div.dt-processing > div:last-child > div {
|
|||||||
width: 13px;
|
width: 13px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgb(13, 110, 253);
|
background: rgb(var(--dt-processing-circle_background));
|
||||||
background: rgb(var(--dt-row-selected));
|
|
||||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||||
}
|
}
|
||||||
div.dt-processing > div:last-child > div:nth-child(1) {
|
div.dt-processing > div:last-child > div:nth-child(1) {
|
||||||
@@ -342,7 +378,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);
|
vertical-align: var(--dt-header-cell_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,
|
||||||
@@ -425,11 +461,16 @@ table.dataTable tbody td.dt-body-nowrap {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*! Bootstrap 5 integration for DataTables
|
:root {
|
||||||
*
|
--dt_background-selected: 13, 110, 253;
|
||||||
* ©2020 SpryMedia Ltd, all rights reserved.
|
}
|
||||||
* License: MIT datatables.net/license/mit
|
|
||||||
*/
|
:root[data-bs-theme=dark] {
|
||||||
|
--dt-row_background-hover: 255, 255, 255;
|
||||||
|
--dt-row_background-stripe: 255, 255, 255;
|
||||||
|
--dt-column-ordering_background: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
table.table.dataTable {
|
table.table.dataTable {
|
||||||
clear: both;
|
clear: both;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -443,31 +484,26 @@ table.table.dataTable > :not(caption) > * > * {
|
|||||||
background-color: var(--bs-table-bg);
|
background-color: var(--bs-table-bg);
|
||||||
}
|
}
|
||||||
table.table.dataTable > tbody > tr {
|
table.table.dataTable > tbody > tr {
|
||||||
background-color: transparent;
|
background-color: var(--dt-row_background);
|
||||||
}
|
}
|
||||||
table.table.dataTable > tbody > tr.selected > * {
|
table.table.dataTable > tbody > tr.selected > * {
|
||||||
box-shadow: inset 0 0 0 9999px rgb(13, 110, 253);
|
box-shadow: inset 0 0 0 9999px rgb(var(--dt-row_background-selected));
|
||||||
box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected));
|
color: rgb(var(--dt-row-text_color-selected));
|
||||||
color: rgb(255, 255, 255);
|
|
||||||
color: rgb(var(--dt-row-selected-text));
|
|
||||||
}
|
}
|
||||||
table.table.dataTable > tbody > tr.selected a {
|
table.table.dataTable > tbody > tr.selected a {
|
||||||
color: rgb(228, 228, 228);
|
color: rgb(var(--dt-row-link_color-selected));
|
||||||
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) > * {
|
||||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05);
|
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-stripe), 0.05);
|
||||||
}
|
}
|
||||||
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1).selected > * {
|
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1).selected > * {
|
||||||
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95);
|
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.95);
|
||||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95);
|
|
||||||
}
|
}
|
||||||
table.table.dataTable.table-hover > tbody > tr:hover > * {
|
table.table.dataTable.table-hover > tbody > tr:hover > * {
|
||||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075);
|
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-hover), 0.075);
|
||||||
}
|
}
|
||||||
table.table.dataTable.table-hover > tbody > tr.selected:hover > * {
|
table.table.dataTable.table-hover > tbody > tr.selected:hover > * {
|
||||||
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975);
|
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.975);
|
||||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dt-container div.dt-layout-start > *:not(:last-child) {
|
div.dt-container div.dt-layout-start > *:not(:last-child) {
|
||||||
@@ -600,6 +636,18 @@ table.dataTable.table-sm > thead > tr td.dt-type-date .dt-column-order,
|
|||||||
table.dataTable.table-sm > thead > tr td.dt-type-numeric .dt-column-order {
|
table.dataTable.table-sm > thead > tr td.dt-type-numeric .dt-column-order {
|
||||||
left: 0.25rem;
|
left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
table.dataTable.table-sm > thead > tr th.dt-left .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-left .dt-column-order,
|
||||||
|
table.dataTable.table-sm > thead > tr td.dt-left .dt-column-order,
|
||||||
|
table.dataTable.table-sm > thead > tr td.dt-head-left .dt-column-order {
|
||||||
|
left: auto;
|
||||||
|
right: 0.25rem;
|
||||||
|
}
|
||||||
|
table.dataTable.table-sm > thead > tr th.dt-right .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-right .dt-column-order,
|
||||||
|
table.dataTable.table-sm > thead > tr td.dt-right .dt-column-order,
|
||||||
|
table.dataTable.table-sm > thead > tr td.dt-head-right .dt-column-order {
|
||||||
|
left: 0.25rem;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
div.dt-scroll-head table.table-bordered {
|
div.dt-scroll-head table.table-bordered {
|
||||||
border-bottom-width: 0;
|
border-bottom-width: 0;
|
||||||
@@ -616,10 +664,4 @@ div.table-responsive > div.dt-container > div.row > div[class^=col-]:last-child
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-bs-theme=dark] {
|
|
||||||
--dt-row-hover: 255, 255, 255;
|
|
||||||
--dt-row-stripe: 255, 255, 255;
|
|
||||||
--dt-column-ordering: 255, 255, 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Vendored
+12834
-14248
File diff suppressed because it is too large.
Load diff
-6856
File diff suppressed because it is too large.
Load diff
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user