mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-08-06 23:33:44 +03:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2dd9cf1d0 | ||
|
|
b30cc08562 | ||
|
|
55f883a566 | ||
|
|
74ceaf2354 | ||
|
|
d518f6f01e | ||
|
|
2629bcbe13 | ||
|
|
46ae59eaf4 | ||
|
|
a6a88e7929 | ||
|
|
5040bcb7c0 | ||
|
|
660faee68e | ||
|
|
683a23e43c | ||
|
|
4a9bcb0694 | ||
|
|
169aa5efcc | ||
|
|
64d28ab66e | ||
|
|
4720cdbe86 | ||
|
|
5447ee6af2 | ||
|
|
5c5e8e1a6f | ||
|
|
7320a1db4b | ||
|
|
a058a35ccd | ||
|
|
a16b5afaaa | ||
|
|
fddc16d2b8 | ||
|
|
ec7fa137b7 | ||
|
|
b25f715364 | ||
|
|
d6a3d539ed | ||
|
|
d626ea81ab | ||
|
|
1ba2c6a26c |
@@ -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 ###
|
||||||
#########################
|
#########################
|
||||||
@@ -324,6 +332,14 @@
|
|||||||
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||||
# IP_HEADER=X-Real-IP
|
# IP_HEADER=X-Real-IP
|
||||||
|
|
||||||
|
## Which addresses the header above is accepted from, defaults to "local".
|
||||||
|
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
|
||||||
|
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
|
||||||
|
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
|
||||||
|
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
|
||||||
|
## proxy as IPs and CIDR ranges if it connects from a public address.
|
||||||
|
# IP_HEADER_TRUSTED_PROXIES=local
|
||||||
|
|
||||||
## Icon service
|
## Icon service
|
||||||
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
@@ -378,6 +394,7 @@
|
|||||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||||
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
||||||
|
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
|
||||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
||||||
@@ -460,6 +477,13 @@
|
|||||||
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
||||||
# LOGIN_RATELIMIT_MAX_BURST=10
|
# LOGIN_RATELIMIT_MAX_BURST=10
|
||||||
|
|
||||||
|
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
|
||||||
|
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
|
||||||
|
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
|
||||||
|
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
|
||||||
|
|
||||||
## BETA FEATURE: Groups
|
## BETA FEATURE: Groups
|
||||||
## Controls whether group support is enabled for organizations
|
## Controls whether group support is enabled for organizations
|
||||||
## This setting applies to organizations.
|
## This setting applies to organizations.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -30,24 +30,25 @@ jobs:
|
|||||||
driver-opts: |
|
driver-opts: |
|
||||||
network=host
|
network=host
|
||||||
|
|
||||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
|
||||||
- name: Download hadolint
|
|
||||||
run: |
|
|
||||||
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
|
|
||||||
sudo chmod +x /usr/local/bin/hadolint
|
|
||||||
env:
|
|
||||||
HADOLINT_VERSION: 2.14.0
|
|
||||||
# End Download hadolint
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|
||||||
# Test Dockerfiles with hadolint
|
# Test Dockerfiles with hadolint
|
||||||
- name: Run hadolint
|
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
|
||||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
|
||||||
|
- name: Run hadolint on Dockerfile.debian
|
||||||
|
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||||
|
with:
|
||||||
|
dockerfile: docker/Dockerfile.debian
|
||||||
|
|
||||||
|
- name: Run hadolint on Dockerfile.alpine
|
||||||
|
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||||
|
with:
|
||||||
|
dockerfile: docker/Dockerfile.alpine
|
||||||
# End Test Dockerfiles with hadolint
|
# End Test Dockerfiles with hadolint
|
||||||
|
|
||||||
# Test Dockerfiles with docker build checks
|
# Test Dockerfiles with docker build checks
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Initialize QEMU binfmt support
|
- name: Initialize QEMU binfmt support
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||||
with:
|
with:
|
||||||
platforms: "arm64,arm"
|
platforms: "arm64,arm"
|
||||||
|
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
# We need fetch-depth of 0 so we also get all the tag metadata
|
# We need fetch-depth of 0 so we also get all the tag metadata
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
|
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
@@ -237,7 +237,7 @@ jobs:
|
|||||||
|
|
||||||
# Upload artifacts to Github Actions and Attest the binaries
|
# Upload artifacts to Github Actions and Attest the binaries
|
||||||
- name: Attest binaries
|
- name: Attest binaries
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ jobs:
|
|||||||
|
|
||||||
merge-manifests:
|
merge-manifests:
|
||||||
name: Merge manifests
|
name: Merge manifests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: docker-build
|
needs: docker-build
|
||||||
environment:
|
environment:
|
||||||
name: release
|
name: release
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -303,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -365,7 +365,7 @@ jobs:
|
|||||||
# Attest container images
|
# Attest container images
|
||||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
@@ -373,7 +373,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.GHCR_REPO }}
|
subject-name: ${{ vars.GHCR_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
@@ -381,7 +381,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.QUAY_REPO }}
|
subject-name: ${{ vars.QUAY_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -50,6 +50,6 @@ jobs:
|
|||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
|
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
zizmor:
|
zizmor:
|
||||||
name: Run zizmor
|
name: Run zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write # To write the security report
|
security-events: write # To write the security report
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@b572f7b1a1c2d41efaab43d504f68d215c3cd727 # v0.5.4
|
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
@@ -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: 5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
|
rev: 8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
|
||||||
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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ extend-ignore-re = [
|
|||||||
"(?i)helo_name",
|
"(?i)helo_name",
|
||||||
"Server name sent during.+HELO",
|
"Server name sent during.+HELO",
|
||||||
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
||||||
|
"COSE",
|
||||||
"COSEKey",
|
"COSEKey",
|
||||||
"COSEAlgorithm",
|
"COSEAlgorithm",
|
||||||
# Ignore this specific string as it's valid
|
# Ignore this specific string as it's valid
|
||||||
|
|||||||
Generated
+676
-750
File diff suppressed because it is too large
Load Diff
+145
-84
@@ -1,6 +1,6 @@
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.93.0"
|
rust-version = "1.95.0"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||||
publish = false
|
publish = false
|
||||||
@@ -14,7 +14,6 @@ version = "1.0.0"
|
|||||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
resolver = "2"
|
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
@@ -23,7 +22,8 @@ publish.workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = [
|
default = [
|
||||||
# "sqlite" or "sqlite_system",
|
# "sqlite",
|
||||||
|
# "sqlite_system",
|
||||||
# "mysql",
|
# "mysql",
|
||||||
# "postgresql",
|
# "postgresql",
|
||||||
]
|
]
|
||||||
@@ -32,14 +32,22 @@ enable_syslog = []
|
|||||||
# Please enable at least one of these DB backends.
|
# Please enable at least one of these DB backends.
|
||||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||||
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"]
|
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite
|
||||||
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Alternative to the above, statically linked SQLite into the binary instead of dynamically.
|
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Statically link SQLite into the binary instead of dynamically.
|
||||||
# Enable to use a vendored and statically linked openssl
|
# Enable to use a vendored and statically linked openssl
|
||||||
vendored_openssl = ["openssl/vendored"]
|
vendored_openssl = ["openssl/vendored"]
|
||||||
# Enable MiMalloc memory allocator to replace the default malloc
|
# Enable MiMalloc memory allocator to replace the default malloc
|
||||||
# This can improve performance for Alpine builds
|
# This can improve performance for Alpine builds
|
||||||
enable_mimalloc = ["dep:mimalloc"]
|
enable_mimalloc = ["dep:mimalloc"]
|
||||||
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:http", "dep:reqsign-aws-v4", "dep:reqsign-core"]
|
s3 = [
|
||||||
|
"opendal/services-s3",
|
||||||
|
"dep:aws-config",
|
||||||
|
"dep:aws-credential-types",
|
||||||
|
"dep:aws-smithy-runtime-api",
|
||||||
|
"dep:http",
|
||||||
|
"dep:reqsign-aws-v4",
|
||||||
|
"dep:reqsign-core",
|
||||||
|
]
|
||||||
|
|
||||||
# OIDC specific features
|
# OIDC specific features
|
||||||
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
||||||
@@ -57,76 +65,90 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.29"
|
log = "0.4.33"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||||
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
|
|
||||||
# A `dotenv` implementation for Rust
|
# A `dotenv` implementation for Rust
|
||||||
dotenvy = { version = "0.15.7", default-features = false }
|
dotenvy = { version = "0.15.7", default-features = false }
|
||||||
|
|
||||||
# Numerical libraries
|
# Numerical libraries
|
||||||
num-traits = "0.2.19"
|
num-traits = "0.2.19"
|
||||||
num-derive = "0.4.2"
|
num-derive = "0.5.1"
|
||||||
bigdecimal = "0.4.10"
|
bigdecimal = "0.4.10"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||||
rocket_ws = { version ="0.1.1" }
|
rocket_ws = { version = "0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
rmpv = "1.3.1" # MessagePack library
|
rmpv = "1.3.1" # MessagePack library
|
||||||
|
|
||||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.2.1"
|
||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.32"
|
futures = "0.3.33"
|
||||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
tokio = { version = "1.53.1", features = [
|
||||||
tokio-util = { version = "0.7.18", features = ["compat"]}
|
"fs",
|
||||||
|
"io-util",
|
||||||
|
"net",
|
||||||
|
"parking_lot",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"signal",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
|
tokio-util = { version = "0.7.19", features = ["compat"] }
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility
|
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
||||||
diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] }
|
|
||||||
diesel_migrations = "2.3.2"
|
diesel_migrations = "2.3.2"
|
||||||
|
|
||||||
derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
derive_more = { version = "2.1.1", features = [
|
||||||
diesel-derive-newtype = "2.1.2"
|
"as_ref",
|
||||||
|
"deref",
|
||||||
|
"display",
|
||||||
|
"from",
|
||||||
|
"into",
|
||||||
|
] }
|
||||||
|
diesel-derive-newtype = "2.1.3"
|
||||||
|
|
||||||
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||||
libsqlite3-sys = { version = "0.37.0", optional = true }
|
libsqlite3-sys = { version = "0.37.0", optional = true }
|
||||||
|
|
||||||
# Crypto-related libraries
|
# Crypto-related libraries
|
||||||
rand = "0.10.1"
|
rand = "0.10.2"
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
rustls = { version = "0.23.40", features = ["ring", "std"], default-features = false }
|
rustls = { version = "0.23.43", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.23.1", features = ["v4"] }
|
uuid = { version = "1.24.0", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
|
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
time = "0.3.47"
|
time = "0.3.55"
|
||||||
|
|
||||||
# Job scheduler
|
# Job scheduler
|
||||||
job_scheduler_ng = "2.4.0"
|
job_scheduler_ng = "2.5.0"
|
||||||
|
|
||||||
# Data encoding library Hex/Base32/Base64
|
# Data encoding library Hex/Base32/Base64
|
||||||
data-encoding = "2.11.0"
|
data-encoding = "2.11.1"
|
||||||
|
|
||||||
# JWT library
|
# JWT library
|
||||||
jsonwebtoken = { version = "10.4.0", features = ["use_pem", "rust_crypto"], default-features = false }
|
jsonwebtoken = { version = "11.0.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", features = ["online-tokio"], default-features = false }
|
yubico_ng = { version = "1.0.0", default-features = false }
|
||||||
|
|
||||||
# WebAuthn libraries
|
# WebAuthn libraries
|
||||||
# danger-allow-state-serialisation is needed to save the state in the db
|
# danger-allow-state-serialisation is needed to save the state in the db
|
||||||
@@ -139,41 +161,78 @@ webauthn-rs-core = "0.5.5"
|
|||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
||||||
# Email libraries
|
# Email libraries
|
||||||
lettre = { version = "0.11.22", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
lettre = { version = "0.11.23", default-features = false, features = [
|
||||||
|
# Misc
|
||||||
|
"tracing",
|
||||||
|
"serde",
|
||||||
|
"builder",
|
||||||
|
"hostname",
|
||||||
|
# TLS/Security
|
||||||
|
"ring",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"tokio1-rustls",
|
||||||
|
# Transport
|
||||||
|
"smtp-transport",
|
||||||
|
"sendmail-transport",
|
||||||
|
] }
|
||||||
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
||||||
email_address = "0.2.9"
|
email_address = "0.2.9"
|
||||||
|
|
||||||
# HTML Template library
|
# HTML Template library
|
||||||
handlebars = { version = "6.4.0", features = ["dir_source"] }
|
handlebars = { version = "6.4.3", 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.3", features = ["rustls-no-provider", "stream", "json", "form", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
reqwest = { version = "0.13.4", default-features = false, features = [
|
||||||
|
# Misc
|
||||||
|
"charset",
|
||||||
|
"cookies",
|
||||||
|
"http2",
|
||||||
|
"json",
|
||||||
|
"form",
|
||||||
|
"rustls-no-provider",
|
||||||
|
"stream",
|
||||||
|
# Compression
|
||||||
|
"brotli",
|
||||||
|
"deflate",
|
||||||
|
"gzip",
|
||||||
|
"zstd",
|
||||||
|
# Proxy
|
||||||
|
"socks",
|
||||||
|
"system-proxy",
|
||||||
|
] }
|
||||||
hickory-resolver = "0.26.1"
|
hickory-resolver = "0.26.1"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.8.3"
|
html5gum = "0.8.4"
|
||||||
regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false }
|
regex = { version = "1.13.1", default-features = false, features = [
|
||||||
|
"perf",
|
||||||
|
"std",
|
||||||
|
"unicode-perl",
|
||||||
|
] }
|
||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
bytes = "1.11.1"
|
bytes = "1.12.1"
|
||||||
svg-hush = "0.9.6"
|
svg-hush = "0.9.6"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "0.59.0", features = ["async"] }
|
cached = { version = "2.0.2", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.1"
|
||||||
cookie_store = "0.22.1"
|
cookie_store = "0.22.1"
|
||||||
|
|
||||||
# Used by U2F, JWT and PostgreSQL
|
# Used by U2F, JWT and PostgreSQL
|
||||||
openssl = "0.10.79"
|
openssl = "0.10.81"
|
||||||
|
|
||||||
# CLI argument parsing
|
# CLI argument parsing
|
||||||
pico-args = "0.5.0"
|
pico-args = "0.5.0"
|
||||||
|
|
||||||
# Macro ident concatenation
|
# Macro ident concatenation
|
||||||
pastey = "0.2.2"
|
pastey = "0.2.3"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
|
|
||||||
|
# CIDR parsing for the trusted proxies of the client IP header
|
||||||
|
ipnet = "2.12.1"
|
||||||
|
|
||||||
# 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.15", features = ["future"] }
|
||||||
@@ -183,29 +242,34 @@ semver = "1.0.28"
|
|||||||
|
|
||||||
# Allow overriding the default memory allocator
|
# Allow overriding the default memory allocator
|
||||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||||
mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true }
|
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
|
||||||
|
|
||||||
which = "8.0.2"
|
which = "8.0.5"
|
||||||
|
|
||||||
# Argon2 library with support for the PHC format
|
# Argon2 library with support for the PHC format
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
|
|
||||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||||
rpassword = "7.5.2"
|
rpassword = "7.5.4"
|
||||||
|
|
||||||
# Loading a dynamic CSS Stylesheet
|
# Loading a dynamic CSS Stylesheet
|
||||||
grass_compiler = { version = "0.13.4", default-features = false }
|
grass_compiler = { version = "0.13.4", default-features = false }
|
||||||
|
|
||||||
# File are accessed through Apache OpenDAL
|
# File are accessed through Apache OpenDAL
|
||||||
opendal = { version = "0.56.0", features = ["services-fs"], default-features = false }
|
opendal = { version = "0.58.1", default-features = false, features = ["services-fs"] }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
aws-config = { version = "1.10.1", optional = true, default-features = false, features = [
|
||||||
aws-credential-types = { version = "1.2.14", optional = true }
|
"behavior-version-latest",
|
||||||
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
"credentials-process",
|
||||||
http = { version = "1.4.0", optional = true }
|
"rt-tokio",
|
||||||
reqsign-aws-v4 = { version = "3.0.0", optional = true }
|
"sso",
|
||||||
reqsign-core = { version = "3.0.0", optional = true }
|
] }
|
||||||
|
aws-credential-types = { version = "1.3.0", optional = true }
|
||||||
|
aws-smithy-runtime-api = { version = "1.14.0", optional = true }
|
||||||
|
http = { version = "1.5.0", optional = true }
|
||||||
|
reqsign-aws-v4 = { version = "3.1.0", optional = true }
|
||||||
|
reqsign-core = { version = "3.2.1", optional = true }
|
||||||
|
|
||||||
# Strip debuginfo from the release builds
|
# Strip debuginfo from the release builds
|
||||||
# The debug symbols are to provide better panic traces
|
# The debug symbols are to provide better panic traces
|
||||||
@@ -265,77 +329,74 @@ unsafe_code = "forbid"
|
|||||||
non_ascii_idents = "forbid"
|
non_ascii_idents = "forbid"
|
||||||
|
|
||||||
# Deny
|
# Deny
|
||||||
deprecated_in_future = "deny"
|
warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end
|
||||||
|
|
||||||
|
# Deny lint groups
|
||||||
deprecated_safe = { level = "deny", priority = -1 }
|
deprecated_safe = { level = "deny", priority = -1 }
|
||||||
future_incompatible = { level = "deny", priority = -1 }
|
future_incompatible = { level = "deny", priority = -1 }
|
||||||
keyword_idents = { level = "deny", priority = -1 }
|
keyword_idents = { level = "deny", priority = -1 }
|
||||||
let_underscore = { level = "deny", priority = -1 }
|
let_underscore = { level = "deny", priority = -1 }
|
||||||
nonstandard_style = { level = "deny", priority = -1 }
|
nonstandard_style = { level = "deny", priority = -1 }
|
||||||
noop_method_call = "deny"
|
|
||||||
refining_impl_trait = { level = "deny", priority = -1 }
|
refining_impl_trait = { level = "deny", priority = -1 }
|
||||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||||
rust_2021_compatibility = { level = "deny", priority = -1 }
|
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||||
rust_2024_compatibility = { level = "deny", priority = -1 }
|
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||||
|
unused = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
# Deny individual lints
|
||||||
|
closure_returning_async_block = "deny"
|
||||||
|
deprecated_in_future = "deny"
|
||||||
single_use_lifetimes = "deny"
|
single_use_lifetimes = "deny"
|
||||||
trivial_casts = "deny"
|
trivial_casts = "deny"
|
||||||
trivial_numeric_casts = "deny"
|
trivial_numeric_casts = "deny"
|
||||||
unused = { level = "deny", priority = -1 }
|
|
||||||
unused_import_braces = "deny"
|
unused_import_braces = "deny"
|
||||||
unused_lifetimes = "deny"
|
unused_lifetimes = "deny"
|
||||||
unused_qualifications = "deny"
|
unused_qualifications = "deny"
|
||||||
variant_size_differences = "deny"
|
variant_size_differences = "deny"
|
||||||
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
|
||||||
# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues
|
|
||||||
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
|
||||||
if_let_rescope = "allow"
|
|
||||||
tail_expr_drop_order = "allow"
|
|
||||||
|
|
||||||
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
# Warn
|
# Warn only so you can still use these during development, but not in the final code
|
||||||
dbg_macro = "warn"
|
dbg_macro = "warn"
|
||||||
todo = "warn"
|
todo = "warn"
|
||||||
|
|
||||||
# Ignore/Allow
|
# Ignore/Allow
|
||||||
result_large_err = "allow"
|
result_large_err = "allow"
|
||||||
|
|
||||||
# Deny
|
# Warn on these lint group (Some might be warn by default already though)
|
||||||
|
# Will be denied during CI!
|
||||||
|
complexity = { level = "warn", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
perf = { level = "warn", priority = -1 }
|
||||||
|
style = { level = "warn", priority = -1 }
|
||||||
|
suspicious = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
|
# Deny individual lints
|
||||||
branches_sharing_code = "deny"
|
branches_sharing_code = "deny"
|
||||||
case_sensitive_file_extension_comparisons = "deny"
|
|
||||||
cast_lossless = "deny"
|
|
||||||
clone_on_ref_ptr = "deny"
|
clone_on_ref_ptr = "deny"
|
||||||
duration_suboptimal_units = "deny"
|
|
||||||
equatable_if_let = "deny"
|
equatable_if_let = "deny"
|
||||||
excessive_precision = "deny"
|
|
||||||
filter_map_next = "deny"
|
|
||||||
float_cmp_const = "deny"
|
float_cmp_const = "deny"
|
||||||
implicit_clone = "deny"
|
|
||||||
inefficient_to_string = "deny"
|
|
||||||
iter_on_empty_collections = "deny"
|
iter_on_empty_collections = "deny"
|
||||||
iter_on_single_items = "deny"
|
iter_on_single_items = "deny"
|
||||||
linkedlist = "deny"
|
|
||||||
macro_use_imports = "deny"
|
|
||||||
manual_assert = "deny"
|
|
||||||
manual_instant_elapsed = "deny"
|
|
||||||
manual_string_new = "deny"
|
|
||||||
match_wildcard_for_single_variants = "deny"
|
|
||||||
mem_forget = "deny"
|
mem_forget = "deny"
|
||||||
needless_borrow = "deny"
|
|
||||||
needless_collect = "deny"
|
needless_collect = "deny"
|
||||||
needless_continue = "deny"
|
|
||||||
needless_lifetimes = "deny"
|
|
||||||
option_option = "deny"
|
|
||||||
redundant_clone = "deny"
|
redundant_clone = "deny"
|
||||||
ref_option = "deny"
|
|
||||||
string_add_assign = "deny"
|
|
||||||
unnecessary_join = "deny"
|
|
||||||
unnecessary_self_imports = "deny"
|
unnecessary_self_imports = "deny"
|
||||||
unnested_or_patterns = "deny"
|
|
||||||
unused_async = "deny"
|
|
||||||
unused_self = "deny"
|
|
||||||
useless_let_if_seq = "deny"
|
useless_let_if_seq = "deny"
|
||||||
verbose_file_reads = "deny"
|
verbose_file_reads = "deny"
|
||||||
zero_sized_map_values = "deny"
|
str_to_string = "deny"
|
||||||
|
|
||||||
|
# Pedantic Opt-Outs
|
||||||
|
inline_always = "allow" # We use this sparsely
|
||||||
|
struct_field_names = "allow" # Noisy and some items are Bitwarden controlled
|
||||||
|
large_futures = "allow" # Causes a fail in some Rocket macro's, since we experience no issues, allow it
|
||||||
|
too_many_lines = "allow" # For now, allow this, good to enable in the future and see if we can refactor
|
||||||
|
unnecessary_wraps = "allow" # Too much false positives because of Rocket integrations
|
||||||
|
# We do not use these doc items
|
||||||
|
doc_link_with_quotes = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::env;
|
use std::{env, io::Error, process::Command};
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
||||||
@@ -42,13 +41,12 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
fn run(args: &[&str]) -> Result<String, Error> {
|
||||||
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
use std::io::Error;
|
|
||||||
return Err(Error::other("Command not successful"));
|
return Err(Error::other("Command not successful"));
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
|
Ok(String::from_utf8(out.stdout).unwrap().trim().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This method reads info from Git, namely tags, branch, and revision
|
/// This method reads info from Git, namely tags, branch, and revision
|
||||||
@@ -58,7 +56,7 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
|||||||
/// - `env!("GIT_BRANCH")`
|
/// - `env!("GIT_BRANCH")`
|
||||||
/// - `env!("GIT_REV")`
|
/// - `env!("GIT_REV")`
|
||||||
/// - `env!("VW_VERSION")`
|
/// - `env!("VW_VERSION")`
|
||||||
fn version_from_git_info() -> Result<String, std::io::Error> {
|
fn version_from_git_info() -> Result<String, Error> {
|
||||||
// The exact tag for the current commit, can be empty when
|
// The exact tag for the current commit, can be empty when
|
||||||
// the current commit doesn't have an associated tag
|
// the current commit doesn't have an associated tag
|
||||||
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2026.4.1"
|
vault_version: "v2026.7.0"
|
||||||
vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe"
|
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
|
||||||
# Cross Compile Docker Helper Scripts v1.9.0
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
rust_version: 1.95.0 # Rust version to be used
|
rust_version: 1.97.1 # Rust version to be used
|
||||||
debian_version: trixie # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
alpine_version: "3.23" # Alpine version to be used
|
alpine_version: "3.24" # Alpine version to be used
|
||||||
# For which platforms/architectures will we try to build images
|
# For which platforms/architectures will we try to build images
|
||||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||||
# Determine the build images per OS/Arch
|
# Determine the build images per OS/Arch
|
||||||
|
|||||||
+17
-16
@@ -19,23 +19,23 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
@@ -66,11 +66,11 @@ RUN USER=root cargo new --bin /app
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -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,7 +126,8 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23
|
# hadolint ignore=DL3065
|
||||||
|
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
ROCKET_ADDRESS=0.0.0.0 \
|
ROCKET_ADDRESS=0.0.0.0 \
|
||||||
|
|||||||
+25
-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.4.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
@@ -36,7 +36,8 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
|
|||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.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,14 @@ 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
export XX_RUSTFLAGS; \
|
||||||
|
fi && \
|
||||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
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 +114,20 @@ 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
export XX_RUSTFLAGS; \
|
||||||
|
fi && \
|
||||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
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 +150,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" \
|
||||||
|
|||||||
+13
-6
@@ -28,8 +28,13 @@
|
|||||||
# [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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||||
|
export XX_RUSTFLAGS; \
|
||||||
|
fi && \
|
||||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
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 +57,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
|
||||||
@@ -106,12 +112,12 @@ WORKDIR /app
|
|||||||
|
|
||||||
{% if base == "alpine" %}
|
{% if base == "alpine" %}
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
cat /env-cargo
|
cat /env-cargo
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
RUN source /env-cargo && \
|
RUN . /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
# Copies over *only* your manifests and build files
|
# Copies over *only* your manifests and build files
|
||||||
@@ -131,7 +137,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 +152,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 +162,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 +185,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" \
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
|||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.45"
|
quote = "1.0.47"
|
||||||
syn = "2.0.117"
|
syn = "3.0.3"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+5
-4
@@ -1,14 +1,15 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
use syn::{DeriveInput, parse_macro_input};
|
||||||
|
|
||||||
#[proc_macro_derive(UuidFromParam)]
|
#[proc_macro_derive(UuidFromParam)]
|
||||||
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
||||||
let ast = syn::parse(input).unwrap();
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_derive_uuid_macro(&ast)
|
impl_derive_uuid_macro(&ast)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
let name = &ast.ident;
|
let name = &ast.ident;
|
||||||
let gen_derive = quote! {
|
let gen_derive = quote! {
|
||||||
#[automatically_derived]
|
#[automatically_derived]
|
||||||
@@ -30,12 +31,12 @@ fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
|||||||
|
|
||||||
#[proc_macro_derive(IdFromParam)]
|
#[proc_macro_derive(IdFromParam)]
|
||||||
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
||||||
let ast = syn::parse(input).unwrap();
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_derive_safestring_macro(&ast)
|
impl_derive_safestring_macro(&ast)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
|
fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
let name = &ast.ident;
|
let name = &ast.ident;
|
||||||
let gen_derive = quote! {
|
let gen_derive = quote! {
|
||||||
#[automatically_derived]
|
#[automatically_derived]
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN signed_public_key;
|
||||||
|
ALTER TABLE users DROP COLUMN security_state;
|
||||||
|
ALTER TABLE users DROP COLUMN security_version;
|
||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_state TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_version INTEGER;
|
||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
CREATE TABLE user_signature_key_pairs (
|
||||||
|
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
user_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
|
||||||
|
signing_key TEXT NOT NULL,
|
||||||
|
verifying_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN signed_public_key;
|
||||||
|
ALTER TABLE users DROP COLUMN security_state;
|
||||||
|
ALTER TABLE users DROP COLUMN security_version;
|
||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_state TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_version INTEGER;
|
||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
CREATE TABLE user_signature_key_pairs (
|
||||||
|
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
|
||||||
|
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
|
||||||
|
signing_key TEXT NOT NULL,
|
||||||
|
verifying_key TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN signed_public_key;
|
||||||
|
ALTER TABLE users DROP COLUMN security_state;
|
||||||
|
ALTER TABLE users DROP COLUMN security_version;
|
||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_state TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN security_version INTEGER;
|
||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS user_signature_key_pairs;
|
||||||
|
|
||||||
|
CREATE TABLE user_signature_key_pairs (
|
||||||
|
uuid TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
|
||||||
|
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
|
||||||
|
signing_key TEXT NOT NULL,
|
||||||
|
verifying_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -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
|
||||||
@@ -70,7 +73,7 @@ services:
|
|||||||
Mysql:
|
Mysql:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_mysql
|
container_name: playwright_mysql
|
||||||
image: mysql:8.4.1
|
image: mysql:9.7.0
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
@@ -82,7 +85,7 @@ services:
|
|||||||
Postgres:
|
Postgres:
|
||||||
profiles: ["playwright"]
|
profiles: ["playwright"]
|
||||||
container_name: playwright_postgres
|
container_name: playwright_postgres
|
||||||
image: postgres:16.3
|
image: postgres:18.4
|
||||||
env_file: test.env
|
env_file: test.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
@@ -94,7 +97,7 @@ services:
|
|||||||
Maildev:
|
Maildev:
|
||||||
profiles: ["vaultwarden", "maildev"]
|
profiles: ["vaultwarden", "maildev"]
|
||||||
container_name: maildev
|
container_name: maildev
|
||||||
image: timshel/maildev:3.0.4
|
image: timshel/maildev:3.2.19
|
||||||
ports:
|
ports:
|
||||||
- ${SMTP_PORT}:1025
|
- ${SMTP_PORT}:1025
|
||||||
- 1080:1080
|
- 1080:1080
|
||||||
@@ -102,7 +105,7 @@ services:
|
|||||||
Keycloak:
|
Keycloak:
|
||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloak-${ENV:-dev}
|
container_name: keycloak-${ENV:-dev}
|
||||||
image: quay.io/keycloak/keycloak:26.3.4
|
image: quay.io/keycloak/keycloak:26.6.2
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
@@ -112,12 +115,12 @@ services:
|
|||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloakSetup-${ENV:-dev}
|
container_name: keycloakSetup-${ENV:-dev}
|
||||||
image: keycloak_setup-${ENV:-dev}
|
image: keycloak_setup-${ENV:-dev}
|
||||||
|
network_mode: "host"
|
||||||
build:
|
build:
|
||||||
context: compose/keycloak
|
context: compose/keycloak
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
KEYCLOAK_VERSION: 26.3.4
|
KEYCLOAK_VERSION: 26.6.2
|
||||||
network_mode: "host"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- Keycloak
|
- Keycloak
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { firefox, type FullConfig } from '@playwright/test';
|
import { type FullConfig } from '@playwright/test';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stopVault(force: boolean = false) {
|
export async function stopVault(force: boolean = false) {
|
||||||
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||||
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Vaultwarden stopping`);
|
console.log(`Vaultwarden stopping`);
|
||||||
@@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanLanding(page: Page) {
|
export async function cleanLanding(page: Page) {
|
||||||
|
await page.context().clearCookies();
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
||||||
|
|
||||||
@@ -248,15 +249,3 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ignoreExtension(page: Page) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
|
||||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Extension setup not visible. Continuing');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
Generated
+582
-580
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.60.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.4.2",
|
||||||
"dotenv-expand": "12.0.3",
|
"dotenv-expand": "13.0.0",
|
||||||
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
"maildev": "npm:@timshel_npm/maildev@3.2.19"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "3.15.3",
|
"mysql2": "3.22.3",
|
||||||
"otpauth": "9.4.1",
|
"otpauth": "9.5.1",
|
||||||
"pg": "8.16.3"
|
"pg": "8.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ export default defineConfig({
|
|||||||
/* Long global timeout for complex tests
|
/* Long global timeout for complex tests
|
||||||
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
||||||
*/
|
*/
|
||||||
timeout: 120 * 1000,
|
timeout: 240 * 1000,
|
||||||
actionTimeout: 20 * 1000,
|
actionTimeout: 40 * 1000,
|
||||||
navigationTimeout: 20 * 1000,
|
navigationTimeout: 40 * 1000,
|
||||||
expect: { timeout: 20 * 1000 },
|
expect: { timeout: 40 * 1000 },
|
||||||
|
|
||||||
|
"permissions": ["clipboard-read"],
|
||||||
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
@@ -37,6 +39,10 @@ export default defineConfig({
|
|||||||
browserName: 'firefox',
|
browserName: 'firefox',
|
||||||
locale: 'en-GB',
|
locale: 'en-GB',
|
||||||
timezoneId: 'Europe/London',
|
timezoneId: 'Europe/London',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--ignore-certificate-errors']
|
||||||
|
},
|
||||||
|
|
||||||
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on',
|
trace: 'on',
|
||||||
|
|||||||
+6
-4
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
|
|||||||
#####################
|
#####################
|
||||||
# Playwright Config #
|
# Playwright Config #
|
||||||
#####################
|
#####################
|
||||||
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
|
PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false}
|
||||||
PW_SMTP_FROM=vaultwarden@playwright.test
|
PW_SMTP_FROM=vaultwarden@playwright.test
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
|
|||||||
###################
|
###################
|
||||||
# Keycloak Config #
|
# Keycloak Config #
|
||||||
###################
|
###################
|
||||||
KEYCLOAK_ADMIN=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||||
KC_HTTP_HOST=127.0.0.1
|
KC_HTTP_HOST=127.0.0.1
|
||||||
KC_HTTP_PORT=8081
|
KC_HTTP_PORT=8081
|
||||||
|
|
||||||
@@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
# Vaultwarden Config #
|
# Vaultwarden Config #
|
||||||
######################
|
######################
|
||||||
ROCKET_PORT=8003
|
ROCKET_PORT=8003
|
||||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
|
||||||
|
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
LOGIN_RATELIMIT_MAX_BURST=100
|
LOGIN_RATELIMIT_MAX_BURST=100
|
||||||
ADMIN_TOKEN=admin
|
ADMIN_TOKEN=admin
|
||||||
|
CLIENT_SUPPRESS_ONBOARDING=true
|
||||||
|
|
||||||
SMTP_SECURITY=off
|
SMTP_SECURITY=off
|
||||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect, type TestInfo } from '@playwright/test';
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
|
|
||||||
|
import * as orgs from './setups/orgs';
|
||||||
import { createAccount } from './setups/user';
|
import { createAccount } from './setups/user';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
@@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
|
|||||||
test('Create', async ({ page }) => {
|
test('Create', async ({ page }) => {
|
||||||
await createAccount(test, page, users.user1);
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
await test.step('Create Org', async () => {
|
await orgs.create(test, page, 'New organisation');
|
||||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
|
||||||
await page.getByLabel('Organisation name (required)').fill('Test');
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Organisation created');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Create Collection', async () => {
|
await test.step('Create Collection', async () => {
|
||||||
await page.getByRole('link', { name: 'Collections' }).click();
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'New' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Collection' }).click();
|
await page.getByRole('menuitem', { name: 'Collection' }).click();
|
||||||
await page.getByLabel('Name (required)').fill('RandomCollec');
|
await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Created collection RandomCollec');
|
await utils.checkNotification(page, 'Created collection RandomCollec');
|
||||||
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP, disableTOTP } from './setups/2fa';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Change Key settings', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Change SHA-256 Iterations', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Keys' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('700000');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Update settings' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Switch to Argon2', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Keys' }).click();
|
||||||
|
|
||||||
|
await page.locator('.ng-arrow-wrapper').click();
|
||||||
|
await page.getByText('Argon2id').click();
|
||||||
|
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF memory (MB) * (required)'}).fill('16');
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('2');
|
||||||
|
await page.getByRole('spinbutton', { name: 'KDF parallelism * (required)'}).fill('1');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update encryption settings' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Update settings' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
@@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
|
|||||||
test('Login', async ({ context, page }) => {
|
test('Login', async ({ context, page }) => {
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await logUser(test, page, users.user1, mailBuffer);
|
await logUser(test, page, users.user1, { mailBuffer });
|
||||||
|
|
||||||
await test.step('verify email', async () => {
|
await test.step('verify email', async () => {
|
||||||
await page.getByText('Verify your account\'s email').click();
|
await page.getByRole('button', { name: "Send email" }).click();
|
||||||
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
||||||
|
|
||||||
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
||||||
@@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
|
|||||||
test('2fa', async ({ page }) => {
|
test('2fa', async ({ page }) => {
|
||||||
const emails = mailserver.buffer(users.user1.email);
|
const emails = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await test.step('login', async () => {
|
await logUser(test, page, users.user1, {
|
||||||
await page.goto('/');
|
mailBuffer: emails,
|
||||||
|
mail2fa: true,
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
});
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
|
||||||
const code = await retrieveEmailCode(test, page, emails);
|
|
||||||
await page.getByLabel(/Verification code/).fill(code);
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add it later' }).click();
|
|
||||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Vaults/);
|
|
||||||
})
|
|
||||||
|
|
||||||
await disableEmail(test, page, users.user1);
|
|
||||||
|
|
||||||
emails.close();
|
emails.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
|
|||||||
import * as utils from '../global-utils';
|
import * as utils from '../global-utils';
|
||||||
import * as orgs from './setups/orgs';
|
import * as orgs from './setups/orgs';
|
||||||
import { createAccount, logUser } from './setups/user';
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import { activateTOTP } from './setups/2fa';
|
||||||
|
|
||||||
let users = utils.loadEnv();
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
|||||||
await utils.startVault(browser, testInfo, {
|
await utils.startVault(browser, testInfo, {
|
||||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
|
EMAIL_2FA_AUTO_FALLBACK: "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
mail1Buffer = mailServer.buffer(users.user1.email);
|
mail1Buffer = mailServer.buffer(users.user1.email);
|
||||||
@@ -40,6 +42,16 @@ test('Invite users', async ({ page }) => {
|
|||||||
await createAccount(test, page, users.user1, mail1Buffer);
|
await createAccount(test, page, users.user1, mail1Buffer);
|
||||||
|
|
||||||
await orgs.create(test, page, 'Test');
|
await orgs.create(test, page, 'Test');
|
||||||
|
|
||||||
|
await test.step(`Set account recovery`, async () => {
|
||||||
|
await orgs.policies(test, page, 'Test');
|
||||||
|
await page.getByRole('button', { name: 'Account recovery' }).click();
|
||||||
|
await page.getByRole('checkbox', { name: 'Turn on' }).check();
|
||||||
|
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Edited policy Account recovery');
|
||||||
|
});
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await orgs.invite(test, page, 'Test', users.user2.email);
|
await orgs.invite(test, page, 'Test', users.user2.email);
|
||||||
await orgs.invite(test, page, 'Test', users.user3.email, {
|
await orgs.invite(test, page, 'Test', users.user3.email, {
|
||||||
@@ -56,18 +68,16 @@ test('invited with new account', async ({ page }) => {
|
|||||||
await page.goto(link);
|
await page.goto(link);
|
||||||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
|
||||||
//await page.getByLabel('Name').fill(users.user2.name);
|
// await page.getByLabel('Name').fill(users.user2.name);
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await utils.checkNotification(page, 'Your new account has been created');
|
await utils.checkNotification(page, 'Your new account has been created');
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// Redirected to the vault
|
// Redirected to the vault
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
// await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -90,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
|
|
||||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Confirm invited user', async ({ page }) => {
|
test('Confirm invited user', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1, mail1Buffer);
|
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await orgs.confirm(test, page, 'Test', users.user2.email);
|
await orgs.confirm(test, page, 'Test', users.user2.email);
|
||||||
@@ -113,7 +121,35 @@ test('Confirm invited user', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Organization is visible', async ({ page }) => {
|
test('Organization is visible', async ({ page }) => {
|
||||||
await logUser(test, page, users.user2, mail2Buffer);
|
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
|
||||||
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Recover user password', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||||
|
|
||||||
|
let newPassword = "TotoNewPassword";
|
||||||
|
|
||||||
|
await orgs.members(test, page, 'Test');
|
||||||
|
await test.step(`Recover ${users.user2.email}`, async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
|
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Recover account' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
|
||||||
|
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Account recovery success');
|
||||||
|
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
let user2 = {
|
||||||
|
email: users.user2.email,
|
||||||
|
name: users.user2.name,
|
||||||
|
password: newPassword,
|
||||||
|
};
|
||||||
|
await logUser(test, page, user2, {
|
||||||
|
mailBuffer: mail2Buffer,
|
||||||
|
notNewDevice: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Password', async ({ context, page }, testInfo: TestInfo) => {
|
||||||
|
const label = 'Test Password';
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Create password entry', async () => {
|
||||||
|
await page.getByRole('button', { name: 'New item' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
|
||||||
|
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item added');
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Check', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||||
|
await page.getByTestId('copy-username').click();
|
||||||
|
await utils.checkNotification(page, 'Username copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
|
||||||
|
await page.getByTestId('copy-password').click();
|
||||||
|
await utils.checkNotification(page, 'Password copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item sent to bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Deleted', async () => {
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
|
||||||
|
const label = 'Test SSH key';
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
const privateKey = await test.step('Create key entry', async () => {
|
||||||
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'SSH key' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item added');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||||
|
await utils.checkNotification(page, 'Private key copied');
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Check', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||||
|
await utils.checkNotification(page, 'Private key copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete', async () => {
|
||||||
|
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await utils.checkNotification(page, 'Item sent to bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log again
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('Deleted', async () => {
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVault(browser, testInfo, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Send', async ({ browser, page }) => {
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
const send_url = await test.step('Create', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Send' }).click();
|
||||||
|
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'New Send', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||||
|
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
const context2 = await browser.newContext();
|
||||||
|
const page2 = await context2.newPage();
|
||||||
|
|
||||||
|
await test.step('View', async () => {
|
||||||
|
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||||
|
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pwd_url = await test.step('Create with password', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Send' }).click();
|
||||||
|
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'New' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
|
||||||
|
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
|
||||||
|
await page.getByRole('combobox', { name: 'Who can view' }).click();
|
||||||
|
await page.getByText('Anyone with a password set by you').click();
|
||||||
|
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||||
|
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('View with password', async () => {
|
||||||
|
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
|
||||||
|
await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
|
||||||
|
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||||
|
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,10 +11,11 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
const secret = await page.getByLabel('Key').innerText();
|
const secret = await page.getByLabel('Key', { exact: true }).innerText();
|
||||||
|
|
||||||
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
||||||
|
|
||||||
await page.getByLabel(/Verification code/).fill(totp.generate());
|
await page.getByLabel(/Verification code/).fill(totp.generate());
|
||||||
@@ -33,8 +34,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').click();
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
await page.getByRole('button', { name: 'Yes' }).click();
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
@@ -49,7 +50,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
await page.getByRole('button', { name: 'Send email' }).click();
|
||||||
});
|
});
|
||||||
@@ -81,8 +82,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
|
|||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').click();
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
await page.getByRole('button', { name: 'Yes' }).click();
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, type Browser, Page } from '@playwright/test';
|
||||||
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
export async function login(test, page: Page) {
|
||||||
|
await test.step(`Admin login`, async () => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN);
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invite(test, page: Page, email: string) {
|
||||||
|
await test.step(`Invite user with ${email}`, async () => {
|
||||||
|
await page.getByRole('link', { name: 'Users' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Enter email' }).fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Invite' }).click();
|
||||||
|
await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
|
|||||||
utils.loadEnv();
|
utils.loadEnv();
|
||||||
|
|
||||||
test('DB teardown ?', async ({ serviceName }) => {
|
test('DB teardown ?', async ({ serviceName }) => {
|
||||||
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
|
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
|
||||||
utils.stopComposeService(serviceName);
|
utils.stopComposeService(serviceName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
|
|||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
|
||||||
export async function create(test, page: Page, name: string) {
|
export async function create(test, page: Page, name: string) {
|
||||||
await test.step('Create Org', async () => {
|
await test.step(`Create Org ${name}`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
|
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
|
||||||
|
if( await pm_locator.count() > 0 ){
|
||||||
|
pm_locator.first().click();
|
||||||
|
}
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
await page.getByRole('link', { name: 'New organisation' }).click();
|
||||||
await page.getByLabel('Organisation name (required)').fill(name);
|
await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name);
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Organisation created');
|
await utils.checkNotification(page, 'Organisation created');
|
||||||
@@ -18,7 +21,7 @@ export async function policies(test, page: Page, name: string) {
|
|||||||
await test.step(`Navigate to ${name} policies`, async () => {
|
await test.step(`Navigate to ${name} policies`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
||||||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
||||||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
|
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
|
||||||
await page.getByRole('link', { name: 'Policies' }).click();
|
await page.getByRole('link', { name: 'Policies' }).click();
|
||||||
@@ -30,11 +33,11 @@ export async function members(test, page: Page, name: string) {
|
|||||||
await test.step(`Navigate to ${name} members`, async () => {
|
await test.step(`Navigate to ${name} members`, async () => {
|
||||||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
|
||||||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
|
||||||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
|
await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
|
||||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
await page.getByRole('link', { name: 'Members' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
|
await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +45,13 @@ export async function invite(test, page: Page, name: string, email: string) {
|
|||||||
await test.step(`Invite ${email}`, async () => {
|
await test.step(`Invite ${email}`, async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Invite member' }).click();
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
await page.getByLabel('Email (required)').fill(email);
|
await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email);
|
||||||
await page.getByRole('tab', { name: 'Collections' }).click();
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
await page.getByRole('combobox', { name: 'Permission' }).click();
|
await page.getByRole('combobox', { name: 'Permission' }).click();
|
||||||
await page.getByText('Edit items', { exact: true }).click();
|
await page.getByText('Edit items', { exact: true }).click();
|
||||||
await page.getByLabel('Select collections').click();
|
await page.getByRole('combobox', { name: 'Select collections' }).click();
|
||||||
await page.getByText('Default collection').click();
|
await page.getByLabel('Options List').getByText('Default collection').click();
|
||||||
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
|
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'User(s) invited');
|
await utils.checkNotification(page, 'User(s) invited');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
|
|||||||
utils.loadEnv();
|
utils.loadEnv();
|
||||||
|
|
||||||
test('Keycloak teardown', async () => {
|
test('Keycloak teardown', async () => {
|
||||||
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||||
console.log("Keep Keycloak running");
|
console.log("Keep Keycloak running");
|
||||||
} else {
|
} else {
|
||||||
console.log("Keycloak stopping");
|
console.log("Keycloak stopping");
|
||||||
|
|||||||
@@ -15,11 +15,8 @@ export async function logNewUser(
|
|||||||
options: { mailBuffer?: MailBuffer } = {}
|
options: { mailBuffer?: MailBuffer } = {}
|
||||||
) {
|
) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
await page.context().clearCookies();
|
|
||||||
|
|
||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
@@ -33,26 +30,24 @@ export async function logNewUser(
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
|
||||||
if( options.mailBuffer ){
|
if( options.mailBuffer ){
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
await test.step('Check emails', async () => {
|
await test.step('Check emails', async () => {
|
||||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
|
||||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,16 +64,14 @@ export async function logUser(
|
|||||||
mailBuffer ?: MailBuffer,
|
mailBuffer ?: MailBuffer,
|
||||||
totp?: OTPAuth.TOTP,
|
totp?: OTPAuth.TOTP,
|
||||||
mail2fa?: boolean,
|
mail2fa?: boolean,
|
||||||
|
notNewDevice?: boolean,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
|
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
await page.context().clearCookies();
|
|
||||||
|
|
||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
@@ -117,14 +110,12 @@ export async function logUser(
|
|||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( mailBuffer && !options.notNewDevice ){
|
||||||
await test.step('Check email', async () => {
|
await test.step('Check email', async () => {
|
||||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
|
|||||||
import { type MailBuffer } from 'maildev';
|
import { type MailBuffer } from 'maildev';
|
||||||
|
|
||||||
import * as utils from '../../global-utils';
|
import * as utils from '../../global-utils';
|
||||||
|
import { retrieveEmailCode } from './2fa';
|
||||||
|
|
||||||
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
@@ -17,12 +18,11 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Vault finish Creation
|
// Vault finish Creation
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Your new account has been created')
|
await utils.checkNotification(page, 'Your new account has been created')
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
@@ -35,7 +35,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
|
export async function logUser(
|
||||||
|
test,
|
||||||
|
page: Page,
|
||||||
|
user: { email: string, password: string },
|
||||||
|
options: {
|
||||||
|
mailBuffer ?: MailBuffer,
|
||||||
|
mail2fa?: boolean,
|
||||||
|
notNewDevice?: boolean,
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
await test.step(`Log user ${user.email}`, async () => {
|
await test.step(`Log user ${user.email}`, async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
@@ -43,16 +52,23 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(user.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
await utils.ignoreExtension(page);
|
if( options.mail2fa ){
|
||||||
|
await test.step('2FA check', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||||
|
let code = await retrieveEmailCode(test, page, options.mailBuffer);
|
||||||
|
await page.getByLabel(/Verification code/).fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( options.mailBuffer && !options.notNewDevice ){
|
||||||
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, type TestInfo } from '@playwright/test';
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
import { MailDev } from 'maildev';
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
import * as admin from "./setups/admin";
|
||||||
import { logNewUser, logUser } from './setups/sso';
|
import { logNewUser, logUser } from './setups/sso';
|
||||||
import { activateEmail, disableEmail } from './setups/2fa';
|
import { activateEmail, disableEmail } from './setups/2fa';
|
||||||
import * as utils from "../global-utils";
|
import * as utils from "../global-utils";
|
||||||
@@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
await utils.startVault(browser, testInfo, {
|
await utils.startVault(browser, testInfo, {
|
||||||
SSO_ENABLED: true,
|
SSO_ENABLED: true,
|
||||||
SSO_ONLY: false,
|
SSO_ONLY: true,
|
||||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||||
});
|
});
|
||||||
@@ -32,22 +33,64 @@ test.afterAll('Teardown', async ({}) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Create and activate 2FA', async ({ page }) => {
|
test('2FA email', async ({ page }) => {
|
||||||
|
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
||||||
|
|
||||||
await activateEmail(test, page, users.user1, mailBuffer);
|
await activateEmail(test, page, users.user1, mailBuffer);
|
||||||
|
|
||||||
mailBuffer.close();
|
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
|
||||||
});
|
|
||||||
|
|
||||||
test('Log and disable', async ({ page }) => {
|
|
||||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
|
||||||
|
|
||||||
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
|
|
||||||
|
|
||||||
await disableEmail(test, page, users.user1);
|
await disableEmail(test, page, users.user1);
|
||||||
|
|
||||||
mailBuffer.close();
|
mailBuffer.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Admin invite', async ({ page }) => {
|
||||||
|
const mailBuffer = mailserver.buffer(users.user2.email);
|
||||||
|
|
||||||
|
await admin.login(test, page);
|
||||||
|
await admin.invite(test, page, users.user2.email);
|
||||||
|
|
||||||
|
|
||||||
|
const link = await test.step('Extract email link', async () => {
|
||||||
|
const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden");
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
return await page.getByTestId("invite").getAttribute("href");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
|
await page.goto(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create Vault account', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Check mails', async () => {
|
||||||
|
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||||
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
mailBuffer.close();
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Other' }).click();
|
await page.getByRole('button', { name: 'Other' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
@@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
||||||
|
|
||||||
// Check that SSO login is available
|
// Check that SSO login is available
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
||||||
@@ -66,7 +67,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
||||||
await utils.restartVault(page, testInfo, {
|
await utils.restartVault(page, testInfo, {
|
||||||
SSO_ENABLED: false
|
SSO_ENABLED: false
|
||||||
@@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
|
||||||
// No SSO button (rely on a correct selector checked in previous test)
|
// No SSO button (rely on a correct selector checked in previous test)
|
||||||
|
await page.getByLabel('Master password');
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
||||||
|
|
||||||
// Can continue to Master password
|
// Can continue to Master password
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,17 +67,16 @@ test('invited with new account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Redirect to Keycloak', async () => {
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
await page.goto(link);
|
await page.goto(link);
|
||||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Keycloak login', async () => {
|
await test.step('Keycloak login', async () => {
|
||||||
@@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
await utils.ignoreExtension(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Enforce password policy', async ({ page }) => {
|
test('Activate password policy', async ({ page }) => {
|
||||||
await logUser(test, page, users.user1);
|
await logUser(test, page, users.user1);
|
||||||
await orgs.policies(test, page, '/Test');
|
await orgs.policies(test, page, '/Test');
|
||||||
|
|
||||||
@@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
await utils.logout(test, page, users.user1);
|
|
||||||
|
test('Unlock trigger policyy', async ({ page }) => {
|
||||||
await test.step(`Unlock trigger policy`, async () => {
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
|
||||||
await page.getByRole('button', { name: 'Use single sign-on' }).click();
|
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
|
||||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
});
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Unlock vault', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
|
||||||
|
await page.getByLabel('Master password').fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.95.0"
|
channel = "1.97.1"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
edition = "2021"
|
edition = "2024"
|
||||||
max_width = 120
|
max_width = 120
|
||||||
newline_style = "Unix"
|
newline_style = "Unix"
|
||||||
use_small_heuristics = "Off"
|
use_small_heuristics = "Off"
|
||||||
|
|||||||
+97
-71
@@ -2,40 +2,40 @@ use std::{env, sync::LazyLock};
|
|||||||
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Catcher, Route,
|
||||||
form::Form,
|
form::Form,
|
||||||
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
response::{content::RawHtml as Html, Redirect},
|
response::{Redirect, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Catcher, Route,
|
|
||||||
};
|
};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG, VERSION,
|
||||||
api::{
|
api::{
|
||||||
|
ApiResult, EmptyResult, JsonResult, Notify,
|
||||||
core::{log_event, two_factor},
|
core::{log_event, two_factor},
|
||||||
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
|
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||||
config::ConfigBuilder,
|
config::ConfigBuilder,
|
||||||
db::{
|
db::{
|
||||||
backup_sqlite, get_sql_server_version,
|
ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version,
|
||||||
models::{
|
models::{
|
||||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbConnType, ACTIVE_DB_TYPE,
|
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
mail,
|
mail,
|
||||||
sso::FAKE_SSO_IDENTIFIER,
|
sso::FAKE_SSO_IDENTIFIER,
|
||||||
util::{
|
util::{
|
||||||
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
|
FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
|
||||||
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
|
get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
|
||||||
},
|
},
|
||||||
CONFIG, VERSION,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -93,8 +93,7 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
static CAN_BACKUP: LazyLock<bool> =
|
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite));
|
||||||
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
|
|
||||||
#[cfg(not(sqlite))]
|
#[cfg(not(sqlite))]
|
||||||
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
|
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
|
||||||
|
|
||||||
@@ -200,13 +199,7 @@ fn post_admin_login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if !_validate_token(&data.token) {
|
if validate_token(&data.token) {
|
||||||
error!("Invalid admin token. IP: {}", ip.ip);
|
|
||||||
Err(AdminResponse::Unauthorized(render_admin_login(
|
|
||||||
Some("Invalid admin token, please try again."),
|
|
||||||
redirect.as_deref(),
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = generate_admin_claims();
|
let claims = generate_admin_claims();
|
||||||
let jwt = encode_jwt(&claims);
|
let jwt = encode_jwt(&claims);
|
||||||
@@ -224,10 +217,16 @@ fn post_admin_login(
|
|||||||
} else {
|
} else {
|
||||||
Err(AdminResponse::Ok(render_admin_page()))
|
Err(AdminResponse::Ok(render_admin_page()))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Invalid admin token. IP: {}", ip.ip);
|
||||||
|
Err(AdminResponse::Unauthorized(render_admin_login(
|
||||||
|
Some("Invalid admin token, please try again."),
|
||||||
|
redirect.as_deref(),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _validate_token(token: &str) -> bool {
|
fn validate_token(token: &str) -> bool {
|
||||||
match CONFIG.admin_token().as_ref() {
|
match CONFIG.admin_token().as_ref() {
|
||||||
None => false,
|
None => false,
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
@@ -307,21 +306,14 @@ async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
|
|||||||
|
|
||||||
#[post("/invite", format = "application/json", data = "<data>")]
|
#[post("/invite", format = "application/json", data = "<data>")]
|
||||||
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||||
let data: InviteData = data.into_inner();
|
async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||||
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
|
||||||
err_code!("User already exists", Status::Conflict.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut user = User::new(&data.email, None);
|
|
||||||
|
|
||||||
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||||
FAKE_SSO_IDENTIFIER.into()
|
FAKE_SSO_IDENTIFIER.into()
|
||||||
} else {
|
} else {
|
||||||
FAKE_ADMIN_UUID.into()
|
FAKE_ADMIN_UUID.into()
|
||||||
};
|
};
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
let invitation = Invitation::new(&user.email);
|
let invitation = Invitation::new(&user.email);
|
||||||
@@ -329,7 +321,14 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
let data: InviteData = data.into_inner();
|
||||||
|
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
||||||
|
err_code!("User already exists", Status::Conflict.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user = User::new(&data.email, None);
|
||||||
|
|
||||||
|
generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||||
user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||||
|
|
||||||
Ok(Json(user.to_json(&conn).await))
|
Ok(Json(user.to_json(&conn).await))
|
||||||
@@ -386,7 +385,7 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
|
|||||||
None => json!("Never"),
|
None => json!("Never"),
|
||||||
};
|
};
|
||||||
|
|
||||||
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
|
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||||
|
|
||||||
users_json.push(usr);
|
users_json.push(usr);
|
||||||
}
|
}
|
||||||
@@ -472,7 +471,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
|||||||
match unregister_push_device(device.push_uuid.as_ref()).await {
|
match unregister_push_device(device.push_uuid.as_ref()).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +527,7 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
|||||||
} else {
|
} else {
|
||||||
FAKE_ADMIN_UUID.into()
|
FAKE_ADMIN_UUID.into()
|
||||||
};
|
};
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -554,9 +553,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
err!("The specified user isn't member of the organization")
|
err!("The specified user isn't member of the organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid type"),
|
} else {
|
||||||
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||||
@@ -643,11 +643,11 @@ async fn has_http_access() -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use cached::proc_macro::cached;
|
use cached::macros::cached;
|
||||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||||
/// Any cache will be lost if Vaultwarden is restarted
|
/// Any cache will be lost if Vaultwarden is restarted
|
||||||
#[cached(time = 600, sync_writes = "default")]
|
#[cached(ttl = 600, sync_writes = "default")]
|
||||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||||
if has_http_access {
|
if has_http_access {
|
||||||
@@ -656,42 +656,40 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r.tag_name,
|
Ok(r) => r.tag_name,
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
||||||
Ok(mut c) => {
|
Ok(mut c) => {
|
||||||
c.sha.truncate(8);
|
c.sha.truncate(8);
|
||||||
c.sha
|
c.sha
|
||||||
}
|
}
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
// Do not fetch the web-vault version when running within a container
|
// Do not fetch the web-vault version when running within a container
|
||||||
// The web-vault version is embedded within the container it self, and should not be updated manually
|
// The web-vault version is embedded within the container it self, and should not be updated manually
|
||||||
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
|
Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
("-".to_string(), "-".to_string(), "-".to_string())
|
("-".to_owned(), "-".to_owned(), "-".to_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_ntp_time(has_http_access: bool) -> String {
|
async fn get_ntp_time(has_http_access: bool) -> String {
|
||||||
if has_http_access {
|
if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||||
if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
for line in cf_trace.lines() {
|
||||||
for line in cf_trace.lines() {
|
if let Some((key, value)) = line.split_once('=')
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
&& key == "ts"
|
||||||
if key == "ts" {
|
{
|
||||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,6 +716,36 @@ fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_template_overrides() -> Vec<&'static str> {
|
||||||
|
let template_folder = std::path::PathBuf::from(CONFIG.templates_folder());
|
||||||
|
let mut overrides = Vec::new();
|
||||||
|
for folder in ["admin", "email", "scss"] {
|
||||||
|
if folder_has_hbs_files(&template_folder.join(folder)) {
|
||||||
|
overrides.push(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder_has_hbs_files(&template_folder) {
|
||||||
|
overrides.push("other");
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_has_hbs_files(dir: &std::path::Path) -> bool {
|
||||||
|
let Ok(files) = std::fs::read_dir(dir) else {
|
||||||
|
// No files in this directory at all, so we can return false
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
files.flatten().any(|f| {
|
||||||
|
// Validate if it is a file and if it has the `.hbs` extension and starts with a-z or 0-9
|
||||||
|
f.file_type().is_ok_and(|t| t.is_file())
|
||||||
|
&& f.path().extension().is_some_and(|e| e.eq_ignore_ascii_case("hbs"))
|
||||||
|
&& f.file_name().to_str().is_some_and(|n| n.starts_with(|c: char| c.is_ascii_alphanumeric()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/diagnostics")]
|
#[get("/diagnostics")]
|
||||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
@@ -734,7 +762,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
// Check if we are able to resolve DNS entries
|
// Check if we are able to resolve DNS entries
|
||||||
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
||||||
Ok(Some(a)) => a.ip().to_string(),
|
Ok(Some(a)) => a.ip().to_string(),
|
||||||
_ => "Unable to resolve domain name.".to_string(),
|
_ => "Unable to resolve domain name.".to_owned(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
||||||
@@ -745,7 +773,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
|
|
||||||
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||||
&CONFIG.experimental_client_feature_flags(),
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
FeatureFlagFilter::InvalidOnly,
|
&FeatureFlagFilter::InvalidOnly,
|
||||||
)
|
)
|
||||||
.into_keys()
|
.into_keys()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -772,6 +800,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"template_overrides": check_template_overrides().join(", "),
|
||||||
"invalid_feature_flags": invalid_feature_flags,
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
@@ -834,33 +863,30 @@ impl<'r> FromRequest<'r> for AdminToken {
|
|||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if !CONFIG.disable_admin_token() {
|
if !CONFIG.disable_admin_token() {
|
||||||
let cookies = request.cookies();
|
let cookies = request.cookies();
|
||||||
|
|
||||||
let access_token = match cookies.get(COOKIE_NAME) {
|
let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
|
||||||
Some(cookie) => cookie.value(),
|
cookie.value()
|
||||||
None => {
|
} else {
|
||||||
let requested_page =
|
let requested_page =
|
||||||
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||||
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
||||||
// Else, return a 401 failure, which will be caught
|
// Else, return a 401 failure, which will be caught
|
||||||
if requested_page.is_empty() {
|
if requested_page.is_empty() {
|
||||||
return Outcome::Forward(Status::Unauthorized);
|
return Outcome::Forward(Status::Unauthorized);
|
||||||
} else {
|
|
||||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||||
};
|
};
|
||||||
|
|
||||||
if decode_admin(access_token).is_err() {
|
if decode_admin(access_token).is_err() {
|
||||||
// Remove admin cookie
|
// Remove admin cookie
|
||||||
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
||||||
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
|
error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
|
||||||
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+445
-123
@@ -1,34 +1,38 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::db::DbPool;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api::{
|
|
||||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
|
||||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
|
||||||
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
|
||||||
},
|
|
||||||
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
|
||||||
crypto,
|
|
||||||
db::{
|
|
||||||
models::{
|
|
||||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
|
|
||||||
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
|
|
||||||
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
|
||||||
},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
mail,
|
|
||||||
util::{deser_opt_nonempty_str, format_date, NumberOrString},
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
http::Status,
|
http::Status,
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
|
serde::json::Json,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{
|
||||||
|
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
|
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||||
|
master_password_policy, register_push_device, unregister_push_device,
|
||||||
|
},
|
||||||
|
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||||
|
crypto,
|
||||||
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
|
models::{
|
||||||
|
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
|
||||||
|
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
|
||||||
|
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, User, UserId,
|
||||||
|
UserKdfType, UserSignatureKeyPair,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mail,
|
||||||
|
util::{NumberOrString, deser_opt_nonempty_str, format_date},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ciphers::{CipherData, update_cipher_from_data},
|
||||||
|
sends::{SendData, update_send_from_data},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<rocket::Route> {
|
pub fn routes() -> Vec<rocket::Route> {
|
||||||
@@ -38,6 +42,8 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
post_profile,
|
post_profile,
|
||||||
put_avatar,
|
put_avatar,
|
||||||
get_public_keys,
|
get_public_keys,
|
||||||
|
get_account_public_keys,
|
||||||
|
get_keys,
|
||||||
post_keys,
|
post_keys,
|
||||||
post_password,
|
post_password,
|
||||||
post_set_password,
|
post_set_password,
|
||||||
@@ -54,9 +60,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
delete_account,
|
delete_account,
|
||||||
revision_date,
|
revision_date,
|
||||||
password_hint,
|
password_hint,
|
||||||
prelogin,
|
post_prelogin,
|
||||||
verify_password,
|
verify_password,
|
||||||
api_key,
|
post_api_key,
|
||||||
rotate_api_key,
|
rotate_api_key,
|
||||||
get_known_device,
|
get_known_device,
|
||||||
get_all_devices,
|
get_all_devices,
|
||||||
@@ -94,14 +100,14 @@ pub struct RegisterData {
|
|||||||
email: String,
|
email: String,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
kdf: KDFData,
|
compat: RegisterDataCompat,
|
||||||
|
|
||||||
#[serde(alias = "userSymmetricKey")]
|
|
||||||
key: String,
|
|
||||||
#[serde(alias = "userAsymmetricKeys")]
|
#[serde(alias = "userAsymmetricKeys")]
|
||||||
keys: Option<KeysData>,
|
keys: Option<KeysData>,
|
||||||
|
|
||||||
master_password_hash: String,
|
// Supersedes `keys`, and the only way a v2 account can be registered.
|
||||||
|
account_keys: Option<AccountKeysData>,
|
||||||
|
|
||||||
master_password_hint: Option<String>,
|
master_password_hint: Option<String>,
|
||||||
|
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -117,16 +123,70 @@ pub struct RegisterData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
struct RegisterDataOld {
|
||||||
pub struct SetPasswordData {
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
kdf: KDFData,
|
kdf: KDFData,
|
||||||
|
|
||||||
|
#[serde(alias = "userSymmetricKey")]
|
||||||
key: String,
|
key: String,
|
||||||
keys: Option<KeysData>,
|
|
||||||
|
#[serde(alias = "masterPasswordHash")]
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
master_password_hint: Option<String>,
|
}
|
||||||
org_identifier: Option<String>,
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RegisterDataCur {
|
||||||
|
master_password_authentication: MasterPasswordAuthentication,
|
||||||
|
master_password_unlock: MasterPasswordUnlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum RegisterDataCompat {
|
||||||
|
RegisterDataOld(RegisterDataOld),
|
||||||
|
RegisterDataCur(RegisterDataCur),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterDataCompat {
|
||||||
|
fn fold<'a, T>(
|
||||||
|
&'a self,
|
||||||
|
fct: impl FnOnce(&'a RegisterDataOld) -> &'a T,
|
||||||
|
fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T,
|
||||||
|
) -> &'a T {
|
||||||
|
match self {
|
||||||
|
RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc),
|
||||||
|
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash(&self) -> String {
|
||||||
|
self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kdf(&self) -> &KDFData {
|
||||||
|
self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self) -> String {
|
||||||
|
self.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When comparing with salt, email need to be normalized:
|
||||||
|
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
|
||||||
|
fn unprocessable(&self, email: &str) -> bool {
|
||||||
|
let mut unprocessable = false;
|
||||||
|
*self.fold(
|
||||||
|
|_| &false,
|
||||||
|
|rdcu| {
|
||||||
|
let email = email.trim().to_lowercase();
|
||||||
|
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|
||||||
|
|| rdcu.master_password_authentication.salt != email
|
||||||
|
|| rdcu.master_password_unlock.salt != email;
|
||||||
|
&unprocessable
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -136,13 +196,208 @@ struct KeysData {
|
|||||||
public_key: String,
|
public_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `accountKeys` payload, which replaces the flat `keys`/`userAsymmetricKeys` object.
|
||||||
|
///
|
||||||
|
/// It carries either a "v1" state (just the encryption key pair) or a "v2" one, which adds a
|
||||||
|
/// signature key pair, a signed public key, and a signed security state. The two deprecated
|
||||||
|
/// top-level fields are still sent by the SDK alongside the nested ones and are only used as a
|
||||||
|
/// fallback for clients that don't send `publicKeyEncryptionKeyPair` yet.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs>
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AccountKeysData {
|
||||||
|
user_key_encrypted_account_private_key: Option<String>,
|
||||||
|
account_public_key: Option<String>,
|
||||||
|
|
||||||
|
public_key_encryption_key_pair: Option<PublicKeyEncryptionKeyPairData>,
|
||||||
|
signature_key_pair: Option<SignatureKeyPairData>,
|
||||||
|
security_state: Option<SecurityStateData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PublicKeyEncryptionKeyPairData {
|
||||||
|
wrapped_private_key: String,
|
||||||
|
public_key: String,
|
||||||
|
signed_public_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SignatureKeyPairData {
|
||||||
|
signature_algorithm: String,
|
||||||
|
wrapped_signing_key: String,
|
||||||
|
verifying_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SecurityStateData {
|
||||||
|
security_state: String,
|
||||||
|
security_version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ValidatedAccountKeys {
|
||||||
|
private_key: String,
|
||||||
|
public_key: String,
|
||||||
|
v2: Option<ValidatedV2AccountKeys>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ValidatedV2AccountKeys {
|
||||||
|
signed_public_key: String,
|
||||||
|
signing_key: String,
|
||||||
|
verifying_key: String,
|
||||||
|
signature_algorithm: SignatureAlgorithm,
|
||||||
|
security_state: String,
|
||||||
|
security_version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountKeysData {
|
||||||
|
/// Checks that the payload describes a complete account cryptographic state.
|
||||||
|
///
|
||||||
|
/// The v2 fields have to be all present or all absent: a client that receives a COSE-wrapped
|
||||||
|
/// private key without the matching signature key pair and security state refuses to unlock the
|
||||||
|
/// vault, so storing half a state would produce an account nobody can log into.
|
||||||
|
pub fn validate(self) -> ApiResult<ValidatedAccountKeys> {
|
||||||
|
let (private_key, public_key, signed_public_key) = if let Some(key_pair) = self.public_key_encryption_key_pair {
|
||||||
|
(key_pair.wrapped_private_key, key_pair.public_key, key_pair.signed_public_key)
|
||||||
|
// Older clients only send the deprecated top-level fields, which are always v1.
|
||||||
|
} else if let (Some(private_key), Some(public_key)) =
|
||||||
|
(self.user_key_encrypted_account_private_key, self.account_public_key)
|
||||||
|
{
|
||||||
|
(private_key, public_key, None)
|
||||||
|
} else {
|
||||||
|
err!("The account keys are missing an encryption key pair")
|
||||||
|
};
|
||||||
|
|
||||||
|
let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) {
|
||||||
|
(Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => {
|
||||||
|
let Some(signature_algorithm) = SignatureAlgorithm::from_str(&signature_key_pair.signature_algorithm)
|
||||||
|
else {
|
||||||
|
err!(format!("Unsupported signature algorithm: {}", signature_key_pair.signature_algorithm))
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(ValidatedV2AccountKeys {
|
||||||
|
signed_public_key,
|
||||||
|
signing_key: signature_key_pair.wrapped_signing_key,
|
||||||
|
verifying_key: signature_key_pair.verifying_key,
|
||||||
|
signature_algorithm,
|
||||||
|
security_state: security_state.security_state,
|
||||||
|
security_version: security_state.security_version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(None, None, None) => None,
|
||||||
|
_ => err!(
|
||||||
|
"Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ValidatedAccountKeys {
|
||||||
|
private_key,
|
||||||
|
public_key,
|
||||||
|
v2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<KeysData> for ValidatedAccountKeys {
|
||||||
|
fn from(keys: KeysData) -> Self {
|
||||||
|
Self {
|
||||||
|
private_key: keys.encrypted_private_key,
|
||||||
|
public_key: keys.public_key,
|
||||||
|
v2: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidatedAccountKeys {
|
||||||
|
/// Writes the parts of the state that live on the user itself. The user still needs saving, and
|
||||||
|
/// [`Self::save_signature_key_pair`] still needs calling once it has been.
|
||||||
|
///
|
||||||
|
/// Rejects downgrading an account from v2 back to v1.
|
||||||
|
pub fn apply(&self, user: &mut User) -> EmptyResult {
|
||||||
|
if user.is_v2() && self.v2.is_none() {
|
||||||
|
err!("Cannot downgrade an account from v2 to v1 encryption")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.private_key = Some(self.private_key.clone());
|
||||||
|
user.public_key = Some(self.public_key.clone());
|
||||||
|
|
||||||
|
user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone());
|
||||||
|
user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone());
|
||||||
|
user.security_version = self.v2.as_ref().map(|v2| v2.security_version);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the signature key pair. Separate from [`Self::apply`] because the row has a foreign
|
||||||
|
/// key to the user, so it can only be written once the user exists.
|
||||||
|
pub async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
|
// Skip if the account is v1, since v1 accounts don't have a signature key pair.
|
||||||
|
let Some(v2) = &self.v2 else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut key_pair = match UserSignatureKeyPair::find_active_by_user(user_id, conn).await {
|
||||||
|
Some(mut key_pair) => {
|
||||||
|
key_pair.signature_algorithm = v2.signature_algorithm as i32;
|
||||||
|
key_pair.signing_key.clone_from(&v2.signing_key);
|
||||||
|
key_pair.verifying_key.clone_from(&v2.verifying_key);
|
||||||
|
key_pair
|
||||||
|
}
|
||||||
|
None => UserSignatureKeyPair::new(
|
||||||
|
user_id.clone(),
|
||||||
|
v2.signature_algorithm,
|
||||||
|
v2.signing_key.clone(),
|
||||||
|
v2.verifying_key.clone(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
key_pair.save(conn).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MasterPasswordAuthentication {
|
||||||
|
kdf: KDFData,
|
||||||
|
salt: String,
|
||||||
|
|
||||||
|
#[serde(alias = "masterPasswordAuthenticationHash")]
|
||||||
|
hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MasterPasswordUnlock {
|
||||||
|
kdf: KDFData,
|
||||||
|
salt: String,
|
||||||
|
|
||||||
|
#[serde(alias = "masterKeyWrappedUserKey")]
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetPasswordData {
|
||||||
|
#[serde(flatten)]
|
||||||
|
compat: RegisterDataCompat,
|
||||||
|
|
||||||
|
keys: Option<KeysData>,
|
||||||
|
// Supersedes `keys`, and the only way a v2 account can be initialized here.
|
||||||
|
account_keys: Option<AccountKeysData>,
|
||||||
|
|
||||||
|
master_password_hint: Option<String>,
|
||||||
|
org_identifier: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
||||||
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
|
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
|
||||||
match password_hint {
|
match password_hint {
|
||||||
None => None,
|
None => None,
|
||||||
Some(h) => match h.trim() {
|
Some(h) => match h.trim() {
|
||||||
"" => None,
|
"" => None,
|
||||||
ht => Some(ht.to_string()),
|
ht => Some(ht.to_owned()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +421,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||||
let mut data: RegisterData = data.into_inner();
|
let mut data: RegisterData = data.into_inner();
|
||||||
let email = data.email.to_lowercase();
|
let email = data.email.to_lowercase();
|
||||||
|
|
||||||
@@ -174,6 +429,10 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
|
|
||||||
let mut pending_emergency_access = None;
|
let mut pending_emergency_access = None;
|
||||||
|
|
||||||
|
if data.compat.unprocessable(&data.email) {
|
||||||
|
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// First, validate the provided verification tokens
|
// First, validate the provided verification tokens
|
||||||
if email_verification {
|
if email_verification {
|
||||||
match (
|
match (
|
||||||
@@ -237,10 +496,10 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
|
|
||||||
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
|
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
|
||||||
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
||||||
if let Some(ref name) = data.name {
|
if let Some(ref name) = data.name
|
||||||
if name.len() > 50 {
|
&& name.len() > 50
|
||||||
err!("The field Name must be a string with a maximum length of 50.");
|
{
|
||||||
}
|
err!("The field Name must be a string with a maximum length of 50.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check against the password hint setting here so if it fails, the user
|
// Check against the password hint setting here so if it fails, the user
|
||||||
@@ -254,8 +513,8 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
err!("Registration not allowed or user already exists")
|
err!("Registration not allowed or user already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(token) = data.org_invite_token {
|
if let Some(token) = data.org_invite_token.as_ref() {
|
||||||
let claims = decode_invite(&token)?;
|
let claims = decode_invite(token)?;
|
||||||
if claims.email == email {
|
if claims.email == email {
|
||||||
// Verify the email address when signing up via a valid invite token
|
// Verify the email address when signing up via a valid invite token
|
||||||
email_verified = true;
|
email_verified = true;
|
||||||
@@ -293,9 +552,9 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
// Make sure we don't leave a lingering invitation.
|
// Make sure we don't leave a lingering invitation.
|
||||||
Invitation::take(&email, &conn).await;
|
Invitation::take(&email, &conn).await;
|
||||||
|
|
||||||
set_kdf_data(&mut user, &data.kdf)?;
|
set_kdf_data(&mut user, data.compat.kdf())?;
|
||||||
|
|
||||||
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
|
user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?;
|
||||||
user.password_hint = password_hint;
|
user.password_hint = password_hint;
|
||||||
|
|
||||||
// Add extra fields if present
|
// Add extra fields if present
|
||||||
@@ -303,9 +562,13 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
user.name = name;
|
user.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(keys) = data.keys {
|
let account_keys = match (data.account_keys, data.keys) {
|
||||||
user.private_key = Some(keys.encrypted_private_key);
|
(Some(account_keys), _) => Some(account_keys.validate()?),
|
||||||
user.public_key = Some(keys.public_key);
|
(None, Some(keys)) => Some(keys.into()),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
if let Some(ref account_keys) = account_keys {
|
||||||
|
account_keys.apply(&mut user)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if email_verified {
|
if email_verified {
|
||||||
@@ -329,6 +592,10 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
|||||||
|
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
|
if let Some(account_keys) = account_keys {
|
||||||
|
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
// accept any open emergency access invitations
|
// accept any open emergency access invitations
|
||||||
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
|
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
|
||||||
for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await {
|
for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await {
|
||||||
@@ -351,16 +618,26 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
err!("Account already initialized, cannot set password")
|
err!("Account already initialized, cannot set password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.compat.unprocessable(&user.email) {
|
||||||
|
err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code);
|
||||||
|
}
|
||||||
|
|
||||||
// Check against the password hint setting here so if it fails,
|
// Check against the password hint setting here so if it fails,
|
||||||
// the user can retry without losing their invitation below.
|
// the user can retry without losing their invitation below.
|
||||||
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||||
|
|
||||||
set_kdf_data(&mut user, &data.kdf)?;
|
let account_keys = match (data.account_keys, data.keys) {
|
||||||
|
(Some(account_keys), _) => Some(account_keys.validate()?),
|
||||||
|
(None, Some(keys)) => Some(keys.into()),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
set_kdf_data(&mut user, data.compat.kdf())?;
|
||||||
|
|
||||||
user.set_password(
|
user.set_password(
|
||||||
&data.master_password_hash,
|
&data.compat.hash(),
|
||||||
Some(data.key),
|
Some(data.compat.key()),
|
||||||
false,
|
false,
|
||||||
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
||||||
&conn,
|
&conn,
|
||||||
@@ -368,23 +645,23 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
.await?;
|
.await?;
|
||||||
user.password_hint = password_hint;
|
user.password_hint = password_hint;
|
||||||
|
|
||||||
if let Some(keys) = data.keys {
|
if let Some(ref account_keys) = account_keys {
|
||||||
user.private_key = Some(keys.encrypted_private_key);
|
account_keys.apply(&mut user)?;
|
||||||
user.public_key = Some(keys.public_key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(identifier) = data.org_identifier {
|
if let Some(identifier) = data.org_identifier
|
||||||
if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
|
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER
|
||||||
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
&& identifier != crate::api::admin::FAKE_ADMIN_UUID
|
||||||
err!("Failed to retrieve the associated organization")
|
{
|
||||||
};
|
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
||||||
|
err!("Failed to retrieve the associated organization")
|
||||||
|
};
|
||||||
|
|
||||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
||||||
err!("Failed to retrieve the invitation")
|
err!("Failed to retrieve the invitation")
|
||||||
};
|
};
|
||||||
|
|
||||||
accept_org_invite(&user, membership, None, &conn).await?;
|
accept_org_invite(&user, membership, None, &conn).await?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -398,6 +675,10 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
|||||||
|
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
|
if let Some(account_keys) = account_keys {
|
||||||
|
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"object": "set-password",
|
"object": "set-password",
|
||||||
"captchaBypassToken": "",
|
"captchaBypassToken": "",
|
||||||
@@ -451,10 +732,10 @@ async fn put_avatar(data: Json<AvatarData>, headers: Headers, conn: DbConn) -> J
|
|||||||
// It looks like it only supports the 6 hex color format.
|
// It looks like it only supports the 6 hex color format.
|
||||||
// If you try to add the short value it will not show that color.
|
// If you try to add the short value it will not show that color.
|
||||||
// Check and force 7 chars, including the #.
|
// Check and force 7 chars, including the #.
|
||||||
if let Some(color) = &data.avatar_color {
|
if let Some(color) = &data.avatar_color
|
||||||
if color.len() != 7 {
|
&& color.len() != 7
|
||||||
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
{
|
||||||
}
|
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
@@ -479,20 +760,60 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/users/<user_id>/keys")]
|
||||||
|
async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
|
let user = match User::find_by_uuid(&user_id, &conn).await {
|
||||||
|
Some(user) if user.public_key.is_some() => user,
|
||||||
|
Some(_) => err_code!("User has no public_key", Status::NotFound.code),
|
||||||
|
None => err_code!("User doesn't exist", Status::NotFound.code),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(user.public_keys_json(&conn).await))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/accounts/keys")]
|
||||||
|
async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
|
let user = headers.user;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"key": user.akey,
|
||||||
|
"privateKey": user.private_key,
|
||||||
|
"publicKey": user.public_key,
|
||||||
|
"accountKeys": user.account_keys_json(&conn).await,
|
||||||
|
"object": "keys"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PostKeysData {
|
||||||
|
#[serde(flatten)]
|
||||||
|
keys: Option<KeysData>,
|
||||||
|
account_keys: Option<AccountKeysData>,
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/accounts/keys", data = "<data>")]
|
#[post("/accounts/keys", data = "<data>")]
|
||||||
async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn post_keys(data: Json<PostKeysData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: KeysData = data.into_inner();
|
let data: PostKeysData = data.into_inner();
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
user.private_key = Some(data.encrypted_private_key);
|
// `accountKeys` supersedes the flat `keys` object when both are sent.
|
||||||
user.public_key = Some(data.public_key);
|
let account_keys = match (data.account_keys, data.keys) {
|
||||||
|
(Some(account_keys), _) => account_keys.validate()?,
|
||||||
|
(None, Some(keys)) => keys.into(),
|
||||||
|
(None, None) => err!("No account keys provided"),
|
||||||
|
};
|
||||||
|
|
||||||
|
account_keys.apply(&mut user)?;
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
|
"key": user.akey,
|
||||||
"privateKey": user.private_key,
|
"privateKey": user.private_key,
|
||||||
"publicKey": user.public_key,
|
"publicKey": user.public_key,
|
||||||
|
"accountKeys": user.account_keys_json(&conn).await,
|
||||||
"object":"keys"
|
"object":"keys"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -599,10 +920,6 @@ struct UnlockData {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ChangeKdfData {
|
struct ChangeKdfData {
|
||||||
#[allow(dead_code)]
|
|
||||||
new_master_password_hash: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
key: String,
|
|
||||||
authentication_data: AuthenticationData,
|
authentication_data: AuthenticationData,
|
||||||
unlock_data: UnlockData,
|
unlock_data: UnlockData,
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
@@ -668,9 +985,6 @@ struct UpdateResetPasswordData {
|
|||||||
reset_password_key: String,
|
reset_password_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::ciphers::CipherData;
|
|
||||||
use super::sends::{update_send_from_data, SendData};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct KeyData {
|
struct KeyData {
|
||||||
@@ -840,7 +1154,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
};
|
};
|
||||||
|
|
||||||
saved_folder.name = folder_data.name;
|
saved_folder.name = folder_data.name;
|
||||||
saved_folder.save(&conn).await?
|
saved_folder.save(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,7 +1167,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
};
|
};
|
||||||
|
|
||||||
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
||||||
saved_emergency_access.save(&conn).await?
|
saved_emergency_access.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update reset password data
|
// Update reset password data
|
||||||
@@ -865,7 +1179,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
};
|
};
|
||||||
|
|
||||||
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
||||||
membership.save(&conn).await?
|
membership.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update send data
|
// Update send data
|
||||||
@@ -878,8 +1192,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update cipher data
|
// Update cipher data
|
||||||
use super::ciphers::update_cipher_from_data;
|
|
||||||
|
|
||||||
for cipher_data in data.account_data.ciphers {
|
for cipher_data in data.account_data.ciphers {
|
||||||
if cipher_data.organization_id.is_none() {
|
if cipher_data.organization_id.is_none() {
|
||||||
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
||||||
@@ -890,7 +1202,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
|||||||
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
||||||
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
||||||
// We force the users to logout after the user has been saved to try and prevent these issues.
|
// We force the users to logout after the user has been saved to try and prevent these issues.
|
||||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?
|
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,24 +1332,22 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
|||||||
err!("Email already in use");
|
err!("Email already in use");
|
||||||
}
|
}
|
||||||
|
|
||||||
match user.email_new {
|
if let Some(ref val) = user.email_new {
|
||||||
Some(ref val) => {
|
if val != &data.new_email {
|
||||||
if val != &data.new_email {
|
err!("Email change mismatch");
|
||||||
err!("Email change mismatch");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => err!("No email change pending"),
|
} else {
|
||||||
|
err!("No email change pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
// Only check the token if we sent out an email...
|
// Only check the token if we sent out an email...
|
||||||
match user.email_new_token {
|
if let Some(ref val) = user.email_new_token {
|
||||||
Some(ref val) => {
|
if *val != data.token.into_string() {
|
||||||
if *val != data.token.into_string() {
|
err!("Token mismatch");
|
||||||
err!("Token mismatch");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => err!("No email change pending"),
|
} else {
|
||||||
|
err!("No email change pending")
|
||||||
}
|
}
|
||||||
user.verified_at = Some(Utc::now().naive_utc());
|
user.verified_at = Some(Utc::now().naive_utc());
|
||||||
} else {
|
} else {
|
||||||
@@ -1110,14 +1420,16 @@ struct DeleteRecoverData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete-recover", data = "<data>")]
|
#[post("/accounts/delete-recover", data = "<data>")]
|
||||||
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult {
|
async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data: DeleteRecoverData = data.into_inner();
|
let data: DeleteRecoverData = data.into_inner();
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
if let Some(user) = User::find_by_mail(&data.email, &conn).await {
|
if let Some(user) = User::find_by_mail(&data.email, &conn).await
|
||||||
if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await {
|
&& let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await
|
||||||
error!("Error sending delete account email: {e:#?}");
|
{
|
||||||
}
|
error!("Error sending delete account email: {e:#?}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -1169,6 +1481,7 @@ async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: D
|
|||||||
user.delete(&conn).await
|
user.delete(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
#[get("/accounts/revision-date")]
|
#[get("/accounts/revision-date")]
|
||||||
fn revision_date(headers: Headers) -> JsonResult {
|
fn revision_date(headers: Headers) -> JsonResult {
|
||||||
let revision_date = headers.user.updated_at.and_utc().timestamp_millis();
|
let revision_date = headers.user.updated_at.and_utc().timestamp_millis();
|
||||||
@@ -1182,13 +1495,15 @@ struct PasswordHintData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/password-hint", data = "<data>")]
|
#[post("/accounts/password-hint", data = "<data>")]
|
||||||
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult {
|
async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||||
|
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
||||||
err!("This server is not configured to provide password hints.");
|
err!("This server is not configured to provide password hints.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
|
||||||
|
|
||||||
let data: PasswordHintData = data.into_inner();
|
let data: PasswordHintData = data.into_inner();
|
||||||
let email = &data.email;
|
let email = &data.email;
|
||||||
|
|
||||||
@@ -1199,9 +1514,9 @@ async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResul
|
|||||||
// There is still a timing side channel here in that the code
|
// There is still a timing side channel here in that the code
|
||||||
// paths that send mail take noticeably longer than ones that
|
// paths that send mail take noticeably longer than ones that
|
||||||
// don't. Add a randomized sleep to mitigate this somewhat.
|
// don't. Add a randomized sleep to mitigate this somewhat.
|
||||||
use rand::{rngs::SmallRng, RngExt};
|
use rand::{RngExt, rngs::SmallRng};
|
||||||
let mut rng: SmallRng = rand::make_rng();
|
let mut rng: SmallRng = rand::make_rng();
|
||||||
let sleep_ms = rng.random_range(900..=1100) as u64;
|
let sleep_ms: u64 = rng.random_range(900..=1100);
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -1229,11 +1544,11 @@ pub struct PreloginData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin", data = "<data>")]
|
#[post("/accounts/prelogin", data = "<data>")]
|
||||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||||
_prelogin(data, conn).await
|
prelogin(data, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||||
let data: PreloginData = data.into_inner();
|
let data: PreloginData = data.into_inner();
|
||||||
|
|
||||||
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
||||||
@@ -1246,6 +1561,13 @@ pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
|||||||
"kdfIterations": kdf_iter,
|
"kdfIterations": kdf_iter,
|
||||||
"kdfMemory": kdf_mem,
|
"kdfMemory": kdf_mem,
|
||||||
"kdfParallelism": kdf_para,
|
"kdfParallelism": kdf_para,
|
||||||
|
"kdfSettings": {
|
||||||
|
"iterations": kdf_iter,
|
||||||
|
"kdfType": kdf_type,
|
||||||
|
"memory": kdf_mem,
|
||||||
|
"parallelism": kdf_para
|
||||||
|
},
|
||||||
|
"salt": null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,9 +1605,7 @@ async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers
|
|||||||
Ok(Json(master_password_policy(&user, &conn).await))
|
Ok(Json(master_password_policy(&user, &conn).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn update_api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
use crate::util::format_date;
|
|
||||||
|
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
@@ -1304,13 +1624,13 @@ async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/api-key", data = "<data>")]
|
#[post("/accounts/api-key", data = "<data>")]
|
||||||
async fn api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn post_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
_api_key(data, false, headers, conn).await
|
update_api_key(data, false, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/rotate-api-key", data = "<data>")]
|
#[post("/accounts/rotate-api-key", data = "<data>")]
|
||||||
async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
_api_key(data, true, headers, conn).await
|
update_api_key(data, true, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/devices/knowndevice")]
|
#[get("/devices/knowndevice")]
|
||||||
@@ -1353,7 +1673,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
|
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
|
||||||
uuid.to_string().into()
|
uuid.to_owned().into()
|
||||||
} else {
|
} else {
|
||||||
return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
|
return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
|
||||||
};
|
};
|
||||||
@@ -1368,7 +1688,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
|||||||
#[get("/devices")]
|
#[get("/devices")]
|
||||||
async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await;
|
let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await;
|
||||||
let devices = devices.iter().map(|device| device.to_json()).collect::<Vec<Value>>();
|
let devices = devices.iter().map(DeviceWithAuthRequest::to_json).collect::<Vec<Value>>();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"data": devices,
|
"data": devices,
|
||||||
@@ -1424,7 +1744,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/devices/identifier/<device_id>/clear-token")]
|
#[put("/devices/identifier/<device_id>/clear-token")]
|
||||||
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
// This only clears push token
|
// This only clears push token
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
||||||
@@ -1446,8 +1768,8 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
|
|||||||
|
|
||||||
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
||||||
#[post("/devices/identifier/<device_id>/clear-token")]
|
#[post("/devices/identifier/<device_id>/clear-token")]
|
||||||
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||||
put_clear_device_token(device_id, conn).await
|
put_clear_device_token(device_id, ip, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/tasks")]
|
#[get("/tasks")]
|
||||||
@@ -1708,6 +2030,6 @@ pub async fn purge_auth_requests(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
AuthRequest::purge_expired_auth_requests(&conn).await;
|
AuthRequest::purge_expired_auth_requests(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging auth requests")
|
error!("Failed to get DB connection while purging auth requests");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-122
@@ -2,30 +2,30 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use rocket::fs::TempFile;
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
form::{Form, FromForm},
|
|
||||||
Route,
|
Route,
|
||||||
|
form::{Form, FromForm},
|
||||||
|
fs::TempFile,
|
||||||
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::auth::ClientVersion;
|
|
||||||
use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
CONFIG,
|
||||||
|
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||||
|
auth::ClientVersion,
|
||||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
|
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
|
||||||
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbPool,
|
|
||||||
},
|
},
|
||||||
CONFIG,
|
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::folders::FolderData;
|
use super::folders::FolderData;
|
||||||
@@ -108,7 +108,7 @@ pub async fn purge_trashed_ciphers(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Cipher::purge_trash(&conn).await;
|
Cipher::purge_trash(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging trashed ciphers")
|
error!("Failed to get DB connection while purging trashed ciphers");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
let domains_json = if data.exclude_domains {
|
let domains_json = if data.exclude_domains {
|
||||||
Value::Null
|
Value::Null
|
||||||
} else {
|
} else {
|
||||||
api::core::_get_eq_domains(&headers, true).into_inner()
|
api::core::get_eq_domains(&headers, true).into_inner()
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is very similar to the the userDecryptionOptions sent in connect/token,
|
// This is very similar to the the userDecryptionOptions sent in connect/token,
|
||||||
@@ -198,6 +198,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
"sends": sends_json,
|
"sends": sends_json,
|
||||||
"userDecryption": {
|
"userDecryption": {
|
||||||
"masterPasswordUnlock": master_password_unlock,
|
"masterPasswordUnlock": master_password_unlock,
|
||||||
|
"v2UpgradeToken": headers.user.v2_upgrade_token_json(),
|
||||||
},
|
},
|
||||||
"object": "sync"
|
"object": "sync"
|
||||||
})))
|
})))
|
||||||
@@ -401,20 +402,34 @@ pub async fn update_cipher_from_data(
|
|||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
ut: UpdateType,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
|
// Cleanup cipher data, like removing the 'Response' key.
|
||||||
|
// This key is somewhere generated during Javascript so no way for us this fix this.
|
||||||
|
// Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key.
|
||||||
|
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
|
||||||
|
// But, we at least know we do not need to store and return this specific key.
|
||||||
|
fn clean_cipher_data(mut json_data: Value) -> Value {
|
||||||
|
if json_data.is_array() {
|
||||||
|
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
|
||||||
|
f.as_object_mut().unwrap().remove("response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
json_data
|
||||||
|
}
|
||||||
|
|
||||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||||
|
|
||||||
// Check that the client isn't updating an existing cipher with stale data.
|
// Check that the client isn't updating an existing cipher with stale data.
|
||||||
// And only perform this check when not importing ciphers, else the date/time check will fail.
|
// And only perform this check when not importing ciphers, else the date/time check will fail.
|
||||||
if ut != UpdateType::None {
|
if ut != UpdateType::None
|
||||||
if let Some(dt) = data.last_known_revision_date {
|
&& let Some(dt) = data.last_known_revision_date
|
||||||
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
{
|
||||||
// ISO 8601 format
|
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
||||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
// ISO 8601 format
|
||||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
||||||
}
|
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
||||||
Ok(_) => (),
|
|
||||||
}
|
}
|
||||||
|
Ok(_) => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +451,9 @@ pub async fn update_cipher_from_data(
|
|||||||
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
||||||
None => err!("You don't have permission to add item to organization"),
|
None => err!("You don't have permission to add item to organization"),
|
||||||
Some(member) => {
|
Some(member) => {
|
||||||
if shared_to_collections.is_some()
|
// A non-empty list of collections implies the caller already validated the user's write
|
||||||
|
// access to them, so we can move the cipher into the organization on that basis.
|
||||||
|
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|
||||||
|| member.has_full_access()
|
|| member.has_full_access()
|
||||||
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|
||||||
{
|
{
|
||||||
@@ -456,25 +473,22 @@ pub async fn update_cipher_from_data(
|
|||||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
cipher.user_uuid = Some(headers.user.uuid.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id
|
||||||
if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() {
|
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
{
|
||||||
}
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify attachments name and keys when rotating
|
// Modify attachments name and keys when rotating
|
||||||
if let Some(attachments) = data.attachments2 {
|
if let Some(attachments) = data.attachments2 {
|
||||||
for (id, attachment) in attachments {
|
for (id, attachment) in attachments {
|
||||||
let mut saved_att = match Attachment::find_by_id(&id, conn).await {
|
let Some(mut saved_att) = Attachment::find_by_id(&id, conn).await else {
|
||||||
Some(att) => att,
|
// Warn and continue here.
|
||||||
None => {
|
// A missing attachment means it was removed via an other client.
|
||||||
// Warn and continue here.
|
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||||
// A missing attachment means it was removed via an other client.
|
// Bitwarden it self ignores these mismatches server side.
|
||||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
warn!("Attachment {id} doesn't exist");
|
||||||
// Bitwarden it self ignores these mismatches server side.
|
continue;
|
||||||
warn!("Attachment {id} doesn't exist");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if saved_att.cipher_uuid != cipher.uuid {
|
if saved_att.cipher_uuid != cipher.uuid {
|
||||||
@@ -491,20 +505,6 @@ pub async fn update_cipher_from_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup cipher data, like removing the 'Response' key.
|
|
||||||
// This key is somewhere generated during Javascript so no way for us this fix this.
|
|
||||||
// Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key.
|
|
||||||
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
|
|
||||||
// But, we at least know we do not need to store and return this specific key.
|
|
||||||
fn _clean_cipher_data(mut json_data: Value) -> Value {
|
|
||||||
if json_data.is_array() {
|
|
||||||
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
|
|
||||||
f.as_object_mut().unwrap().remove("response");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
json_data
|
|
||||||
}
|
|
||||||
|
|
||||||
let type_data_opt = match data.r#type {
|
let type_data_opt = match data.r#type {
|
||||||
1 => data.login,
|
1 => data.login,
|
||||||
2 => data.secure_note,
|
2 => data.secure_note,
|
||||||
@@ -514,23 +514,22 @@ pub async fn update_cipher_from_data(
|
|||||||
_ => err!("Invalid type"),
|
_ => err!("Invalid type"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let type_data = match type_data_opt {
|
let type_data = if let Some(mut data) = type_data_opt {
|
||||||
Some(mut data) => {
|
// Remove the 'Response' key from the base object.
|
||||||
// Remove the 'Response' key from the base object.
|
data.as_object_mut().unwrap().remove("response");
|
||||||
data.as_object_mut().unwrap().remove("response");
|
// Remove the 'Response' key from every Uri.
|
||||||
// Remove the 'Response' key from every Uri.
|
if data["uris"].is_array() {
|
||||||
if data["uris"].is_array() {
|
data["uris"] = clean_cipher_data(data["uris"].clone());
|
||||||
data["uris"] = _clean_cipher_data(data["uris"].clone());
|
|
||||||
}
|
|
||||||
data
|
|
||||||
}
|
}
|
||||||
None => err!("Data missing"),
|
data
|
||||||
|
} else {
|
||||||
|
err!("Data missing")
|
||||||
};
|
};
|
||||||
|
|
||||||
cipher.key = data.key;
|
cipher.key = data.key;
|
||||||
cipher.name = data.name;
|
cipher.name = data.name;
|
||||||
cipher.notes = data.notes;
|
cipher.notes = data.notes;
|
||||||
cipher.fields = data.fields.map(|f| _clean_cipher_data(f).to_string());
|
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
|
||||||
cipher.data = type_data.to_string();
|
cipher.data = type_data.to_string();
|
||||||
cipher.password_history = data.password_history.map(|f| f.to_string());
|
cipher.password_history = data.password_history.map(|f| f.to_string());
|
||||||
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
||||||
@@ -612,7 +611,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
let existing_folders: HashSet<Option<FolderId>> =
|
let existing_folders: HashSet<Option<FolderId>> =
|
||||||
Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect();
|
Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect();
|
||||||
let mut folders: Vec<FolderId> = Vec::with_capacity(data.folders.len());
|
let mut folders: Vec<FolderId> = Vec::with_capacity(data.folders.len());
|
||||||
for folder in data.folders.into_iter() {
|
for folder in data.folders {
|
||||||
let folder_id = if existing_folders.contains(&folder.id) {
|
let folder_id = if existing_folders.contains(&folder.id) {
|
||||||
folder.id.unwrap()
|
folder.id.unwrap()
|
||||||
} else {
|
} else {
|
||||||
@@ -633,7 +632,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
|
|
||||||
// Read and create the ciphers
|
// Read and create the ciphers
|
||||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||||
let folder_id = relations_map.get(&index).map(|i| folders[*i].clone());
|
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||||
cipher_data.folder_id = folder_id;
|
cipher_data.folder_id = folder_id;
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
||||||
@@ -737,10 +736,10 @@ async fn put_cipher_partial(
|
|||||||
err!("Cipher does not exist", "Cipher is not accessible for the current user")
|
err!("Cipher does not exist", "Cipher is not accessible for the current user")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id
|
||||||
if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() {
|
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none()
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
{
|
||||||
}
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move cipher
|
// Move cipher
|
||||||
@@ -872,7 +871,7 @@ async fn put_collections_admin(
|
|||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> JsonResult {
|
||||||
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,7 +882,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 {
|
||||||
@@ -942,7 +941,7 @@ async fn post_collections_admin(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1004,7 +1003,7 @@ async fn put_cipher_share_selected(
|
|||||||
err!("You must select at least one collection.")
|
err!("You must select at least one collection.")
|
||||||
}
|
}
|
||||||
|
|
||||||
for cipher in data.ciphers.iter() {
|
for cipher in &data.ciphers {
|
||||||
if cipher.id.is_none() {
|
if cipher.id.is_none() {
|
||||||
err!("Request missing ids field")
|
err!("Request missing ids field")
|
||||||
}
|
}
|
||||||
@@ -1016,11 +1015,10 @@ async fn put_cipher_share_selected(
|
|||||||
collection_ids: data.collection_ids.clone(),
|
collection_ids: data.collection_ids.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match shared_cipher_data.cipher.id.take() {
|
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||||
Some(id) => {
|
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
} else {
|
||||||
}
|
err!("Request missing ids field")
|
||||||
None => err!("Request missing ids field"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,17 +1036,23 @@ async fn share_cipher_by_uuid(
|
|||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
override_ut: Option<UpdateType>,
|
override_ut: Option<UpdateType>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await {
|
let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await {
|
||||||
Some(cipher) => {
|
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||||
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
cipher
|
||||||
cipher
|
} else {
|
||||||
} else {
|
err!("Cipher is not write accessible")
|
||||||
err!("Cipher is not write accessible")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => err!("Cipher doesn't exist"),
|
} else {
|
||||||
|
err!("Cipher doesn't exist")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `update_cipher_from_data()` rejects this too, but only after the collections below were
|
||||||
|
// already linked. There are no transactions, so that would leave the cipher linked to a
|
||||||
|
// collection of another organization.
|
||||||
|
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id {
|
||||||
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
|
}
|
||||||
|
|
||||||
let mut shared_to_collections = vec![];
|
let mut shared_to_collections = vec![];
|
||||||
|
|
||||||
if let Some(organization_id) = &data.cipher.organization_id {
|
if let Some(organization_id) = &data.cipher.organization_id {
|
||||||
@@ -1065,7 +1069,7 @@ async fn share_cipher_by_uuid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
|
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
|
||||||
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
|
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
|
||||||
@@ -1263,10 +1267,10 @@ async fn save_attachment(
|
|||||||
err!("Cipher is neither owned by a user nor an organization");
|
err!("Cipher is neither owned by a user nor an organization");
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(size_limit) = size_limit {
|
if let Some(size_limit) = size_limit
|
||||||
if size > size_limit {
|
&& size > size_limit
|
||||||
err!("Attachment storage limit exceeded with this file");
|
{
|
||||||
}
|
err!("Attachment storage limit exceeded with this file");
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_id = match &attachment {
|
let file_id = match &attachment {
|
||||||
@@ -1408,7 +1412,7 @@ async fn post_attachment_share(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?;
|
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?;
|
||||||
post_attachment(cipher_id, data, headers, conn, nt).await
|
post_attachment(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,7 +1446,7 @@ async fn delete_attachment(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/ciphers/<cipher_id>/attachment/<attachment_id>/admin")]
|
#[delete("/ciphers/<cipher_id>/attachment/<attachment_id>/admin")]
|
||||||
@@ -1453,42 +1457,42 @@ async fn delete_attachment_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>/delete")]
|
#[post("/ciphers/<cipher_id>/delete")]
|
||||||
async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>/delete-admin")]
|
#[post("/ciphers/<cipher_id>/delete-admin")]
|
||||||
async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/delete")]
|
#[put("/ciphers/<cipher_id>/delete")]
|
||||||
async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/delete-admin")]
|
#[put("/ciphers/<cipher_id>/delete-admin")]
|
||||||
async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/ciphers/<cipher_id>")]
|
#[delete("/ciphers/<cipher_id>")]
|
||||||
async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/ciphers/<cipher_id>/admin")]
|
#[delete("/ciphers/<cipher_id>/admin")]
|
||||||
async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,7 +1503,7 @@ async fn delete_cipher_selected(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,7 +1514,7 @@ async fn delete_cipher_selected_post(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,7 +1525,7 @@ async fn delete_cipher_selected_put(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1532,7 +1536,7 @@ async fn delete_cipher_selected_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,7 +1547,7 @@ async fn delete_cipher_selected_post_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||||
// permanent delete
|
// permanent delete
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1554,18 +1558,18 @@ async fn delete_cipher_selected_put_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||||
// soft delete
|
// soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/restore")]
|
#[put("/ciphers/<cipher_id>/restore")]
|
||||||
async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
_restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/restore-admin")]
|
#[put("/ciphers/<cipher_id>/restore-admin")]
|
||||||
async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
_restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/restore-admin", data = "<data>")]
|
#[put("/ciphers/restore-admin", data = "<data>")]
|
||||||
@@ -1575,7 +1579,7 @@ async fn restore_cipher_selected_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/restore", data = "<data>")]
|
#[put("/ciphers/restore", data = "<data>")]
|
||||||
@@ -1585,7 +1589,7 @@ async fn restore_cipher_selected(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1606,10 +1610,10 @@ async fn move_cipher_selected(
|
|||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
let user_id = &headers.user.uuid;
|
let user_id = &headers.user.uuid;
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id {
|
if let Some(ref folder_id) = data.folder_id
|
||||||
if Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() {
|
&& Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none()
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
{
|
||||||
}
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cipher_count = data.ids.len();
|
let cipher_count = data.ids.len();
|
||||||
@@ -1773,7 +1777,7 @@ pub enum CipherDeleteOptions {
|
|||||||
HardMulti,
|
HardMulti,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_cipher_by_uuid(
|
async fn delete_cipher_by_uuid(
|
||||||
cipher_id: &CipherId,
|
cipher_id: &CipherId,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -1839,7 +1843,7 @@ struct CipherIdsData {
|
|||||||
ids: Vec<CipherId>,
|
ids: Vec<CipherId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_multiple_ciphers(
|
async fn delete_multiple_ciphers(
|
||||||
data: Json<CipherIdsData>,
|
data: Json<CipherIdsData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
@@ -1849,9 +1853,9 @@ async fn _delete_multiple_ciphers(
|
|||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
for cipher_id in data.ids {
|
for cipher_id in data.ids {
|
||||||
if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await {
|
if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await {
|
||||||
return error;
|
return error;
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here
|
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here
|
||||||
@@ -1860,7 +1864,7 @@ async fn _delete_multiple_ciphers(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _restore_cipher_by_uuid(
|
async fn restore_cipher_by_uuid(
|
||||||
cipher_id: &CipherId,
|
cipher_id: &CipherId,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
multi_restore: bool,
|
multi_restore: bool,
|
||||||
@@ -1906,7 +1910,7 @@ async fn _restore_cipher_by_uuid(
|
|||||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _restore_multiple_ciphers(
|
async fn restore_multiple_ciphers(
|
||||||
data: Json<CipherIdsData>,
|
data: Json<CipherIdsData>,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -1916,7 +1920,7 @@ async fn _restore_multiple_ciphers(
|
|||||||
|
|
||||||
let mut ciphers: Vec<Value> = Vec::new();
|
let mut ciphers: Vec<Value> = Vec::new();
|
||||||
for cipher_id in data.ids {
|
for cipher_id in data.ids {
|
||||||
match _restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await {
|
match restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await {
|
||||||
Ok(json) => ciphers.push(json.into_inner()),
|
Ok(json) => ciphers.push(json.into_inner()),
|
||||||
err => return err,
|
err => return err,
|
||||||
}
|
}
|
||||||
@@ -1932,7 +1936,7 @@ async fn _restore_multiple_ciphers(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_cipher_attachment_by_id(
|
async fn delete_cipher_attachment_by_id(
|
||||||
cipher_id: &CipherId,
|
cipher_id: &CipherId,
|
||||||
attachment_id: &AttachmentId,
|
attachment_id: &AttachmentId,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
@@ -2141,9 +2145,9 @@ impl CipherSyncData {
|
|||||||
// Organization Sync does not support Folders, Favorites, or Archives.
|
// Organization Sync does not support Folders, Favorites, or Archives.
|
||||||
// If these are set, it will cause issues in the web-vault.
|
// If these are set, it will cause issues in the web-vault.
|
||||||
CipherSyncType::Organization => {
|
CipherSyncType::Organization => {
|
||||||
cipher_folders = HashMap::with_capacity(0);
|
cipher_folders = HashMap::new();
|
||||||
cipher_favorites = HashSet::with_capacity(0);
|
cipher_favorites = HashSet::new();
|
||||||
cipher_archives = HashMap::with_capacity(0);
|
cipher_archives = HashMap::new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2206,11 +2210,11 @@ impl CipherSyncData {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cipher_archives,
|
|
||||||
cipher_attachments,
|
cipher_attachments,
|
||||||
cipher_folders,
|
cipher_folders,
|
||||||
cipher_favorites,
|
cipher_favorites,
|
||||||
cipher_collections,
|
cipher_collections,
|
||||||
|
cipher_archives,
|
||||||
members,
|
members,
|
||||||
user_collections,
|
user_collections,
|
||||||
user_collections_groups,
|
user_collections_groups,
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use rocket::{serde::json::Json, Route};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{CipherSyncData, CipherSyncType},
|
|
||||||
EmptyResult, JsonResult,
|
EmptyResult, JsonResult,
|
||||||
|
core::{CipherSyncData, CipherSyncType},
|
||||||
},
|
},
|
||||||
auth::{decode_emergency_access_invite, Headers},
|
auth::{Headers, decode_emergency_access_invite},
|
||||||
db::{
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbPool,
|
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -55,7 +55,7 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||||
for ea in emergency_access_list {
|
for ea in emergency_access_list {
|
||||||
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
||||||
emergency_access_list_json.push(grantee)
|
emergency_access_list_json.push(grantee);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +89,14 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await {
|
if let Some(emergency_access) =
|
||||||
Some(emergency_access) => Ok(Json(
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
|
{
|
||||||
|
Ok(Json(
|
||||||
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
|
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
|
||||||
)),
|
))
|
||||||
None => err!("Emergency access not valid."),
|
} else {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +139,10 @@ async fn post_emergency_access(
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid emergency access type."),
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.atype = new_type;
|
emergency_access.atype = new_type;
|
||||||
@@ -205,9 +209,10 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
|
|
||||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
|
|
||||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid emergency access type."),
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let grantor_user = headers.user;
|
let grantor_user = headers.user;
|
||||||
@@ -342,12 +347,11 @@ async fn accept_invite(
|
|||||||
err!("Claim email does not match current users email")
|
err!("Claim email does not match current users email")
|
||||||
}
|
}
|
||||||
|
|
||||||
let grantee_user = match User::find_by_mail(&claims.email, &conn).await {
|
let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await {
|
||||||
Some(user) => {
|
Invitation::take(&claims.email, &conn).await;
|
||||||
Invitation::take(&claims.email, &conn).await;
|
user
|
||||||
user
|
} else {
|
||||||
}
|
err!("Invited user not found")
|
||||||
None => err!("Invited user not found"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
||||||
@@ -766,7 +770,7 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while searching emergency request timed out")
|
error!("Failed to get DB connection while searching emergency request timed out");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,6 +829,6 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while searching emergency notification reminder")
|
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-61
@@ -1,18 +1,18 @@
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use rocket::{form::FromForm, serde::json::Json, Route};
|
use rocket::{Route, form::FromForm, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{EmptyResult, JsonResult},
|
api::{EmptyResult, JsonResult},
|
||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||||
},
|
},
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// ###############################################################################################################
|
/// ###############################################################################################################
|
||||||
@@ -38,9 +38,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
|
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when we org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||||
Vec::with_capacity(0)
|
|
||||||
} else {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -51,8 +49,10 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.to_json())
|
.map(Event::to_json)
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -64,27 +64,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
|
|
||||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||||
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
||||||
Vec::with_capacity(0)
|
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
||||||
} else {
|
{
|
||||||
let mut events_json = Vec::with_capacity(0);
|
let start_date = parse_date(&data.start);
|
||||||
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
let start_date = parse_date(&data.start);
|
parse_date(before_date)
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
} else {
|
||||||
parse_date(before_date)
|
parse_date(&data.end)
|
||||||
} else {
|
};
|
||||||
parse_date(&data.end)
|
|
||||||
};
|
|
||||||
|
|
||||||
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn)
|
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
||||||
.await
|
} else {
|
||||||
.iter()
|
Vec::new()
|
||||||
.map(|e| e.to_json())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
events_json
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -107,9 +101,7 @@ async fn get_user_events(
|
|||||||
}
|
}
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when we org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||||
Vec::with_capacity(0)
|
|
||||||
} else {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -120,8 +112,10 @@ async fn get_user_events(
|
|||||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.to_json())
|
.map(Event::to_json)
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -134,7 +128,8 @@ async fn get_user_events(
|
|||||||
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
||||||
// When the length of the vec equals the max page_size there probably is more data
|
// When the length of the vec equals the max page_size there probably is more data
|
||||||
// When it is less, then all events are loaded.
|
// When it is less, then all events are loaded.
|
||||||
if events_json.len() as i64 == Event::PAGE_SIZE {
|
#[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")]
|
||||||
|
if events_json.len() == Event::PAGE_SIZE as usize {
|
||||||
if let Some(last_event) = events_json.last() {
|
if let Some(last_event) = events_json.last() {
|
||||||
last_event["date"].as_str()
|
last_event["date"].as_str()
|
||||||
} else {
|
} else {
|
||||||
@@ -176,7 +171,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
let event_date = parse_date(&event.date);
|
let event_date = parse_date(&event.date);
|
||||||
match event.r#type {
|
match event.r#type {
|
||||||
1000..=1099 => {
|
1000..=1099 => {
|
||||||
_log_user_event(
|
log_user_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
@@ -187,8 +182,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
1600..=1699 => {
|
1600..=1699 => {
|
||||||
if let Some(org_id) = &event.organization_id {
|
// Only allow logging events for an organization the user is actually a member of.
|
||||||
_log_event(
|
if let Some(org_id) = &event.organization_id
|
||||||
|
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
|
||||||
|
{
|
||||||
|
log_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
org_id,
|
org_id,
|
||||||
org_id,
|
org_id,
|
||||||
@@ -202,22 +200,24 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(cipher_uuid) = &event.cipher_id {
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await {
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(org_id) = cipher.organization_uuid {
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
_log_event(
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
event.r#type,
|
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
cipher_uuid,
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
&org_id,
|
{
|
||||||
&headers.user.uuid,
|
log_event_impl(
|
||||||
headers.device.atype,
|
event.r#type,
|
||||||
Some(event_date),
|
cipher_uuid,
|
||||||
&headers.ip.ip,
|
&org_id,
|
||||||
&conn,
|
&headers.user.uuid,
|
||||||
)
|
headers.device.atype,
|
||||||
.await;
|
Some(event_date),
|
||||||
}
|
&headers.ip.ip,
|
||||||
}
|
&conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,10 +229,10 @@ pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32,
|
|||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log_user_event(event_type, user_id, device_type, None, ip, conn).await;
|
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _log_user_event(
|
async fn log_user_event_impl(
|
||||||
event_type: i32,
|
event_type: i32,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
device_type: i32,
|
device_type: i32,
|
||||||
@@ -278,11 +278,11 @@ pub async fn log_event(
|
|||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[expect(clippy::too_many_arguments)]
|
||||||
async fn _log_event(
|
async fn log_event_impl(
|
||||||
event_type: i32,
|
event_type: i32,
|
||||||
source_uuid: &str,
|
source_uuid: &str,
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
@@ -298,24 +298,24 @@ async fn _log_event(
|
|||||||
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
||||||
// Cipher Events
|
// Cipher Events
|
||||||
1100..=1199 => {
|
1100..=1199 => {
|
||||||
event.cipher_uuid = Some(source_uuid.to_string().into());
|
event.cipher_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Collection Events
|
// Collection Events
|
||||||
1300..=1399 => {
|
1300..=1399 => {
|
||||||
event.collection_uuid = Some(source_uuid.to_string().into());
|
event.collection_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Group Events
|
// Group Events
|
||||||
1400..=1499 => {
|
1400..=1499 => {
|
||||||
event.group_uuid = Some(source_uuid.to_string().into());
|
event.group_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Org User Events
|
// Org User Events
|
||||||
1500..=1599 => {
|
1500..=1599 => {
|
||||||
event.org_user_uuid = Some(source_uuid.to_string().into());
|
event.org_user_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
||||||
// Policy Events
|
// Policy Events
|
||||||
1700..=1799 => {
|
1700..=1799 => {
|
||||||
event.policy_uuid = Some(source_uuid.to_string().into());
|
event.policy_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Ignore others
|
// Ignore others
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -338,6 +338,6 @@ pub async fn event_cleanup_job(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Event::clean_events(&conn).await.ok();
|
Event::clean_events(&conn).await.ok();
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table")
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::{
|
|||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{
|
db::{
|
||||||
models::{Folder, FolderId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{Folder, FolderId},
|
||||||
},
|
},
|
||||||
util::deser_opt_nonempty_str,
|
util::deser_opt_nonempty_str,
|
||||||
};
|
};
|
||||||
@@ -29,9 +29,10 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
|
|
||||||
#[get("/folders/<folder_id>")]
|
#[get("/folders/<folder_id>")]
|
||||||
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
||||||
Some(folder) => Ok(Json(folder.to_json())),
|
Ok(Json(folder.to_json()))
|
||||||
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
|
} else {
|
||||||
|
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-40
@@ -1,4 +1,6 @@
|
|||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod two_factor;
|
||||||
|
|
||||||
mod ciphers;
|
mod ciphers;
|
||||||
mod emergency_access;
|
mod emergency_access;
|
||||||
mod events;
|
mod events;
|
||||||
@@ -6,17 +8,32 @@ mod folders;
|
|||||||
mod organizations;
|
mod organizations;
|
||||||
mod public;
|
mod public;
|
||||||
mod sends;
|
mod sends;
|
||||||
pub mod two_factor;
|
|
||||||
|
|
||||||
pub use accounts::purge_auth_requests;
|
pub use accounts::purge_auth_requests;
|
||||||
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
|
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
|
||||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||||
use reqwest::Method;
|
|
||||||
pub use sends::purge_sends;
|
pub use sends::purge_sends;
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
|
auth::Headers,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
http_client::make_http_request,
|
||||||
|
mail,
|
||||||
|
util::{FeatureFlagFilter, parse_experimental_client_feature_flags},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
|
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
|
||||||
let mut hibp_routes = routes![hibp_breach];
|
let mut hibp_routes = routes![hibp_breach];
|
||||||
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||||
|
|
||||||
@@ -44,25 +61,6 @@ pub fn events_routes() -> Vec<Route> {
|
|||||||
routes
|
routes
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// Move this somewhere else
|
|
||||||
//
|
|
||||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
|
||||||
auth::Headers,
|
|
||||||
db::{
|
|
||||||
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
error::Error,
|
|
||||||
http_client::make_http_request,
|
|
||||||
mail,
|
|
||||||
util::{parse_experimental_client_feature_flags, FeatureFlagFilter},
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GlobalDomain {
|
struct GlobalDomain {
|
||||||
@@ -73,15 +71,17 @@ struct GlobalDomain {
|
|||||||
|
|
||||||
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
||||||
|
|
||||||
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
#[get("/settings/domains")]
|
#[get("/settings/domains")]
|
||||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
fn get_settings_domains(headers: Headers) -> Json<Value> {
|
||||||
_get_eq_domains(&headers, false)
|
get_eq_domains(&headers, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||||
let user = &headers.user;
|
|
||||||
use serde_json::from_str;
|
use serde_json::from_str;
|
||||||
|
|
||||||
|
let user = &headers.user;
|
||||||
|
|
||||||
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
||||||
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
||||||
|
|
||||||
@@ -110,17 +110,23 @@ struct EquivDomainData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/settings/domains", data = "<data>")]
|
#[post("/settings/domains", data = "<data>")]
|
||||||
async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_settings_domains(
|
||||||
|
data: Json<EquivDomainData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
use serde_json::to_string;
|
||||||
|
|
||||||
let data: EquivDomainData = data.into_inner();
|
let data: EquivDomainData = data.into_inner();
|
||||||
|
|
||||||
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
||||||
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
use serde_json::to_string;
|
|
||||||
|
|
||||||
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
|
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_owned());
|
||||||
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
|
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned());
|
||||||
|
|
||||||
user.save(&conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
@@ -130,8 +136,13 @@ async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: Db
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/settings/domains", data = "<data>")]
|
#[put("/settings/domains", data = "<data>")]
|
||||||
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn put_settings_domains(
|
||||||
post_eq_domains(data, headers, conn, nt).await
|
data: Json<EquivDomainData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
post_settings_domains(data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/hibp/breach?<username>")]
|
#[get("/hibp/breach?<username>")]
|
||||||
@@ -206,9 +217,9 @@ fn config() -> Json<Value> {
|
|||||||
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||||
let mut feature_states = parse_experimental_client_feature_flags(
|
let mut feature_states = parse_experimental_client_feature_flags(
|
||||||
&CONFIG.experimental_client_feature_flags(),
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
FeatureFlagFilter::ValidOnly,
|
&FeatureFlagFilter::ValidOnly,
|
||||||
);
|
);
|
||||||
feature_states.insert("pm-19148-innovation-archive".to_string(), true);
|
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
// Note: The clients use this version to handle backwards compatibility concerns
|
// Note: The clients use this version to handle backwards compatibility concerns
|
||||||
@@ -217,14 +228,17 @@ fn config() -> Json<Value> {
|
|||||||
// Version history:
|
// Version history:
|
||||||
// - Individual cipher key encryption: 2024.2.0
|
// - Individual cipher key encryption: 2024.2.0
|
||||||
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||||
"version": "2025.12.0",
|
"version": "2026.6.0",
|
||||||
"gitHash": option_env!("GIT_REV"),
|
"gitHash": option_env!("GIT_REV"),
|
||||||
"server": {
|
"server": {
|
||||||
"name": "Vaultwarden",
|
"name": "Vaultwarden",
|
||||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"disableUserRegistration": CONFIG.is_signup_disabled()
|
"disableUserRegistration": CONFIG.is_signup_disabled(),
|
||||||
|
// When enabled, this setting signals to clients that onboarding interstitials
|
||||||
|
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
|
||||||
|
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"vault": domain,
|
"vault": domain,
|
||||||
@@ -240,6 +254,10 @@ fn config() -> Json<Value> {
|
|||||||
"vapidPublicKey": null
|
"vapidPublicKey": null
|
||||||
},
|
},
|
||||||
"featureStates": feature_states,
|
"featureStates": feature_states,
|
||||||
|
// Not supported right now
|
||||||
|
// Used for by clients to learn if the server requires extra work to establish a connection.
|
||||||
|
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
|
||||||
|
"communication": null,
|
||||||
"object": "config",
|
"object": "config",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -278,9 +296,8 @@ async fn accept_org_invite(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
|
||||||
Some(org) => org,
|
err!("Organization not found.")
|
||||||
None => err!("Organization not found."),
|
|
||||||
};
|
};
|
||||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
// User was invited to an organization, so they must be confirmed manually after acceptance
|
||||||
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
||||||
|
|||||||
+249
-162
@@ -1,28 +1,28 @@
|
|||||||
use num_traits::FromPrimitive;
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::api::admin::FAKE_ADMIN_UUID;
|
use num_traits::FromPrimitive;
|
||||||
|
use rocket::{Route, serde::json::Json};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::admin::FAKE_ADMIN_UUID,
|
||||||
api::{
|
api::{
|
||||||
core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType},
|
|
||||||
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
|
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
||||||
},
|
},
|
||||||
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders},
|
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
||||||
db::{
|
db::{
|
||||||
|
DbConn,
|
||||||
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, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn,
|
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
sso::FAKE_SSO_IDENTIFIER,
|
sso::FAKE_SSO_IDENTIFIER,
|
||||||
util::{convert_json_key_lcase_first, NumberOrString},
|
util::{NumberOrString, convert_json_key_lcase_first},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -96,8 +96,9 @@ pub fn routes() -> Vec<Route> {
|
|||||||
put_reset_password_enrollment,
|
put_reset_password_enrollment,
|
||||||
get_reset_password_details,
|
get_reset_password_details,
|
||||||
put_reset_password,
|
put_reset_password,
|
||||||
|
put_recover_account,
|
||||||
get_org_export,
|
get_org_export,
|
||||||
api_key,
|
post_api_key,
|
||||||
rotate_api_key,
|
rotate_api_key,
|
||||||
get_billing_metadata,
|
get_billing_metadata,
|
||||||
get_billing_warnings,
|
get_billing_warnings,
|
||||||
@@ -286,9 +287,10 @@ async fn get_organization(org_id: OrganizationId, headers: OwnerHeaders, conn: D
|
|||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
match Organization::find_by_uuid(&org_id, &conn).await {
|
if let Some(organization) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||||
Some(organization) => Ok(Json(organization.to_json())),
|
Ok(Json(organization.to_json()))
|
||||||
None => err!("Can't find organization details"),
|
} else {
|
||||||
|
err!("Can't find organization details")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +369,7 @@ async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (id, identifier, rp_auto_enroll) = match org {
|
let (id, identifier, rp_auto_enroll) = match org {
|
||||||
None => (identifier.to_string(), identifier.to_string(), false),
|
None => (identifier.to_owned(), identifier.to_owned(), false),
|
||||||
Some(org) => (
|
Some(org) => (
|
||||||
org.uuid.to_string(),
|
org.uuid.to_string(),
|
||||||
org.uuid.to_string(),
|
org.uuid.to_string(),
|
||||||
@@ -393,7 +395,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"data": _get_org_collections(&org_id, &conn).await,
|
"data": get_org_collections_impl(&org_id, &conn).await,
|
||||||
"object": "list",
|
"object": "list",
|
||||||
"continuationToken": null,
|
"continuationToken": null,
|
||||||
})))
|
})))
|
||||||
@@ -465,10 +467,10 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
|
|||||||
CollectionGroup::find_by_collection(&col.uuid, &conn)
|
CollectionGroup::find_by_collection(&col.uuid, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|collection_group| collection_group.to_json_details_for_group())
|
.map(CollectionGroup::to_json_details_for_group)
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::with_capacity(0)
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
|
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
|
||||||
@@ -477,7 +479,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
|
|||||||
json_object["groups"] = json!(groups);
|
json_object["groups"] = json!(groups);
|
||||||
json_object["object"] = json!("collectionAccessDetails");
|
json_object["object"] = json!("collectionAccessDetails");
|
||||||
json_object["unmanaged"] = json!(false);
|
json_object["unmanaged"] = json!(false);
|
||||||
data.push(json_object)
|
data.push(json_object);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -487,7 +489,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _get_org_collections(org_id: &OrganizationId, conn: &DbConn) -> Value {
|
async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value {
|
||||||
Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::<Value>()
|
Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::<Value>()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +575,14 @@ async fn post_bulk_access_collections(
|
|||||||
|
|
||||||
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
|
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
|
||||||
err!("Can't find organization details")
|
err!("Can't find organization details")
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// The collections and members are checked below, the groups only here.
|
||||||
|
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
||||||
|
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
|
||||||
|
}
|
||||||
|
|
||||||
for col_id in data.collection_ids {
|
for col_id in data.collection_ids {
|
||||||
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
|
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
|
||||||
@@ -650,7 +659,7 @@ async fn post_organization_collection_update(
|
|||||||
|
|
||||||
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
|
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
|
||||||
err!("Can't find organization details")
|
err!("Can't find organization details")
|
||||||
};
|
}
|
||||||
|
|
||||||
let Some(mut collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
|
let Some(mut collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
|
||||||
err!("Collection not found")
|
err!("Collection not found")
|
||||||
@@ -701,7 +710,7 @@ async fn post_organization_collection_update(
|
|||||||
Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await))
|
Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_organization_collection(
|
async fn delete_organization_collection_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
col_id: &CollectionId,
|
col_id: &CollectionId,
|
||||||
headers: &ManagerHeaders,
|
headers: &ManagerHeaders,
|
||||||
@@ -733,7 +742,7 @@ async fn delete_organization_collection(
|
|||||||
headers: ManagerHeaders,
|
headers: ManagerHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_organization_collection(&org_id, &col_id, &headers, &conn).await
|
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/organizations/<org_id>/collections/<col_id>/delete")]
|
#[post("/organizations/<org_id>/collections/<col_id>/delete")]
|
||||||
@@ -743,7 +752,7 @@ async fn post_organization_collection_delete(
|
|||||||
headers: ManagerHeaders,
|
headers: ManagerHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_organization_collection(&org_id, &col_id, &headers, &conn).await
|
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
@@ -769,7 +778,7 @@ async fn bulk_delete_organization_collections(
|
|||||||
let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?;
|
let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?;
|
||||||
|
|
||||||
for col_id in collections {
|
for col_id in collections {
|
||||||
_delete_organization_collection(&org_id, &col_id, &headers, &conn).await?
|
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -799,12 +808,12 @@ async fn get_org_collection_detail(
|
|||||||
CollectionGroup::find_by_collection(&collection.uuid, &conn)
|
CollectionGroup::find_by_collection(&collection.uuid, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|collection_group| collection_group.to_json_details_for_group())
|
.map(CollectionGroup::to_json_details_for_group)
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
|
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
|
||||||
// so just act as if there are no groups.
|
// so just act as if there are no groups.
|
||||||
Vec::with_capacity(0)
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
@@ -886,13 +895,13 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?,
|
"data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?,
|
||||||
"object": "list",
|
"object": "list",
|
||||||
"continuationToken": null,
|
"continuationToken": null,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _get_org_details(
|
async fn get_org_details_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
host: &str,
|
host: &str,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
@@ -944,6 +953,11 @@ async fn get_members(
|
|||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !headers.membership.has_full_access() {
|
||||||
|
err_code!("Resource not found.", "User does not have full access", rocket::http::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 {
|
||||||
users_json.push(
|
users_json.push(
|
||||||
@@ -975,14 +989,13 @@ async fn post_org_keys(
|
|||||||
}
|
}
|
||||||
let data: OrgKeyData = data.into_inner();
|
let data: OrgKeyData = data.into_inner();
|
||||||
|
|
||||||
let mut org = match Organization::find_by_uuid(&org_id, &conn).await {
|
let mut org = if let Some(organization) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||||
Some(organization) => {
|
if organization.private_key.is_some() && organization.public_key.is_some() {
|
||||||
if organization.private_key.is_some() && organization.public_key.is_some() {
|
err!("Organization Keys already exist")
|
||||||
err!("Organization Keys already exist")
|
|
||||||
}
|
|
||||||
organization
|
|
||||||
}
|
}
|
||||||
None => err!("Can't find organization details"),
|
organization
|
||||||
|
} else {
|
||||||
|
err!("Can't find organization details")
|
||||||
};
|
};
|
||||||
|
|
||||||
org.private_key = Some(data.encrypted_private_key);
|
org.private_key = Some(data.encrypted_private_key);
|
||||||
@@ -1043,9 +1056,10 @@ async fn send_invite(
|
|||||||
// The from_str() will convert the custom role type into a manager role type
|
// The from_str() will convert the custom role type into a manager role type
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
// Membership::from_str will convert custom (4) to manager (3)
|
// Membership::from_str will convert custom (4) to manager (3)
|
||||||
let new_type = match MembershipType::from_str(raw_type) {
|
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid type"),
|
} else {
|
||||||
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
||||||
@@ -1062,7 +1076,7 @@ async fn send_invite(
|
|||||||
&& 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 = false;
|
||||||
for email in data.emails.iter() {
|
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 {
|
||||||
None => {
|
None => {
|
||||||
@@ -1086,13 +1100,17 @@ async fn send_invite(
|
|||||||
Some(user) => {
|
Some(user) => {
|
||||||
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
|
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
|
||||||
err!(format!("User already in organization: {email}"))
|
err!(format!("User already in organization: {email}"))
|
||||||
} else {
|
}
|
||||||
// automatically accept existing users if mail is disabled
|
|
||||||
if !CONFIG.mail_enabled() && !user.password_hash.is_empty() {
|
if !CONFIG.mail_enabled() {
|
||||||
|
if user.password_hash.is_empty() {
|
||||||
|
Invitation::new(email).save(&conn).await?;
|
||||||
|
} else {
|
||||||
|
// automatically accept existing users if mail is disabled
|
||||||
member_status = MembershipStatus::Accepted as i32;
|
member_status = MembershipStatus::Accepted as i32;
|
||||||
}
|
}
|
||||||
user
|
|
||||||
}
|
}
|
||||||
|
user
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1103,9 +1121,10 @@ async fn send_invite(
|
|||||||
new_member.save(&conn).await?;
|
new_member.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_name = match Organization::find_by_uuid(&org_id, &conn).await {
|
let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||||
Some(org) => org.name,
|
org.name
|
||||||
None => err!("Error looking up organization"),
|
} else {
|
||||||
|
err!("Error looking up organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = mail::send_invite(
|
if let Err(e) = mail::send_invite(
|
||||||
@@ -1159,7 +1178,10 @@ async fn send_invite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for group_id in data.groups.iter() {
|
for group_id in &data.groups {
|
||||||
|
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
|
||||||
|
err!("Group not found in Organization")
|
||||||
|
}
|
||||||
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
|
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
|
||||||
group_entry.save(&conn).await?;
|
group_entry.save(&conn).await?;
|
||||||
}
|
}
|
||||||
@@ -1182,8 +1204,8 @@ async fn bulk_reinvite_members(
|
|||||||
|
|
||||||
let mut bulk_response = Vec::new();
|
let mut bulk_response = Vec::new();
|
||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await {
|
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
||||||
Ok(_) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => format!("{e:?}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1193,7 +1215,7 @@ async fn bulk_reinvite_members(
|
|||||||
"id": member_id,
|
"id": member_id,
|
||||||
"error": err_msg
|
"error": err_msg
|
||||||
}
|
}
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -1213,10 +1235,10 @@ async fn reinvite_member(
|
|||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
_reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await
|
reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _reinvite_member(
|
async fn reinvite_member_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
member_id: &MembershipId,
|
member_id: &MembershipId,
|
||||||
invited_by_email: &str,
|
invited_by_email: &str,
|
||||||
@@ -1238,13 +1260,14 @@ async fn _reinvite_member(
|
|||||||
err!("Invitations are not allowed.")
|
err!("Invitations are not allowed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_name = match Organization::find_by_uuid(org_id, conn).await {
|
let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await {
|
||||||
Some(org) => org.name,
|
org.name
|
||||||
None => err!("Error looking up organization."),
|
} else {
|
||||||
|
err!("Error looking up organization.")
|
||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_string())).await?;
|
mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_owned())).await?;
|
||||||
} else if user.password_hash.is_empty() {
|
} else if user.password_hash.is_empty() {
|
||||||
let invitation = Invitation::new(&user.email);
|
let invitation = Invitation::new(&user.email);
|
||||||
invitation.save(conn).await?;
|
invitation.save(conn).await?;
|
||||||
@@ -1352,8 +1375,8 @@ async fn bulk_confirm_invite(
|
|||||||
for invite in keys {
|
for invite in keys {
|
||||||
let member_id = invite.id.unwrap();
|
let member_id = invite.id.unwrap();
|
||||||
let user_key = invite.key.unwrap_or_default();
|
let user_key = invite.key.unwrap_or_default();
|
||||||
let err_msg = match _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
||||||
Ok(_) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => format!("{e:?}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1387,10 +1410,10 @@ async fn confirm_invite(
|
|||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
let user_key = data.key.unwrap_or_default();
|
let user_key = data.key.unwrap_or_default();
|
||||||
_confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await
|
confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _confirm_invite(
|
async fn confirm_invite_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
member_id: &MembershipId,
|
member_id: &MembershipId,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -1418,7 +1441,7 @@ async fn _confirm_invite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
member_to_confirm.status = MembershipStatus::Confirmed as i32;
|
member_to_confirm.status = MembershipStatus::Confirmed as i32;
|
||||||
member_to_confirm.akey = key.to_string();
|
member_to_confirm.akey = key.to_owned();
|
||||||
|
|
||||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||||
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
|
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
|
||||||
@@ -1435,13 +1458,15 @@ async fn _confirm_invite(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_name = match Organization::find_by_uuid(org_id, conn).await {
|
let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await {
|
||||||
Some(org) => org.name,
|
org.name
|
||||||
None => err!("Error looking up organization."),
|
} else {
|
||||||
|
err!("Error looking up organization.")
|
||||||
};
|
};
|
||||||
let address = match User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
|
let address = if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
|
||||||
Some(user) => user.email,
|
user.email
|
||||||
None => err!("Error looking up user."),
|
} else {
|
||||||
|
err!("Error looking up user.")
|
||||||
};
|
};
|
||||||
mail::send_invite_confirmed(&address, &org_name).await?;
|
mail::send_invite_confirmed(&address, &org_name).await?;
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1629,9 @@ async fn edit_member(
|
|||||||
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
|
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
|
||||||
|
|
||||||
for group_id in data.groups.iter().flatten() {
|
for group_id in data.groups.iter().flatten() {
|
||||||
|
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
|
||||||
|
err!("Group not found in Organization")
|
||||||
|
}
|
||||||
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
|
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
|
||||||
group_entry.save(&conn).await?;
|
group_entry.save(&conn).await?;
|
||||||
}
|
}
|
||||||
@@ -1637,8 +1665,8 @@ async fn bulk_delete_member(
|
|||||||
|
|
||||||
let mut bulk_response = Vec::new();
|
let mut bulk_response = Vec::new();
|
||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match _delete_member(&org_id, &member_id, &headers, &conn, &nt).await {
|
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
||||||
Ok(_) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => format!("{e:?}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1648,7 +1676,7 @@ async fn bulk_delete_member(
|
|||||||
"id": member_id,
|
"id": member_id,
|
||||||
"error": err_msg
|
"error": err_msg
|
||||||
}
|
}
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -1666,10 +1694,10 @@ async fn delete_member(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_member(&org_id, &member_id, &headers, &conn, &nt).await
|
delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_member(
|
async fn delete_member_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
member_id: &MembershipId,
|
member_id: &MembershipId,
|
||||||
headers: &AdminHeaders,
|
headers: &AdminHeaders,
|
||||||
@@ -1708,6 +1736,15 @@ async fn _delete_member(
|
|||||||
|
|
||||||
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await {
|
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await {
|
||||||
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
|
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
|
||||||
|
|
||||||
|
if !CONFIG.mail_enabled()
|
||||||
|
&& !Membership::find_invited_by_user(&user.uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.any(|m| m.uuid != member_to_delete.uuid)
|
||||||
|
{
|
||||||
|
Invitation::take(&user.email, conn).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
member_to_delete.delete(conn).await
|
member_to_delete.delete(conn).await
|
||||||
@@ -1753,8 +1790,8 @@ async fn bulk_public_keys(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::ciphers::update_cipher_from_data;
|
|
||||||
use super::ciphers::CipherData;
|
use super::ciphers::CipherData;
|
||||||
|
use super::ciphers::update_cipher_from_data;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -1794,19 +1831,19 @@ async fn post_org_import(
|
|||||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||||
Cipher::validate_cipher_data(&data.ciphers)?;
|
Cipher::validate_cipher_data(&data.ciphers)?;
|
||||||
|
|
||||||
let existing_collections: HashSet<Option<CollectionId>> =
|
let existing_collections: HashMap<CollectionId, Collection> =
|
||||||
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect();
|
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
|
||||||
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
|
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
|
||||||
for col in data.collections {
|
for col in data.collections {
|
||||||
let collection_uuid = if existing_collections.contains(&col.id) {
|
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
|
||||||
let col_id = col.id.unwrap();
|
let collection_uuid = if let Some(collection) = existing {
|
||||||
// When not an Owner or Admin, check if the member is allowed to access the collection.
|
// When not an Owner or Admin, check if the member is allowed to write to the collection.
|
||||||
if headers.membership.atype < MembershipType::Admin
|
if headers.membership.atype < MembershipType::Admin
|
||||||
&& !Collection::can_access_collection(&headers.membership, &col_id, &conn).await
|
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
|
||||||
{
|
{
|
||||||
err!(Compact, "The current user isn't allowed to manage this collection")
|
err!(Compact, "The current user isn't allowed to manage this collection")
|
||||||
}
|
}
|
||||||
col_id
|
collection.uuid.clone()
|
||||||
} else {
|
} else {
|
||||||
// We do not allow users or managers which can not manage all collections to create new collections
|
// We do not allow users or managers which can not manage all collections to create new collections
|
||||||
// If there is any collection other than an existing import collection, abort the import.
|
// If there is any collection other than an existing import collection, abort the import.
|
||||||
@@ -1834,6 +1871,8 @@ async fn post_org_import(
|
|||||||
for mut cipher_data in data.ciphers {
|
for mut cipher_data in data.ciphers {
|
||||||
// Always clear folder_id's via an organization import
|
// Always clear folder_id's via an organization import
|
||||||
cipher_data.folder_id = None;
|
cipher_data.folder_id = None;
|
||||||
|
// Replace the client-provided, unvalidated organizationId with the real target org
|
||||||
|
cipher_data.organization_id = Some(org_id.clone());
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
||||||
update_cipher_from_data(
|
update_cipher_from_data(
|
||||||
&mut cipher,
|
&mut cipher,
|
||||||
@@ -1851,8 +1890,9 @@ async fn post_org_import(
|
|||||||
|
|
||||||
// Assign the collections
|
// Assign the collections
|
||||||
for (cipher_index, col_index) in relations {
|
for (cipher_index, col_index) in relations {
|
||||||
let cipher_id = &ciphers[cipher_index];
|
let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
|
||||||
let col_id = &collections[col_index];
|
err!(Compact, "Invalid collection relationship")
|
||||||
|
};
|
||||||
CollectionCipher::save(cipher_id, col_id, &conn).await?;
|
CollectionCipher::save(cipher_id, col_id, &conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1902,24 +1942,24 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for cipher_id in data.cipher_ids.iter() {
|
for cipher_id in &data.cipher_ids {
|
||||||
// Only act on existing cipher uuid's
|
// Only act on existing cipher uuid's
|
||||||
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
||||||
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await {
|
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
||||||
if cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await {
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
{
|
||||||
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
||||||
if data.remove_collections {
|
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
||||||
for collection in &data.collection_ids {
|
if data.remove_collections {
|
||||||
CollectionCipher::delete(&cipher.uuid, collection, &conn).await?;
|
for collection in &data.collection_ids {
|
||||||
}
|
CollectionCipher::delete(&cipher.uuid, collection, &conn).await?;
|
||||||
} else {
|
}
|
||||||
for collection in &data.collection_ids {
|
} else {
|
||||||
CollectionCipher::save(&cipher.uuid, collection, &conn).await?;
|
for collection in &data.collection_ids {
|
||||||
}
|
CollectionCipher::save(&cipher.uuid, collection, &conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1969,7 +2009,7 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn)
|
|||||||
fn get_dummy_master_password_policy() -> JsonResult {
|
fn get_dummy_master_password_policy() -> JsonResult {
|
||||||
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
|
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
|
||||||
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
|
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
|
||||||
_ => (false, "null".to_string()),
|
_ => (false, "null".to_owned()),
|
||||||
};
|
};
|
||||||
let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data);
|
let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data);
|
||||||
Ok(Json(policy.to_json()))
|
Ok(Json(policy.to_json()))
|
||||||
@@ -1982,7 +2022,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH
|
|||||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| {
|
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| {
|
||||||
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
|
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
|
||||||
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
|
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
|
||||||
_ => (false, "null".to_string()),
|
_ => (false, "null".to_owned()),
|
||||||
};
|
};
|
||||||
|
|
||||||
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, enabled, data)
|
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, enabled, data)
|
||||||
@@ -2003,7 +2043,7 @@ async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders
|
|||||||
|
|
||||||
let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()),
|
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_owned()),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(policy.to_json()))
|
Ok(Json(policy.to_json()))
|
||||||
@@ -2015,18 +2055,27 @@ struct PolicyData {
|
|||||||
data: Option<Value>,
|
data: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PutPolicy {
|
||||||
|
policy: PolicyData,
|
||||||
|
// Ignore metadata for now as we do not yet support this
|
||||||
|
// "metadata": {
|
||||||
|
// "defaultUserCollectionName": "2.xx|xx==|xx="
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
|
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
|
||||||
async fn put_policy(
|
async fn put_policy(
|
||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
pol_type: i32,
|
pol_type: i32,
|
||||||
data: Json<PolicyData>,
|
data: Json<PutPolicy>,
|
||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
let data: PolicyData = data.into_inner();
|
let data: PolicyData = data.into_inner().policy;
|
||||||
|
|
||||||
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
|
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
|
||||||
err!("Invalid or unsupported policy type")
|
err!("Invalid or unsupported policy type")
|
||||||
@@ -2078,7 +2127,7 @@ async fn put_policy(
|
|||||||
|
|
||||||
// When enabling the SingleOrg policy, remove this org's members that are members of other orgs
|
// When enabling the SingleOrg policy, remove this org's members that are members of other orgs
|
||||||
if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled {
|
if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled {
|
||||||
for mut member in Membership::find_by_org(&org_id, &conn).await.into_iter() {
|
for mut member in Membership::find_by_org(&org_id, &conn).await {
|
||||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||||
// Exclude invited and revoked users when checking for this policy.
|
// Exclude invited and revoked users when checking for this policy.
|
||||||
// Those users will not be allowed to accept or be activated because of the policy checks done there.
|
// Those users will not be allowed to accept or be activated because of the policy checks done there.
|
||||||
@@ -2113,7 +2162,7 @@ async fn put_policy(
|
|||||||
|
|
||||||
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()),
|
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
||||||
};
|
};
|
||||||
|
|
||||||
policy.enabled = data.enabled;
|
policy.enabled = data.enabled;
|
||||||
@@ -2134,26 +2183,16 @@ async fn put_policy(
|
|||||||
Ok(Json(policy.to_json()))
|
Ok(Json(policy.to_json()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
// Deprecated with client v2026.5.0
|
||||||
struct PolicyDataVnext {
|
|
||||||
policy: PolicyData,
|
|
||||||
// Ignore metadata for now as we do not yet support this
|
|
||||||
// "metadata": {
|
|
||||||
// "defaultUserCollectionName": "2.xx|xx==|xx="
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
|
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
|
||||||
async fn put_policy_vnext(
|
async fn put_policy_vnext(
|
||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
pol_type: i32,
|
pol_type: i32,
|
||||||
data: Json<PolicyDataVnext>,
|
data: Json<PutPolicy>,
|
||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: PolicyDataVnext = data.into_inner();
|
put_policy(org_id, pol_type, data, headers, conn).await
|
||||||
let policy: PolicyData = data.policy;
|
|
||||||
put_policy(org_id, pol_type, Json(policy), headers, conn).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/plans")]
|
#[get("/plans")]
|
||||||
@@ -2187,7 +2226,7 @@ fn get_plans() -> Json<Value> {
|
|||||||
#[get("/organizations/<_org_id>/billing/metadata")]
|
#[get("/organizations/<_org_id>/billing/metadata")]
|
||||||
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
|
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
|
||||||
// Prevent a 404 error, which also causes Javascript errors.
|
// Prevent a 404 error, which also causes Javascript errors.
|
||||||
Json(_empty_data_json())
|
Json(empty_data_json())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
|
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
|
||||||
@@ -2209,7 +2248,7 @@ fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHe
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _empty_data_json() -> Value {
|
fn empty_data_json() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"object": "list",
|
"object": "list",
|
||||||
"data": [],
|
"data": [],
|
||||||
@@ -2230,7 +2269,7 @@ async fn revoke_member(
|
|||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_revoke_member(&org_id, &member_id, &headers, &conn).await
|
revoke_member_impl(&org_id, &member_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/organizations/<org_id>/users/revoke", data = "<data>")]
|
#[put("/organizations/<org_id>/users/revoke", data = "<data>")]
|
||||||
@@ -2249,8 +2288,8 @@ async fn bulk_revoke_members(
|
|||||||
match data.ids {
|
match data.ids {
|
||||||
Some(members) => {
|
Some(members) => {
|
||||||
for member_id in members {
|
for member_id in members {
|
||||||
let err_msg = match _revoke_member(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(_) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => format!("{e:?}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2273,7 +2312,7 @@ async fn bulk_revoke_members(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _revoke_member(
|
async fn revoke_member_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
member_id: &MembershipId,
|
member_id: &MembershipId,
|
||||||
headers: &AdminHeaders,
|
headers: &AdminHeaders,
|
||||||
@@ -2325,7 +2364,7 @@ async fn restore_member_vnext(
|
|||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
// Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy.
|
// Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy.
|
||||||
// Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member
|
// Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member
|
||||||
_restore_member(&org_id, &member_id, &headers, &conn).await
|
restore_member_impl(&org_id, &member_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/organizations/<org_id>/users/<member_id>/restore")]
|
#[put("/organizations/<org_id>/users/<member_id>/restore")]
|
||||||
@@ -2335,7 +2374,7 @@ async fn restore_member(
|
|||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_restore_member(&org_id, &member_id, &headers, &conn).await
|
restore_member_impl(&org_id, &member_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/organizations/<org_id>/users/restore", data = "<data>")]
|
#[put("/organizations/<org_id>/users/restore", data = "<data>")]
|
||||||
@@ -2352,8 +2391,8 @@ async fn bulk_restore_members(
|
|||||||
|
|
||||||
let mut bulk_response = Vec::new();
|
let mut bulk_response = Vec::new();
|
||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match _restore_member(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(_) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => format!("{e:?}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2373,7 +2412,7 @@ async fn bulk_restore_members(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _restore_member(
|
async fn restore_member_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
member_id: &MembershipId,
|
member_id: &MembershipId,
|
||||||
headers: &AdminHeaders,
|
headers: &AdminHeaders,
|
||||||
@@ -2423,24 +2462,41 @@ async fn get_groups_data(
|
|||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The details view (group→collection/user mappings) needs full org access; the plain list only
|
||||||
|
// needs manage access to a collection, so a manager of a collection (directly or via a group)
|
||||||
|
// can load it to assign groups.
|
||||||
|
let has_full_access = headers.membership.has_full_access()
|
||||||
|
|| (CONFIG.org_groups_enabled()
|
||||||
|
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
|
||||||
|
let allowed = if details {
|
||||||
|
has_full_access
|
||||||
|
} else {
|
||||||
|
has_full_access
|
||||||
|
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
|
||||||
|
};
|
||||||
|
if !allowed {
|
||||||
|
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
|
||||||
|
}
|
||||||
|
|
||||||
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
||||||
let groups = Group::find_by_organization(&org_id, &conn).await;
|
let groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
let mut groups_json = Vec::with_capacity(groups.len());
|
let mut groups_json = Vec::with_capacity(groups.len());
|
||||||
|
|
||||||
if details {
|
if details {
|
||||||
for g in groups {
|
for g in groups {
|
||||||
groups_json.push(g.to_json_details(&conn).await)
|
groups_json.push(g.to_json_details(&conn).await);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for g in groups {
|
for g in groups {
|
||||||
groups_json.push(g.to_json())
|
groups_json.push(g.to_json());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
groups_json
|
groups_json
|
||||||
} else {
|
} else {
|
||||||
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
|
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
|
||||||
// so just act as if there are no groups.
|
// so just act as if there are no groups.
|
||||||
Vec::with_capacity(0)
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -2672,15 +2728,15 @@ async fn post_delete_group(
|
|||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
_delete_group(&org_id, &group_id, &headers, &conn).await
|
delete_group_impl(&org_id, &group_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/organizations/<org_id>/groups/<group_id>")]
|
#[delete("/organizations/<org_id>/groups/<group_id>")]
|
||||||
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
||||||
_delete_group(&org_id, &group_id, &headers, &conn).await
|
delete_group_impl(&org_id, &group_id, &headers, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _delete_group(
|
async fn delete_group_impl(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
group_id: &GroupId,
|
group_id: &GroupId,
|
||||||
headers: &AdminHeaders,
|
headers: &AdminHeaders,
|
||||||
@@ -2728,7 +2784,7 @@ async fn bulk_delete_groups(
|
|||||||
let data: BulkGroupIds = data.into_inner();
|
let data: BulkGroupIds = data.into_inner();
|
||||||
|
|
||||||
for group_id in data.ids {
|
for group_id in data.ids {
|
||||||
_delete_group(&org_id, &group_id, &headers, &conn).await?
|
delete_group_impl(&org_id, &group_id, &headers, &conn).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2765,7 +2821,7 @@ async fn get_group_members(
|
|||||||
|
|
||||||
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
|
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
|
||||||
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
|
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
|
||||||
};
|
}
|
||||||
|
|
||||||
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &org_id, &conn)
|
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &org_id, &conn)
|
||||||
.await
|
.await
|
||||||
@@ -2793,7 +2849,7 @@ async fn put_group_members(
|
|||||||
|
|
||||||
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
|
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
|
||||||
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
|
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
|
||||||
};
|
}
|
||||||
|
|
||||||
let assigned_members = data.into_inner();
|
let assigned_members = data.into_inner();
|
||||||
|
|
||||||
@@ -2870,9 +2926,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct OrganizationUserResetPasswordRequest {
|
struct OrganizationUserRecoverAccountRequest {
|
||||||
new_master_password_hash: String,
|
new_master_password_hash: String,
|
||||||
key: String,
|
key: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
reset_master_password: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
reset_two_factor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upstream reports this is the renamed endpoint instead of `/keys`
|
// Upstream reports this is the renamed endpoint instead of `/keys`
|
||||||
@@ -2900,12 +2961,43 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders
|
|||||||
get_organization_public_key(org_id, headers, conn).await
|
get_organization_public_key(org_id, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Will allow to reset 2FA too
|
||||||
|
// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts
|
||||||
|
#[put("/organizations/<org_id>/users/<member_id>/recover-account", data = "<data>")]
|
||||||
|
async fn put_recover_account(
|
||||||
|
org_id: OrganizationId,
|
||||||
|
member_id: MembershipId,
|
||||||
|
headers: AdminHeaders,
|
||||||
|
data: Json<OrganizationUserRecoverAccountRequest>,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> EmptyResult {
|
||||||
|
let req = data.into_inner();
|
||||||
|
if req.reset_master_password && !req.reset_two_factor {
|
||||||
|
recover_account(org_id, member_id, headers, req, conn, nt).await
|
||||||
|
} else {
|
||||||
|
err!("Unsupported operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated since `v2026.4.2`
|
||||||
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
|
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
|
||||||
async fn put_reset_password(
|
async fn put_reset_password(
|
||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
data: Json<OrganizationUserResetPasswordRequest>,
|
data: Json<OrganizationUserRecoverAccountRequest>,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> EmptyResult {
|
||||||
|
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recover_account(
|
||||||
|
org_id: OrganizationId,
|
||||||
|
member_id: MembershipId,
|
||||||
|
headers: AdminHeaders,
|
||||||
|
reset_request: OrganizationUserRecoverAccountRequest,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
@@ -2939,8 +3031,6 @@ async fn put_reset_password(
|
|||||||
err!(format!("Error sending user reset password email: {e:#?}"));
|
err!(format!("Error sending user reset password email: {e:#?}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let reset_request = data.into_inner();
|
|
||||||
|
|
||||||
let mut user = user;
|
let mut user = user;
|
||||||
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
|
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -3100,12 +3190,12 @@ async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbC
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"collections": convert_json_key_lcase_first(_get_org_collections(&org_id, &conn).await),
|
"collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await),
|
||||||
"ciphers": convert_json_key_lcase_first(_get_org_details(&org_id, &headers.host, &headers.user.uuid, &conn).await?),
|
"ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _api_key(
|
async fn api_key(
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
data: Json<PasswordOrOtpData>,
|
data: Json<PasswordOrOtpData>,
|
||||||
rotate: bool,
|
rotate: bool,
|
||||||
@@ -3121,21 +3211,18 @@ async fn _api_key(
|
|||||||
// Validate the admin users password/otp
|
// Validate the admin users password/otp
|
||||||
data.validate(&user, true, &conn).await?;
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
|
let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
|
||||||
Some(mut org_api_key) => {
|
if rotate {
|
||||||
if rotate {
|
org_api_key.api_key = crate::crypto::generate_api_key();
|
||||||
org_api_key.api_key = crate::crypto::generate_api_key();
|
org_api_key.revision_date = chrono::Utc::now().naive_utc();
|
||||||
org_api_key.revision_date = chrono::Utc::now().naive_utc();
|
org_api_key.save(&conn).await.expect("Error rotating organization API Key");
|
||||||
org_api_key.save(&conn).await.expect("Error rotating organization API Key");
|
|
||||||
}
|
|
||||||
org_api_key
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let api_key = crate::crypto::generate_api_key();
|
|
||||||
let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key);
|
|
||||||
new_org_api_key.save(&conn).await.expect("Error creating organization API Key");
|
|
||||||
new_org_api_key
|
|
||||||
}
|
}
|
||||||
|
org_api_key
|
||||||
|
} else {
|
||||||
|
let api_key = crate::crypto::generate_api_key();
|
||||||
|
let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key);
|
||||||
|
new_org_api_key.save(&conn).await.expect("Error creating organization API Key");
|
||||||
|
new_org_api_key
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -3146,13 +3233,13 @@ async fn _api_key(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/organizations/<org_id>/api-key", data = "<data>")]
|
#[post("/organizations/<org_id>/api-key", data = "<data>")]
|
||||||
async fn api_key(
|
async fn post_api_key(
|
||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
data: Json<PasswordOrOtpData>,
|
data: Json<PasswordOrOtpData>,
|
||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_api_key(&org_id, data, false, headers, conn).await
|
api_key(&org_id, data, false, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
|
#[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
|
||||||
@@ -3162,5 +3249,5 @@ async fn rotate_api_key(
|
|||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
_api_key(&org_id, data, true, headers, conn).await
|
api_key(&org_id, data, true, headers, conn).await
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-68
@@ -1,23 +1,24 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use rocket::{
|
|
||||||
request::{FromRequest, Outcome},
|
|
||||||
serde::json::Json,
|
|
||||||
Request, Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use rocket::{
|
||||||
|
Request, Route,
|
||||||
|
request::{FromRequest, Outcome},
|
||||||
|
serde::json::Json,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
auth,
|
auth,
|
||||||
db::{
|
db::{
|
||||||
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
|
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
|
||||||
OrganizationApiKey, OrganizationId, User,
|
OrganizationApiKey, OrganizationId, User,
|
||||||
},
|
},
|
||||||
DbConn,
|
|
||||||
},
|
},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -83,26 +84,32 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
}
|
}
|
||||||
// If user is part of the organization, restore it
|
// If user is part of the organization, restore it
|
||||||
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||||
let restored = member.restore();
|
let mut restored = member.restore();
|
||||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||||
|
// Enforce org policies as every other restore path does.
|
||||||
|
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
|
||||||
|
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
|
||||||
|
warn!("Not restoring {}: {e:?}", user_data.email);
|
||||||
|
member.revoke();
|
||||||
|
restored = false;
|
||||||
|
}
|
||||||
if restored || ext_modified {
|
if restored || ext_modified {
|
||||||
member.save(&conn).await?;
|
member.save(&conn).await?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If user is not part of the organization
|
// If user is not part of the organization
|
||||||
let user = match User::find_by_mail(&user_data.email, &conn).await {
|
let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await {
|
||||||
Some(user) => user, // exists in vaultwarden
|
user
|
||||||
None => {
|
} else {
|
||||||
// User does not exist yet
|
// User does not exist yet
|
||||||
let mut new_user = User::new(&user_data.email, None);
|
let mut new_user = User::new(&user_data.email, None);
|
||||||
new_user.save(&conn).await?;
|
new_user.save(&conn).await?;
|
||||||
|
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
Invitation::new(&new_user.email).save(&conn).await?;
|
Invitation::new(&new_user.email).save(&conn).await?;
|
||||||
}
|
|
||||||
user_created = true;
|
|
||||||
new_user
|
|
||||||
}
|
}
|
||||||
|
user_created = true;
|
||||||
|
new_user
|
||||||
};
|
};
|
||||||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||||
MembershipStatus::Invited as i32
|
MembershipStatus::Invited as i32
|
||||||
@@ -110,9 +117,10 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||||
};
|
};
|
||||||
|
|
||||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await {
|
let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||||
Some(org) => (org.name, org.billing_email),
|
(org.name, org.billing_email)
|
||||||
None => err!("Error looking up organization"),
|
} else {
|
||||||
|
err!("Error looking up organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||||
@@ -123,37 +131,33 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
|
|
||||||
new_member.save(&conn).await?;
|
new_member.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled()
|
||||||
if let Err(e) =
|
&& let Err(e) =
|
||||||
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||||
{
|
{
|
||||||
// Upon error delete the user, invite and org member records when needed
|
// Upon error delete the user, invite and org member records when needed
|
||||||
if user_created {
|
if user_created {
|
||||||
user.delete(&conn).await?;
|
user.delete(&conn).await?;
|
||||||
} else {
|
} else {
|
||||||
new_member.delete(&conn).await?;
|
new_member.delete(&conn).await?;
|
||||||
}
|
|
||||||
|
|
||||||
err!(format!("Error sending invite: {e:?} "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err!(format!("Error sending invite: {e:?} "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.org_groups_enabled() {
|
if CONFIG.org_groups_enabled() {
|
||||||
for group_data in &data.groups {
|
for group_data in &data.groups {
|
||||||
let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await {
|
let group_uuid = if let Some(group) =
|
||||||
Some(group) => group.uuid,
|
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
||||||
None => {
|
{
|
||||||
let mut group = Group::new(
|
group.uuid
|
||||||
org_id.clone(),
|
} else {
|
||||||
group_data.name.clone(),
|
let mut group =
|
||||||
false,
|
Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()));
|
||||||
Some(group_data.external_id.clone()),
|
group.save(&conn).await?;
|
||||||
);
|
group.uuid
|
||||||
group.save(&conn).await?;
|
|
||||||
group.uuid
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||||
@@ -174,18 +178,17 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
|||||||
// Generate a HashSet to quickly verify if a member is listed or not.
|
// Generate a HashSet to quickly verify if a member is listed or not.
|
||||||
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
||||||
for member in Membership::find_by_org(&org_id, &conn).await {
|
for member in Membership::find_by_org(&org_id, &conn).await {
|
||||||
if let Some(ref user_external_id) = member.external_id {
|
if let Some(ref user_external_id) = member.external_id
|
||||||
if !sync_members.contains(user_external_id) {
|
&& !sync_members.contains(user_external_id)
|
||||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
{
|
||||||
// Removing owner, check that there is at least one other confirmed owner
|
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
|
// Removing owner, check that there is at least one other confirmed owner
|
||||||
{
|
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
||||||
warn!("Can't delete the last owner");
|
warn!("Can't delete the last owner");
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
member.delete(&conn).await?;
|
|
||||||
}
|
}
|
||||||
|
member.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,12 +205,14 @@ impl<'r> FromRequest<'r> for PublicToken {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
// Get access_token
|
// Get access_token
|
||||||
let access_token: &str = match headers.get_one("Authorization") {
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
Some(a) => match a.rsplit("Bearer ").next() {
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
Some(split) => split,
|
split
|
||||||
None => err_handler!("No access token provided"),
|
} else {
|
||||||
},
|
err_handler!("No access token provided")
|
||||||
None => err_handler!("No access token provided"),
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
};
|
};
|
||||||
// Check JWT token is valid and get device and user from it
|
// Check JWT token is valid and get device and user from it
|
||||||
let Ok(claims) = auth::decode_api_org(access_token) else {
|
let Ok(claims) = auth::decode_api_org(access_token) else {
|
||||||
@@ -229,14 +234,13 @@ impl<'r> FromRequest<'r> for PublicToken {
|
|||||||
|
|
||||||
// Check if claims.sub is org_api_key.uuid
|
// Check if claims.sub is org_api_key.uuid
|
||||||
// Check if claims.client_sub is org_api_key.org_uuid
|
// Check if claims.client_sub is org_api_key.org_uuid
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
||||||
err_handler!("Malformed client_id")
|
err_handler!("Malformed client_id")
|
||||||
};
|
};
|
||||||
let org_id: OrganizationId = org_id.to_string().into();
|
let org_id: OrganizationId = org_id.to_owned().into();
|
||||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
||||||
err_handler!("Invalid client_id")
|
err_handler!("Invalid client_id")
|
||||||
};
|
};
|
||||||
|
|||||||
+92
-54
@@ -10,15 +10,15 @@ use rocket::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::{ClientIp, Headers, Host},
|
auth::{ClientIp, Headers, Host, SendHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
db::{
|
db::{
|
||||||
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||||
},
|
},
|
||||||
util::{save_temp_file, NumberOrString},
|
util::{NumberOrString, save_temp_file},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
||||||
@@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
post_send,
|
post_send,
|
||||||
post_send_file,
|
post_send_file,
|
||||||
post_access,
|
post_access,
|
||||||
|
post_access_legacy,
|
||||||
post_access_file,
|
post_access_file,
|
||||||
|
post_access_file_legacy,
|
||||||
put_send,
|
put_send,
|
||||||
delete_send,
|
delete_send,
|
||||||
put_remove_password,
|
put_remove_password,
|
||||||
@@ -63,7 +65,7 @@ pub async fn purge_sends(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Send::purge(&conn).await;
|
Send::purge(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging sends")
|
error!("Failed to get DB connection while purging sends");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ pub struct SendData {
|
|||||||
deletion_date: DateTime<Utc>,
|
deletion_date: DateTime<Utc>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
hide_email: Option<bool>,
|
hide_email: Option<bool>,
|
||||||
|
emails: Option<String>,
|
||||||
|
|
||||||
// Data field
|
// Data field
|
||||||
name: String,
|
name: String,
|
||||||
@@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.emails.is_some() {
|
||||||
|
err!("Sends with email verification is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
||||||
send.user_uuid = Some(user_id);
|
send.user_uuid = Some(user_id);
|
||||||
send.notes = data.notes;
|
send.notes = data.notes;
|
||||||
@@ -168,7 +175,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
|||||||
#[get("/sends")]
|
#[get("/sends")]
|
||||||
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let sends = Send::find_by_user(&headers.user.uuid, &conn);
|
let sends = Send::find_by_user(&headers.user.uuid, &conn);
|
||||||
let sends_json: Vec<Value> = sends.await.iter().map(|s| s.to_json()).collect();
|
let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect();
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"data": sends_json,
|
"data": sends_json,
|
||||||
@@ -179,9 +186,10 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
|
|
||||||
#[get("/sends/<send_id>")]
|
#[get("/sends/<send_id>")]
|
||||||
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
||||||
Some(send) => Ok(Json(send.to_json())),
|
Ok(Json(send.to_json()))
|
||||||
None => err!("Send not found", "Invalid send uuid or does not belong to user"),
|
} else {
|
||||||
|
err!("Send not found", "Invalid send uuid or does not belong to user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,9 +318,10 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
|||||||
|
|
||||||
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||||
|
|
||||||
let file_length = match &data.file_length {
|
let file_length = if let Some(m) = &data.file_length {
|
||||||
Some(m) => m.into_i64()?,
|
m.into_i64()?
|
||||||
_ => err!("Invalid send length"),
|
} else {
|
||||||
|
err!("Invalid send length")
|
||||||
};
|
};
|
||||||
if file_length < 0 {
|
if file_length < 0 {
|
||||||
err!("Send size can't be negative")
|
err!("Send size can't be negative")
|
||||||
@@ -369,7 +378,7 @@ pub struct SendFileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
|
||||||
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
|
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
|
||||||
async fn post_send_file_v2_data(
|
async fn post_send_file_v2_data(
|
||||||
send_id: SendId,
|
send_id: SendId,
|
||||||
file_id: SendFileId,
|
file_id: SendFileId,
|
||||||
@@ -439,41 +448,45 @@ async fn post_send_file_v2_data(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/sends/access")]
|
||||||
|
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
|
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
};
|
||||||
|
if !send.is_accessible() {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
process_access(send, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SendAccessData {
|
pub struct SendAccessData {
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy since web-2026.6.0
|
||||||
#[post("/sends/access/<access_id>", data = "<data>")]
|
#[post("/sends/access/<access_id>", data = "<data>")]
|
||||||
async fn post_access(
|
async fn post_access_legacy(
|
||||||
access_id: &str,
|
access_id: &str,
|
||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(max_access_count) = send.max_access_count {
|
if let Some(max_access_count) = send.max_access_count
|
||||||
if send.access_count >= max_access_count {
|
&& send.access_count >= max_access_count
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
{
|
||||||
}
|
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(expiration) = send.expiration_date {
|
if !send.is_accessible() {
|
||||||
if Utc::now().naive_utc() >= expiration {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if Utc::now().naive_utc() >= send.deletion_date {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
if send.disabled {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +500,17 @@ async fn post_access(
|
|||||||
|
|
||||||
// Files are incremented during the download
|
// Files are incremented during the download
|
||||||
if send.atype == SendType::Text as i32 {
|
if send.atype == SendType::Text as i32 {
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
send.save(&conn).await?;
|
process_access(send, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
@@ -504,36 +523,47 @@ async fn post_access(
|
|||||||
Ok(Json(send.to_json_access(&conn).await))
|
Ok(Json(send.to_json_access(&conn).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
#[post("/sends/access/file/<file_id>", rank = 1)]
|
||||||
async fn post_access_file(
|
async fn post_access_file(
|
||||||
|
file_id: SendFileId,
|
||||||
|
headers: SendHeaders,
|
||||||
|
host: Host,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
};
|
||||||
|
if !send.is_accessible() {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy since web-2026.6.0
|
||||||
|
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
||||||
|
async fn post_access_file_legacy(
|
||||||
send_id: SendId,
|
send_id: SendId,
|
||||||
file_id: SendFileId,
|
file_id: SendFileId,
|
||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
host: Host,
|
host: Host,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(max_access_count) = send.max_access_count {
|
if let Some(max_access_count) = send.max_access_count
|
||||||
if send.access_count >= max_access_count {
|
&& send.access_count >= max_access_count
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
{
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expiration) = send.expiration_date {
|
|
||||||
if Utc::now().naive_utc() >= expiration {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if Utc::now().naive_utc() >= send.deletion_date {
|
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if send.disabled {
|
if !send.is_accessible() {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,10 +575,14 @@ async fn post_access_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
|
||||||
send.save(&conn).await?;
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
@@ -561,7 +595,7 @@ async fn post_access_file(
|
|||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"object": "send-fileDownload",
|
"object": "send-fileDownload",
|
||||||
"id": file_id,
|
"id": file_id,
|
||||||
"url": download_url(&host, &send_id, &file_id).await?,
|
"url": download_url(&host, &send.uuid, &file_id).await?,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +606,7 @@ async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Re
|
|||||||
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
||||||
let token = crate::auth::encode_jwt(&token_claims);
|
let token = crate::auth::encode_jwt(&token_claims);
|
||||||
|
|
||||||
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host))
|
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", host.host))
|
||||||
} else {
|
} else {
|
||||||
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
||||||
}
|
}
|
||||||
@@ -580,10 +614,10 @@ async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Re
|
|||||||
|
|
||||||
#[get("/sends/<send_id>/<file_id>?<t>")]
|
#[get("/sends/<send_id>/<file_id>?<t>")]
|
||||||
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
||||||
if let Ok(claims) = crate::auth::decode_send(t) {
|
if let Ok(claims) = crate::auth::decode_send(t)
|
||||||
if claims.sub == format!("{send_id}/{file_id}") {
|
&& claims.sub == format!("{send_id}/{file_id}")
|
||||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
{
|
||||||
}
|
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -599,6 +633,10 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
|
|||||||
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if data.emails.is_some() {
|
||||||
|
err!("Sends with email verification is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
|
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
|
||||||
|
|
||||||
Ok(Json(send.to_json()))
|
Ok(Json(send.to_json()))
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData},
|
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
|
||||||
auth::{ClientIp, Headers},
|
auth::{ClientIp, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
@@ -70,9 +69,10 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Validate key as base32 and 20 bytes length
|
// Validate key as base32 and 20 bytes length
|
||||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
||||||
Ok(decoded) => decoded,
|
decoded
|
||||||
_ => err!("Invalid totp secret"),
|
} else {
|
||||||
|
err!("Invalid totp secret")
|
||||||
};
|
};
|
||||||
|
|
||||||
if decoded_key.len() != 20 {
|
if decoded_key.len() != 20 {
|
||||||
@@ -82,7 +82,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
// Validate the token provided with the key, and save new twofactor
|
// Validate the token provided with the key, and save new twofactor
|
||||||
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
|
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ pub async fn validate_totp_code(
|
|||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
use totp_lite::{totp_custom, Sha1};
|
use totp_lite::{Sha1, totp_custom};
|
||||||
|
|
||||||
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
||||||
err!("Invalid TOTP secret")
|
err!("Invalid TOTP secret")
|
||||||
@@ -128,7 +128,7 @@ pub async fn validate_totp_code(
|
|||||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||||
{
|
{
|
||||||
Some(tf) => tf,
|
Some(tf) => tf,
|
||||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()),
|
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The amount of steps back and forward in time
|
// The amount of steps back and forward in time
|
||||||
@@ -145,7 +145,7 @@ pub async fn validate_totp_code(
|
|||||||
|
|
||||||
// We need to calculate the time offsite and cast it as an u64.
|
// We need to calculate the time offsite and cast it as an u64.
|
||||||
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
||||||
let time = (current_timestamp + step * 30i64) as u64;
|
let time: u64 = (current_timestamp + step * 30i64).cast_unsigned();
|
||||||
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
||||||
|
|
||||||
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use data_encoding::BASE64;
|
use data_encoding::BASE64;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult,
|
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
|
||||||
PasswordOrOtpData,
|
core::two_factor::generate_recover_code,
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -82,8 +81,7 @@ enum DuoStatus {
|
|||||||
impl DuoStatus {
|
impl DuoStatus {
|
||||||
fn data(self) -> Option<DuoData> {
|
fn data(self) -> Option<DuoData> {
|
||||||
match self {
|
match self {
|
||||||
DuoStatus::Global(data) => Some(data),
|
DuoStatus::Global(data) | DuoStatus::User(data) => Some(data),
|
||||||
DuoStatus::User(data) => Some(data),
|
|
||||||
DuoStatus::Disabled(_) => None,
|
DuoStatus::Disabled(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +180,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
|||||||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
||||||
twofactor.save(&conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -201,14 +199,14 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||||
use reqwest::{header, Method};
|
use reqwest::{Method, header};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
// https://duo.com/docs/authapi#api-details
|
// https://duo.com/docs/authapi#api-details
|
||||||
let url = format!("https://{}{path}", &data.host);
|
let url = format!("https://{}{path}", data.host);
|
||||||
let date = Utc::now().to_rfc2822();
|
let dt = Utc::now().to_rfc2822();
|
||||||
let username = &data.ik;
|
let username = &data.ik;
|
||||||
let fields = [&date, method, &data.host, path, params];
|
let fields = [&dt, method, &data.host, path, params];
|
||||||
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
||||||
|
|
||||||
let m = Method::from_str(method).unwrap_or_default();
|
let m = Method::from_str(method).unwrap_or_default();
|
||||||
@@ -216,7 +214,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
|||||||
make_http_request(m, &url)?
|
make_http_request(m, &url)?
|
||||||
.basic_auth(username, Some(password))
|
.basic_auth(username, Some(password))
|
||||||
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
||||||
.header(header::DATE, date)
|
.header(header::DATE, dt)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
@@ -356,9 +354,10 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
|
|||||||
err!("Invalid ikey")
|
err!("Invalid ikey")
|
||||||
}
|
}
|
||||||
|
|
||||||
let expire: i64 = match expire.parse() {
|
let expire: i64 = if let Ok(e) = expire.parse() {
|
||||||
Ok(e) => e,
|
e
|
||||||
Err(_) => err!("Invalid expire time"),
|
} else {
|
||||||
|
err!("Invalid expire time")
|
||||||
};
|
};
|
||||||
|
|
||||||
if time >= expire {
|
if time >= expire {
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use reqwest::{header, StatusCode};
|
use reqwest::{StatusCode, header};
|
||||||
use ring::digest::{digest, Digest, SHA512_256};
|
use ring::digest::{Digest, SHA512_256, digest};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
|
CONFIG,
|
||||||
|
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
||||||
// built in to the Bitwarden clients.
|
// built in to the Bitwarden clients.
|
||||||
@@ -124,7 +125,7 @@ impl DuoClient {
|
|||||||
ClientAssertion {
|
ClientAssertion {
|
||||||
iss: self.client_id.clone(),
|
iss: self.client_id.clone(),
|
||||||
sub: self.client_id.clone(),
|
sub: self.client_id.clone(),
|
||||||
aud: url.to_string(),
|
aud: url.to_owned(),
|
||||||
exp: now + JWT_VALIDITY_SECS,
|
exp: now + JWT_VALIDITY_SECS,
|
||||||
jti: jwt_id,
|
jti: jwt_id,
|
||||||
iat: now,
|
iat: now,
|
||||||
@@ -302,7 +303,7 @@ impl DuoClient {
|
|||||||
|
|
||||||
if !(matching_nonces && matching_usernames) {
|
if !(matching_nonces && matching_usernames) {
|
||||||
err!("Error validating Duo authorization, nonce or username mismatch.")
|
err!("Error validating Duo authorization, nonce or username mismatch.")
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -347,7 +348,7 @@ pub async fn purge_duo_contexts(pool: DbPool) {
|
|||||||
if let Ok(conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging expired Duo authentications")
|
error!("Failed to get DB connection while purging expired Duo authentications");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +395,7 @@ pub async fn get_duo_auth_url(
|
|||||||
match client.health_check().await {
|
match client.health_check().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
}
|
||||||
|
|
||||||
// Generate random OAuth2 state and OIDC Nonce
|
// Generate random OAuth2 state and OIDC Nonce
|
||||||
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||||
@@ -438,16 +439,13 @@ pub async fn validate_duo_login(
|
|||||||
|
|
||||||
// Get the context by the state reported by the client. If we don't have one,
|
// Get the context by the state reported by the client. If we don't have one,
|
||||||
// it means the context is either missing or expired.
|
// it means the context is either missing or expired.
|
||||||
let ctx = match extract_context(state, conn).await {
|
let Some(ctx) = extract_context(state, conn).await else {
|
||||||
Some(c) => c,
|
err!(
|
||||||
None => {
|
"Error validating duo authentication",
|
||||||
err!(
|
ErrorEvent {
|
||||||
"Error validating duo authentication",
|
event: EventType::UserFailedLogIn2fa
|
||||||
ErrorEvent {
|
}
|
||||||
event: EventType::UserFailedLogIn2fa
|
)
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Context validation steps
|
// Context validation steps
|
||||||
@@ -476,13 +474,13 @@ pub async fn validate_duo_login(
|
|||||||
match client.health_check().await {
|
match client.health_check().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
}
|
||||||
|
|
||||||
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
||||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||||
|
|
||||||
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
||||||
Ok(_) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
err!(
|
err!(
|
||||||
"Error validating duo authentication",
|
"Error validating duo authentication",
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, Headers},
|
auth::{ClientHeaders, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -232,7 +232,7 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
|||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
twofactor.save(&conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -284,9 +284,9 @@ pub async fn validate_email_code_str(
|
|||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
twofactor.save(conn).await?;
|
twofactor.save(conn).await?;
|
||||||
|
|
||||||
let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||||
let max_time = CONFIG.email_expiration_time() as i64;
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
@@ -342,9 +342,10 @@ impl EmailTokenData {
|
|||||||
|
|
||||||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
||||||
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
||||||
match res {
|
if let Ok(x) = res {
|
||||||
Ok(x) => Ok(x),
|
Ok(x)
|
||||||
Err(_) => err!("Could not decode EmailTokenData from string"),
|
} else {
|
||||||
|
err!("Could not decode EmailTokenData from string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,18 +363,17 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
|
|||||||
pub fn obscure_email(email: &str) -> String {
|
pub fn obscure_email(email: &str) -> String {
|
||||||
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
|
|
||||||
let mut name = split[1].to_string();
|
let mut name = split[1].to_owned();
|
||||||
let domain = &split[0];
|
let domain = &split[0];
|
||||||
|
|
||||||
let name_size = name.chars().count();
|
let name_size = name.chars().count();
|
||||||
|
|
||||||
let new_name = match name_size {
|
let new_name = if let 1..=3 = name_size {
|
||||||
1..=3 => "*".repeat(name_size),
|
"*".repeat(name_size)
|
||||||
_ => {
|
} else {
|
||||||
let stars = "*".repeat(name_size - 2);
|
let stars = "*".repeat(name_size - 2);
|
||||||
name.truncate(2);
|
name.truncate(2);
|
||||||
format!("{name}{stars}")
|
format!("{name}{stars}")
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
format!("{new_name}@{domain}")
|
format!("{new_name}@{domain}")
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_event, log_user_event},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_event, log_user_event},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn, DbPool,
|
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod authenticator;
|
pub mod authenticator;
|
||||||
@@ -37,7 +36,7 @@ fn has_global_duo_credentials() -> bool {
|
|||||||
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool {
|
pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DuoProviderData {
|
struct DuoProviderData {
|
||||||
host: String,
|
host: String,
|
||||||
@@ -46,7 +45,7 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
|||||||
}
|
}
|
||||||
|
|
||||||
match provider_type {
|
match provider_type {
|
||||||
TwoFactorType::Authenticator => true,
|
TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true,
|
||||||
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||||
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||||
provider_data
|
provider_data
|
||||||
@@ -59,7 +58,6 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
|||||||
}
|
}
|
||||||
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||||
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||||
TwoFactorType::RecoveryCode => true,
|
|
||||||
TwoFactorType::U2f
|
TwoFactorType::U2f
|
||||||
| TwoFactorType::U2fRegisterChallenge
|
| TwoFactorType::U2fRegisterChallenge
|
||||||
| TwoFactorType::U2fLoginChallenge
|
| TwoFactorType::U2fLoginChallenge
|
||||||
@@ -96,7 +94,7 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tf| {
|
.filter_map(|tf| {
|
||||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||||
if user.totp_recover.is_none() {
|
if user.totp_recover.is_none() {
|
||||||
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||||
user.totp_recover = Some(totp_recover);
|
user.totp_recover = Some(totp_recover);
|
||||||
@@ -180,9 +178,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
ip: &std::net::IpAddr,
|
ip: &std::net::IpAddr,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
for member in
|
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
|
||||||
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
|
|
||||||
{
|
|
||||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||||
if member.atype < MembershipType::Admin {
|
if member.atype < MembershipType::Admin {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -217,7 +213,7 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
||||||
for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() {
|
for member in Membership::find_confirmed_by_org(org_id, conn).await {
|
||||||
// Don't enforce the policy for Admins and Owners.
|
// Don't enforce the policy for Admins and Owners.
|
||||||
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -251,12 +247,9 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = match pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
Ok(conn) => conn,
|
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||||
_ => {
|
return;
|
||||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
@@ -278,7 +271,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
if let Err(e) = login.delete(&conn).await {
|
if let Err(e) = login.delete(&conn).await {
|
||||||
error!("Error deleting incomplete 2FA record: {e:#?}");
|
error!("Error deleting incomplete 2FA record: {e:#?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds};
|
||||||
use rocket::{serde::json::Json, Route};
|
use rocket::{Route, serde::json::Json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -44,9 +45,10 @@ impl ProtectedActionData {
|
|||||||
|
|
||||||
pub fn from_json(string: &str) -> Result<Self, Error> {
|
pub fn from_json(string: &str) -> Result<Self, Error> {
|
||||||
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
||||||
match res {
|
if let Ok(x) = res {
|
||||||
Ok(x) => Ok(x),
|
Ok(x)
|
||||||
Err(_) => err!("Could not decode ProtectedActionData from string"),
|
} else {
|
||||||
|
err!("Could not decode ProtectedActionData from string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,9 @@ impl ProtectedActionData {
|
|||||||
#[post("/accounts/request-otp")]
|
#[post("/accounts/request-otp")]
|
||||||
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
err!(
|
||||||
|
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -102,7 +106,9 @@ struct ProtectedActionVerify {
|
|||||||
#[post("/accounts/verify-otp", data = "<data>")]
|
#[post("/accounts/verify-otp", data = "<data>")]
|
||||||
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
err!(
|
||||||
|
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -133,7 +139,7 @@ pub async fn validate_protected_action_otp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token has expired (Using the email 2fa expiration time)
|
// Check if the token has expired (Using the email 2fa expiration time)
|
||||||
let max_time = CONFIG.email_expiration_time() as i64;
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||||
pa.delete(conn).await?;
|
pa.delete(conn).await?;
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
use crate::{
|
use std::{str::FromStr, sync::LazyLock, time::Duration};
|
||||||
api::{
|
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
use rocket::{Route, serde::json::Json};
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
|
||||||
},
|
|
||||||
auth::Headers,
|
|
||||||
crypto::ct_eq,
|
|
||||||
db::{
|
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
error::Error,
|
|
||||||
util::NumberOrString,
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
|
use webauthn_rs::{
|
||||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
Webauthn, WebauthnBuilder,
|
||||||
|
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
|
||||||
|
};
|
||||||
use webauthn_rs_proto::{
|
use webauthn_rs_proto::{
|
||||||
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
||||||
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
||||||
RequestAuthenticationExtensions, UserVerificationPolicy,
|
RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{
|
||||||
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
|
},
|
||||||
|
auth::Headers,
|
||||||
|
crypto::ct_eq,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
util::NumberOrString,
|
||||||
|
};
|
||||||
|
|
||||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||||
let domain = CONFIG.domain();
|
let domain = CONFIG.domain();
|
||||||
let domain_origin = CONFIG.domain_origin();
|
let domain_origin = CONFIG.domain_origin();
|
||||||
@@ -149,7 +150,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
state["rs"]["policy"] = Value::String("discouraged".to_string());
|
state["rs"]["policy"] = Value::String("discouraged".to_owned());
|
||||||
state["rs"]["extensions"].as_object_mut().unwrap().clear();
|
state["rs"]["extensions"].as_object_mut().unwrap().clear();
|
||||||
|
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
||||||
@@ -265,13 +266,12 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
|
|
||||||
// Retrieve and delete the saved challenge state
|
// Retrieve and delete the saved challenge state
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
||||||
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
Some(tf) => {
|
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
tf.delete(&conn).await?;
|
||||||
tf.delete(&conn).await?;
|
state
|
||||||
state
|
} else {
|
||||||
}
|
err!("Can't recover challenge")
|
||||||
None => err!("Can't recover challenge"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify the credentials with the saved state
|
// Verify the credentials with the saved state
|
||||||
@@ -291,7 +291,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
|||||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||||
.save(&conn)
|
.save(&conn)
|
||||||
.await?;
|
.await?;
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
@@ -342,9 +342,10 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo
|
|||||||
// If entry is migrated from u2f, delete the u2f entry as well
|
// If entry is migrated from u2f, delete the u2f entry as well
|
||||||
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
||||||
{
|
{
|
||||||
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
|
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
|
||||||
Ok(d) => d,
|
d
|
||||||
Err(_) => err!("Error parsing U2F data"),
|
} else {
|
||||||
|
err!("Error parsing U2F data")
|
||||||
};
|
};
|
||||||
|
|
||||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||||
@@ -388,10 +389,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
|||||||
|
|
||||||
// Modify to discourage user verification
|
// Modify to discourage user verification
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
state["ast"]["policy"] = Value::String("discouraged".to_string());
|
state["ast"]["policy"] = Value::String("discouraged".to_owned());
|
||||||
|
|
||||||
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
||||||
let app_id = format!("{}/app-id.json", &CONFIG.domain());
|
let app_id = format!("{}/app-id.json", CONFIG.domain());
|
||||||
state["ast"]["appid"] = Value::String(app_id.clone());
|
state["ast"]["appid"] = Value::String(app_id.clone());
|
||||||
|
|
||||||
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||||
@@ -416,18 +417,17 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
|||||||
|
|
||||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||||
let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||||
Some(tf) => {
|
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
tf.delete(conn).await?;
|
||||||
tf.delete(conn).await?;
|
state
|
||||||
state
|
} else {
|
||||||
}
|
err!(
|
||||||
None => err!(
|
|
||||||
"Can't recover login challenge",
|
"Can't recover login challenge",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn2fa
|
event: EventType::UserFailedLogIn2fa
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yubico::{config::Config, verify_async};
|
use yubico_ng::{
|
||||||
|
Verifier, YubicoError,
|
||||||
|
config::Config,
|
||||||
|
transport::{AsyncTransport, Response},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
CONFIG,
|
http_client,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct HttpClientTransport {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientTransport {
|
||||||
|
fn new() -> Result<Self, reqwest::Error> {
|
||||||
|
http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(
|
||||||
|
|client| Self {
|
||||||
|
client,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncTransport for HttpClientTransport {
|
||||||
|
type Error = YubicoError;
|
||||||
|
|
||||||
|
async fn yubico_get(&self, url: &str) -> Result<Response, Self::Error> {
|
||||||
|
let response = self.client.get(url).send().await.map_err(YubicoError::transport)?;
|
||||||
|
Ok(Response {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
body: response.text().await.map_err(YubicoError::transport)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EnableYubikeyData {
|
struct EnableYubikeyData {
|
||||||
@@ -45,8 +75,7 @@ pub struct YubikeyMetadata {
|
|||||||
|
|
||||||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||||
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
||||||
|
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
|
||||||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||||
@@ -64,22 +93,25 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
|
|||||||
err!("Yubico support is disabled");
|
err!("Yubico support is disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||||
(Some(id), Some(secret)) => Ok((id, secret)),
|
Ok((id, secret))
|
||||||
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
|
} else {
|
||||||
|
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
||||||
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
||||||
|
|
||||||
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
|
let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
|
||||||
|
if let Some(yubico_server) = CONFIG.yubico_server() {
|
||||||
match CONFIG.yubico_server() {
|
config = config.set_api_host(yubico_server);
|
||||||
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
|
|
||||||
None => verify_async(otp, config).await,
|
|
||||||
}
|
}
|
||||||
.map_res("Failed to verify OTP")
|
|
||||||
|
let client = HttpClientTransport::new()?;
|
||||||
|
let verifier = Verifier::with_client(config, client)?;
|
||||||
|
|
||||||
|
verifier.verify(otp).await.map_res("Failed to verify OTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-yubikey", data = "<data>")]
|
#[post("/two-factor/get-yubikey", data = "<data>")]
|
||||||
@@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
let yubikeys = parse_yubikeys(&data);
|
let yubikeys = parse_yubikeys(&data);
|
||||||
|
|
||||||
if yubikeys.is_empty() {
|
if yubikeys.is_empty() {
|
||||||
return Ok(Json(json!({
|
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
|
||||||
"enabled": false,
|
// To remove all keys users should click the `Deactivate all keys` button
|
||||||
"object": "twoFactorU2f",
|
err!("A key is required.");
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure they are valid OTPs
|
// Ensure they are valid OTPs
|
||||||
@@ -162,7 +193,7 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
|||||||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
||||||
yubikey_data.save(&conn).await?;
|
yubikey_data.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
|
|||||||
+54
-39
@@ -6,28 +6,29 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use bytes::{Bytes, BytesMut};
|
use bytes::{Bytes, BytesMut};
|
||||||
use futures::{stream::StreamExt, TryFutureExt};
|
use futures::{TryFutureExt, stream::StreamExt};
|
||||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{self, HeaderMap, HeaderValue},
|
|
||||||
Client, Response,
|
Client, Response,
|
||||||
|
header::{self, HeaderMap, HeaderValue},
|
||||||
};
|
};
|
||||||
use rocket::{http::ContentType, response::Redirect, Route};
|
use rocket::{Route, http::ContentType, response::Redirect};
|
||||||
use svg_hush::{data_url_filter, Filter};
|
use svg_hush::{Filter, data_url_filter};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
config::PathType,
|
config::PathType,
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::{get_reqwest_client_builder, get_valid_host, should_block_host, CustomHttpClientError},
|
http_client::{CustomHttpClientError, get_reqwest_client_builder, get_valid_host, should_block_host},
|
||||||
util::Cached,
|
util::Cached,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
match CONFIG.icon_service().as_str() {
|
if CONFIG.icon_service().as_str() == "internal" {
|
||||||
"internal" => routes![icon_internal],
|
routes![icon_internal]
|
||||||
_ => routes![icon_external],
|
} else {
|
||||||
|
routes![icon_external]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
|||||||
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
let pool_idle_timeout = Duration::from_secs(10);
|
||||||
// Reuse the client between requests
|
// Reuse the client between requests
|
||||||
get_reqwest_client_builder()
|
get_reqwest_client_builder(true)
|
||||||
.cookie_provider(Arc::clone(&cookie_store))
|
.cookie_provider(Arc::clone(&cookie_store))
|
||||||
.timeout(icon_download_timeout)
|
.timeout(icon_download_timeout)
|
||||||
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
||||||
@@ -147,7 +148,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
|
|
||||||
if let Some(icon) = get_cached_icon(&path).await {
|
if let Some(icon) = get_cached_icon(&path).await {
|
||||||
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
||||||
return Some((icon, icon_type.to_string()));
|
return Some((icon, icon_type.to_owned()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.disable_icon_download() {
|
if CONFIG.disable_icon_download() {
|
||||||
@@ -158,7 +159,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
match download_icon(domain).await {
|
match download_icon(domain).await {
|
||||||
Ok((icon, icon_type)) => {
|
Ok((icon, icon_type)) => {
|
||||||
save_icon(&path, icon.to_vec()).await;
|
save_icon(&path, icon.to_vec()).await;
|
||||||
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string()))
|
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_owned()))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// If this error comes from the custom resolver, this means this is a blocked domain
|
// If this error comes from the custom resolver, this means this is a blocked domain
|
||||||
@@ -183,10 +184,10 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to read the cached icon, and return it if it exists
|
// Try to read the cached icon, and return it if it exists
|
||||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)
|
||||||
if let Ok(buf) = operator.read(path).await {
|
&& let Ok(buf) = operator.read(path).await
|
||||||
return Some(buf.to_vec());
|
{
|
||||||
}
|
return Some(buf.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -280,17 +281,17 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
}
|
}
|
||||||
|
|
||||||
for icon_tag in icon_tags {
|
for icon_tag in icon_tags {
|
||||||
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) {
|
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF)
|
||||||
if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) {
|
&& let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default())
|
||||||
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
{
|
||||||
std::str::from_utf8(v).unwrap_or_default()
|
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||||
} else {
|
std::str::from_utf8(v).unwrap_or_default()
|
||||||
""
|
} else {
|
||||||
};
|
""
|
||||||
let priority = get_icon_priority(full_href.as_str(), sizes);
|
};
|
||||||
icons.push(Icon::new(priority, full_href.to_string()));
|
let priority = get_icon_priority(full_href.as_str(), sizes);
|
||||||
}
|
icons.push(Icon::new(priority, full_href.to_string()));
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,9 +405,25 @@ async fn get_page(url: &str) -> Result<Response, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
||||||
|
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
|
||||||
|
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
|
||||||
|
let Ok(parsed_url) = url::Url::parse(url) else {
|
||||||
|
err_silent!("Invalid URL", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
err_silent!("Invalid scheme", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(host) = parsed_url.host() else {
|
||||||
|
err_silent!("Invalid host", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
should_block_host(&host)?;
|
||||||
|
|
||||||
let mut client = CLIENT.get(url);
|
let mut client = CLIENT.get(url);
|
||||||
if !referer.is_empty() {
|
if !referer.is_empty() {
|
||||||
client = client.header("Referer", referer)
|
client = client.header("Referer", referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(client.send().await?.error_for_status()?)
|
Ok(client.send().await?.error_for_status()?)
|
||||||
@@ -494,12 +511,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
let mut buffer = Bytes::new();
|
let mut buffer = Bytes::new();
|
||||||
let mut icon_type: Option<&str> = None;
|
let mut icon_type: Option<&str> = None;
|
||||||
|
|
||||||
use data_url::DataUrl;
|
|
||||||
|
|
||||||
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
||||||
while let Some(icon) = icons.next() {
|
while let Some(icon) = icons.next() {
|
||||||
if icon.href.starts_with("data:image") {
|
if icon.href.starts_with("data:image") {
|
||||||
let Ok(datauri) = DataUrl::process(&icon.href) else {
|
let Ok(datauri) = data_url::DataUrl::process(&icon.href) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Check if we are able to decode the data uri
|
// Check if we are able to decode the data uri
|
||||||
@@ -523,7 +538,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => debug!("Extracted icon from data:image uri is invalid"),
|
_ => debug!("Extracted icon from data:image uri is invalid"),
|
||||||
};
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("Trying {}", icon.href);
|
debug!("Trying {}", icon.href);
|
||||||
// Make sure all icons are checked before returning error
|
// Make sure all icons are checked before returning error
|
||||||
@@ -587,10 +602,10 @@ async fn save_icon(path: &str, icon: Vec<u8>) {
|
|||||||
fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
|
fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
|
||||||
fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
||||||
// Look for SVG tag within the first 1KB
|
// Look for SVG tag within the first 1KB
|
||||||
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) {
|
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)])
|
||||||
if content.contains("<svg") || content.contains("<SVG") {
|
&& (content.contains("<svg") || content.contains("<SVG"))
|
||||||
return Some("svg+xml");
|
{
|
||||||
}
|
return Some("svg+xml");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -733,7 +748,7 @@ impl FaviconEmitter {
|
|||||||
let rel_value =
|
let rel_value =
|
||||||
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
||||||
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
||||||
self.emit_token = true
|
self.emit_token = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
@@ -806,13 +821,13 @@ impl Emitter for FaviconEmitter {
|
|||||||
|
|
||||||
fn push_attribute_name(&mut self, s: &[u8]) {
|
fn push_attribute_name(&mut self, s: &[u8]) {
|
||||||
if let Some(attr) = &mut self.current_attribute {
|
if let Some(attr) = &mut self.current_attribute {
|
||||||
attr.0.extend(s)
|
attr.0.extend(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_attribute_value(&mut self, s: &[u8]) {
|
fn push_attribute_value(&mut self, s: &[u8]) {
|
||||||
if let Some(attr) = &mut self.current_attribute {
|
if let Some(attr) = &mut self.current_attribute {
|
||||||
attr.1.extend(s)
|
attr.1.extend(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+161
-160
@@ -1,18 +1,20 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Route,
|
||||||
form::{Form, FromForm},
|
form::{Form, FromForm},
|
||||||
http::{Cookie, CookieJar, SameSite},
|
http::{Cookie, CookieJar, SameSite},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Route,
|
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
|
ApiResult, EmptyResult, JsonResult,
|
||||||
core::{
|
core::{
|
||||||
accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
|
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
|
||||||
log_user_event,
|
log_user_event,
|
||||||
two_factor::{
|
two_factor::{
|
||||||
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
||||||
@@ -21,29 +23,28 @@ use crate::{
|
|||||||
},
|
},
|
||||||
master_password_policy,
|
master_password_policy,
|
||||||
push::register_push_device,
|
push::register_push_device,
|
||||||
ApiResult, EmptyResult, JsonResult,
|
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure},
|
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
||||||
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
|
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||||
UserId,
|
TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
DbConn,
|
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
mail, sso,
|
mail, sso,
|
||||||
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
||||||
util, CONFIG,
|
util,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![
|
routes![
|
||||||
login,
|
login,
|
||||||
prelogin,
|
post_prelogin,
|
||||||
prelogin_password,
|
prelogin_password,
|
||||||
identity_register,
|
identity_register,
|
||||||
register_verification_email,
|
register_verification_email,
|
||||||
@@ -68,45 +69,59 @@ async fn login(
|
|||||||
|
|
||||||
let login_result = match data.grant_type.as_ref() {
|
let login_result = match data.grant_type.as_ref() {
|
||||||
"refresh_token" => {
|
"refresh_token" => {
|
||||||
_check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
|
check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
|
||||||
_refresh_login(data, &conn, &client_header.ip).await
|
refresh_login(data, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||||
"password" => {
|
"password" => {
|
||||||
_check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
_check_is_some(data.password.as_ref(), "password cannot be blank")?;
|
check_is_some(data.password.as_ref(), "password cannot be blank")?;
|
||||||
_check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||||
_check_is_some(data.username.as_ref(), "username cannot be blank")?;
|
check_is_some(data.username.as_ref(), "username cannot be blank")?;
|
||||||
|
|
||||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||||
_check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
_check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
_password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
||||||
}
|
}
|
||||||
"client_credentials" => {
|
"client_credentials" => {
|
||||||
_check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
_check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||||
_check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||||
|
|
||||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||||
_check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
_check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
_api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"authorization_code" if CONFIG.sso_enabled() => {
|
"authorization_code" if CONFIG.sso_enabled() => {
|
||||||
_check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
_check_is_some(data.code.as_ref(), "code cannot be blank")?;
|
check_is_some(data.code.as_ref(), "code cannot be blank")?;
|
||||||
_check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?;
|
check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?;
|
||||||
|
|
||||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||||
_check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
_check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
_sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
|
||||||
}
|
}
|
||||||
"authorization_code" => err!("SSO sign-in is not available"),
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
|
"send_access" => {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
|
||||||
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
|
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
|
||||||
|
|
||||||
|
let tokens = auth::SendTokens::generate_tokens(
|
||||||
|
data.send_id.as_ref().unwrap(),
|
||||||
|
data.password_hash_b64,
|
||||||
|
&client_header.ip,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(tokens.to_json()))
|
||||||
|
}
|
||||||
t => err!("Invalid type", t),
|
t => err!("Invalid type", t),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +140,7 @@ async fn login(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Some(ev) = e.get_event() {
|
if let Some(ev) = e.get_event() {
|
||||||
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
|
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
|
||||||
.await
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +149,7 @@ async fn login(
|
|||||||
login_result
|
login_result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
|
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
|
||||||
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
|
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
|
||||||
// See the link below for details
|
// See the link below for details
|
||||||
@@ -175,7 +190,7 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||||
async fn _sso_login(
|
async fn sso_login(
|
||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -344,7 +359,7 @@ async fn _sso_login(
|
|||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _password_login(
|
async fn password_login(
|
||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -428,9 +443,9 @@ async fn _password_login(
|
|||||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||||
if user.last_verifying_at.is_none()
|
if user.last_verifying_at.is_none()
|
||||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||||
> CONFIG.signups_verify_resend_time() as i64
|
> CONFIG.signups_verify_resend_time().cast_signed()
|
||||||
{
|
{
|
||||||
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
|
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
||||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||||
// We want to send another email verification if we require signups to verify
|
// We want to send another email verification if we require signups to verify
|
||||||
// their email address, and we haven't sent them a reminder in a while...
|
// their email address, and we haven't sent them a reminder in a while...
|
||||||
@@ -519,18 +534,7 @@ async fn authenticated_response(
|
|||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"access_token": auth_tokens.access_token(),
|
"access_token": auth_tokens.access_token(),
|
||||||
@@ -566,19 +570,19 @@ async fn authenticated_response(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
// Validate scope
|
// Validate scope
|
||||||
match data.scope.as_ref() {
|
match data.scope.as_ref() {
|
||||||
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => user_api_key_login(data, user_id, conn, ip).await,
|
||||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||||
_ => err!("Scope not supported"),
|
_ => err!("Scope not supported"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _user_api_key_login(
|
async fn user_api_key_login(
|
||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -670,18 +674,7 @@ async fn _user_api_key_login(
|
|||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: No refresh_token is returned. The CLI just repeats the
|
// Note: No refresh_token is returned. The CLI just repeats the
|
||||||
// client_credentials login flow when the existing token expires.
|
// client_credentials login flow when the existing token expires.
|
||||||
@@ -710,13 +703,13 @@ async fn _user_api_key_login(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
// Get the org via the client_id
|
// Get the org via the client_id
|
||||||
let client_id = data.client_id.as_ref().unwrap();
|
let client_id = data.client_id.as_ref().unwrap();
|
||||||
let Some(org_id) = client_id.strip_prefix("organization.") else {
|
let Some(org_id) = client_id.strip_prefix("organization.") else {
|
||||||
err!("Malformed client_id", format!("IP: {}.", ip.ip))
|
err!("Malformed client_id", format!("IP: {}.", ip.ip))
|
||||||
};
|
};
|
||||||
let org_id: OrganizationId = org_id.to_string().into();
|
let org_id: OrganizationId = org_id.to_owned().into();
|
||||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else {
|
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else {
|
||||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||||
};
|
};
|
||||||
@@ -747,14 +740,13 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult
|
|||||||
let device_name = data.device_name.clone().expect("No device name provided");
|
let device_name = data.device_name.clone().expect("No device name provided");
|
||||||
|
|
||||||
// Find device or create new
|
// Find device or create new
|
||||||
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
if let Some(device) = Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||||
Some(device) => Ok(device),
|
Ok(device)
|
||||||
None => {
|
} else {
|
||||||
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
||||||
// save device without updating `device.updated_at`
|
// save device without updating `device.updated_at`
|
||||||
device.save(false, conn).await?;
|
device.save(false, conn).await?;
|
||||||
Ok(device)
|
Ok(device)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +772,7 @@ async fn twofactor_auth(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|tf| {
|
.filter_map(|tf| {
|
||||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
|
(tf.enabled && is_twofactor_provider_usable(&provider_type, Some(&tf.data))).then_some(tf.atype)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if twofactor_ids.is_empty() {
|
if twofactor_ids.is_empty() {
|
||||||
@@ -788,59 +780,51 @@ async fn twofactor_auth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
||||||
// Ignore Remember and RecoveryCode Types during this check, these are special
|
// Ignore Remember and RecoveryCode Types during this check, these are special
|
||||||
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
|
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
|
||||||
&& !twofactor_ids.contains(&selected_id)
|
&& !twofactor_ids.contains(&selected_id)
|
||||||
{
|
{
|
||||||
err_json!(
|
err_json!(
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
"Invalid two factor provider"
|
"Invalid two factor provider"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let twofactor_code = match data.two_factor_token {
|
let Some(ref twofactor_code) = data.two_factor_token else {
|
||||||
Some(ref code) => code,
|
err_json!(
|
||||||
None => {
|
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
err_json!(
|
"2FA token not provided"
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
)
|
||||||
"2FA token not provided"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
||||||
|
|
||||||
use crate::crypto::ct_eq;
|
let selected_data = selected_data(selected_twofactor);
|
||||||
|
|
||||||
let selected_data = _selected_data(selected_twofactor);
|
|
||||||
|
|
||||||
match TwoFactorType::from_i32(selected_id) {
|
match TwoFactorType::from_i32(selected_id) {
|
||||||
Some(TwoFactorType::Authenticator) => {
|
Some(TwoFactorType::Authenticator) => {
|
||||||
authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?
|
authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?;
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
||||||
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
||||||
Some(TwoFactorType::Duo) => {
|
Some(TwoFactorType::Duo) => {
|
||||||
match CONFIG.duo_use_iframe() {
|
if CONFIG.duo_use_iframe() {
|
||||||
true => {
|
// Legacy iframe prompt flow
|
||||||
// Legacy iframe prompt flow
|
duo::validate_duo_login(&user.email, twofactor_code, conn).await?;
|
||||||
duo::validate_duo_login(&user.email, twofactor_code, conn).await?
|
} else {
|
||||||
}
|
// OIDC based flow
|
||||||
false => {
|
duo_oidc::validate_duo_login(
|
||||||
// OIDC based flow
|
&user.email,
|
||||||
duo_oidc::validate_duo_login(
|
twofactor_code,
|
||||||
&user.email,
|
data.client_id.as_ref().unwrap(),
|
||||||
twofactor_code,
|
data.device_identifier.as_ref().unwrap(),
|
||||||
data.client_id.as_ref().unwrap(),
|
conn,
|
||||||
data.device_identifier.as_ref().unwrap(),
|
)
|
||||||
conn,
|
.await?;
|
||||||
)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Email) => {
|
Some(TwoFactorType::Email) => {
|
||||||
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?
|
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Remember) => {
|
Some(TwoFactorType::Remember) => {
|
||||||
match device.twofactor_remember {
|
match device.twofactor_remember {
|
||||||
@@ -848,7 +832,7 @@ async fn twofactor_auth(
|
|||||||
// If it is invalid we need to trigger the 2FA Login prompt
|
// If it is invalid we need to trigger the 2FA Login prompt
|
||||||
Some(ref token)
|
Some(ref token)
|
||||||
if !CONFIG.disable_2fa_remember()
|
if !CONFIG.disable_2fa_remember()
|
||||||
&& (ct_eq(token, twofactor_code)
|
&& (crypto::ct_eq(token, twofactor_code)
|
||||||
&& auth::decode_2fa_remember(twofactor_code)
|
&& auth::decode_2fa_remember(twofactor_code)
|
||||||
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
|
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -859,7 +843,7 @@ async fn twofactor_auth(
|
|||||||
device.save(true, conn).await?;
|
device.save(true, conn).await?;
|
||||||
}
|
}
|
||||||
err_json!(
|
err_json!(
|
||||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||||
"2FA Remember token not provided or expired"
|
"2FA Remember token not provided or expired"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -900,11 +884,11 @@ async fn twofactor_auth(
|
|||||||
Ok(two_factor)
|
Ok(two_factor)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
fn selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
||||||
tf.map(|t| t.data).map_res("Two factor doesn't exist")
|
tf.map(|t| t.data).map_res("Two factor doesn't exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _json_err_twofactor(
|
async fn json_err_twofactor(
|
||||||
providers: &[i32],
|
providers: &[i32],
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
data: &ConnectData,
|
data: &ConnectData,
|
||||||
@@ -925,42 +909,38 @@ async fn _json_err_twofactor(
|
|||||||
result["TwoFactorProviders2"][provider.to_string()] = Value::Null;
|
result["TwoFactorProviders2"][provider.to_string()] = Value::Null;
|
||||||
|
|
||||||
match TwoFactorType::from_i32(*provider) {
|
match TwoFactorType::from_i32(*provider) {
|
||||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
|
||||||
|
|
||||||
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
||||||
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(TwoFactorType::Duo) => {
|
Some(TwoFactorType::Duo) => {
|
||||||
let email = match User::find_by_uuid(user_id, conn).await {
|
let email = if let Some(u) = User::find_by_uuid(user_id, conn).await {
|
||||||
Some(u) => u.email,
|
u.email
|
||||||
None => err!("User does not exist"),
|
} else {
|
||||||
|
err!("User does not exist")
|
||||||
};
|
};
|
||||||
|
|
||||||
match CONFIG.duo_use_iframe() {
|
if CONFIG.duo_use_iframe() {
|
||||||
true => {
|
// Legacy iframe prompt flow
|
||||||
// Legacy iframe prompt flow
|
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
||||||
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
"Host": host,
|
||||||
"Host": host,
|
"Signature": signature,
|
||||||
"Signature": signature,
|
});
|
||||||
})
|
} else {
|
||||||
}
|
// OIDC based flow
|
||||||
false => {
|
let auth_url = duo_oidc::get_duo_auth_url(
|
||||||
// OIDC based flow
|
&email,
|
||||||
let auth_url = duo_oidc::get_duo_auth_url(
|
data.client_id.as_ref().unwrap(),
|
||||||
&email,
|
data.device_identifier.as_ref().unwrap(),
|
||||||
data.client_id.as_ref().unwrap(),
|
conn,
|
||||||
data.device_identifier.as_ref().unwrap(),
|
)
|
||||||
conn,
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"AuthUrl": auth_url,
|
"AuthUrl": auth_url,
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,7 +953,7 @@ async fn _json_err_twofactor(
|
|||||||
|
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"Nfc": yubikey_metadata.nfc,
|
"Nfc": yubikey_metadata.nfc,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(tf_type @ TwoFactorType::Email) => {
|
Some(tf_type @ TwoFactorType::Email) => {
|
||||||
@@ -991,16 +971,30 @@ async fn _json_err_twofactor(
|
|||||||
|
|
||||||
// Send email immediately if email is the only 2FA option.
|
// Send email immediately if email is the only 2FA option.
|
||||||
if providers.len() == 1 && !disabled_send {
|
if providers.len() == 1 && !disabled_send {
|
||||||
email::send_token(user_id, conn).await?
|
email::send_token(user_id, conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
|
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
|
||||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"Email": email::obscure_email(&email_data.email),
|
"Email": email::obscure_email(&email_data.email),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {}
|
None
|
||||||
|
| Some(
|
||||||
|
TwoFactorType::Authenticator
|
||||||
|
| TwoFactorType::EmailVerificationChallenge
|
||||||
|
| TwoFactorType::OrganizationDuo
|
||||||
|
| TwoFactorType::ProtectedActions
|
||||||
|
| TwoFactorType::RecoveryCode
|
||||||
|
| TwoFactorType::Remember
|
||||||
|
| TwoFactorType::U2f
|
||||||
|
| TwoFactorType::U2fLoginChallenge
|
||||||
|
| TwoFactorType::U2fRegisterChallenge
|
||||||
|
| TwoFactorType::Webauthn
|
||||||
|
| TwoFactorType::WebauthnLoginChallenge
|
||||||
|
| TwoFactorType::WebauthnRegisterChallenge,
|
||||||
|
) => { /* Nothing special to do for these providers */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,18 +1002,18 @@ async fn _json_err_twofactor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/prelogin", data = "<data>")]
|
#[post("/accounts/prelogin", data = "<data>")]
|
||||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||||
_prelogin(data, conn).await
|
prelogin(data, 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>, conn: DbConn) -> Json<Value> {
|
||||||
_prelogin(data, conn).await
|
prelogin(data, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register", data = "<data>")]
|
#[post("/accounts/register", data = "<data>")]
|
||||||
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||||
_register(data, false, conn).await
|
register(data, false, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1040,8 +1034,11 @@ enum RegisterVerificationResponse {
|
|||||||
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
||||||
async fn register_verification_email(
|
async fn register_verification_email(
|
||||||
data: Json<RegisterVerificationData>,
|
data: Json<RegisterVerificationData>,
|
||||||
|
ip: ClientIp,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> ApiResult<RegisterVerificationResponse> {
|
) -> ApiResult<RegisterVerificationResponse> {
|
||||||
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
// the registration can only continue if signup is allowed or there exists an invitation
|
// the registration can only continue if signup is allowed or there exists an invitation
|
||||||
@@ -1058,13 +1055,13 @@ async fn register_verification_email(
|
|||||||
|
|
||||||
if should_send_mail {
|
if should_send_mail {
|
||||||
let user = User::find_by_mail(&data.email, &conn).await;
|
let user = User::find_by_mail(&data.email, &conn).await;
|
||||||
if user.filter(|u| u.private_key.is_some()).is_some() {
|
if user.as_ref().is_some_and(|u| u.private_key.is_some()) {
|
||||||
// There is still a timing side channel here in that the code
|
// There is still a timing side channel here in that the code
|
||||||
// paths that send mail take noticeably longer than ones that don't.
|
// paths that send mail take noticeably longer than ones that don't.
|
||||||
// Add a randomized sleep to mitigate this somewhat.
|
// Add a randomized sleep to mitigate this somewhat.
|
||||||
use rand::{rngs::SmallRng, RngExt};
|
use rand::{RngExt, rngs::SmallRng};
|
||||||
let mut rng: SmallRng = rand::make_rng();
|
let mut rng: SmallRng = rand::make_rng();
|
||||||
let sleep_ms = rng.random_range(900..=1100) as u64;
|
let sleep_ms: u64 = rng.random_range(900..=1100);
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||||
} else {
|
} else {
|
||||||
mail::send_register_verify_email(&data.email, &token).await?;
|
mail::send_register_verify_email(&data.email, &token).await?;
|
||||||
@@ -1080,7 +1077,7 @@ async fn register_verification_email(
|
|||||||
|
|
||||||
#[post("/accounts/register/finish", data = "<data>")]
|
#[post("/accounts/register/finish", data = "<data>")]
|
||||||
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||||
_register(data, true, conn).await
|
register(data, true, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
||||||
@@ -1142,8 +1139,12 @@ struct ConnectData {
|
|||||||
code: Option<OIDCCode>,
|
code: Option<OIDCCode>,
|
||||||
#[field(name = uncased("code_verifier"))]
|
#[field(name = uncased("code_verifier"))]
|
||||||
code_verifier: Option<OIDCCodeVerifier>,
|
code_verifier: Option<OIDCCodeVerifier>,
|
||||||
|
|
||||||
|
// Needed for send access
|
||||||
|
send_id: Option<SendId>,
|
||||||
|
password_hash_b64: Option<String>,
|
||||||
}
|
}
|
||||||
fn _check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
|
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
|
||||||
if value.is_none() {
|
if value.is_none() {
|
||||||
err!(msg)
|
err!(msg)
|
||||||
}
|
}
|
||||||
@@ -1166,7 +1167,7 @@ const SSO_BINDING_COOKIE: &str = "VW_SSO_BINDING";
|
|||||||
|
|
||||||
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
||||||
async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<Redirect> {
|
async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<Redirect> {
|
||||||
_oidcsignin_redirect(state, code, None, cookies, &mut conn).await
|
oidcsignin_redirect(state, code, None, cookies, &mut conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bitwarden client appear to only care for code and state
|
// Bitwarden client appear to only care for code and state
|
||||||
@@ -1180,7 +1181,7 @@ async fn oidcsignin_error(
|
|||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
mut conn: DbConn,
|
mut conn: DbConn,
|
||||||
) -> ApiResult<Redirect> {
|
) -> ApiResult<Redirect> {
|
||||||
_oidcsignin_redirect(
|
oidcsignin_redirect(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
state.into(),
|
state.into(),
|
||||||
Some(OIDCCodeResponseError {
|
Some(OIDCCodeResponseError {
|
||||||
@@ -1195,7 +1196,8 @@ async fn oidcsignin_error(
|
|||||||
|
|
||||||
// The state was encoded using Base64 to ensure no issue with providers.
|
// The state was encoded using Base64 to ensure no issue with providers.
|
||||||
// iss and scope parameters are needed for redirection to work on IOS.
|
// iss and scope parameters are needed for redirection to work on IOS.
|
||||||
async fn _oidcsignin_redirect(
|
// We pass the state as the code to get it back later on.
|
||||||
|
async fn oidcsignin_redirect(
|
||||||
base64_state: String,
|
base64_state: String,
|
||||||
code: OIDCCode,
|
code: OIDCCode,
|
||||||
error: Option<OIDCCodeResponseError>,
|
error: Option<OIDCCodeResponseError>,
|
||||||
@@ -1204,14 +1206,13 @@ async fn _oidcsignin_redirect(
|
|||||||
) -> ApiResult<Redirect> {
|
) -> ApiResult<Redirect> {
|
||||||
let state = sso::decode_state(&base64_state)?;
|
let state = sso::decode_state(&base64_state)?;
|
||||||
|
|
||||||
let mut sso_auth = match SsoAuth::find(&state, conn).await {
|
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else {
|
||||||
None => err!(format!("Cannot retrieve sso_auth for {state}")),
|
err!(format!("Cannot retrieve sso_auth for {state}"))
|
||||||
Some(sso_auth) => sso_auth,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Browser-binding check
|
// Browser-binding check
|
||||||
// The cookie was set on /connect/authorize and must come from the same browser that initiated the flow.
|
// The cookie was set on /connect/authorize and must come from the same browser that initiated the flow.
|
||||||
let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_string());
|
let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_owned());
|
||||||
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
|
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
|
||||||
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
|
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
|
||||||
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
|
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
|
||||||
|
|||||||
+8
-5
@@ -30,13 +30,15 @@ pub use crate::api::{
|
|||||||
},
|
},
|
||||||
web::catchers as web_catchers,
|
web::catchers as web_catchers,
|
||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::static_files,
|
web::{invalidate_css_cache, static_files},
|
||||||
};
|
};
|
||||||
use crate::db::{
|
use crate::{
|
||||||
models::{OrgPolicy, OrgPolicyType, User},
|
CONFIG,
|
||||||
DbConn,
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{OrgPolicy, OrgPolicyType, User},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use crate::CONFIG;
|
|
||||||
|
|
||||||
// Type aliases for API methods results
|
// Type aliases for API methods results
|
||||||
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||||
@@ -74,6 +76,7 @@ impl PasswordOrOtpData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")]
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MasterPasswordPolicy {
|
pub struct MasterPasswordPolicy {
|
||||||
|
|||||||
+71
-20
@@ -6,17 +6,22 @@ use std::{
|
|||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use rmpv::Value;
|
use rmpv::Value;
|
||||||
use rocket::{futures::StreamExt, Route};
|
use rocket::{Route, futures::StreamExt};
|
||||||
use rocket_ws::{Message, WebSocket};
|
use rocket_ws::{Message, WebSocket};
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG, Error,
|
||||||
auth::{ClientIp, WsAccessTokenHeader},
|
auth::{ClientIp, WsAccessTokenHeader},
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
||||||
},
|
},
|
||||||
Error, CONFIG,
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
||||||
|
push_send_update, push_user_update,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
||||||
@@ -28,13 +33,13 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
|||||||
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
||||||
Arc::new(AnonymousWebSocketSubscriptions {
|
Arc::new(AnonymousWebSocketSubscriptions {
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
|
connections: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
use super::{
|
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
|
||||||
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
||||||
push_send_update, push_user_update,
|
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
||||||
};
|
|
||||||
|
|
||||||
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||||
|
|
||||||
@@ -82,14 +87,21 @@ impl Drop for WSEntryMapGuard {
|
|||||||
struct WSAnonymousEntryMapGuard {
|
struct WSAnonymousEntryMapGuard {
|
||||||
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
token: String,
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
addr: IpAddr,
|
addr: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WSAnonymousEntryMapGuard {
|
impl WSAnonymousEntryMapGuard {
|
||||||
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self {
|
fn new(
|
||||||
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
|
addr: IpAddr,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
subscriptions,
|
subscriptions,
|
||||||
token,
|
token,
|
||||||
|
entry_uuid,
|
||||||
addr,
|
addr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,11 +110,15 @@ impl WSAnonymousEntryMapGuard {
|
|||||||
impl Drop for WSAnonymousEntryMapGuard {
|
impl Drop for WSAnonymousEntryMapGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
info!("Closing WS connection from {}", self.addr);
|
info!("Closing WS connection from {}", self.addr);
|
||||||
self.subscriptions.map.remove(&self.token);
|
if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
|
||||||
|
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
|
||||||
|
}
|
||||||
|
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
|
||||||
|
self.subscriptions.release(self.addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(tail_expr_drop_order)]
|
#[expect(tail_expr_drop_order)]
|
||||||
#[get("/hub?<data..>")]
|
#[get("/hub?<data..>")]
|
||||||
fn websockets_hub<'r>(
|
fn websockets_hub<'r>(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -186,7 +202,7 @@ fn websockets_hub<'r>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(tail_expr_drop_order)]
|
#[expect(tail_expr_drop_order)]
|
||||||
#[get("/anonymous-hub?<token..>")]
|
#[get("/anonymous-hub?<token..>")]
|
||||||
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
||||||
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
||||||
@@ -194,12 +210,19 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
let (mut rx, guard) = {
|
let (mut rx, guard) = {
|
||||||
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
||||||
|
|
||||||
// Add a channel to send messages to this client to the map
|
if !subscriptions.try_reserve(ip.ip) {
|
||||||
|
err_code!("Too many connections", 429)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a channel to send messages to this client to the map.
|
||||||
|
// Clients reconnect with the same token while a login request is still pending, so keep
|
||||||
|
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
||||||
subscriptions.map.insert(token.clone(), tx);
|
let entry_uuid = uuid::Uuid::new_v4();
|
||||||
|
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
|
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -268,14 +291,15 @@ fn serialize(val: &Value) -> Vec<u8> {
|
|||||||
let mut len_buf: Vec<u8> = Vec::new();
|
let mut len_buf: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut size_part = size & 0x7f;
|
#[expect(clippy::cast_possible_truncation, reason = "masked to 7 bits, fits u8")]
|
||||||
|
let mut size_part = (size & 0x7f) as u8;
|
||||||
size >>= 7;
|
size >>= 7;
|
||||||
|
|
||||||
if size > 0 {
|
if size > 0 {
|
||||||
size_part |= 0x80;
|
size_part |= 0x80;
|
||||||
}
|
}
|
||||||
|
|
||||||
len_buf.push(size_part as u8);
|
len_buf.push(size_part);
|
||||||
|
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
break;
|
break;
|
||||||
@@ -329,7 +353,7 @@ pub struct WebSocketUsers {
|
|||||||
impl WebSocketUsers {
|
impl WebSocketUsers {
|
||||||
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
||||||
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
||||||
for (_, sender) in user.iter() {
|
for (_, sender) in &user {
|
||||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
@@ -533,12 +557,39 @@ impl WebSocketUsers {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AnonymousWebSocketSubscriptions {
|
pub struct AnonymousWebSocketSubscriptions {
|
||||||
map: Arc<dashmap::DashMap<String, Sender<Message>>>,
|
map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
|
||||||
|
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnonymousWebSocketSubscriptions {
|
impl AnonymousWebSocketSubscriptions {
|
||||||
|
/// Takes a connection slot for this address, returns false when it already reached the limit.
|
||||||
|
fn try_reserve(&self, addr: IpAddr) -> bool {
|
||||||
|
let mut count = self.connections.entry(addr).or_insert(0);
|
||||||
|
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases a slot taken by `try_reserve`.
|
||||||
|
fn release(&self, addr: IpAddr) {
|
||||||
|
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
*count == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// Only remove once the guard above is dropped, otherwise this deadlocks.
|
||||||
|
if empty {
|
||||||
|
self.connections.remove_if(&addr, |_, count| *count == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_update(&self, token: &str, data: &[u8]) {
|
async fn send_update(&self, token: &str, data: &[u8]) {
|
||||||
if let Some(sender) = self.map.get(token).map(|v| v.clone()) {
|
// Clone the senders so the map isn't kept locked while sending.
|
||||||
|
let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
|
||||||
|
for (_, sender) in senders {
|
||||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
@@ -582,7 +633,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
|
|||||||
V::Nil,
|
V::Nil,
|
||||||
"ReceiveMessage".into(),
|
"ReceiveMessage".into(),
|
||||||
V::Array(vec![V::Map(vec![
|
V::Array(vec![V::Map(vec![
|
||||||
("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)),
|
("ContextId".into(), acting_device_id.map_or(V::Nil, |v| v.to_string().into())),
|
||||||
("Type".into(), (ut as i32).into()),
|
("Type".into(), (ut as i32).into()),
|
||||||
("Payload".into(), payload.into()),
|
("Payload".into(), payload.into()),
|
||||||
])]),
|
])]),
|
||||||
|
|||||||
+24
-24
@@ -4,21 +4,21 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
|
||||||
Method,
|
Method,
|
||||||
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, UpdateType},
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
},
|
},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
util::{format_date, get_uuid},
|
util::{format_date, get_uuid},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -74,9 +74,9 @@ async fn get_auth_api_token() -> ApiResult<String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut api_token = API_TOKEN.write().await;
|
let mut api_token = API_TOKEN.write().await;
|
||||||
api_token.valid_until = Instant::now()
|
// Token valid for half the specified time
|
||||||
.checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time
|
let half_expires_in = u64::from((json_pushtoken.expires_in / 2).max(0).cast_unsigned());
|
||||||
.unwrap();
|
api_token.valid_until = Instant::now().checked_add(Duration::from_secs(half_expires_in)).unwrap();
|
||||||
|
|
||||||
api_token.access_token = json_pushtoken.access_token;
|
api_token.access_token = json_pushtoken.access_token;
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
||||||
if cipher.organization_uuid.is_some() {
|
if cipher.organization_uuid.is_some() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let Some(user_id) = &cipher.user_uuid else {
|
let Some(user_id) = &cipher.user_uuid else {
|
||||||
debug!("Cipher has no uuid");
|
debug!("Cipher has no uuid");
|
||||||
return;
|
return;
|
||||||
@@ -244,23 +244,23 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) {
|
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) {
|
||||||
if let Some(s) = &send.user_uuid {
|
if let Some(s) = &send.user_uuid
|
||||||
if Device::check_user_has_push_device(s, conn).await {
|
&& Device::check_user_has_push_device(s, conn).await
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
{
|
||||||
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
|
"userId": send.user_uuid,
|
||||||
|
"organizationId": null,
|
||||||
|
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
|
||||||
|
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
|
||||||
|
"type": ut as i32,
|
||||||
|
"payload": {
|
||||||
|
"id": send.uuid,
|
||||||
"userId": send.user_uuid,
|
"userId": send.user_uuid,
|
||||||
"organizationId": null,
|
"revisionDate": format_date(&send.revision_date)
|
||||||
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
|
},
|
||||||
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
|
"clientType": null,
|
||||||
"type": ut as i32,
|
"installationId": null
|
||||||
"payload": {
|
})));
|
||||||
"id": send.uuid,
|
|
||||||
"userId": send.user_uuid,
|
|
||||||
"revisionDate": format_date(&send.revision_date)
|
|
||||||
},
|
|
||||||
"clientType": null,
|
|
||||||
"installationId": null
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ async fn send_to_push_relay(notification_data: Value) {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!("An error occurred while sending a send update to the push relay: {e}");
|
error!("An error occurred while sending a send update to the push relay: {e}");
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) {
|
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) {
|
||||||
|
|||||||
+77
-16
@@ -1,21 +1,28 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{content::RawCss as Css, content::RawHtml as Html, Redirect},
|
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Catcher, Route,
|
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::now, ApiResult, EmptyResult},
|
|
||||||
auth::decode_file_download,
|
|
||||||
db::models::{AttachmentId, CipherId},
|
|
||||||
error::Error,
|
|
||||||
util::Cached,
|
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
api::{ApiResult, EmptyResult, core::now},
|
||||||
|
auth::decode_file_download,
|
||||||
|
crypto::sha256_hex,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{AttachmentId, CipherId},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
util::{Cached, EtagCached},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -23,12 +30,20 @@ pub fn routes() -> Vec<Route> {
|
|||||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||||
let mut routes = routes![attachments, alive, alive_head, static_files];
|
let mut routes = routes![attachments, alive, alive_head, static_files];
|
||||||
if CONFIG.web_vault_enabled() {
|
if CONFIG.web_vault_enabled() {
|
||||||
routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
|
routes.append(&mut routes![
|
||||||
|
web_index,
|
||||||
|
web_index_direct,
|
||||||
|
web_index_head,
|
||||||
|
app_id,
|
||||||
|
apple_app_site_association,
|
||||||
|
web_files,
|
||||||
|
vaultwarden_css
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
if CONFIG.reload_templates() {
|
if CONFIG.reload_templates() {
|
||||||
routes.append(&mut routes![_static_files_dev]);
|
routes.append(&mut routes![static_files_dev]);
|
||||||
}
|
}
|
||||||
|
|
||||||
routes
|
routes
|
||||||
@@ -52,8 +67,27 @@ 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!({
|
||||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||||
"load_user_scss": true,
|
"load_user_scss": true,
|
||||||
@@ -101,8 +135,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache for one day should be enough and not too much
|
let etag = sha256_hex(css.as_bytes());
|
||||||
Cached::ttl(Css(css), 86_400, false)
|
let cached = Arc::new(CssCache {
|
||||||
|
css,
|
||||||
|
etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
if !CONFIG.reload_templates() {
|
||||||
|
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||||
|
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -160,6 +204,24 @@ fn app_id() -> Cached<(ContentType, Json<Value>)> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/.well-known/apple-app-site-association")]
|
||||||
|
fn apple_app_site_association() -> Cached<(ContentType, Json<Value>)> {
|
||||||
|
Cached::long(
|
||||||
|
(
|
||||||
|
ContentType::JSON,
|
||||||
|
Json(json!({
|
||||||
|
"webcredentials": {
|
||||||
|
"apps": [
|
||||||
|
"LTZ2PFU5D6.com.8bit.bitwarden",
|
||||||
|
"LTZ2PFU5D6.com.8bit.bitwarden.beta"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
||||||
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
||||||
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
||||||
@@ -178,7 +240,6 @@ async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
||||||
use crate::db::DbConn;
|
|
||||||
#[get("/alive")]
|
#[get("/alive")]
|
||||||
fn alive(_conn: DbConn) -> Json<String> {
|
fn alive(_conn: DbConn) -> Json<String> {
|
||||||
now()
|
now()
|
||||||
@@ -197,7 +258,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
|||||||
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
#[get("/vw_static/<filename>", rank = 1)]
|
#[get("/vw_static/<filename>", rank = 1)]
|
||||||
pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||||
warn!("LOADING STATIC FILES FROM DISK");
|
warn!("LOADING STATIC FILES FROM DISK");
|
||||||
let file = filename.to_str().unwrap_or_default();
|
let file = filename.to_str().unwrap_or_default();
|
||||||
let ext = filename.extension().unwrap_or_default();
|
let ext = filename.extension().unwrap_or_default();
|
||||||
@@ -210,7 +271,7 @@ pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
|||||||
|
|
||||||
if let Ok(path) = path {
|
if let Ok(path) = path {
|
||||||
return NamedFile::open(path).await.ok();
|
return NamedFile::open(path).await.ok();
|
||||||
};
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-92
@@ -1,3 +1,8 @@
|
|||||||
|
#[path = "auth/send.rs"]
|
||||||
|
pub mod send;
|
||||||
|
pub type SendTokens = send::SendTokens;
|
||||||
|
pub type SendHeaders = send::SendHeaders;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
@@ -5,21 +10,31 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
use ipnet::IpNet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use openssl::rsa::Rsa;
|
use openssl::rsa::Rsa;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::{de::DeserializeOwned, ser::Serialize};
|
||||||
use serde::ser::Serialize;
|
|
||||||
|
use rocket::{
|
||||||
|
outcome::try_outcome,
|
||||||
|
request::{FromRequest, Outcome, Request},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::ApiResult,
|
api::ApiResult,
|
||||||
config::PathType,
|
config::PathType,
|
||||||
db::models::{
|
db::{
|
||||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
DbConn,
|
||||||
OrganizationId, SendFileId, SendId, UserId,
|
models::{
|
||||||
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
|
||||||
|
SendId, User, UserId, UserStampException,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
sso, CONFIG,
|
sso,
|
||||||
};
|
};
|
||||||
|
|
||||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||||
@@ -52,12 +67,12 @@ static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
|||||||
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||||
|
|
||||||
pub async fn initialize_keys() -> Result<(), Error> {
|
pub async fn initialize_keys() -> Result<(), Error> {
|
||||||
use std::io::Error;
|
use std::io::Error as IoError;
|
||||||
|
|
||||||
let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
|
let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
|
||||||
.ok_or_else(|| Error::other("Private RSA key path missing filename"))?;
|
.ok_or_else(|| IoError::other("Private RSA key path missing filename"))?;
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(IoError::other)?;
|
||||||
|
|
||||||
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
||||||
Ok(buffer) => Some(buffer),
|
Ok(buffer) => Some(buffer),
|
||||||
@@ -226,7 +241,7 @@ impl LoginJwtClaims {
|
|||||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
|
||||||
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||||
warn!("Raise access_token lifetime to more than 5min.")
|
warn!("Raise access_token lifetime to more than 5min.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the JWT claims struct, to send to the client
|
// Create the JWT claims struct, to send to the client
|
||||||
@@ -253,7 +268,7 @@ impl LoginJwtClaims {
|
|||||||
sstamp: user.security_stamp.clone(),
|
sstamp: user.security_stamp.clone(),
|
||||||
device: device.uuid.clone(),
|
device: device.uuid.clone(),
|
||||||
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
||||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
client_id: client_id.unwrap_or("undefined".to_owned()),
|
||||||
scope,
|
scope,
|
||||||
amr: vec!["Application".into()],
|
amr: vec!["Application".into()],
|
||||||
}
|
}
|
||||||
@@ -478,6 +493,16 @@ pub struct BasicJwtClaims {
|
|||||||
pub sub: String,
|
pub sub: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BasicJwtClaims {
|
||||||
|
pub fn expires_in(&self) -> i64 {
|
||||||
|
self.exp - Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> String {
|
||||||
|
encode_jwt(&self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
@@ -506,7 +531,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims {
|
|||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||||
sub: "admin_panel".to_string(),
|
sub: "admin_panel".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,16 +548,6 @@ pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtC
|
|||||||
//
|
//
|
||||||
// Bearer token authentication
|
// Bearer token authentication
|
||||||
//
|
//
|
||||||
use rocket::{
|
|
||||||
outcome::try_outcome,
|
|
||||||
request::{FromRequest, Outcome, Request},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::db::{
|
|
||||||
models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException},
|
|
||||||
DbConn,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Host {
|
pub struct Host {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
}
|
}
|
||||||
@@ -548,7 +563,7 @@ impl<'r> FromRequest<'r> for Host {
|
|||||||
let host = if CONFIG.domain_set() {
|
let host = if CONFIG.domain_set() {
|
||||||
CONFIG.domain()
|
CONFIG.domain()
|
||||||
} else if let Some(referer) = headers.get_one("Referer") {
|
} else if let Some(referer) = headers.get_one("Referer") {
|
||||||
referer.to_string()
|
referer.to_owned()
|
||||||
} else {
|
} else {
|
||||||
// Try to guess from the headers
|
// Try to guess from the headers
|
||||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||||
@@ -584,13 +599,15 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
|||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
// When unknown or unable to parse, return 'UnknownBrowser'
|
||||||
let device_type: i32 =
|
let device_type: i32 = request
|
||||||
request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14);
|
.headers()
|
||||||
|
.get_one("device-type")
|
||||||
|
.and_then(|d| d.parse().ok())
|
||||||
|
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||||
|
|
||||||
Outcome::Success(ClientHeaders {
|
Outcome::Success(ClientHeaders {
|
||||||
device_type,
|
device_type,
|
||||||
@@ -614,18 +631,19 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
|
|
||||||
let host = try_outcome!(Host::from_request(request).await).host;
|
let host = try_outcome!(Host::from_request(request).await).host;
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get access_token
|
// Get access_token
|
||||||
let access_token: &str = match headers.get_one("Authorization") {
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
Some(a) => match a.rsplit("Bearer ").next() {
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
Some(split) => split,
|
split
|
||||||
None => err_handler!("No access token provided"),
|
} else {
|
||||||
},
|
err_handler!("No access token provided")
|
||||||
None => err_handler!("No access token provided"),
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check JWT token is valid and get device and user from it
|
// Check JWT token is valid and get device and user from it
|
||||||
@@ -636,9 +654,8 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
let device_id = claims.device;
|
let device_id = claims.device;
|
||||||
let user_id = claims.sub;
|
let user_id = claims.sub;
|
||||||
|
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
||||||
@@ -669,7 +686,7 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
error!("Error updating user: {e:#?}");
|
error!("Error updating user: {e:#?}");
|
||||||
}
|
}
|
||||||
err_handler!("Stamp exception is expired")
|
err_handler!("Stamp exception is expired")
|
||||||
} else if !stamp_exception.routes.contains(¤t_route.to_string()) {
|
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
||||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
||||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
} else if stamp_exception.security_stamp != claims.sstamp {
|
||||||
err_handler!("Invalid security stamp for matched stamp exception")
|
err_handler!("Invalid security stamp for matched stamp exception")
|
||||||
@@ -757,9 +774,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
|||||||
|
|
||||||
match url_org_id {
|
match url_org_id {
|
||||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -831,16 +847,16 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
|||||||
// but there could be cases where it is a query value.
|
// but there could be cases where it is a query value.
|
||||||
// First check the path, if this is not a valid uuid, try the query values.
|
// First check the path, if this is not a valid uuid, try the query values.
|
||||||
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
||||||
if let Some(Ok(col_id)) = request.param::<String>(3) {
|
if let Some(Ok(col_id)) = request.param::<String>(3)
|
||||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||||
return Some(col_id.into());
|
{
|
||||||
}
|
return Some(col_id.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId") {
|
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId")
|
||||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||||
return Some(col_id.into());
|
{
|
||||||
}
|
return Some(col_id.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -864,18 +880,16 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||||
if headers.is_confirmed_and_manager() {
|
if headers.is_confirmed_and_manager() {
|
||||||
match get_col_id(request) {
|
if let Some(col_id) = get_col_id(request) {
|
||||||
Some(col_id) => {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
let conn = match DbConn::from_request(request).await {
|
err_handler!("Error getting DB")
|
||||||
Outcome::Success(conn) => conn,
|
};
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
||||||
err_handler!("The current user isn't a manager for this collection")
|
err_handler!("The current user isn't a manager for this collection")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => err_handler!("Error getting the collection id"),
|
} else {
|
||||||
|
err_handler!("Error getting the collection id")
|
||||||
}
|
}
|
||||||
|
|
||||||
Outcome::Success(Self {
|
Outcome::Success(Self {
|
||||||
@@ -1036,17 +1050,49 @@ impl From<OrgMemberHeaders> for Headers {
|
|||||||
//
|
//
|
||||||
// Client IP address detection
|
// Client IP address detection
|
||||||
//
|
//
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
pub struct ClientIp {
|
pub struct ClientIp {
|
||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
|
||||||
|
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
|
||||||
|
let entry = entry.trim();
|
||||||
|
match entry.parse::<IpNet>() {
|
||||||
|
Ok(net) => Some(net),
|
||||||
|
// Without a prefix length it is a single address, which is a valid way to write this.
|
||||||
|
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
|
||||||
|
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
|
||||||
|
let trusted = CONFIG.ip_header_trusted_proxies();
|
||||||
|
let trusted = trusted.trim();
|
||||||
|
if trusted.eq_ignore_ascii_case("all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(remote) = remote else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
|
||||||
|
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
|
||||||
|
let remote = remote.to_canonical();
|
||||||
|
if trusted.eq_ignore_ascii_case("local") {
|
||||||
|
return !crate::util::is_global(remote);
|
||||||
|
}
|
||||||
|
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for ClientIp {
|
impl<'r> FromRequest<'r> for ClientIp {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = if CONFIG._ip_header_enabled() {
|
let remote = req.remote().map(|r| r.ip());
|
||||||
|
|
||||||
|
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
|
||||||
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
||||||
match ip.find(',') {
|
match ip.find(',') {
|
||||||
Some(idx) => &ip[..idx],
|
Some(idx) => &ip[..idx],
|
||||||
@@ -1057,10 +1103,15 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
|
||||||
|
// Log the canonical IP, which is what the user filter will need to match against
|
||||||
|
let remote = remote.map(|ip| ip.to_canonical());
|
||||||
|
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
|
||||||
|
}
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
||||||
|
|
||||||
Outcome::Success(ClientIp {
|
Outcome::Success(ClientIp {
|
||||||
ip,
|
ip,
|
||||||
@@ -1068,6 +1119,7 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
pub struct Secure {
|
pub struct Secure {
|
||||||
pub https: bool,
|
pub https: bool,
|
||||||
}
|
}
|
||||||
@@ -1153,15 +1205,14 @@ pub enum AuthMethod {
|
|||||||
impl AuthMethod {
|
impl AuthMethod {
|
||||||
pub fn scope(&self) -> String {
|
pub fn scope(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
AuthMethod::OrgApiKey => "api.organization".to_owned(),
|
||||||
AuthMethod::Password => "api offline_access".to_string(),
|
AuthMethod::UserApiKey => "api".to_owned(),
|
||||||
AuthMethod::Sso => "api offline_access".to_string(),
|
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
|
||||||
AuthMethod::UserApiKey => "api".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scope_vec(&self) -> Vec<String> {
|
pub fn scope_vec(&self) -> Vec<String> {
|
||||||
self.scope().split_whitespace().map(str::to_string).collect()
|
self.scope().split_whitespace().map(str::to_owned).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||||
@@ -1255,36 +1306,22 @@ pub async fn refresh_tokens(
|
|||||||
) -> ApiResult<(Device, AuthTokens)> {
|
) -> ApiResult<(Device, AuthTokens)> {
|
||||||
let refresh_claims = match decode_refresh(refresh_token) {
|
let refresh_claims = match decode_refresh(refresh_token) {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
|
error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
|
||||||
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
err_silent!("Invalid refresh token")
|
||||||
|
|
||||||
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
|
|
||||||
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
|
|
||||||
// check expiration or issuer, so they're not included here.
|
|
||||||
RefreshJwtClaims {
|
|
||||||
nbf: 0,
|
|
||||||
exp: 0,
|
|
||||||
iss: String::new(),
|
|
||||||
sub: AuthMethod::Password,
|
|
||||||
device_token: refresh_token.into(),
|
|
||||||
token: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(claims) => claims,
|
Ok(claims) => claims,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get device by refresh token
|
// Get device by refresh token
|
||||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
let Some(mut device) = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await else {
|
||||||
None => err!("Invalid refresh token"),
|
err!("Invalid refresh token")
|
||||||
Some(device) => device,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to update `updated_at`.
|
// Save to update `updated_at`.
|
||||||
device.save(true, conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
let Some(user) = User::find_by_uuid(&device.user_uuid, conn).await else {
|
||||||
None => err!("Impossible to find user"),
|
err!("Impossible to find user")
|
||||||
Some(user) => user,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_tokens = match refresh_claims.sub {
|
let auth_tokens = match refresh_claims.sub {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use chrono::{TimeDelta, Utc};
|
||||||
|
|
||||||
|
use rocket::request::{FromRequest, Outcome, Request};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiResult,
|
||||||
|
auth,
|
||||||
|
auth::{BasicJwtClaims, ClientIp},
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Send, SendId},
|
||||||
|
},
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
BasicJwtClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||||
|
iss: auth::JWT_SEND_ISSUER.to_string(),
|
||||||
|
sub: format!("{send_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SendTokens {
|
||||||
|
pub access_claims: BasicJwtClaims,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendTokens {
|
||||||
|
pub fn as_send_id(access_id: &str) -> Option<SendId> {
|
||||||
|
data_encoding::BASE64URL_NOPAD
|
||||||
|
.decode(access_id.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"access_token": self.access_claims.token(),
|
||||||
|
"expires_in": self.access_claims.expires_in(),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": "api.send.access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
|
||||||
|
let err = json!({
|
||||||
|
"kind": "expected_server",
|
||||||
|
"error": "invalid_request",
|
||||||
|
"send_access_error_type": error_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
|
||||||
|
let err = json!({
|
||||||
|
"kind": "expected_server",
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"send_access_error_type": error_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_tokens(
|
||||||
|
access_id: &str,
|
||||||
|
password: Option<String>,
|
||||||
|
ip: &ClientIp,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> ApiResult<SendTokens> {
|
||||||
|
let Some(send_id) = Self::as_send_id(access_id) else {
|
||||||
|
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||||
|
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(max_access_count) = send.max_access_count
|
||||||
|
&& send.access_count >= max_access_count
|
||||||
|
{
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !send.is_accessible() {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if send.password_hash.is_some() {
|
||||||
|
match password {
|
||||||
|
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
|
||||||
|
Some(_) => {
|
||||||
|
return Self::invalid_error(
|
||||||
|
&format!("Send {send_id}, Invalid password from {}", ip.ip),
|
||||||
|
"password_hash_b64_invalid",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => return Self::expected_error("Password required", "password_hash_b64_required"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !send.register_access(conn).await? {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
access_claims: generate_send_access_claims(&send_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SendHeaders {
|
||||||
|
pub send_id: SendId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for SendHeaders {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let headers = request.headers();
|
||||||
|
|
||||||
|
// Get access_token
|
||||||
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
|
split
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check JWT token is valid and get send_id
|
||||||
|
let Ok(claims) = auth::decode_send(access_token) else {
|
||||||
|
err_handler!("Invalid claim")
|
||||||
|
};
|
||||||
|
|
||||||
|
Outcome::Success(SendHeaders {
|
||||||
|
send_id: claims.sub.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-126
@@ -3,8 +3,8 @@ use std::{
|
|||||||
fmt,
|
fmt,
|
||||||
process::exit,
|
process::exit,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
LazyLock, RwLock,
|
LazyLock, RwLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ use crate::{
|
|||||||
error::Error,
|
error::Error,
|
||||||
storage,
|
storage,
|
||||||
util::{
|
util::{
|
||||||
get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags,
|
FeatureFlagFilter, get_active_web_release, get_env, get_env_bool, is_valid_email,
|
||||||
FeatureFlagFilter,
|
parse_experimental_client_feature_flags,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
|
|||||||
});
|
});
|
||||||
|
|
||||||
static CONFIG_FILE_PARENT_DIR: LazyLock<String> =
|
static CONFIG_FILE_PARENT_DIR: LazyLock<String> =
|
||||||
LazyLock::new(|| storage::parent(&CONFIG_FILE).unwrap_or_else(|| "data".to_string()));
|
LazyLock::new(|| storage::parent(&CONFIG_FILE).unwrap_or_else(|| "data".to_owned()));
|
||||||
|
|
||||||
static CONFIG_FILENAME: LazyLock<String> =
|
static CONFIG_FILENAME: LazyLock<String> =
|
||||||
LazyLock::new(|| storage::file_name(&CONFIG_FILE).unwrap_or_else(|| "config.json".to_string()));
|
LazyLock::new(|| storage::file_name(&CONFIG_FILE).unwrap_or_else(|| "config.json".to_owned()));
|
||||||
|
|
||||||
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
@@ -360,13 +360,7 @@ macro_rules! make_config {
|
|||||||
)+)+
|
)+)+
|
||||||
|
|
||||||
pub fn prepare_json(&self) -> serde_json::Value {
|
pub fn prepare_json(&self) -> serde_json::Value {
|
||||||
let (def, cfg, overridden) = {
|
fn get_form_type(rust_type: &'static str) -> &'static str {
|
||||||
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
|
||||||
let inner = &self.inner.read().unwrap();
|
|
||||||
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
fn _get_form_type(rust_type: &'static str) -> &'static str {
|
|
||||||
match rust_type {
|
match rust_type {
|
||||||
"Pass" => "password",
|
"Pass" => "password",
|
||||||
"String" => "text",
|
"String" => "text",
|
||||||
@@ -375,7 +369,7 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_doc(doc_str: &'static str) -> ElementDoc {
|
fn get_doc(doc_str: &'static str) -> ElementDoc {
|
||||||
let mut split = doc_str.split("|>").map(str::trim);
|
let mut split = doc_str.split("|>").map(str::trim);
|
||||||
ElementDoc {
|
ElementDoc {
|
||||||
name: split.next().unwrap_or_default(),
|
name: split.next().unwrap_or_default(),
|
||||||
@@ -383,6 +377,12 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (def, cfg, overridden) = {
|
||||||
|
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
||||||
|
let inner = &self.inner.read().unwrap();
|
||||||
|
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
||||||
|
};
|
||||||
|
|
||||||
let data: Vec<GroupData> = vec![
|
let data: Vec<GroupData> = vec![
|
||||||
$( // This repetition is for each group
|
$( // This repetition is for each group
|
||||||
GroupData {
|
GroupData {
|
||||||
@@ -397,8 +397,8 @@ macro_rules! make_config {
|
|||||||
name: stringify!($name),
|
name: stringify!($name),
|
||||||
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
|
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
|
||||||
default: serde_json::to_value(&def.$name).unwrap_or_default(),
|
default: serde_json::to_value(&def.$name).unwrap_or_default(),
|
||||||
r#type: _get_form_type(stringify!($ty)),
|
r#type: get_form_type(stringify!($ty)),
|
||||||
doc: _get_doc(concat!($($doc),+)),
|
doc: get_doc(concat!($($doc),+)),
|
||||||
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
|
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
|
||||||
},
|
},
|
||||||
)+], // End of elements repetition
|
)+], // End of elements repetition
|
||||||
@@ -408,9 +408,31 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_support_json(&self) -> serde_json::Value {
|
pub fn get_support_json(&self) -> serde_json::Value {
|
||||||
|
/// We map over the string and remove all alphanumeric, _ and - characters.
|
||||||
|
/// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds)
|
||||||
|
fn privacy_mask(value: &str) -> String {
|
||||||
|
let mut n: u16 = 0;
|
||||||
|
let mut colon_match = false;
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
n += 1;
|
||||||
|
match c {
|
||||||
|
':' if n <= 11 => {
|
||||||
|
colon_match = true;
|
||||||
|
c
|
||||||
|
}
|
||||||
|
'/' if n <= 13 && colon_match => c,
|
||||||
|
',' => c,
|
||||||
|
_ => '*',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
// Define which config keys need to be masked.
|
// Define which config keys need to be masked.
|
||||||
// Pass types will always be masked and no need to put them in the list.
|
// Pass types will always be masked and no need to put them in the list.
|
||||||
// Besides Pass, only String types will be masked via _privacy_mask.
|
// Besides Pass, only String types will be masked via privacy_mask.
|
||||||
const PRIVACY_CONFIG: &[&str] = &[
|
const PRIVACY_CONFIG: &[&str] = &[
|
||||||
"allowed_connect_src",
|
"allowed_connect_src",
|
||||||
"allowed_iframe_ancestors",
|
"allowed_iframe_ancestors",
|
||||||
@@ -437,28 +459,6 @@ macro_rules! make_config {
|
|||||||
inner.config.clone()
|
inner.config.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
/// We map over the string and remove all alphanumeric, _ and - characters.
|
|
||||||
/// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds)
|
|
||||||
fn _privacy_mask(value: &str) -> String {
|
|
||||||
let mut n: u16 = 0;
|
|
||||||
let mut colon_match = false;
|
|
||||||
value
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
n += 1;
|
|
||||||
match c {
|
|
||||||
':' if n <= 11 => {
|
|
||||||
colon_match = true;
|
|
||||||
c
|
|
||||||
}
|
|
||||||
'/' if n <= 13 && colon_match => c,
|
|
||||||
',' => c,
|
|
||||||
_ => '*',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::Value::Object({
|
serde_json::Value::Object({
|
||||||
let mut json = serde_json::Map::new();
|
let mut json = serde_json::Map::new();
|
||||||
$($(
|
$($(
|
||||||
@@ -468,7 +468,7 @@ macro_rules! make_config {
|
|||||||
for mask_key in PRIVACY_CONFIG {
|
for mask_key in PRIVACY_CONFIG {
|
||||||
if let Some(value) = json.get_mut(*mask_key) {
|
if let Some(value) = json.get_mut(*mask_key) {
|
||||||
if let Some(s) = value.as_str() {
|
if let Some(s) = value.as_str() {
|
||||||
*value = _privacy_mask(s).into();
|
*value = privacy_mask(s).into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +502,7 @@ macro_rules! make_config {
|
|||||||
make_config! {
|
make_config! {
|
||||||
folders {
|
folders {
|
||||||
/// Data folder |> Main data folder
|
/// Data folder |> Main data folder
|
||||||
data_folder: String, false, def, "data".to_string();
|
data_folder: String, false, def, "data".to_owned();
|
||||||
/// Database URL
|
/// Database URL
|
||||||
database_url: String, false, auto, |c| format!("sqlite://{}", storage::join_path(&c.data_folder, "db.sqlite3"));
|
database_url: String, false, auto, |c| format!("sqlite://{}", storage::join_path(&c.data_folder, "db.sqlite3"));
|
||||||
/// Icon cache folder
|
/// Icon cache folder
|
||||||
@@ -518,7 +518,7 @@ make_config! {
|
|||||||
/// Session JWT key
|
/// Session JWT key
|
||||||
rsa_key_filename: String, false, auto, |c| storage::join_path(&c.data_folder, "rsa_key");
|
rsa_key_filename: String, false, auto, |c| storage::join_path(&c.data_folder, "rsa_key");
|
||||||
/// Web vault folder
|
/// Web vault folder
|
||||||
web_vault_folder: String, false, def, "web-vault/".to_string();
|
web_vault_folder: String, false, def, "web-vault/".to_owned();
|
||||||
},
|
},
|
||||||
ws {
|
ws {
|
||||||
/// Enable websocket notifications
|
/// Enable websocket notifications
|
||||||
@@ -528,9 +528,9 @@ make_config! {
|
|||||||
/// Enable push notifications
|
/// Enable push notifications
|
||||||
push_enabled: bool, false, def, false;
|
push_enabled: bool, false, def, false;
|
||||||
/// Push relay uri
|
/// Push relay uri
|
||||||
push_relay_uri: String, false, def, "https://push.bitwarden.com".to_string();
|
push_relay_uri: String, false, def, "https://push.bitwarden.com".to_owned();
|
||||||
/// Push identity uri
|
/// Push identity uri
|
||||||
push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_string();
|
push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_owned();
|
||||||
/// Installation id |> The installation id from https://bitwarden.com/host
|
/// Installation id |> The installation id from https://bitwarden.com/host
|
||||||
push_installation_id: Pass, false, def, String::new();
|
push_installation_id: Pass, false, def, String::new();
|
||||||
/// Installation key |> The installation key from https://bitwarden.com/host
|
/// Installation key |> The installation key from https://bitwarden.com/host
|
||||||
@@ -542,38 +542,38 @@ make_config! {
|
|||||||
job_poll_interval_ms: u64, false, def, 30_000;
|
job_poll_interval_ms: u64, false, def, 30_000;
|
||||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||||
/// Defaults to hourly. Set blank to disable this job.
|
/// Defaults to hourly. Set blank to disable this job.
|
||||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_string();
|
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||||
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string();
|
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_owned();
|
||||||
/// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins.
|
/// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins.
|
||||||
/// Defaults to once every minute. Set blank to disable this job.
|
/// Defaults to once every minute. Set blank to disable this job.
|
||||||
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string();
|
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||||
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors.
|
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors.
|
||||||
/// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job.
|
/// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job.
|
||||||
emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_string();
|
emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_owned();
|
||||||
/// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time.
|
/// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time.
|
||||||
/// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job.
|
/// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job.
|
||||||
emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_string();
|
emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_owned();
|
||||||
/// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table.
|
/// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_string();
|
event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_owned();
|
||||||
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
/// Defaults to every minute. Set blank to disable this job.
|
/// Defaults to every minute. Set blank to disable this job.
|
||||||
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||||
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||||
/// Defaults to once every minute. Set blank to disable this job.
|
/// Defaults to once every minute. Set blank to disable this job.
|
||||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||||
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
|
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
|
||||||
/// Defaults to daily. Set blank to disable this job.
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string();
|
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned();
|
||||||
},
|
},
|
||||||
|
|
||||||
/// General settings
|
/// General settings
|
||||||
settings {
|
settings {
|
||||||
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://'
|
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://'
|
||||||
/// and port, if it's different than the default. Some server functions don't work correctly without this value
|
/// and port, if it's different than the default. Some server functions don't work correctly without this value
|
||||||
domain: String, true, def, "http://localhost".to_string();
|
domain: String, true, def, "http://localhost".to_owned();
|
||||||
/// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used.
|
/// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used.
|
||||||
domain_set: bool, false, def, false;
|
domain_set: bool, false, def, false;
|
||||||
/// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin)
|
/// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin)
|
||||||
@@ -653,26 +653,37 @@ make_config! {
|
|||||||
admin_token: Pass, true, option;
|
admin_token: Pass, true, option;
|
||||||
|
|
||||||
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
||||||
invitation_org_name: String, true, def, "Vaultwarden".to_string();
|
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
||||||
|
|
||||||
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
||||||
events_days_retain: i64, false, option;
|
events_days_retain: i64, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
client {
|
||||||
|
/// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals
|
||||||
|
client_suppress_onboarding: bool, true, def, false;
|
||||||
|
},
|
||||||
|
|
||||||
/// Advanced settings
|
/// Advanced settings
|
||||||
advanced {
|
advanced {
|
||||||
/// Client IP header |> If not present, the remote IP is used.
|
/// Client IP header |> If not present, the remote IP is used.
|
||||||
/// Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
/// Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||||
ip_header: String, true, def, "X-Real-IP".to_string();
|
ip_header: String, true, def, "X-Real-IP".to_owned();
|
||||||
/// Internal IP header property, used to avoid recomputing each time
|
/// Internal IP header property, used to avoid recomputing each time
|
||||||
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
||||||
|
/// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any
|
||||||
|
/// other address use the remote IP instead, so a client can't spoof the header.
|
||||||
|
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
|
||||||
|
/// running on the same host or container network), the string "all" to accept it from anywhere,
|
||||||
|
/// or a comma separated list of IPs and CIDR ranges.
|
||||||
|
ip_header_trusted_proxies: String, true, def, "local".to_owned();
|
||||||
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
||||||
/// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external
|
/// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external
|
||||||
/// service is set, an icon request to Vaultwarden will return an HTTP redirect to the
|
/// service is set, an icon request to Vaultwarden will return an HTTP redirect to the
|
||||||
/// corresponding icon at the external service.
|
/// corresponding icon at the external service.
|
||||||
icon_service: String, false, def, "internal".to_string();
|
icon_service: String, false, def, "internal".to_owned();
|
||||||
/// _icon_service_url
|
/// _icon_service_url
|
||||||
_icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service);
|
_icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service);
|
||||||
/// _icon_service_csp
|
/// _icon_service_csp
|
||||||
@@ -723,14 +734,14 @@ make_config! {
|
|||||||
/// Enable extended logging
|
/// Enable extended logging
|
||||||
extended_logging: bool, false, def, true;
|
extended_logging: bool, false, def, true;
|
||||||
/// Log timestamp format
|
/// Log timestamp format
|
||||||
log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_string();
|
log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_owned();
|
||||||
/// Enable the log to output to Syslog
|
/// Enable the log to output to Syslog
|
||||||
use_syslog: bool, false, def, false;
|
use_syslog: bool, false, def, false;
|
||||||
/// Log file path
|
/// Log file path
|
||||||
log_file: String, false, option;
|
log_file: String, false, option;
|
||||||
/// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
/// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
||||||
/// For a specific module append it as a comma separated value "info,path::to::module=debug"
|
/// For a specific module append it as a comma separated value "info,path::to::module=debug"
|
||||||
log_level: String, false, def, "info".to_string();
|
log_level: String, false, def, "info".to_owned();
|
||||||
|
|
||||||
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems,
|
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems,
|
||||||
/// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
|
/// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
|
||||||
@@ -768,6 +779,11 @@ make_config! {
|
|||||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||||
login_ratelimit_max_burst: u32, false, def, 10;
|
login_ratelimit_max_burst: u32, false, def, 10;
|
||||||
|
|
||||||
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
|
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
||||||
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
|
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u64, false, def, 300;
|
admin_ratelimit_seconds: u64, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
@@ -812,7 +828,7 @@ make_config! {
|
|||||||
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
|
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
|
||||||
sso_authority: String, true, def, String::new();
|
sso_authority: String, true, def, String::new();
|
||||||
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
|
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
|
||||||
sso_scopes: String, true, def, "email profile".to_string();
|
sso_scopes: String, true, def, "email profile".to_owned();
|
||||||
/// Authorization request extra parameters
|
/// Authorization request extra parameters
|
||||||
sso_authorize_extra_params: String, true, def, String::new();
|
sso_authorize_extra_params: String, true, def, String::new();
|
||||||
/// Use PKCE during Authorization flow
|
/// Use PKCE during Authorization flow
|
||||||
@@ -880,7 +896,7 @@ make_config! {
|
|||||||
/// From Address
|
/// From Address
|
||||||
smtp_from: String, true, def, String::new();
|
smtp_from: String, true, def, String::new();
|
||||||
/// From Name
|
/// From Name
|
||||||
smtp_from_name: String, true, def, "Vaultwarden".to_string();
|
smtp_from_name: String, true, def, "Vaultwarden".to_owned();
|
||||||
/// Username
|
/// Username
|
||||||
smtp_username: String, true, option;
|
smtp_username: String, true, option;
|
||||||
/// Password
|
/// Password
|
||||||
@@ -930,18 +946,30 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
let file_path = url.strip_prefix("sqlite://").unwrap_or(url);
|
let file_path = url.strip_prefix("sqlite://").unwrap_or(url);
|
||||||
if file_path.contains('/') {
|
if file_path.contains('/') {
|
||||||
let path = std::path::Path::new(file_path);
|
let path = std::path::Path::new(file_path);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent()
|
||||||
if !parent.is_dir() {
|
&& !parent.is_dir()
|
||||||
err!(format!(
|
{
|
||||||
"SQLite database directory `{}` does not exist or is not a directory",
|
err!(format!(
|
||||||
parent.display()
|
"SQLite database directory `{}` does not exist or is not a directory",
|
||||||
));
|
parent.display()
|
||||||
}
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let trusted_proxies = cfg.ip_header_trusted_proxies.trim();
|
||||||
|
if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") {
|
||||||
|
for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) {
|
||||||
|
if crate::auth::parse_trusted_proxy(entry).is_none() {
|
||||||
|
err!(format!(
|
||||||
|
"Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range",
|
||||||
|
entry.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.password_iterations < 100_000 {
|
if cfg.password_iterations < 100_000 {
|
||||||
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
|
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
|
||||||
}
|
}
|
||||||
@@ -956,13 +984,13 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.database_min_conns > cfg.database_max_conns {
|
if cfg.database_min_conns > cfg.database_max_conns {
|
||||||
err!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",));
|
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(log_file) = &cfg.log_file {
|
if let Some(log_file) = &cfg.log_file
|
||||||
if std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() {
|
&& std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err()
|
||||||
err!("Unable to write to log file", log_file);
|
{
|
||||||
}
|
err!("Unable to write to log file", log_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dom = cfg.domain.to_lowercase();
|
let dom = cfg.domain.to_lowercase();
|
||||||
@@ -975,7 +1003,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||||
for url in connect_src.split_whitespace() {
|
for url in connect_src.split_whitespace() {
|
||||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||||
err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed");
|
err!(
|
||||||
|
"ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,11 +1021,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`ORG_CREATION_USERS` contains invalid email addresses");
|
err!("`ORG_CREATION_USERS` contains invalid email addresses");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref token) = cfg.admin_token {
|
if let Some(ref token) = cfg.admin_token
|
||||||
if token.trim().is_empty() && !cfg.disable_admin_token {
|
&& token.trim().is_empty()
|
||||||
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
|
&& !cfg.disable_admin_token
|
||||||
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
|
{
|
||||||
}
|
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
|
||||||
|
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) {
|
if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) {
|
||||||
@@ -1029,37 +1060,41 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let invalid_flags =
|
let invalid_flags = parse_experimental_client_feature_flags(
|
||||||
parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags, FeatureFlagFilter::InvalidOnly);
|
&cfg.experimental_client_feature_flags,
|
||||||
|
&FeatureFlagFilter::InvalidOnly,
|
||||||
|
);
|
||||||
if !invalid_flags.is_empty() {
|
if !invalid_flags.is_empty() {
|
||||||
let feature_flags_error = format!("Unrecognized experimental client feature flags: {:?}.\n\
|
let feature_flags_error = format!(
|
||||||
|
"Unrecognized experimental client feature flags: {invalid_flags:?}.\n\
|
||||||
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
||||||
Supported flags: {:?}\n", invalid_flags, SUPPORTED_FEATURE_FLAGS);
|
Supported flags: {SUPPORTED_FEATURE_FLAGS:?}\n"
|
||||||
|
);
|
||||||
if on_update {
|
if on_update {
|
||||||
err!(feature_flags_error);
|
err!(feature_flags_error);
|
||||||
} else {
|
|
||||||
println!("[WARNING] {feature_flags_error}");
|
|
||||||
}
|
}
|
||||||
|
println!("[WARNING] {feature_flags_error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::items_after_statements, reason = "Keep this close to where it is used")]
|
||||||
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
||||||
|
|
||||||
if let Some(limit) = cfg.user_attachment_limit {
|
if let Some(limit) = cfg.user_attachment_limit
|
||||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||||
err!("`USER_ATTACHMENT_LIMIT` is out of bounds");
|
{
|
||||||
}
|
err!("`USER_ATTACHMENT_LIMIT` is out of bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = cfg.org_attachment_limit {
|
if let Some(limit) = cfg.org_attachment_limit
|
||||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||||
err!("`ORG_ATTACHMENT_LIMIT` is out of bounds");
|
{
|
||||||
}
|
err!("`ORG_ATTACHMENT_LIMIT` is out of bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = cfg.user_send_limit {
|
if let Some(limit) = cfg.user_send_limit
|
||||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||||
err!("`USER_SEND_LIMIT` is out of bounds");
|
{
|
||||||
}
|
err!("`USER_SEND_LIMIT` is out of bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg._enable_duo
|
if cfg._enable_duo
|
||||||
@@ -1087,7 +1122,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
if let Some(yubico_server) = &cfg.yubico_server {
|
if let Some(yubico_server) = &cfg.yubico_server {
|
||||||
let yubico_server = yubico_server.to_lowercase();
|
let yubico_server = yubico_server.to_lowercase();
|
||||||
if !yubico_server.starts_with("https://") {
|
if !yubico_server.starts_with("https://") {
|
||||||
err!("`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL.")
|
err!(
|
||||||
|
"`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1176,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
|
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
|
||||||
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`")
|
err!(
|
||||||
|
"Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,7 +1339,7 @@ fn extract_url_origin(url: &str) -> String {
|
|||||||
/// All trailing '/' chars are trimmed, even if the path is a lone '/'.
|
/// All trailing '/' chars are trimmed, even if the path is a lone '/'.
|
||||||
fn extract_url_path(url: &str) -> String {
|
fn extract_url_path(url: &str) -> String {
|
||||||
match Url::parse(url) {
|
match Url::parse(url) {
|
||||||
Ok(u) => u.path().trim_end_matches('/').to_string(),
|
Ok(u) => u.path().trim_end_matches('/').to_owned(),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// We already print it in the method above, no need to do it again
|
// We already print it in the method above, no need to do it again
|
||||||
String::new()
|
String::new()
|
||||||
@@ -1310,7 +1349,7 @@ fn extract_url_path(url: &str) -> String {
|
|||||||
|
|
||||||
fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
||||||
if embed_images {
|
if embed_images {
|
||||||
"cid:".to_string()
|
"cid:".to_owned()
|
||||||
} else {
|
} else {
|
||||||
// normalize base_url
|
// normalize base_url
|
||||||
let base_url = domain.trim_end_matches('/');
|
let base_url = domain.trim_end_matches('/');
|
||||||
@@ -1329,10 +1368,10 @@ fn generate_sso_callback_path(domain: &str) -> String {
|
|||||||
fn generate_icon_service_url(icon_service: &str) -> String {
|
fn generate_icon_service_url(icon_service: &str) -> String {
|
||||||
match icon_service {
|
match icon_service {
|
||||||
"internal" => String::new(),
|
"internal" => String::new(),
|
||||||
"bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_string(),
|
"bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_owned(),
|
||||||
"duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_string(),
|
"duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_owned(),
|
||||||
"google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_string(),
|
"google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_owned(),
|
||||||
_ => icon_service.to_string(),
|
_ => icon_service.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,7 +1380,7 @@ fn generate_icon_service_csp(icon_service: &str, icon_service_url: &str) -> Stri
|
|||||||
// We split on the first '{', since that is the variable delimiter for an icon service URL.
|
// We split on the first '{', since that is the variable delimiter for an icon service URL.
|
||||||
// Everything up until the first '{' should be fixed and can be used as an CSP string.
|
// Everything up until the first '{' should be fixed and can be used as an CSP string.
|
||||||
let csp_string = match icon_service_url.split_once('{') {
|
let csp_string = match icon_service_url.split_once('{') {
|
||||||
Some((c, _)) => c.to_string(),
|
Some((c, _)) => c.to_owned(),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1358,12 +1397,12 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls
|
|||||||
println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead.");
|
println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead.");
|
||||||
}
|
}
|
||||||
if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() {
|
if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() {
|
||||||
return "force_tls".to_string();
|
return "force_tls".to_owned();
|
||||||
} else if smtp_ssl.is_some() && !smtp_ssl.unwrap() {
|
} else if smtp_ssl.is_some() && !smtp_ssl.unwrap() {
|
||||||
return "off".to_string();
|
return "off".to_owned();
|
||||||
}
|
}
|
||||||
// Return the default `starttls` in all other cases
|
// Return the default `starttls` in all other cases
|
||||||
"starttls".to_string()
|
"starttls".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PathType {
|
pub enum PathType {
|
||||||
@@ -1393,6 +1432,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
// 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",
|
||||||
// Mobile Team
|
// Mobile Team
|
||||||
"anon-addy-self-host-alias",
|
"anon-addy-self-host-alias",
|
||||||
"simple-login-self-host-alias",
|
"simple-login-self-host-alias",
|
||||||
@@ -1406,12 +1446,12 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load() -> Result<Self, Error> {
|
pub async fn load() -> Result<Self, Error> {
|
||||||
// Loading from env and file
|
// Loading from env and file
|
||||||
let _env = ConfigBuilder::from_env();
|
let env = ConfigBuilder::from_env();
|
||||||
let _usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||||
|
|
||||||
// Create merged config, config file overwrites env
|
// Create merged config, config file overwrites env
|
||||||
let mut _overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
let builder = _env.merge(&_usr, true, &mut _overrides);
|
let builder = env.merge(&usr, true, &mut overrides);
|
||||||
|
|
||||||
// Fill any missing with defaults
|
// Fill any missing with defaults
|
||||||
let config = builder.build();
|
let config = builder.build();
|
||||||
@@ -1424,9 +1464,9 @@ impl Config {
|
|||||||
rocket_shutdown_handle: None,
|
rocket_shutdown_handle: None,
|
||||||
templates: load_templates(&config.templates_folder),
|
templates: load_templates(&config.templates_folder),
|
||||||
config,
|
config,
|
||||||
_env,
|
_env: env,
|
||||||
_usr,
|
_usr: usr,
|
||||||
_overrides,
|
_overrides: overrides,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1466,14 +1506,17 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
|
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
|
||||||
let builder = {
|
let builder = {
|
||||||
let usr = &self.inner.read().unwrap()._usr;
|
let usr = &self.inner.read().unwrap()._usr;
|
||||||
let mut _overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
usr.merge(&other, false, &mut _overrides)
|
usr.merge(&other, false, &mut overrides)
|
||||||
};
|
};
|
||||||
self.update_config(builder, false).await
|
self.update_config(builder, false).await
|
||||||
}
|
}
|
||||||
@@ -1496,11 +1539,11 @@ impl Config {
|
|||||||
/// Tests whether signup is allowed for an email address, taking into
|
/// Tests whether signup is allowed for an email address, taking into
|
||||||
/// account the signups_allowed and signups_domains_whitelist settings.
|
/// account the signups_allowed and signups_domains_whitelist settings.
|
||||||
pub fn is_signup_allowed(&self, email: &str) -> bool {
|
pub fn is_signup_allowed(&self, email: &str) -> bool {
|
||||||
if !self.signups_domains_whitelist().is_empty() {
|
if self.signups_domains_whitelist().is_empty() {
|
||||||
|
self.signups_allowed()
|
||||||
|
} else {
|
||||||
// The whitelist setting overrides the signups_allowed setting.
|
// The whitelist setting overrides the signups_allowed setting.
|
||||||
self.is_email_domain_allowed(email)
|
self.is_email_domain_allowed(email)
|
||||||
} else {
|
|
||||||
self.signups_allowed()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,6 +1591,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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,10 +1667,10 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn shutdown(&self) {
|
pub fn shutdown(&self) {
|
||||||
if let Ok(mut c) = self.inner.write() {
|
if let Ok(mut c) = self.inner.write()
|
||||||
if let Some(handle) = c.rocket_shutdown_handle.take() {
|
&& let Some(handle) = c.rocket_shutdown_handle.take()
|
||||||
handle.notify();
|
{
|
||||||
}
|
handle.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1641,7 +1687,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
||||||
self.sso_scopes().split_whitespace().map(str::to_string).collect()
|
self.sso_scopes().split_whitespace().map(str::to_owned).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
||||||
@@ -1751,7 +1797,7 @@ fn case_helper<'reg, 'rc>(
|
|||||||
let value = param.value().clone();
|
let value = param.value().clone();
|
||||||
|
|
||||||
if h.params().iter().skip(1).any(|x| x.value() == &value) {
|
if h.params().iter().skip(1).any(|x| x.value() == &value) {
|
||||||
h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or_else(|| Ok(()))
|
h.template().map_or(Ok(()), |t| t.render(r, ctx, rc, out))
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-29
@@ -6,25 +6,23 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use diesel::{
|
use diesel::{
|
||||||
|
Connection, RunQueryDsl,
|
||||||
connection::SimpleConnection,
|
connection::SimpleConnection,
|
||||||
r2d2::{CustomizeConnection, Pool, PooledConnection},
|
r2d2::{CustomizeConnection, Pool, PooledConnection},
|
||||||
Connection, RunQueryDsl,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Request,
|
||||||
http::Status,
|
http::Status,
|
||||||
request::{FromRequest, Outcome},
|
request::{FromRequest, Outcome},
|
||||||
Request,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{Mutex, OwnedSemaphorePermit, Semaphore},
|
sync::{Mutex, OwnedSemaphorePermit, Semaphore},
|
||||||
time::timeout,
|
time::timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{Error, MapResult},
|
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
error::{Error, MapResult},
|
||||||
};
|
};
|
||||||
|
|
||||||
// These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools
|
// These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools
|
||||||
@@ -62,7 +60,7 @@ pub struct DbConnManager {
|
|||||||
impl DbConnManager {
|
impl DbConnManager {
|
||||||
pub fn new(database_url: &str) -> Self {
|
pub fn new(database_url: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
database_url: database_url.to_string(),
|
database_url: database_url.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +222,7 @@ impl DbPool {
|
|||||||
|
|
||||||
// Set a global to determine the database more easily throughout the rest of the code
|
// Set a global to determine the database more easily throughout the rest of the code
|
||||||
if ACTIVE_DB_TYPE.set(conn_type).is_err() {
|
if ACTIVE_DB_TYPE.set(conn_type).is_err() {
|
||||||
error!("Tried to set the active database connection type more than once.")
|
error!("Tried to set the active database connection type more than once.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(DbPool {
|
Ok(DbPool {
|
||||||
@@ -279,34 +277,33 @@ impl DbConnType {
|
|||||||
|
|
||||||
#[cfg(not(sqlite))]
|
#[cfg(not(sqlite))]
|
||||||
err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled")
|
err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
// No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist.
|
// No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist.
|
||||||
// This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database.
|
// This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database.
|
||||||
} else {
|
#[cfg(sqlite)]
|
||||||
#[cfg(sqlite)]
|
{
|
||||||
{
|
if std::path::Path::new(url).exists() {
|
||||||
if std::path::Path::new(url).exists() {
|
return Ok(DbConnType::Sqlite);
|
||||||
return Ok(DbConnType::Sqlite);
|
|
||||||
}
|
|
||||||
err!(format!(
|
|
||||||
"`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \
|
|
||||||
and no existing SQLite database was found at '{url}'. \
|
|
||||||
If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \
|
|
||||||
Otherwise, check your DATABASE_URL for typos or quoting issues."
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
err!(format!(
|
||||||
#[cfg(not(sqlite))]
|
"`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \
|
||||||
err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)")
|
and no existing SQLite database was found at '{url}'. \
|
||||||
|
If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \
|
||||||
|
Otherwise, check your DATABASE_URL for typos or quoting issues."
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(sqlite))]
|
||||||
|
err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_init_stmts(&self) -> String {
|
pub fn get_init_stmts(&self) -> String {
|
||||||
let init_stmts = CONFIG.database_conn_init();
|
let init_stmts = CONFIG.database_conn_init();
|
||||||
if !init_stmts.is_empty() {
|
if init_stmts.is_empty() {
|
||||||
init_stmts
|
|
||||||
} else {
|
|
||||||
self.default_init_stmts()
|
self.default_init_stmts()
|
||||||
|
} else {
|
||||||
|
init_stmts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +314,7 @@ impl DbConnType {
|
|||||||
#[cfg(postgresql)]
|
#[cfg(postgresql)]
|
||||||
Self::Postgresql => String::new(),
|
Self::Postgresql => String::new(),
|
||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(),
|
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +405,7 @@ pub fn backup_sqlite() -> Result<String, Error> {
|
|||||||
use diesel::Connection;
|
use diesel::Connection;
|
||||||
|
|
||||||
let db_url = CONFIG.database_url();
|
let db_url = CONFIG.database_url();
|
||||||
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
|
if DbConnType::from_url(&CONFIG.database_url()).is_ok_and(|t| t == DbConnType::Sqlite) {
|
||||||
// Strip the sqlite:// prefix if present to get the raw file path
|
// Strip the sqlite:// prefix if present to get the raw file path
|
||||||
let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url);
|
let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url);
|
||||||
// Open a read-only connection for the backup
|
// Open a read-only connection for the backup
|
||||||
@@ -443,12 +440,12 @@ pub async fn get_sql_server_version(conn: &DbConn) -> String {
|
|||||||
postgresql,mysql {
|
postgresql,mysql {
|
||||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("version();"))
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("version();"))
|
||||||
.get_result::<String>(conn)
|
.get_result::<String>(conn)
|
||||||
.unwrap_or_else(|_| "Unknown".to_string())
|
.unwrap_or_else(|_| "Unknown".to_owned())
|
||||||
}
|
}
|
||||||
sqlite {
|
sqlite {
|
||||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("sqlite_version();"))
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("sqlite_version();"))
|
||||||
.get_result::<String>(conn)
|
.get_result::<String>(conn)
|
||||||
.unwrap_or_else(|_| "Unknown".to_string())
|
.unwrap_or_else(|_| "Unknown".to_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-14
@@ -1,11 +1,13 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{DbConn, schema::archives},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{CipherId, User, UserId};
|
use super::{CipherId, User, UserId};
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::db::schema::archives;
|
|
||||||
use crate::db::DbConn;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
#[diesel(table_name = archives)]
|
#[diesel(table_name = archives)]
|
||||||
@@ -19,13 +21,15 @@ pub struct Archive {
|
|||||||
impl Archive {
|
impl Archive {
|
||||||
// Returns the date the specified cipher was archived
|
// Returns the date the specified cipher was archived
|
||||||
pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
|
pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
archives::table
|
archives::table
|
||||||
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
||||||
.filter(archives::user_uuid.eq(user_uuid))
|
.filter(archives::user_uuid.eq(user_uuid))
|
||||||
.select(archives::archived_at)
|
.select(archives::archived_at)
|
||||||
.first::<NaiveDateTime>(conn).ok()
|
.first::<NaiveDateTime>(conn)
|
||||||
}}
|
.ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves (inserts or updates) an archive record with the provided timestamp
|
// Saves (inserts or updates) an archive record with the provided timestamp
|
||||||
@@ -66,26 +70,26 @@ impl Archive {
|
|||||||
// Deletes an archive record for a specific cipher
|
// Deletes an archive record for a specific cipher
|
||||||
pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
archives::table
|
archives::table.filter(archives::user_uuid.eq(user_uuid)).filter(archives::cipher_uuid.eq(cipher_uuid)),
|
||||||
.filter(archives::user_uuid.eq(user_uuid))
|
|
||||||
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
|
||||||
)
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting archive")
|
.map_res("Error deleting archive")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a vec with (cipher_uuid, archived_at)
|
/// Return a vec with (cipher_uuid, archived_at)
|
||||||
/// This is used during a full sync so we only need one query for all archive matches
|
/// This is used during a full sync so we only need one query for all archive matches
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
archives::table
|
archives::table
|
||||||
.filter(archives::user_uuid.eq(user_uuid))
|
.filter(archives::user_uuid.eq(user_uuid))
|
||||||
.select((archives::cipher_uuid, archives::archived_at))
|
.select((archives::cipher_uuid, archives::archived_at))
|
||||||
.load::<(CipherId, NaiveDateTime)>(conn)
|
.load::<(CipherId, NaiveDateTime)>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-35
@@ -1,13 +1,24 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use bigdecimal::{BigDecimal, ToPrimitive};
|
use bigdecimal::{BigDecimal, ToPrimitive};
|
||||||
use derive_more::{AsRef, Deref, Display};
|
use derive_more::{AsRef, Deref, Display};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::time::Duration;
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::EmptyResult,
|
||||||
|
auth::{encode_jwt, generate_file_download_claims},
|
||||||
|
config::PathType,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
schema::{attachments, ciphers},
|
||||||
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
use macros::IdFromParam;
|
||||||
|
|
||||||
use super::{CipherId, OrganizationId, UserId};
|
use super::{CipherId, OrganizationId, UserId};
|
||||||
use crate::db::schema::{attachments, ciphers};
|
|
||||||
use crate::{config::PathType, CONFIG};
|
|
||||||
use macros::IdFromParam;
|
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = attachments)]
|
#[diesel(table_name = attachments)]
|
||||||
@@ -67,12 +78,6 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::auth::{encode_jwt, generate_file_download_claims};
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl Attachment {
|
impl Attachment {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -107,15 +112,15 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
crate::util::retry(||
|
crate::util::retry(
|
||||||
diesel::delete(attachments::table.filter(attachments::id.eq(&self.id)))
|
|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn),
|
||||||
.execute(conn),
|
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_res("Error deleting attachment")
|
.map_res("Error deleting attachment")
|
||||||
}}?;
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||||
let file_path = self.get_file_path();
|
let file_path = self.get_file_path();
|
||||||
@@ -139,25 +144,22 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| attachments::table.filter(attachments::id.eq(id.to_lowercase())).first::<Self>(conn).ok())
|
||||||
attachments::table
|
.await
|
||||||
.filter(attachments::id.eq(id.to_lowercase()))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.filter(attachments::cipher_uuid.eq(cipher_uuid))
|
.filter(attachments::cipher_uuid.eq(cipher_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading attachments")
|
.expect("Error loading attachments")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
let result: Option<BigDecimal> = attachments::table
|
let result: Option<BigDecimal> = attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||||
@@ -168,24 +170,26 @@ impl Attachment {
|
|||||||
match result.map(|r| r.to_i64()) {
|
match result.map(|r| r.to_i64()) {
|
||||||
Some(Some(r)) => r,
|
Some(Some(r)) => r,
|
||||||
Some(None) => i64::MAX,
|
Some(None) => i64::MAX,
|
||||||
None => 0
|
None => 0,
|
||||||
}
|
}
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
let result: Option<BigDecimal> = attachments::table
|
let result: Option<BigDecimal> = attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||||
@@ -196,20 +200,22 @@ impl Attachment {
|
|||||||
match result.map(|r| r.to_i64()) {
|
match result.map(|r| r.to_i64()) {
|
||||||
Some(Some(r)) => r,
|
Some(Some(r)) => r,
|
||||||
Some(None) => i64::MAX,
|
Some(None) => i64::MAX,
|
||||||
None => 0
|
None => 0,
|
||||||
}
|
}
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// This will return all attachments linked to the user or org
|
// This will return all attachments linked to the user or org
|
||||||
@@ -220,7 +226,7 @@ impl Attachment {
|
|||||||
org_uuids: &Vec<OrganizationId>,
|
org_uuids: &Vec<OrganizationId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
attachments::table
|
attachments::table
|
||||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||||
@@ -228,7 +234,8 @@ impl Attachment {
|
|||||||
.select(attachments::all_columns)
|
.select(attachments::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading attachments")
|
.expect("Error loading attachments")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
use super::{DeviceId, OrganizationId, UserId};
|
|
||||||
use crate::db::schema::auth_requests;
|
|
||||||
use crate::{crypto::ct_eq, util::format_date};
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use macros::UuidFromParam;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
crypto::ct_eq,
|
||||||
|
db::{DbConn, schema::auth_requests},
|
||||||
|
error::MapResult,
|
||||||
|
util::format_date,
|
||||||
|
};
|
||||||
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
|
use super::{DeviceId, OrganizationId, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
|
||||||
#[diesel(table_name = auth_requests)]
|
#[diesel(table_name = auth_requests)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
@@ -74,11 +81,6 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
impl AuthRequest {
|
impl AuthRequest {
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
@@ -112,31 +114,28 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| auth_requests::table.filter(auth_requests::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||||
auth_requests::table
|
|
||||||
.filter(auth_requests::uuid.eq(uuid))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::uuid.eq(uuid))
|
.filter(auth_requests::uuid.eq(uuid))
|
||||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading auth_requests")
|
.expect("Error loading auth_requests")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user_and_requested_device(
|
pub async fn find_by_user_and_requested_device(
|
||||||
@@ -144,7 +143,7 @@ impl AuthRequest {
|
|||||||
device_uuid: &DeviceId,
|
device_uuid: &DeviceId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||||
.filter(auth_requests::request_device_identifier.eq(device_uuid))
|
.filter(auth_requests::request_device_identifier.eq(device_uuid))
|
||||||
@@ -152,24 +151,27 @@ impl AuthRequest {
|
|||||||
.order_by(auth_requests::creation_date.desc())
|
.order_by(auth_requests::creation_date.desc())
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
auth_requests::table
|
auth_requests::table
|
||||||
.filter(auth_requests::creation_date.lt(dt))
|
.filter(auth_requests::creation_date.lt(dt))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading auth_requests")
|
.expect("Error loading auth_requests")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
|
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting auth request")
|
.map_res("Error deleting auth request")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_access_code(&self, access_code: &str) -> bool {
|
pub fn check_access_code(&self, access_code: &str) -> bool {
|
||||||
|
|||||||
+361
-359
File diff suppressed because it is too large
Load Diff
+449
-309
File diff suppressed because it is too large
Load Diff
+39
-44
@@ -1,18 +1,20 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
|
||||||
use data_encoding::BASE64URL;
|
use data_encoding::BASE64URL;
|
||||||
use derive_more::{Display, From};
|
use derive_more::{Display, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::{AuthRequest, UserId};
|
|
||||||
use crate::db::schema::devices;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
crypto,
|
crypto,
|
||||||
|
db::{DbConn, schema::devices},
|
||||||
|
error::MapResult,
|
||||||
util::{format_date, get_uuid},
|
util::{format_date, get_uuid},
|
||||||
};
|
};
|
||||||
use diesel::prelude::*;
|
|
||||||
use macros::{IdFromParam, UuidFromParam};
|
use macros::{IdFromParam, UuidFromParam};
|
||||||
|
|
||||||
|
use super::{AuthRequest, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = devices)]
|
#[diesel(table_name = devices)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
@@ -135,10 +137,6 @@ impl DeviceWithAuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl Device {
|
impl Device {
|
||||||
@@ -171,21 +169,23 @@ impl Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing devices for user")
|
.map_res("Error removing devices for user")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
devices::table
|
devices::table
|
||||||
.filter(devices::uuid.eq(uuid))
|
.filter(devices::uuid.eq(uuid))
|
||||||
.filter(devices::user_uuid.eq(user_uuid))
|
.filter(devices::user_uuid.eq(user_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<DeviceWithAuthRequest> {
|
pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<DeviceWithAuthRequest> {
|
||||||
@@ -199,71 +199,65 @@ impl Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
devices::table
|
devices::table.filter(devices::user_uuid.eq(user_uuid)).load::<Self>(conn).expect("Error loading devices")
|
||||||
.filter(devices::user_uuid.eq(user_uuid))
|
})
|
||||||
.load::<Self>(conn)
|
.await
|
||||||
.expect("Error loading devices")
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| devices::table.filter(devices::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||||
devices::table
|
|
||||||
.filter(devices::uuid.eq(uuid))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::update(devices::table)
|
diesel::update(devices::table)
|
||||||
.filter(devices::uuid.eq(uuid))
|
.filter(devices::uuid.eq(uuid))
|
||||||
.set(devices::push_token.eq::<Option<String>>(None))
|
.set(devices::push_token.eq::<Option<String>>(None))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing push token")
|
.map_res("Error removing push token")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| devices::table.filter(devices::refresh_token.eq(refresh_token)).first::<Self>(conn).ok())
|
||||||
devices::table
|
.await
|
||||||
.filter(devices::refresh_token.eq(refresh_token))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
devices::table
|
devices::table
|
||||||
.filter(devices::user_uuid.eq(user_uuid))
|
.filter(devices::user_uuid.eq(user_uuid))
|
||||||
.order(devices::updated_at.desc())
|
.order(devices::updated_at.desc())
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
devices::table
|
devices::table
|
||||||
.filter(devices::user_uuid.eq(user_uuid))
|
.filter(devices::user_uuid.eq(user_uuid))
|
||||||
.filter(devices::push_token.is_not_null())
|
.filter(devices::push_token.is_not_null())
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading push devices")
|
.expect("Error loading push devices")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
devices::table
|
devices::table
|
||||||
.filter(devices::user_uuid.eq(user_uuid))
|
.filter(devices::user_uuid.eq(user_uuid))
|
||||||
.filter(devices::push_token.is_not_null())
|
.filter(devices::push_token.is_not_null())
|
||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
.unwrap_or(0) != 0
|
.unwrap_or(0)
|
||||||
}}
|
!= 0
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -337,6 +331,7 @@ pub enum DeviceType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceType {
|
impl DeviceType {
|
||||||
|
#[expect(clippy::match_same_arms, reason = "Specifically define 14 and have a fallback for new types")]
|
||||||
pub fn from_i32(value: i32) -> DeviceType {
|
pub fn from_i32(value: i32) -> DeviceType {
|
||||||
match value {
|
match value {
|
||||||
0 => DeviceType::Android,
|
0 => DeviceType::Android,
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::{User, UserId};
|
use crate::{
|
||||||
use crate::db::schema::emergency_access;
|
api::EmptyResult,
|
||||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
|
db::{DbConn, schema::emergency_access},
|
||||||
use diesel::prelude::*;
|
error::MapResult,
|
||||||
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
|
use super::{User, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = emergency_access)]
|
#[diesel(table_name = emergency_access)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
@@ -87,13 +91,12 @@ impl EmergencyAccess {
|
|||||||
User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.")
|
User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.")
|
||||||
} else {
|
} else {
|
||||||
let email = self.email.as_deref()?;
|
let email = self.email.as_deref()?;
|
||||||
match User::find_by_mail(email, conn).await {
|
if let Some(user) = User::find_by_mail(email, conn).await {
|
||||||
Some(user) => user,
|
user
|
||||||
None => {
|
} else {
|
||||||
// remove outstanding invitations which should not exist
|
// remove outstanding invitations which should not exist
|
||||||
Self::delete_all_by_grantee_email(email, conn).await.ok();
|
Self::delete_all_by_grantee_email(email, conn).await.ok();
|
||||||
return None;
|
return None;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,28 +186,36 @@ impl EmergencyAccess {
|
|||||||
self.status = status;
|
self.status = status;
|
||||||
date.clone_into(&mut self.updated_at);
|
date.clone_into(&mut self.updated_at);
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
crate::util::retry(|| {
|
crate::util::retry(
|
||||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
|| {
|
||||||
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||||
.execute(conn)
|
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
||||||
}, 10)
|
.execute(conn)
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
.map_res("Error updating emergency access status")
|
.map_res("Error updating emergency access status")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||||
self.last_notification_at = Some(date.to_owned());
|
self.last_notification_at = Some(date.to_owned());
|
||||||
date.clone_into(&mut self.updated_at);
|
date.clone_into(&mut self.updated_at);
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
crate::util::retry(|| {
|
crate::util::retry(
|
||||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
|| {
|
||||||
.set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date)))
|
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||||
.execute(conn)
|
.set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date)))
|
||||||
}, 10)
|
.execute(conn)
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
.map_res("Error updating emergency access status")
|
.map_res("Error updating emergency access status")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -227,11 +238,12 @@ impl EmergencyAccess {
|
|||||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
User::update_uuid_revision(&self.grantor_uuid, conn).await;
|
User::update_uuid_revision(&self.grantor_uuid, conn).await;
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid)))
|
diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing user from emergency access")
|
.map_res("Error removing user from emergency access")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email(
|
pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email(
|
||||||
@@ -240,23 +252,25 @@ impl EmergencyAccess {
|
|||||||
email: &str,
|
email: &str,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email)))
|
.filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email)))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec<Self> {
|
pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32))
|
.filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32))
|
||||||
.filter(emergency_access::recovery_initiated_at.is_not_null())
|
.filter(emergency_access::recovery_initiated_at.is_not_null())
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading emergency_access")
|
.expect("Error loading emergency_access")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_grantor_uuid(
|
pub async fn find_by_uuid_and_grantor_uuid(
|
||||||
@@ -264,13 +278,14 @@ impl EmergencyAccess {
|
|||||||
grantor_uuid: &UserId,
|
grantor_uuid: &UserId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::uuid.eq(uuid))
|
.filter(emergency_access::uuid.eq(uuid))
|
||||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_grantee_uuid(
|
pub async fn find_by_uuid_and_grantee_uuid(
|
||||||
@@ -278,13 +293,14 @@ impl EmergencyAccess {
|
|||||||
grantee_uuid: &UserId,
|
grantee_uuid: &UserId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::uuid.eq(uuid))
|
.filter(emergency_access::uuid.eq(uuid))
|
||||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_grantee_email(
|
pub async fn find_by_uuid_and_grantee_email(
|
||||||
@@ -292,61 +308,67 @@ impl EmergencyAccess {
|
|||||||
grantee_email: &str,
|
grantee_email: &str,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::uuid.eq(uuid))
|
.filter(emergency_access::uuid.eq(uuid))
|
||||||
.filter(emergency_access::email.eq(grantee_email))
|
.filter(emergency_access::email.eq(grantee_email))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading emergency_access")
|
.expect("Error loading emergency_access")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option<Self> {
|
pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::email.eq(grantee_email))
|
.filter(emergency_access::email.eq(grantee_email))
|
||||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::email.eq(grantee_email))
|
.filter(emergency_access::email.eq(grantee_email))
|
||||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading emergency_access")
|
.expect("Error loading emergency_access")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading emergency_access")
|
.expect("Error loading emergency_access")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
emergency_access::table
|
emergency_access::table
|
||||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||||
.filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32))
|
.filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading emergency_access")
|
.expect("Error loading emergency_access")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
|
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
|
||||||
|
|||||||
+43
-29
@@ -1,11 +1,18 @@
|
|||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
//use derive_more::{AsRef, Deref, Display, From};
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
schema::{event, users_organizations},
|
||||||
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId};
|
use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId};
|
||||||
use crate::db::schema::{event, users_organizations};
|
|
||||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
|
|
||||||
// https://bitwarden.com/help/event-logs/
|
// https://bitwarden.com/help/event-logs/
|
||||||
|
|
||||||
@@ -249,11 +256,10 @@ impl Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(event::table.filter(event::uuid.eq(self.uuid)))
|
diesel::delete(event::table.filter(event::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting event")
|
||||||
.execute(conn)
|
})
|
||||||
.map_res("Error deleting event")
|
.await
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ##############
|
/// ##############
|
||||||
@@ -264,7 +270,7 @@ impl Event {
|
|||||||
end: &NaiveDateTime,
|
end: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
event::table
|
event::table
|
||||||
.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))
|
||||||
@@ -272,18 +278,15 @@ impl Event {
|
|||||||
.limit(Self::PAGE_SIZE)
|
.limit(Self::PAGE_SIZE)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error filtering events")
|
.expect("Error filtering events")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
event::table
|
event::table.filter(event::org_uuid.eq(org_uuid)).count().first::<i64>(conn).ok().unwrap_or(0)
|
||||||
.filter(event::org_uuid.eq(org_uuid))
|
})
|
||||||
.count()
|
.await
|
||||||
.first::<i64>(conn)
|
|
||||||
.ok()
|
|
||||||
.unwrap_or(0)
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_org_and_member(
|
pub async fn find_by_org_and_member(
|
||||||
@@ -293,18 +296,27 @@ impl Event {
|
|||||||
end: &NaiveDateTime,
|
end: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { 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(event::user_uuid.eq(users_organizations::user_uuid.nullable()).or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())))
|
.filter(
|
||||||
|
event::org_user_uuid
|
||||||
|
.eq(member_uuid)
|
||||||
|
.or(event::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)
|
||||||
.order_by(event::event_date.desc())
|
.order_by(event::event_date.desc())
|
||||||
.limit(Self::PAGE_SIZE)
|
.limit(Self::PAGE_SIZE)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error filtering events")
|
.expect("Error filtering events")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_cipher_uuid(
|
pub async fn find_by_cipher_uuid(
|
||||||
@@ -313,7 +325,7 @@ impl Event {
|
|||||||
end: &NaiveDateTime,
|
end: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
event::table
|
event::table
|
||||||
.filter(event::cipher_uuid.eq(cipher_uuid))
|
.filter(event::cipher_uuid.eq(cipher_uuid))
|
||||||
.filter(event::event_date.between(start, end))
|
.filter(event::event_date.between(start, end))
|
||||||
@@ -321,17 +333,19 @@ impl Event {
|
|||||||
.limit(Self::PAGE_SIZE)
|
.limit(Self::PAGE_SIZE)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error filtering events")
|
.expect("Error filtering events")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||||
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
||||||
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error cleaning old events")
|
.map_res("Error cleaning old events")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-30
@@ -1,7 +1,13 @@
|
|||||||
use super::{CipherId, User, UserId};
|
|
||||||
use crate::db::schema::favorites;
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{DbConn, schema::favorites},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{CipherId, User, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
#[diesel(table_name = favorites)]
|
#[diesel(table_name = favorites)]
|
||||||
#[diesel(primary_key(user_uuid, cipher_uuid))]
|
#[diesel(primary_key(user_uuid, cipher_uuid))]
|
||||||
@@ -10,24 +16,18 @@ pub struct Favorite {
|
|||||||
pub cipher_uuid: CipherId,
|
pub cipher_uuid: CipherId,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
impl Favorite {
|
impl Favorite {
|
||||||
// Returns whether the specified cipher is a favorite of the specified user.
|
// Returns whether the specified cipher is a favorite of the specified user.
|
||||||
pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
let query = favorites::table
|
let query = favorites::table
|
||||||
.filter(favorites::cipher_uuid.eq(cipher_uuid))
|
.filter(favorites::cipher_uuid.eq(cipher_uuid))
|
||||||
.filter(favorites::user_uuid.eq(user_uuid))
|
.filter(favorites::user_uuid.eq(user_uuid))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
query.first::<i64>(conn)
|
query.first::<i64>(conn).ok().unwrap_or(0) != 0
|
||||||
.ok()
|
})
|
||||||
.unwrap_or(0) != 0
|
.await
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets whether the specified cipher is a favorite of the specified user.
|
// Sets whether the specified cipher is a favorite of the specified user.
|
||||||
@@ -41,27 +41,26 @@ impl Favorite {
|
|||||||
match (old, new) {
|
match (old, new) {
|
||||||
(false, true) => {
|
(false, true) => {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::insert_into(favorites::table)
|
diesel::insert_into(favorites::table)
|
||||||
.values((
|
.values((favorites::user_uuid.eq(user_uuid), favorites::cipher_uuid.eq(cipher_uuid)))
|
||||||
favorites::user_uuid.eq(user_uuid),
|
.execute(conn)
|
||||||
favorites::cipher_uuid.eq(cipher_uuid),
|
.map_res("Error adding favorite")
|
||||||
))
|
})
|
||||||
.execute(conn)
|
.await
|
||||||
.map_res("Error adding favorite")
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
favorites::table
|
favorites::table
|
||||||
.filter(favorites::user_uuid.eq(user_uuid))
|
.filter(favorites::user_uuid.eq(user_uuid))
|
||||||
.filter(favorites::cipher_uuid.eq(cipher_uuid))
|
.filter(favorites::cipher_uuid.eq(cipher_uuid)),
|
||||||
)
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing favorite")
|
.map_res("Error removing favorite")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
// Otherwise, the favorite status is already what it should be.
|
// Otherwise, the favorite status is already what it should be.
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
@@ -70,31 +69,34 @@ impl Favorite {
|
|||||||
|
|
||||||
// Delete all favorite entries associated with the specified cipher.
|
// Delete all favorite entries associated with the specified cipher.
|
||||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid)))
|
diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing favorites by cipher")
|
.map_res("Error removing favorites by cipher")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all favorite entries associated with the specified user.
|
// Delete all favorite entries associated with the specified user.
|
||||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid)))
|
diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing favorites by user")
|
.map_res("Error removing favorites by user")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers
|
/// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers
|
||||||
/// This is used during a full sync so we only need one query for all favorite cipher matches.
|
/// This is used during a full sync so we only need one query for all favorite cipher matches.
|
||||||
pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<CipherId> {
|
pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<CipherId> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
favorites::table
|
favorites::table
|
||||||
.filter(favorites::user_uuid.eq(user_uuid))
|
.filter(favorites::user_uuid.eq(user_uuid))
|
||||||
.select(favorites::cipher_uuid)
|
.select(favorites::cipher_uuid)
|
||||||
.load::<CipherId>(conn)
|
.load::<CipherId>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-31
@@ -1,12 +1,20 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::{CipherId, User, UserId};
|
use crate::{
|
||||||
use crate::db::schema::{folders, folders_ciphers};
|
api::EmptyResult,
|
||||||
use diesel::prelude::*;
|
db::{
|
||||||
|
DbConn,
|
||||||
|
schema::{folders, folders_ciphers},
|
||||||
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
|
use super::{CipherId, User, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = folders)]
|
#[diesel(table_name = folders)]
|
||||||
#[diesel(primary_key(uuid))]
|
#[diesel(primary_key(uuid))]
|
||||||
@@ -56,17 +64,12 @@ impl Folder {
|
|||||||
impl FolderCipher {
|
impl FolderCipher {
|
||||||
pub fn new(folder_uuid: FolderId, cipher_uuid: CipherId) -> Self {
|
pub fn new(folder_uuid: FolderId, cipher_uuid: CipherId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
folder_uuid,
|
|
||||||
cipher_uuid,
|
cipher_uuid,
|
||||||
|
folder_uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl Folder {
|
impl Folder {
|
||||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -107,11 +110,12 @@ impl Folder {
|
|||||||
User::update_uuid_revision(&self.user_uuid, conn).await;
|
User::update_uuid_revision(&self.user_uuid, conn).await;
|
||||||
FolderCipher::delete_all_by_folder(&self.uuid, conn).await?;
|
FolderCipher::delete_all_by_folder(&self.uuid, conn).await?;
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid)))
|
diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting folder")
|
.map_res("Error deleting folder")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -122,22 +126,21 @@ impl Folder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_user(uuid: &FolderId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid_and_user(uuid: &FolderId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
folders::table
|
folders::table
|
||||||
.filter(folders::uuid.eq(uuid))
|
.filter(folders::uuid.eq(uuid))
|
||||||
.filter(folders::user_uuid.eq(user_uuid))
|
.filter(folders::user_uuid.eq(user_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
folders::table
|
folders::table.filter(folders::user_uuid.eq(user_uuid)).load::<Self>(conn).expect("Error loading folders")
|
||||||
.filter(folders::user_uuid.eq(user_uuid))
|
})
|
||||||
.load::<Self>(conn)
|
.await
|
||||||
.expect("Error loading folders")
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +168,7 @@ impl FolderCipher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
folders_ciphers::table
|
folders_ciphers::table
|
||||||
.filter(folders_ciphers::cipher_uuid.eq(self.cipher_uuid))
|
.filter(folders_ciphers::cipher_uuid.eq(self.cipher_uuid))
|
||||||
@@ -173,23 +176,26 @@ impl FolderCipher {
|
|||||||
)
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing cipher from folder")
|
.map_res("Error removing cipher from folder")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)))
|
diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing cipher from folders")
|
.map_res("Error removing cipher from folders")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid)))
|
diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing ciphers from folder")
|
.map_res("Error removing ciphers from folder")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_folder_and_cipher(
|
pub async fn find_by_folder_and_cipher(
|
||||||
@@ -197,35 +203,38 @@ impl FolderCipher {
|
|||||||
cipher_uuid: &CipherId,
|
cipher_uuid: &CipherId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
folders_ciphers::table
|
folders_ciphers::table
|
||||||
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
||||||
.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))
|
.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
folders_ciphers::table
|
folders_ciphers::table
|
||||||
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading folders")
|
.expect("Error loading folders")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a vec with (cipher_uuid, folder_uuid)
|
/// Return a vec with (cipher_uuid, folder_uuid)
|
||||||
/// This is used during a full sync so we only need one query for all folder matches.
|
/// This is used during a full sync so we only need one query for all folder matches.
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, FolderId)> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, FolderId)> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
folders_ciphers::table
|
folders_ciphers::table
|
||||||
.inner_join(folders::table)
|
.inner_join(folders::table)
|
||||||
.filter(folders::user_uuid.eq(user_uuid))
|
.filter(folders::user_uuid.eq(user_uuid))
|
||||||
.select(folders_ciphers::all_columns)
|
.select(folders_ciphers::all_columns)
|
||||||
.load::<(CipherId, FolderId)>(conn)
|
.load::<(CipherId, FolderId)>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+145
-122
@@ -1,14 +1,20 @@
|
|||||||
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations};
|
|
||||||
use crate::db::DbConn;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use macros::UuidFromParam;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
schema::{collections, collections_groups, groups, groups_users, users_organizations},
|
||||||
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
|
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = groups)]
|
#[diesel(table_name = groups)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
@@ -197,33 +203,31 @@ impl Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups::table
|
groups::table
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading groups")
|
.expect("Error loading groups")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups::table
|
groups::table.filter(groups::organizations_uuid.eq(org_uuid)).count().first::<i64>(conn).ok().unwrap_or(0)
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
})
|
||||||
.count()
|
.await
|
||||||
.first::<i64>(conn)
|
|
||||||
.ok()
|
|
||||||
.unwrap_or(0)
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_org(uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid_and_org(uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups::table
|
groups::table
|
||||||
.filter(groups::uuid.eq(uuid))
|
.filter(groups::uuid.eq(uuid))
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_external_id_and_org(
|
pub async fn find_by_external_id_and_org(
|
||||||
@@ -231,77 +235,87 @@ impl Group {
|
|||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups::table
|
groups::table
|
||||||
.filter(groups::external_id.eq(external_id))
|
.filter(groups::external_id.eq(external_id))
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
//Returns all organizations the user has full access to
|
//Returns all organizations the user has full access to
|
||||||
pub async fn get_orgs_by_user_with_full_access(user_uuid: &UserId, conn: &DbConn) -> Vec<OrganizationId> {
|
pub async fn get_orgs_by_user_with_full_access(user_uuid: &UserId, conn: &DbConn) -> Vec<OrganizationId> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups_users::table
|
groups_users::table
|
||||||
.inner_join(users_organizations::table.on(
|
.inner_join(
|
||||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
|
||||||
))
|
)
|
||||||
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
.inner_join(
|
||||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
groups::table.on(groups::uuid
|
||||||
))
|
.eq(groups_users::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||||
|
)
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(groups::access_all.eq(true))
|
.filter(groups::access_all.eq(true))
|
||||||
.select(groups::organizations_uuid)
|
.select(groups::organizations_uuid)
|
||||||
.distinct()
|
.distinct()
|
||||||
.load::<OrganizationId>(conn)
|
.load::<OrganizationId>(conn)
|
||||||
.expect("Error loading organization group full access information for user")
|
.expect("Error loading organization group full access information for user")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool {
|
pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups::table
|
groups::table
|
||||||
.inner_join(groups_users::table.on(
|
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
|
||||||
groups_users::groups_uuid.eq(groups::uuid)
|
.inner_join(
|
||||||
))
|
users_organizations::table.on(users_organizations::uuid
|
||||||
.inner_join(users_organizations::table.on(
|
.eq(groups_users::users_organizations_uuid)
|
||||||
users_organizations::uuid.eq(groups_users::users_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))
|
||||||
.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)
|
||||||
.first::<bool>(conn)
|
.first::<bool>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
||||||
GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
|
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting group")
|
.map_res("Error deleting group")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_revision(uuid: &GroupId, conn: &DbConn) {
|
pub async fn update_revision(uuid: &GroupId, conn: &DbConn) {
|
||||||
if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await {
|
if let Err(e) = Self::update_revision_impl(uuid, &Utc::now().naive_utc(), conn).await {
|
||||||
warn!("Failed to update revision for {uuid}: {e:#?}");
|
warn!("Failed to update revision for {uuid}: {e:#?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _update_revision(uuid: &GroupId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
async fn update_revision_impl(uuid: &GroupId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
crate::util::retry(|| {
|
crate::util::retry(
|
||||||
diesel::update(groups::table.filter(groups::uuid.eq(uuid)))
|
|| {
|
||||||
.set(groups::revision_date.eq(date))
|
diesel::update(groups::table.filter(groups::uuid.eq(uuid)))
|
||||||
.execute(conn)
|
.set(groups::revision_date.eq(date))
|
||||||
}, 10)
|
.execute(conn)
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
.map_res("Error updating group revision")
|
.map_res("Error updating group revision")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,60 +380,63 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
collections_groups::table
|
collections_groups::table
|
||||||
.inner_join(groups::table.on(
|
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)))
|
||||||
groups::uuid.eq(collections_groups::groups_uuid)
|
.inner_join(
|
||||||
))
|
collections::table.on(collections::uuid
|
||||||
.inner_join(collections::table.on(
|
.eq(collections_groups::collections_uuid)
|
||||||
collections::uuid.eq(collections_groups::collections_uuid)
|
.and(collections::org_uuid.eq(groups::organizations_uuid))),
|
||||||
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
)
|
||||||
))
|
|
||||||
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
||||||
.filter(collections::org_uuid.eq(org_uuid))
|
.filter(collections::org_uuid.eq(org_uuid))
|
||||||
.select(collections_groups::all_columns)
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading collection groups")
|
.expect("Error loading collection groups")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
collections_groups::table
|
collections_groups::table
|
||||||
.inner_join(groups_users::table.on(
|
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid)))
|
||||||
groups_users::groups_uuid.eq(collections_groups::groups_uuid)
|
.inner_join(
|
||||||
))
|
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
|
||||||
.inner_join(users_organizations::table.on(
|
)
|
||||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
.inner_join(
|
||||||
))
|
groups::table.on(groups::uuid
|
||||||
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
.eq(collections_groups::groups_uuid)
|
||||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||||
))
|
)
|
||||||
.inner_join(collections::table.on(
|
.inner_join(
|
||||||
collections::uuid.eq(collections_groups::collections_uuid)
|
collections::table.on(collections::uuid
|
||||||
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
.eq(collections_groups::collections_uuid)
|
||||||
))
|
.and(collections::org_uuid.eq(groups::organizations_uuid))),
|
||||||
|
)
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.select(collections_groups::all_columns)
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user collection groups")
|
.expect("Error loading user collection groups")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
collections_groups::table
|
collections_groups::table
|
||||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||||
.inner_join(collections::table.on(
|
.inner_join(collections::table.on(collections::uuid.eq(collections_groups::collections_uuid)))
|
||||||
collections::uuid.eq(collections_groups::collections_uuid)
|
.inner_join(
|
||||||
))
|
groups::table.on(groups::uuid
|
||||||
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
.eq(collections_groups::groups_uuid)
|
||||||
.and(groups::organizations_uuid.eq(collections::org_uuid))
|
.and(groups::organizations_uuid.eq(collections::org_uuid))),
|
||||||
))
|
)
|
||||||
.select(collections_groups::all_columns)
|
.select(collections_groups::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading collection groups")
|
.expect("Error loading collection groups")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -428,13 +445,14 @@ impl CollectionGroup {
|
|||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(collections_groups::table)
|
diesel::delete(collections_groups::table)
|
||||||
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
|
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
|
||||||
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
|
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting collection group")
|
.map_res("Error deleting collection group")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -443,12 +461,13 @@ impl CollectionGroup {
|
|||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(collections_groups::table)
|
diesel::delete(collections_groups::table)
|
||||||
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting collection group")
|
.map_res("Error deleting collection group")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_collection(
|
pub async fn delete_all_by_collection(
|
||||||
@@ -464,12 +483,13 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(collections_groups::table)
|
diesel::delete(collections_groups::table)
|
||||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting collection group")
|
.map_res("Error deleting collection group")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,30 +541,31 @@ impl GroupUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups_users::table
|
groups_users::table
|
||||||
.inner_join(groups::table.on(
|
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.inner_join(
|
||||||
))
|
users_organizations::table.on(users_organizations::uuid
|
||||||
.inner_join(users_organizations::table.on(
|
.eq(groups_users::users_organizations_uuid)
|
||||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
|
||||||
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))
|
)
|
||||||
))
|
|
||||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||||
.select(groups_users::all_columns)
|
.select(groups_users::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading group users")
|
.expect("Error loading group users")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_member(member_uuid: &MembershipId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_member(member_uuid: &MembershipId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups_users::table
|
groups_users::table
|
||||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading groups for user")
|
.expect("Error loading groups for user")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has_access_to_collection_by_member(
|
pub async fn has_access_to_collection_by_member(
|
||||||
@@ -552,24 +573,23 @@ impl GroupUser {
|
|||||||
member_uuid: &MembershipId,
|
member_uuid: &MembershipId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups_users::table
|
groups_users::table
|
||||||
.inner_join(collections_groups::table.on(
|
.inner_join(collections_groups::table.on(collections_groups::groups_uuid.eq(groups_users::groups_uuid)))
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
||||||
))
|
.inner_join(
|
||||||
.inner_join(groups::table.on(
|
collections::table.on(collections::uuid
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
.eq(collections_groups::collections_uuid)
|
||||||
))
|
.and(collections::org_uuid.eq(groups::organizations_uuid))),
|
||||||
.inner_join(collections::table.on(
|
)
|
||||||
collections::uuid.eq(collections_groups::collections_uuid)
|
|
||||||
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
|
||||||
))
|
|
||||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
.unwrap_or(0) != 0
|
.unwrap_or(0)
|
||||||
}}
|
!= 0
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has_full_access_by_member(
|
pub async fn has_full_access_by_member(
|
||||||
@@ -577,18 +597,18 @@ impl GroupUser {
|
|||||||
member_uuid: &MembershipId,
|
member_uuid: &MembershipId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
groups_users::table
|
groups_users::table
|
||||||
.inner_join(groups::table.on(
|
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
||||||
groups::uuid.eq(groups_users::groups_uuid)
|
|
||||||
))
|
|
||||||
.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))
|
||||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
.unwrap_or(0) != 0
|
.unwrap_or(0)
|
||||||
}}
|
!= 0
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_user_revision(&self, conn: &DbConn) {
|
pub async fn update_user_revision(&self, conn: &DbConn) {
|
||||||
@@ -606,15 +626,16 @@ impl GroupUser {
|
|||||||
match Membership::find_by_uuid(member_uuid, conn).await {
|
match Membership::find_by_uuid(member_uuid, conn).await {
|
||||||
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
|
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
|
||||||
None => warn!("Member could not be found!"),
|
None => warn!("Member could not be found!"),
|
||||||
};
|
}
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(groups_users::table)
|
diesel::delete(groups_users::table)
|
||||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting group users")
|
.map_res("Error deleting group users")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -623,12 +644,13 @@ impl GroupUser {
|
|||||||
group_user.update_user_revision(conn).await;
|
group_user.update_user_revision(conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(groups_users::table)
|
diesel::delete(groups_users::table)
|
||||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting group users")
|
.map_res("Error deleting group users")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_member(member_uuid: &MembershipId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_member(member_uuid: &MembershipId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -637,12 +659,13 @@ impl GroupUser {
|
|||||||
None => warn!("Member could not be found!"),
|
None => warn!("Member could not be found!"),
|
||||||
}
|
}
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(groups_users::table)
|
diesel::delete(groups_users::table)
|
||||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting user groups")
|
.map_res("Error deleting user groups")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ mod two_factor;
|
|||||||
mod two_factor_duo_context;
|
mod two_factor_duo_context;
|
||||||
mod two_factor_incomplete;
|
mod two_factor_incomplete;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod user_signature_key_pair;
|
||||||
|
|
||||||
pub use self::archive::Archive;
|
pub use self::archive::Archive;
|
||||||
pub use self::attachment::{Attachment, AttachmentId};
|
pub use self::attachment::{Attachment, AttachmentId};
|
||||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||||
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
||||||
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||||
pub use self::device::{Device, DeviceId, DeviceType, PushId};
|
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
||||||
pub use self::event::{Event, EventType};
|
pub use self::event::{Event, EventType};
|
||||||
pub use self::favorite::Favorite;
|
pub use self::favorite::Favorite;
|
||||||
@@ -34,12 +35,10 @@ pub use self::organization::{
|
|||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||||
OrganizationId,
|
OrganizationId,
|
||||||
};
|
};
|
||||||
pub use self::send::{
|
pub use self::send::{Send, SendFileId, SendId, SendType};
|
||||||
id::{SendFileId, SendId},
|
|
||||||
Send, SendType,
|
|
||||||
};
|
|
||||||
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
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, SsoUser, User, UserId, UserKdfType, UserStampException};
|
||||||
|
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
|
||||||
|
|||||||
+74
-70
@@ -1,14 +1,17 @@
|
|||||||
use derive_more::{AsRef, From};
|
use derive_more::{AsRef, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::api::core::two_factor;
|
use crate::{
|
||||||
use crate::api::EmptyResult;
|
CONFIG,
|
||||||
use crate::db::schema::{org_policies, users_organizations};
|
api::{EmptyResult, core::two_factor},
|
||||||
use crate::db::DbConn;
|
db::{
|
||||||
use crate::error::MapResult;
|
DbConn,
|
||||||
use crate::CONFIG;
|
schema::{org_policies, users_organizations},
|
||||||
use diesel::prelude::*;
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
||||||
|
|
||||||
@@ -148,37 +151,38 @@ impl OrgPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(org_policies::table.filter(org_policies::uuid.eq(self.uuid)))
|
diesel::delete(org_policies::table.filter(org_policies::uuid.eq(self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting org_policy")
|
.map_res("Error deleting org_policy")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
org_policies::table
|
org_policies::table
|
||||||
.filter(org_policies::org_uuid.eq(org_uuid))
|
.filter(org_policies::org_uuid.eq(org_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading org_policy")
|
.expect("Error loading org_policy")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
org_policies::table
|
org_policies::table
|
||||||
.inner_join(
|
.inner_join(
|
||||||
users_organizations::table.on(
|
users_organizations::table.on(users_organizations::org_uuid
|
||||||
users_organizations::org_uuid.eq(org_policies::org_uuid)
|
.eq(org_policies::org_uuid)
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.select(org_policies::all_columns)
|
.select(org_policies::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading org_policy")
|
.expect("Error loading org_policy")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_org_and_type(
|
pub async fn find_by_org_and_type(
|
||||||
@@ -186,21 +190,23 @@ impl OrgPolicy {
|
|||||||
policy_type: OrgPolicyType,
|
policy_type: OrgPolicyType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
org_policies::table
|
org_policies::table
|
||||||
.filter(org_policies::org_uuid.eq(org_uuid))
|
.filter(org_policies::org_uuid.eq(org_uuid))
|
||||||
.filter(org_policies::atype.eq(policy_type as i32))
|
.filter(org_policies::atype.eq(policy_type as i32))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid)))
|
diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting org_policy")
|
.map_res("Error deleting org_policy")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_accepted_and_confirmed_by_user_and_active_policy(
|
pub async fn find_accepted_and_confirmed_by_user_and_active_policy(
|
||||||
@@ -208,25 +214,22 @@ impl OrgPolicy {
|
|||||||
policy_type: OrgPolicyType,
|
policy_type: OrgPolicyType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
org_policies::table
|
org_policies::table
|
||||||
.inner_join(
|
.inner_join(
|
||||||
users_organizations::table.on(
|
users_organizations::table.on(users_organizations::org_uuid
|
||||||
users_organizations::org_uuid.eq(org_policies::org_uuid)
|
.eq(org_policies::org_uuid)
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
users_organizations::status.eq(MembershipStatus::Accepted as i32)
|
|
||||||
)
|
|
||||||
.or_filter(
|
|
||||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Accepted as i32))
|
||||||
|
.or_filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(org_policies::atype.eq(policy_type as i32))
|
.filter(org_policies::atype.eq(policy_type as i32))
|
||||||
.filter(org_policies::enabled.eq(true))
|
.filter(org_policies::enabled.eq(true))
|
||||||
.select(org_policies::all_columns)
|
.select(org_policies::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading org_policy")
|
.expect("Error loading org_policy")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_confirmed_by_user_and_active_policy(
|
pub async fn find_confirmed_by_user_and_active_policy(
|
||||||
@@ -234,22 +237,21 @@ impl OrgPolicy {
|
|||||||
policy_type: OrgPolicyType,
|
policy_type: OrgPolicyType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
org_policies::table
|
org_policies::table
|
||||||
.inner_join(
|
.inner_join(
|
||||||
users_organizations::table.on(
|
users_organizations::table.on(users_organizations::org_uuid
|
||||||
users_organizations::org_uuid.eq(org_policies::org_uuid)
|
.eq(org_policies::org_uuid)
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.filter(org_policies::atype.eq(policy_type as i32))
|
.filter(org_policies::atype.eq(policy_type as i32))
|
||||||
.filter(org_policies::enabled.eq(true))
|
.filter(org_policies::enabled.eq(true))
|
||||||
.select(org_policies::all_columns)
|
.select(org_policies::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading org_policy")
|
.expect("Error loading org_policy")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the user belongs to an org that has enabled the specified policy type,
|
/// Returns true if the user belongs to an org that has enabled the specified policy type,
|
||||||
@@ -269,10 +271,10 @@ impl OrgPolicy {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await
|
||||||
if user.atype < MembershipType::Admin {
|
&& user.atype < MembershipType::Admin
|
||||||
return true;
|
{
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -282,13 +284,13 @@ impl OrgPolicy {
|
|||||||
if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) {
|
if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) {
|
||||||
// Enforce TwoFactor/TwoStep login
|
// Enforce TwoFactor/TwoStep login
|
||||||
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await
|
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await
|
||||||
|
&& p.enabled
|
||||||
|
&& TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty()
|
||||||
{
|
{
|
||||||
if p.enabled && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() {
|
if CONFIG.email_2fa_auto_fallback() {
|
||||||
if CONFIG.email_2fa_auto_fallback() {
|
two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?;
|
||||||
two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?;
|
} else {
|
||||||
} else {
|
err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid));
|
||||||
err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +302,14 @@ impl OrgPolicy {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await {
|
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await
|
||||||
if p.enabled
|
&& p.enabled
|
||||||
&& Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0
|
&& Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0
|
||||||
{
|
{
|
||||||
err!(format!("Cannot {} because the organization policy forbids being part of other organization (membership {})", action, m.uuid));
|
err!(format!(
|
||||||
}
|
"Cannot {} because the organization policy forbids being part of other organization (membership {})",
|
||||||
|
action, m.uuid
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,16 +336,16 @@ impl OrgPolicy {
|
|||||||
for policy in
|
for policy in
|
||||||
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
|
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
|
||||||
{
|
{
|
||||||
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await
|
||||||
if user.atype < MembershipType::Admin {
|
&& user.atype < MembershipType::Admin
|
||||||
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
|
{
|
||||||
Ok(opts) => {
|
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
|
||||||
if opts.disable_hide_email {
|
Ok(opts) => {
|
||||||
return true;
|
if opts.disable_hide_email {
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
_ => error!("Failed to deserialize SendOptionsPolicyData: {}", policy.data),
|
|
||||||
}
|
}
|
||||||
|
_ => error!("Failed to deserialize SendOptionsPolicyData: {}", policy.data),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,10 +353,10 @@ impl OrgPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool {
|
pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool {
|
||||||
if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await {
|
if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await
|
||||||
if let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await {
|
&& let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await
|
||||||
return policy.enabled;
|
{
|
||||||
}
|
return policy.enabled;
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-187
@@ -1,23 +1,32 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use num_traits::FromPrimitive;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::{
|
use std::{
|
||||||
cmp::Ordering,
|
cmp::Ordering,
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use num_traits::FromPrimitive;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
schema::{
|
||||||
|
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key,
|
||||||
|
organizations, users, users_collections, users_organizations,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error::MapResult,
|
||||||
|
};
|
||||||
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
|
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
|
||||||
OrgPolicyType, TwoFactor, User, UserId,
|
OrgPolicyType, TwoFactor, User, UserId,
|
||||||
};
|
};
|
||||||
use crate::db::schema::{
|
|
||||||
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key,
|
|
||||||
organizations, users, users_collections, users_organizations,
|
|
||||||
};
|
|
||||||
use crate::CONFIG;
|
|
||||||
use macros::UuidFromParam;
|
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = organizations)]
|
#[diesel(table_name = organizations)]
|
||||||
@@ -93,6 +102,10 @@ pub enum MembershipType {
|
|||||||
|
|
||||||
impl MembershipType {
|
impl MembershipType {
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
#[expect(
|
||||||
|
clippy::match_same_arms,
|
||||||
|
reason = "Specifically define `4|Custom` since this is a hack, not a default"
|
||||||
|
)]
|
||||||
match s {
|
match s {
|
||||||
"0" | "Owner" => Some(MembershipType::Owner),
|
"0" | "Owner" => Some(MembershipType::Owner),
|
||||||
"1" | "Admin" => Some(MembershipType::Admin),
|
"1" | "Admin" => Some(MembershipType::Admin),
|
||||||
@@ -204,11 +217,18 @@ impl Organization {
|
|||||||
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
|
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
|
||||||
"selfHost": true,
|
"selfHost": true,
|
||||||
"useApi": true,
|
"useApi": true,
|
||||||
|
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
|
||||||
|
"useInviteLinks": false, // Not (yet) supported
|
||||||
|
"useMyItems": false, // Not (yet) supported
|
||||||
|
"useOrganizationDomains": false, // Not supported (Linked to SSO)
|
||||||
|
"usePam": false, // Not supported
|
||||||
|
"usePhishingBlocker": false,
|
||||||
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
|
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
|
||||||
"useResetPassword": CONFIG.mail_enabled(),
|
"useResetPassword": CONFIG.mail_enabled(),
|
||||||
"allowAdminAccessToAllCollectionItems": true,
|
"allowAdminAccessToAllCollectionItems": true,
|
||||||
"limitCollectionCreation": true,
|
"limitCollectionCreation": true,
|
||||||
"limitCollectionDeletion": true,
|
"limitCollectionDeletion": true,
|
||||||
|
"limitItemDeletion": false,
|
||||||
|
|
||||||
"businessName": self.name,
|
"businessName": self.name,
|
||||||
"businessAddress1": null,
|
"businessAddress1": null,
|
||||||
@@ -321,11 +341,6 @@ impl OrganizationApiKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::db::DbConn;
|
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
|
||||||
use crate::error::MapResult;
|
|
||||||
|
|
||||||
/// Database methods
|
/// Database methods
|
||||||
impl Organization {
|
impl Organization {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -333,7 +348,7 @@ impl Organization {
|
|||||||
err!(format!("BillingEmail {} is not a valid email address", self.billing_email))
|
err!(format!("BillingEmail {} is not a valid email address", self.billing_email))
|
||||||
}
|
}
|
||||||
|
|
||||||
for member in Membership::find_by_org(&self.uuid, conn).await.iter() {
|
for member in &Membership::find_by_org(&self.uuid, conn).await {
|
||||||
User::update_uuid_revision(&member.user_uuid, conn).await;
|
User::update_uuid_revision(&member.user_uuid, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +384,6 @@ impl Organization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
use super::{Cipher, Collection};
|
|
||||||
|
|
||||||
Cipher::delete_all_by_organization(&self.uuid, conn).await?;
|
Cipher::delete_all_by_organization(&self.uuid, conn).await?;
|
||||||
Collection::delete_all_by_organization(&self.uuid, conn).await?;
|
Collection::delete_all_by_organization(&self.uuid, conn).await?;
|
||||||
Membership::delete_all_by_organization(&self.uuid, conn).await?;
|
Membership::delete_all_by_organization(&self.uuid, conn).await?;
|
||||||
@@ -378,43 +391,30 @@ impl Organization {
|
|||||||
Group::delete_all_by_organization(&self.uuid, conn).await?;
|
Group::delete_all_by_organization(&self.uuid, conn).await?;
|
||||||
OrganizationApiKey::delete_all_by_organization(&self.uuid, conn).await?;
|
OrganizationApiKey::delete_all_by_organization(&self.uuid, conn).await?;
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid)))
|
diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving organization")
|
.map_res("Error saving organization")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| organizations::table.filter(organizations::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||||
organizations::table
|
|
||||||
.filter(organizations::uuid.eq(uuid))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_name(name: &str, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_name(name: &str, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| organizations::table.filter(organizations::name.eq(name)).first::<Self>(conn).ok()).await
|
||||||
organizations::table
|
|
||||||
.filter(organizations::name.eq(name))
|
|
||||||
.first::<Self>(conn)
|
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all(conn: &DbConn) -> Vec<Self> {
|
pub async fn get_all(conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| organizations::table.load::<Self>(conn).expect("Error loading organizations")).await
|
||||||
organizations::table
|
|
||||||
.load::<Self>(conn)
|
|
||||||
.expect("Error loading organizations")
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_main_org_user_email(user_email: &str, conn: &DbConn) -> Option<Self> {
|
pub async fn find_main_org_user_email(user_email: &str, conn: &DbConn) -> Option<Self> {
|
||||||
let lower_mail = user_email.to_lowercase();
|
let lower_mail = user_email.to_lowercase();
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
organizations::table
|
organizations::table
|
||||||
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
|
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
|
||||||
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
|
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
|
||||||
@@ -424,13 +424,14 @@ impl Organization {
|
|||||||
.select(organizations::all_columns)
|
.select(organizations::all_columns)
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_org_user_email(user_email: &str, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_org_user_email(user_email: &str, conn: &DbConn) -> Vec<Self> {
|
||||||
let lower_mail = user_email.to_lowercase();
|
let lower_mail = user_email.to_lowercase();
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
organizations::table
|
organizations::table
|
||||||
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
|
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
|
||||||
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
|
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
|
||||||
@@ -440,7 +441,8 @@ impl Organization {
|
|||||||
.select(organizations::all_columns)
|
.select(organizations::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user orgs")
|
.expect("Error loading user orgs")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +502,12 @@ impl Membership {
|
|||||||
"useActivateAutofillPolicy": false,
|
"useActivateAutofillPolicy": false,
|
||||||
"useAdminSponsoredFamilies": false,
|
"useAdminSponsoredFamilies": false,
|
||||||
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
|
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
|
||||||
|
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
|
||||||
|
"useInviteLinks": false, // Not (yet) supported
|
||||||
|
"useMyItems": false, // Not (yet) supported
|
||||||
|
"useOrganizationDomains": false, // Not supported (Linked to SSO)
|
||||||
|
"usePam": false, // Not supported
|
||||||
|
"usePhishingBlocker": false,
|
||||||
|
|
||||||
"organizationUserId": self.uuid,
|
"organizationUserId": self.uuid,
|
||||||
"providerId": null,
|
"providerId": null,
|
||||||
@@ -555,7 +563,7 @@ impl Membership {
|
|||||||
} else {
|
} else {
|
||||||
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
|
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
|
||||||
// so just act as if there are no groups.
|
// so just act as if there are no groups.
|
||||||
Vec::with_capacity(0)
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
@@ -609,7 +617,7 @@ impl Membership {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::with_capacity(0)
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// HACK: Convert the manager type to a custom type
|
// HACK: Convert the manager type to a custom type
|
||||||
@@ -780,11 +788,12 @@ impl Membership {
|
|||||||
CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?;
|
CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?;
|
||||||
GroupUser::delete_all_by_member(&self.uuid, conn).await?;
|
GroupUser::delete_all_by_member(&self.uuid, conn).await?;
|
||||||
|
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid)))
|
diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing user from organization")
|
.map_res("Error removing user from organization")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
@@ -802,10 +811,10 @@ impl Membership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_email_and_org(email: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Membership> {
|
pub async fn find_by_email_and_org(email: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Membership> {
|
||||||
if let Some(user) = User::find_by_mail(email, conn).await {
|
if let Some(user) = User::find_by_mail(email, conn).await
|
||||||
if let Some(member) = Membership::find_by_user_and_org(&user.uuid, org_uuid, conn).await {
|
&& let Some(member) = Membership::find_by_user_and_org(&user.uuid, org_uuid, conn).await
|
||||||
return Some(member);
|
{
|
||||||
}
|
return Some(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -824,64 +833,67 @@ impl Membership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
||||||
.filter(users_organizations::uuid.eq(uuid))
|
})
|
||||||
.first::<Self>(conn)
|
.await
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid_and_org(uuid: &MembershipId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid_and_org(uuid: &MembershipId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::uuid.eq(uuid))
|
.filter(users_organizations::uuid.eq(uuid))
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.unwrap_or_default()
|
.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> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Invited as i32))
|
.filter(users_organizations::status.eq(MembershipStatus::Invited as i32))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be used only when email are disabled.
|
// Should be used only when email are disabled.
|
||||||
// In Organizations::send_invite status is set to Accepted only if the user has a password.
|
// In Organizations::send_invite status is set to Accepted only if the user has a password.
|
||||||
pub async fn accept_user_invitations(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
pub async fn accept_user_invitations(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::update(users_organizations::table)
|
diesel::update(users_organizations::table)
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Invited as i32))
|
.filter(users_organizations::status.eq(MembershipStatus::Invited as i32))
|
||||||
.set(users_organizations::status.eq(MembershipStatus::Accepted as i32))
|
.set(users_organizations::status.eq(MembershipStatus::Accepted as i32))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error confirming invitations")
|
.map_res("Error confirming invitations")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_accepted_and_confirmed_by_user(
|
pub async fn count_accepted_and_confirmed_by_user(
|
||||||
@@ -889,70 +901,83 @@ impl Membership {
|
|||||||
excluded_org: &OrganizationId,
|
excluded_org: &OrganizationId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> i64 {
|
) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::org_uuid.ne(excluded_org))
|
.filter(users_organizations::org_uuid.ne(excluded_org))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)))
|
.filter(
|
||||||
|
users_organizations::status
|
||||||
|
.eq(MembershipStatus::Accepted as i32)
|
||||||
|
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
|
||||||
|
)
|
||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user organizations")
|
.expect("Error loading user organizations")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_confirmed_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_confirmed_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all users which are either owner or admin, or a manager which can manage/access all
|
// Get all users which are either owner or admin, or a manager which can manage/access all
|
||||||
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
.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
|
||||||
.or(users_organizations::atype.eq(MembershipType::Manager as i32).and(users_organizations::access_all.eq(true)))
|
.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])
|
||||||
|
.or(users_organizations::atype
|
||||||
|
.eq(MembershipType::Manager as i32)
|
||||||
|
.and(users_organizations::access_all.eq(true))),
|
||||||
)
|
)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_org_and_type(org_uuid: &OrganizationId, atype: MembershipType, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_org_and_type(org_uuid: &OrganizationId, atype: MembershipType, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.filter(users_organizations::atype.eq(atype as i32))
|
.filter(users_organizations::atype.eq(atype as i32))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user organizations")
|
.expect("Error loading user organizations")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count_confirmed_by_org_and_type(
|
pub async fn count_confirmed_by_org_and_type(
|
||||||
@@ -960,7 +985,7 @@ impl Membership {
|
|||||||
atype: MembershipType,
|
atype: MembershipType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> i64 {
|
) -> i64 {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.filter(users_organizations::atype.eq(atype as i32))
|
.filter(users_organizations::atype.eq(atype as i32))
|
||||||
@@ -968,17 +993,19 @@ impl Membership {
|
|||||||
.count()
|
.count()
|
||||||
.first::<i64>(conn)
|
.first::<i64>(conn)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user_and_org(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_user_and_org(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_confirmed_by_user_and_org(
|
pub async fn find_confirmed_by_user_and_org(
|
||||||
@@ -986,78 +1013,76 @@ impl Membership {
|
|||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.filter(
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
|
||||||
)
|
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user organizations")
|
.expect("Error loading user organizations")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<OrganizationId> {
|
pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<OrganizationId> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.select(users_organizations::org_uuid)
|
.select(users_organizations::org_uuid)
|
||||||
.load::<OrganizationId>(conn)
|
.load::<OrganizationId>(conn)
|
||||||
.unwrap_or_default()
|
.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> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.inner_join(
|
.inner_join(
|
||||||
org_policies::table.on(
|
org_policies::table.on(org_policies::org_uuid
|
||||||
org_policies::org_uuid.eq(users_organizations::org_uuid)
|
.eq(users_organizations::org_uuid)
|
||||||
.and(users_organizations::user_uuid.eq(user_uuid))
|
.and(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.and(org_policies::atype.eq(policy_type as i32))
|
.and(org_policies::atype.eq(policy_type as i32))
|
||||||
.and(org_policies::enabled.eq(true)))
|
.and(org_policies::enabled.eq(true))),
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
|
||||||
)
|
)
|
||||||
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
.select(users_organizations::all_columns)
|
.select(users_organizations::all_columns)
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_cipher_and_org(cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
pub async fn find_by_cipher_and_org(cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.left_join(users_collections::table.on(
|
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
|
||||||
users_collections::user_uuid.eq(users_organizations::user_uuid)
|
.left_join(
|
||||||
))
|
ciphers_collections::table.on(ciphers_collections::collection_uuid
|
||||||
.left_join(ciphers_collections::table.on(
|
.eq(users_collections::collection_uuid)
|
||||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid).and(
|
.and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))),
|
||||||
ciphers_collections::cipher_uuid.eq(&cipher_uuid)
|
|
||||||
)
|
)
|
||||||
))
|
.filter(users_organizations::access_all.eq(true).or(
|
||||||
.filter(
|
// AccessAll..
|
||||||
users_organizations::access_all.eq(true).or( // AccessAll..
|
ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection with cipher
|
||||||
ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection with cipher
|
))
|
||||||
)
|
.select(users_organizations::all_columns)
|
||||||
)
|
.distinct()
|
||||||
.select(users_organizations::all_columns)
|
.load::<Self>(conn)
|
||||||
.distinct()
|
.expect("Error loading user organizations")
|
||||||
.load::<Self>(conn)
|
})
|
||||||
.expect("Error loading user organizations")
|
.await
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_cipher_and_org_with_group(
|
pub async fn find_by_cipher_and_org_with_group(
|
||||||
@@ -1065,45 +1090,54 @@ impl Membership {
|
|||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.inner_join(groups_users::table.on(
|
.inner_join(
|
||||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
|
||||||
))
|
|
||||||
.left_join(collections_groups::table.on(
|
|
||||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
|
||||||
))
|
|
||||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
|
||||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
|
||||||
))
|
|
||||||
.left_join(ciphers_collections::table.on(
|
|
||||||
ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))
|
|
||||||
|
|
||||||
))
|
|
||||||
.filter(
|
|
||||||
groups::access_all.eq(true).or( // AccessAll via groups
|
|
||||||
ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection via group
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.left_join(collections_groups::table.on(collections_groups::groups_uuid.eq(groups_users::groups_uuid)))
|
||||||
|
.left_join(
|
||||||
|
groups::table.on(groups::uuid
|
||||||
|
.eq(groups_users::groups_uuid)
|
||||||
|
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||||
|
)
|
||||||
|
.left_join(
|
||||||
|
ciphers_collections::table.on(ciphers_collections::collection_uuid
|
||||||
|
.eq(collections_groups::collections_uuid)
|
||||||
|
.and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))),
|
||||||
|
)
|
||||||
|
.filter(groups::access_all.eq(true).or(
|
||||||
|
// AccessAll via groups
|
||||||
|
ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection via group
|
||||||
|
))
|
||||||
.select(users_organizations::all_columns)
|
.select(users_organizations::all_columns)
|
||||||
.distinct()
|
.distinct()
|
||||||
.load::<Self>(conn)
|
.load::<Self>(conn)
|
||||||
.expect("Error loading user organizations with groups")
|
.expect("Error loading user organizations with groups")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool {
|
pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.inner_join(ciphers::table.on(ciphers::uuid.eq(cipher_uuid).and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))))
|
.inner_join(
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
ciphers::table.on(ciphers::uuid
|
||||||
.filter(users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]))
|
.eq(cipher_uuid)
|
||||||
.count()
|
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
|
||||||
.first::<i64>(conn)
|
)
|
||||||
.ok()
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.unwrap_or(0) != 0
|
.filter(
|
||||||
}}
|
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
.first::<i64>(conn)
|
||||||
|
.ok()
|
||||||
|
.unwrap_or(0)
|
||||||
|
!= 0
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_collection_and_org(
|
pub async fn find_by_collection_and_org(
|
||||||
@@ -1111,44 +1145,41 @@ impl Membership {
|
|||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Vec<Self> {
|
) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
.left_join(users_collections::table.on(
|
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
|
||||||
users_collections::user_uuid.eq(users_organizations::user_uuid)
|
.filter(users_organizations::access_all.eq(true).or(
|
||||||
))
|
// AccessAll..
|
||||||
.filter(
|
users_collections::collection_uuid.eq(&collection_uuid), // ..or access to collection with cipher
|
||||||
users_organizations::access_all.eq(true).or( // AccessAll..
|
))
|
||||||
users_collections::collection_uuid.eq(&collection_uuid) // ..or access to collection with cipher
|
.select(users_organizations::all_columns)
|
||||||
)
|
.load::<Self>(conn)
|
||||||
)
|
.expect("Error loading user organizations")
|
||||||
.select(users_organizations::all_columns)
|
})
|
||||||
.load::<Self>(conn)
|
.await
|
||||||
.expect("Error loading user organizations")
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_external_id_and_org(ext_id: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_external_id_and_org(ext_id: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(
|
.filter(users_organizations::external_id.eq(ext_id).and(users_organizations::org_uuid.eq(org_uuid)))
|
||||||
users_organizations::external_id.eq(ext_id)
|
.first::<Self>(conn)
|
||||||
.and(users_organizations::org_uuid.eq(org_uuid))
|
.ok()
|
||||||
)
|
})
|
||||||
.first::<Self>(conn)
|
.await
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_main_user_org(user_uuid: &str, conn: &DbConn) -> Option<Self> {
|
pub async fn find_main_user_org(user_uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
|
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
|
||||||
.order(users_organizations::atype.asc())
|
.order(users_organizations::atype.asc())
|
||||||
.first::<Self>(conn)
|
.first::<Self>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,20 +1217,19 @@ impl OrganizationApiKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_org_uuid(org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_org_uuid(org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
organization_api_key::table
|
organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid)).first::<Self>(conn).ok()
|
||||||
.filter(organization_api_key::org_uuid.eq(org_uuid))
|
})
|
||||||
.first::<Self>(conn)
|
.await
|
||||||
.ok()
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
conn.run(move |conn| {
|
||||||
diesel::delete(organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid)))
|
diesel::delete(organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error removing organization api key from organization")
|
.map_res("Error removing organization api key from organization")
|
||||||
}}
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user